165 lines
7.0 KiB
TypeScript
165 lines
7.0 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { buildInsertTriple, escapeIri, escapeLiteral, isIriSafe } from "../src/sparql";
|
|
import { asNuri, isNuri } from "../src/nuri";
|
|
|
|
// --- what a reference is, and what it is not ------------------------------
|
|
|
|
test("isNuri accepts a NURI and rejects everything that is not one", () => {
|
|
expect(isNuri("did:ng:o:abc-123")).toBe(true);
|
|
expect(isNuri("did:ng:")).toBe(false);
|
|
expect(isNuri("https://example.org/thing")).toBe(false);
|
|
expect(isNuri(42)).toBe(false);
|
|
expect(isNuri(null)).toBe(false);
|
|
expect(isNuri({ nuri: "did:ng:o:abc" })).toBe(false);
|
|
});
|
|
|
|
test("isNuri rejects a reference carrying characters that would break out of an IRI", () => {
|
|
expect(isNuri("did:ng:o:a> <urn:evil> ?x")).toBe(false);
|
|
expect(isNuri('did:ng:o:a"b')).toBe(false);
|
|
expect(isNuri("did:ng:o:a\nb")).toBe(false);
|
|
});
|
|
|
|
test("asNuri throws on a non-reference rather than passing it on", () => {
|
|
expect(() => asNuri("nope")).toThrow(/not a NURI/);
|
|
});
|
|
|
|
// --- escaping -------------------------------------------------------------
|
|
|
|
test("escapeLiteral leaves no raw quote that could close a SPARQL literal", () => {
|
|
const attack = '" } ; DROP ALL ; INSERT DATA { GRAPH <urn:evil> { <a> <b> "';
|
|
const escaped = escapeLiteral(attack);
|
|
expect(/(^|[^\\])"/.test(escaped)).toBe(false);
|
|
});
|
|
|
|
test("escapeLiteral escapes the backslash before the quote, so neither is undone", () => {
|
|
expect(escapeLiteral('\\"')).toBe('\\\\\\"');
|
|
});
|
|
|
|
test("escapeLiteral escapes the whitespace controls", () => {
|
|
expect(escapeLiteral("a\nb\rc\td")).toBe("a\\nb\\rc\\td");
|
|
});
|
|
|
|
test("escapeIri percent-encodes every character that could close an IRI", () => {
|
|
expect(escapeIri("a>b")).toBe("a%3Eb");
|
|
expect(escapeIri("a<b")).toBe("a%3Cb");
|
|
expect(escapeIri('a"b')).toBe("a%22b");
|
|
expect(escapeIri("a b")).toBe("a%20b");
|
|
expect(escapeIri("a\nb")).toBe("a%0Ab");
|
|
expect(escapeIri("a\\b")).toBe("a%5Cb");
|
|
expect(escapeIri("a{b}c|d^e`f")).toBe("a%7Bb%7Dc%7Cd%5Ee%60f");
|
|
});
|
|
|
|
test("escapeIri leaves the characters a NURI is actually made of alone", () => {
|
|
expect(escapeIri("did:ng:o:AbC-123_x.y")).toBe("did:ng:o:AbC-123_x.y");
|
|
});
|
|
|
|
test("escapeIri leaves printable non-ASCII alone, since encoding it would corrupt it", () => {
|
|
expect(escapeIri("élan")).toBe("élan");
|
|
});
|
|
|
|
test("isIriSafe agrees with escapeIri on what needs encoding", () => {
|
|
expect(isIriSafe("did:ng:o:abc")).toBe(true);
|
|
expect(isIriSafe("did:ng:o:a b")).toBe(false);
|
|
expect(isIriSafe("did:ng:o:a>b")).toBe(false);
|
|
});
|
|
|
|
// --- the one statement this package writes --------------------------------
|
|
|
|
test("buildInsertTriple writes the anchored INSERT DATA, with NO GRAPH clause", () => {
|
|
// The document is named ONCE, as `docs.sparqlUpdate`'s anchor, never inside the
|
|
// statement — the polyfill's canonical shape (`src/surface/inbox.ts`). The builder
|
|
// takes no graph parameter at all, so the statement cannot name a second one.
|
|
expect(buildInsertTriple("did:ng:o:object", "urn:ng-helpers:index:value", "2026-01-02")).toBe(
|
|
'INSERT DATA { <did:ng:o:object> <urn:ng-helpers:index:value> "2026-01-02" }',
|
|
);
|
|
});
|
|
|
|
/** Quotes that are NOT preceded by an odd run of backslashes — i.e. real delimiters. */
|
|
function unescapedQuotes(query: string): number {
|
|
let count = 0;
|
|
for (let i = 0; i < query.length; i += 1) {
|
|
if (query[i] !== '"') continue;
|
|
let backslashes = 0;
|
|
for (let j = i - 1; j >= 0 && query[j] === "\\"; j -= 1) backslashes += 1;
|
|
if (backslashes % 2 === 0) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
test("buildInsertTriple neutralises a breakout attempt in the value", () => {
|
|
const query = buildInsertTriple(
|
|
"did:ng:o:object",
|
|
"urn:ng-helpers:index:value",
|
|
'" } ; DROP ALL ; INSERT DATA { <a> <b> "c',
|
|
);
|
|
// The attack text survives as TEXT, which is fine — what matters is that it
|
|
// cannot leave the literal. Exactly two quotes are real delimiters: the ones
|
|
// this builder wrote. Every quote in the value is escaped, so the `DROP ALL`
|
|
// and the second `INSERT DATA` are inert characters, not statements.
|
|
expect(unescapedQuotes(query)).toBe(2);
|
|
expect(query.startsWith("INSERT DATA { <did:ng:o:object> ")).toBe(true);
|
|
expect(query.endsWith('" }')).toBe(true);
|
|
});
|
|
|
|
test("buildInsertTriple neutralises a breakout attempt in the subject", () => {
|
|
const query = buildInsertTriple(
|
|
"did:ng:o:a> <urn:evil> <urn:x",
|
|
"urn:ng-helpers:index:value",
|
|
"v",
|
|
);
|
|
// The injected angle brackets are percent-encoded, so the subject stays one IRI.
|
|
expect(query).toBe(
|
|
'INSERT DATA { <did:ng:o:a%3E%20%3Curn:evil%3E%20%3Curn:x> <urn:ng-helpers:index:value> "v" }',
|
|
);
|
|
});
|
|
|
|
test("every builder emits an INSERT and nothing else", async () => {
|
|
const sparql: Record<string, unknown> = await import("../src/sparql");
|
|
const emitted: string[] = [];
|
|
for (const [name, exported] of Object.entries(sparql)) {
|
|
if (typeof exported !== "function" || !name.startsWith("build")) continue;
|
|
const builder = exported as (...args: string[]) => unknown;
|
|
emitted.push(String(builder("did:ng:o:s", "urn:p", "v")));
|
|
}
|
|
expect(emitted.length).toBeGreaterThan(0); // not vacuously true
|
|
for (const query of emitted) {
|
|
expect(query.startsWith("INSERT DATA")).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("a tripwire: no destructive SPARQL keyword is written anywhere under src/", async () => {
|
|
// SECONDARY, and worth being honest about what it is worth. A pattern over
|
|
// SOURCE can always be out-written, and an earlier version of this test was:
|
|
// four evasions passed it with the whole suite green — `COPY DEFAULT TO GRAPH`,
|
|
// a keyword hidden behind the comment-stripper, `DELETE{` with no space, and a
|
|
// literal split across concatenated lines.
|
|
//
|
|
// What actually guards the invariant is `test/adapter.test.ts`,
|
|
// which RUNS the adapter and reads back every query it emits; all four evasions
|
|
// fail there. This stays as a cheap tripwire that catches the obvious
|
|
// regression early and names the file — it is not the proof.
|
|
//
|
|
// It fails CLOSED: a trailing `// … DROP GRAPH …` comment trips it. That is the
|
|
// right direction for a tripwire, and stripping trailing comments properly
|
|
// would need a tokenizer, since `//` appears inside every `http://` IRI.
|
|
const { Glob } = await import("bun");
|
|
const root = new URL("../src/", import.meta.url).pathname;
|
|
const files = await Array.fromAsync(
|
|
new Glob("**/*.{ts,mts,cts,js,mjs,cjs}").scan({ cwd: root, absolute: true }),
|
|
);
|
|
expect(files.length).toBeGreaterThan(0);
|
|
|
|
// Bare keywords, destructive and data-moving alike. For a tripwire a false
|
|
// positive is far cheaper than a miss, so this does not try to match forms.
|
|
const destructive = /\b(DELETE|DROP|CLEAR|MOVE|COPY|MODIFY|LOAD)\b/i;
|
|
|
|
const offenders: string[] = [];
|
|
for (const file of files) {
|
|
const code = (await Bun.file(file).text())
|
|
.replace(/\/\*[^]*?\*\//g, " ") // block comments
|
|
.replace(/^\s*\/\/.*$/gm, " "); // whole-line comments
|
|
if (destructive.test(code)) offenders.push(file.slice(root.length));
|
|
}
|
|
expect(offenders).toEqual([]);
|
|
});
|