75378fc5a4
Trois trouvailles adverses survivaient à leur premier correctif. Elles avaient la même faiblesse : ce qui prouve le code n'exerçait pas le code. L'adaptateur n'était pas testé dans son comportement — six méthodes sur sept pouvaient être vidées avec la suite verte. Une doublure de polyfill en mémoire les exerce désormais toutes : elle RÉPOND au lieu de rendre des constantes, et elle EXÉCUTE le SPARQL, avec un analyseur qui n'accepte qu'un INSERT DATA ancré de triplets littéraux. Les six vidages ont été vérifiés rouges, entre 2 et 10 échecs chacun. Et la garde anti-suppression change de nature. Elle n'instrumentait que deux méthodes, et cinq contournements passaient — dont un littéral coupé placé dans readDeposits, qui est le chemin de la prochaine fonctionnalité. Maintenant le moteur REFUSE la requête : « expected the keyword INSERT, found DELETE ». La suppression n'est plus détectée, elle est inexécutable — ce qui ne se contourne pas par une écriture plus habile. Une nouvelle méthode est couverte par construction, sans liste à tenir : le test lit Object.keys du port. Vérifié — une huitième méthode sans pilote fait rougir. Le README disait qu'une entrée ne change jamais après sa création, alors qu'une valeur plus petite l'écrase. Remplacé par ce qui est vrai — un objet déjà indexé n'est jamais relu — avec le cas reproduit. Et « ne fait que grandir » dit désormais que la suppression n'a JAMAIS été construite, pas qu'elle a été retirée : celui qui en aura besoin doit le lire, pas le déduire d'une fonction manquante. Enfin la forme d'écriture s'aligne sur celle du polyfill — l'écriture ancrée sans clause GRAPH, le document nommé une seule fois comme ancre. buildInsertTriple ne prend plus de graphe, donc elle ne peut plus dériver.
178 lines
7.7 KiB
TypeScript
178 lines
7.7 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { buildInsertTriple, escapeIri, escapeLiteral, isIriSafe } from "../src/sparql";
|
|
import { asNuri, isNuri } from "../src/nuri";
|
|
import { decodeReference } from "../src/deposit";
|
|
|
|
// --- 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/);
|
|
});
|
|
|
|
test("a deposit is a bare reference — anything else decodes to null", () => {
|
|
expect(decodeReference("did:ng:o:doc-7")).toBe("did:ng:o:doc-7");
|
|
// The shapes a well-meaning caller might invent, all refused: the payload IS
|
|
// the reference, it is not wrapped and it carries nothing else.
|
|
expect(decodeReference({ object: "did:ng:o:doc-7" })).toBeNull();
|
|
expect(decodeReference({ assert: "published", object: "did:ng:o:doc-7" })).toBeNull();
|
|
expect(decodeReference(["did:ng:o:doc-7"])).toBeNull();
|
|
expect(decodeReference("please index did:ng:o:doc-7")).toBeNull();
|
|
expect(decodeReference(null)).toBeNull();
|
|
expect(decodeReference(7)).toBeNull();
|
|
});
|
|
|
|
// --- 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-write-path.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([]);
|
|
});
|