Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/system-record-managed-ownership.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ on:
- 'packages/rdf-utils/**'
- 'packages/storage/**'
- 'scripts/check-managed-store-raw-channels.mjs'
- 'scripts/managed-store-dynamic-query-inventory.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
Expand All @@ -65,6 +66,7 @@ on:
- 'packages/rdf-utils/**'
- 'packages/storage/**'
- 'scripts/check-managed-store-raw-channels.mjs'
- 'scripts/managed-store-dynamic-query-inventory.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"test:live:system-record-managed-ownership:verify": "node --import tsx devnet/issue-2052-managed-ownership/verify.ts",
"typecheck:live:system-record-managed-ownership": "pnpm --filter @devnet/issue-2052-managed-ownership typecheck",
"check:managed-store-raw-channels": "node --import tsx scripts/check-managed-store-raw-channels.mjs",
"check:managed-store-raw-channels:write-inventory": "node --import tsx scripts/check-managed-store-raw-channels.mjs --write-inventory",
"test:watch": "vitest --config vitest.config.ts",
"test:coverage": "turbo test:coverage",
"bench": "pnpm --filter @origintrail-official/dkg-storage build && esbench --config esbench.config.mjs",
Expand Down
1 change: 0 additions & 1 deletion packages/agent/src/generic-sql-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,6 @@ async function createMssqlClient(
}
request.input(name, value);
}
// dkg-raw-channel-non-store: optional mssql Request, not an RDF TripleStore.
const result = await request.query(sql);
return result.recordset ?? [];
},
Expand Down
105 changes: 62 additions & 43 deletions packages/core/src/sparql-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,9 @@ export interface SparqlOperationAnalysis {

const PREFIX_DECL = /\s*PREFIX\s+[^\s:]*:\s*(?:<[^<>"{}|^`\\\x00-\x20]*>)?/iy;
const BASE_DECL = /\s*BASE\b\s*(?:<[^<>"{}|^`\\\x00-\x20]*>)?/iy;
const OPERATION_AT_START = new RegExp(
`\\s*(${[...SPARQL_READ_ONLY_OPERATIONS, ...SPARQL_UPDATE_OPERATIONS].join('|')})\\b`,
'iy',
);
const MUTATING_TOKEN_PATTERN = new RegExp(
`\\b(${SPARQL_MUTATING_KEYWORDS.join('|')})\\b`,
'ig',
);
const UPDATE_OPERATION_SET = new Set<string>(SPARQL_UPDATE_OPERATIONS);
const READ_ONLY_OPERATION_SET = new Set<string>(SPARQL_READ_ONLY_OPERATIONS);
const MUTATING_KEYWORD_SET = new Set<string>(SPARQL_MUTATING_KEYWORDS);

function isSparqlIriRefBodyChar(ch: string | undefined): ch is string {
return !!ch && !/[<>"{}|^`\\\s]/.test(ch) && ch >= '\x21';
Expand Down Expand Up @@ -129,10 +122,10 @@ function detectSparqlOperationFormFromStripped(stripped: string): SparqlDetected
}
break;
}
OPERATION_AT_START.lastIndex = offset;
const operationHit = OPERATION_AT_START.exec(stripped);
while (/\s/u.test(stripped[offset] ?? '')) offset++;
const operationHit = readStandaloneSparqlWord(stripped, offset);
if (!operationHit) return 'UNKNOWN';
const operation = operationHit[1].toUpperCase();
const operation = operationHit.word;
return isReadOnlySparqlOperation(operation) || isSparqlUpdateOperationForm(operation)
? operation
: 'UNKNOWN';
Expand All @@ -152,10 +145,10 @@ function classifySparqlOperationForm(form: SparqlDetectedOperation): SparqlOpera
return { kind: 'unknown' };
}

function isSparqlNameAdjacent(ch: string | undefined): boolean {
function isSparqlNameCharacter(ch: string | undefined): boolean {
return ch !== undefined && (
isSparqlWordContinuation(ch)
|| /[\p{L}\p{N}\p{M}?$:@.-]/u.test(ch)
|| /[\p{L}\p{N}\p{M}:@-]/u.test(ch)
);
}

Expand All @@ -169,12 +162,33 @@ function isEscapedPnLocalCharAt(src: string, index: number): boolean {
&& PN_LOCAL_ESC_CHAR.test(src[index] ?? '');
}

/** A dot joins PN_LOCAL text only when the same uninterrupted token has a prefix colon. */
function isPrefixedNameDotBefore(src: string, index: number): boolean {
if (src[index - 1] !== '.') return false;
for (let cursor = index - 2; cursor >= 0; cursor--) {
const ch = src[cursor];
if (ch === ':') return true;
if (ch === '.' || isSparqlNameCharacter(ch)) continue;
if (isEscapedPnLocalCharAt(src, cursor)) {
cursor--;
continue;
}
return false;
}
return false;
}

function isSparqlNameAdjacentBefore(src: string, index: number): boolean {
return isSparqlNameAdjacent(src[index - 1]) || isEscapedPnLocalCharAt(src, index - 1);
const previous = src[index - 1];
return isSparqlNameCharacter(previous)
|| previous === '?'
|| previous === '$'
|| isPrefixedNameDotBefore(src, index)
|| isEscapedPnLocalCharAt(src, index - 1);
}

function isSparqlNameAdjacentAfter(src: string, index: number): boolean {
return isSparqlNameAdjacent(src[index])
return isSparqlNameCharacter(src[index])
|| (src[index] === '\\' && PN_LOCAL_ESC_CHAR.test(src[index + 1] ?? ''));
}

Expand All @@ -186,35 +200,43 @@ function isSparqlWordStart(ch: string | undefined): boolean {
);
}

/** Shared ASCII keyword boundary used by admission and query rewriting. */
/** @deprecated Use readStandaloneSparqlWord so boundary and token length share one model. */
export function isSparqlWordContinuation(ch: string | undefined): ch is string {
return isSparqlWordStart(ch) || (!!ch && ch >= '0' && ch <= '9');
}

export function isSparqlKeywordStart(src: string, idx: number): boolean {
const ch = src[idx];
if (!isSparqlWordStart(ch)) return false;
const prev = idx > 0 ? src[idx - 1] : '';
return !prev || (
!isSparqlWordContinuation(prev)
&& prev !== '?'
&& prev !== '$'
&& prev !== ':'
&& prev !== '#'
);
export interface StandaloneSparqlWord {
readonly word: string;
readonly start: number;
readonly end: number;
}

/** Read one standalone ASCII SPARQL word using the canonical name boundary model. */
export function readStandaloneSparqlWord(
src: string,
start: number,
): StandaloneSparqlWord | null {
if (!isSparqlWordStart(src[start]) || isSparqlNameAdjacentBefore(src, start)) return null;
let end = start + 1;
while (end < src.length && isSparqlWordContinuation(src[end])) end++;
if (isSparqlNameAdjacentAfter(src, end)) return null;
return Object.freeze({ word: src.slice(start, end).toUpperCase(), start, end });
}

/** @deprecated Use readStandaloneSparqlWord and inspect the returned token. */
export function isSparqlKeywordStart(src: string, start: number): boolean {
Comment thread
Jurij89 marked this conversation as resolved.
Outdated
return readStandaloneSparqlWord(src, start) !== null;
}

/** @deprecated Use readStandaloneSparqlWord and inspect the returned token. */
export function isSparqlKeyword(
src: string,
start: number,
end: number,
keyword: string,
): boolean {
const next = src[end];
return src.slice(start, end).toUpperCase() === keyword
&& next !== ':'
&& next !== '-'
&& next !== '.';
const token = readStandaloneSparqlWord(src, start);
return token?.end === end && token.word === keyword;
}

/**
Expand All @@ -225,19 +247,16 @@ export function isSparqlKeyword(
* tokens from variable, prefixed-name, language-tag, and identifier text.
*/
function findMutatingKeyword(stripped: string): string | null {
MUTATING_TOKEN_PATTERN.lastIndex = 0;
for (;;) {
const match = MUTATING_TOKEN_PATTERN.exec(stripped);
if (!match) return null;
const start = match.index;
const end = start + match[0].length;
if (
!isSparqlNameAdjacentBefore(stripped, start)
&& !isSparqlNameAdjacentAfter(stripped, end)
) {
return match[1] ?? null;
for (let index = 0; index < stripped.length;) {
const token = readStandaloneSparqlWord(stripped, index);
if (!token) {
index++;
continue;
}
if (MUTATING_KEYWORD_SET.has(token.word)) return token.word;
index = token.end;
}
return null;
}

export function analyzeSparqlOperation(sparql: string): SparqlOperationAnalysis {
Expand Down
68 changes: 68 additions & 0 deletions packages/core/test/sparql-operation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
analyzeSparqlOperation,
readStandaloneSparqlWord,
recognizedReadOnlySparqlForm,
} from '../src/sparql-operation.js';

describe('canonical standalone SPARQL word scanner', () => {
it('recognizes standalone words and rejects legal name adjacency', () => {
const standalone = ' SELECT ?s WHERE { ?s ?p ?o }';
expect(readStandaloneSparqlWord(standalone, standalone.indexOf('SELECT'))).toEqual({
word: 'SELECT',
start: 2,
end: 8,
});

for (const source of [
'?DELETE',
'ex:DELETE',
'foo\\-DELETE',
'DELETE:value',
'ex:foo.DELETE',
]) {
expect(readStandaloneSparqlWord(source, source.indexOf('DELETE'))).toBeNull();
}

const dotSeparated = '?s ?p ?o.GRAPH <urn:outside> {}';
Comment thread
Jurij89 marked this conversation as resolved.
const graphStart = dotSeparated.indexOf('GRAPH');
expect(readStandaloneSparqlWord(dotSeparated, graphStart)).toEqual({
word: 'GRAPH',
start: graphStart,
end: graphStart + 'GRAPH'.length,
});

expect(readStandaloneSparqlWord('GRAPH?g{}', 0)).toEqual({
word: 'GRAPH',
start: 0,
end: 5,
});
expect(analyzeSparqlOperation('SELECT * WHERE {}; DELETE?subject {}').mutatingKeyword)
.toBe('DELETE');
});

it('shares the same boundary model with operation admission', () => {
const read = 'SELECT * WHERE { BIND(ex:foo\\-DELETE AS ?value) }';
expect(recognizedReadOnlySparqlForm(analyzeSparqlOperation(read))).toBe('SELECT');

const mixed = `${read}; DELETE WHERE { ?s ?p ?o }`;
expect(recognizedReadOnlySparqlForm(analyzeSparqlOperation(mixed))).toBeNull();

const dotSeparatedMutation = 'SELECT * WHERE { ?s ?p ?o.DELETE WHERE { ?x ?y ?z } }';
expect(analyzeSparqlOperation(dotSeparatedMutation).mutatingKeyword).toBe('DELETE');
expect(recognizedReadOnlySparqlForm(analyzeSparqlOperation(dotSeparatedMutation))).toBeNull();

const prefixedName = 'SELECT * WHERE { BIND(ex:foo.DELETE AS ?value) }';
expect(analyzeSparqlOperation(prefixedName).mutatingKeyword).toBeNull();
expect(recognizedReadOnlySparqlForm(analyzeSparqlOperation(prefixedName))).toBe('SELECT');
});

it('has no shared regex or cursor state across interleaved calls', () => {
const update = 'SELECT * WHERE { ?s ?p ?o }; DROP ALL';
const read = 'ASK { ?s ?p ?o }';
for (let iteration = 0; iteration < 20; iteration++) {
expect(analyzeSparqlOperation(update).mutatingKeyword).toBe('DROP');
expect(recognizedReadOnlySparqlForm(analyzeSparqlOperation(read))).toBe('ASK');
}
});
});
Loading
Loading