c85c635f63
loadShim() read EVERY account record in the shim (SELECT over the whole anchor graph). On a shim that accumulates accounts (the shared wallet grows), that is O(accounts) and hangs the hot path (~90s at ensureAccount → loadShim). Same principle as per-doc reads: never scan a shared structure. Add resolveAccount(username): a BOUNDED SELECT anchored on the single subject accountSubject(username) → O(1), independent of account count. Cache in a per-account Map (cleared by resetRegistryCache). Hot paths now use it: ensureAccount (existence check), indexInboxNuri/@index (discovery), resolveInbox Anchor, resolveWriteGraph, createEntityDoc, listMyEntityDocs. loadShim kept only for genuine all-accounts needs (allAccounts, the listEntityDocs fan-out fallback). The 90s ensureAccount/loadShim hang is gone. 93 tests pass; tsc rc=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
import {
|
|
ensureAccount,
|
|
allAccounts,
|
|
loadShim,
|
|
resolveWriteGraph,
|
|
resolveReadGraphs,
|
|
resolveScopeGraph,
|
|
resolveInboxAnchor,
|
|
createEntityDoc,
|
|
listEntityDocs,
|
|
resetRegistryCache,
|
|
} from "../src/store-registry";
|
|
import type { RegistrySession } from "../src/store-registry";
|
|
import {
|
|
configure,
|
|
configureStoreRegistry,
|
|
resetStoreRegistry,
|
|
resetConfig,
|
|
} from "../src/polyfill";
|
|
|
|
// This suite injects a fake `ng` via configure(); bun runs test files in a
|
|
// shared process with a single module singleton, and may run this file BEFORE
|
|
// docs.test.ts's order-dependent "not configured" guard. Restore the un-
|
|
// configured state when we're done so that guard still sees a null config.
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
});
|
|
|
|
// NOTE ORDER: the "not configured → throw" case MUST run first — configure*()
|
|
// sets module-level singletons and this suite never fully un-injects the real
|
|
// `ng` (docs' getConfig has no reset), so we exercise the registry-deps guard.
|
|
|
|
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
|
resetStoreRegistry();
|
|
resetRegistryCache();
|
|
await expect(ensureAccount("alice")).rejects.toThrow(
|
|
/configureStoreRegistry\(\) must be called before use/,
|
|
);
|
|
});
|
|
|
|
// --- A stateful fake `ng` that emulates just enough SPARQL over an in-memory
|
|
// quad store: INSERT DATA parsing + the two SELECT shapes the registry issues.
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
|
function unescapeLiteral(s: string): string {
|
|
let out = "";
|
|
for (let i = 0; i < s.length; i++) {
|
|
if (s[i] === "\\" && i + 1 < s.length) {
|
|
const next = s[++i];
|
|
out +=
|
|
next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
|
|
} else {
|
|
out += s[i];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function makeFakeNg() {
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
|
|
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
|
|
|
// Parses `INSERT DATA { GRAPH <g> { <s> <p> "o"/<o>/;-lists } }`. The literal
|
|
// pattern honours backslash-escapes (`\"`, `\\`, `\n`…) so an escaped quote
|
|
// inside a value does NOT terminate the literal — this is what proves the
|
|
// injection escaping keeps the query well-formed.
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
|
if (!gm) return undefined;
|
|
const g = gm[1]!;
|
|
const body = gm[2]!;
|
|
// Subject is the first <...> token in the body.
|
|
const sm = body.match(/<([^>]+)>/);
|
|
if (!sm) return undefined;
|
|
const s = sm[1]!;
|
|
// Predicate/object pairs: `<p> "o"` (escape-aware) or `<p> <o>`; `a <type>`.
|
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
let m: RegExpExecArray | null;
|
|
// Skip the subject token so we don't treat it as a predicate.
|
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
while ((m = pairRe.exec(after)) !== null) {
|
|
const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish
|
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
quads.push({ g, s, p, o });
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const sparql_query = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[3] as string | undefined;
|
|
if (query.includes("<urn:ng-eventually:shim:username>")) {
|
|
// Account SELECT, scoped to the anchor graph. Two shapes: the full scan
|
|
// (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
|
|
// <Account>`) which binds one subject — honour that subject so the bounded
|
|
// query returns exactly that account (or nothing).
|
|
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
const onlySubject = subjM ? subjM[1]! : null;
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
if (q.g !== anchor) continue;
|
|
if (onlySubject !== null && q.s !== onlySubject) continue;
|
|
const rec = bySubject.get(q.s) ?? {};
|
|
if (q.p === "urn:ng-eventually:shim:username") rec.username = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
|
bySubject.set(q.s, rec);
|
|
}
|
|
const bindings = [...bySubject.values()]
|
|
.filter((r) => r.username)
|
|
.map((r) => ({
|
|
username: { value: r.username! },
|
|
docPublic: { value: r.docPublic ?? "" },
|
|
docProtected: { value: r.docProtected ?? "" },
|
|
docPrivate: { value: r.docPrivate ?? "" },
|
|
}));
|
|
return { results: { bindings } };
|
|
}
|
|
// Entity-index SELECT: `<index> <contains> ?e` in the anchor graph.
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
|
.map((q) => ({ e: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
});
|
|
|
|
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
|
}
|
|
|
|
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
|
|
|
function inject() {
|
|
const ng = makeFakeNg();
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => SESSION,
|
|
normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
|
});
|
|
resetRegistryCache();
|
|
return ng;
|
|
}
|
|
|
|
let fake: ReturnType<typeof makeFakeNg>;
|
|
beforeEach(() => {
|
|
fake = inject();
|
|
});
|
|
|
|
test("ensureAccount creates 3 scope docs and persists them to the shim", async () => {
|
|
const rec = await ensureAccount("Alice");
|
|
expect(rec.username).toBe("Alice");
|
|
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
|
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
|
expect(rec.docProtected).not.toBe(rec.docPublic);
|
|
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
|
// Persisted into the shim anchor graph (did:ng:PRIV).
|
|
expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV");
|
|
});
|
|
|
|
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
|
|
const a = await ensureAccount("Alice");
|
|
const b = await ensureAccount("@alice");
|
|
expect(b).toEqual(a);
|
|
expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6
|
|
});
|
|
|
|
test("loadShim round-trips a persisted account across a cache reset", async () => {
|
|
await ensureAccount("Bob");
|
|
resetRegistryCache(); // force a re-read from the fake store
|
|
const map = await loadShim();
|
|
const rec = map.get("bob");
|
|
expect(rec?.username).toBe("Bob");
|
|
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/);
|
|
});
|
|
|
|
test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => {
|
|
const rec = await ensureAccount("Carol");
|
|
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
|
|
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
|
|
});
|
|
|
|
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
|
|
// Session with all three store ids: private → private store; public+protected
|
|
// co-locate on the protected native store (the polyfill's Axis-A placement).
|
|
const ng = makeFakeNg();
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => ({
|
|
sessionId: "sid-2",
|
|
privateStoreId: "PRIV",
|
|
protectedStoreId: "PROT",
|
|
publicStoreId: "PUB",
|
|
}),
|
|
});
|
|
resetRegistryCache();
|
|
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
|
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
|
|
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
|
|
// The inbox anchor is now a DEDICATED inbox DOCUMENT (a reserved account's
|
|
// public scope doc, from docCreate) — NOT the private-store root — so inbox
|
|
// deposits don't bloat the shim graph. It is a real repo NURI and STABLE
|
|
// across calls (same reserved account → same document).
|
|
const anchor = await resolveInboxAnchor();
|
|
expect(anchor).toMatch(/^did:ng:o:doc/);
|
|
expect(anchor).not.toBe("did:ng:PRIV");
|
|
expect(await resolveInboxAnchor()).toBe(anchor); // stable
|
|
});
|
|
|
|
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
|
|
// The default SESSION carries only privateStoreId — non-private scopes fall
|
|
// back to the private store rather than emitting a broken NURI.
|
|
expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV");
|
|
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
|
|
});
|
|
|
|
test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => {
|
|
const rec = await ensureAccount("Dave");
|
|
const e1 = await createEntityDoc("dave", "public");
|
|
const e2 = await createEntityDoc("dave", "public");
|
|
const other = await createEntityDoc("dave", "protected");
|
|
// Public listing unions dave's public entities only.
|
|
const pub = await listEntityDocs("public");
|
|
expect(pub.sort()).toEqual([e1, e2].sort());
|
|
const prot = await listEntityDocs("protected");
|
|
expect(prot).toEqual([other]);
|
|
// The index append targets the account's public index doc.
|
|
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
|
});
|
|
|
|
test("listEntityDocs fans out across multiple accounts", async () => {
|
|
await ensureAccount("Eve");
|
|
await ensureAccount("Frank");
|
|
const e = await createEntityDoc("eve", "public");
|
|
const f = await createEntityDoc("frank", "public");
|
|
expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort());
|
|
});
|
|
|
|
test("allAccounts reflects every ensured account", async () => {
|
|
await ensureAccount("Gina");
|
|
await ensureAccount("Hank");
|
|
const names = (await allAccounts()).map((a) => a.username).sort();
|
|
expect(names).toEqual(["Gina", "Hank"]);
|
|
});
|
|
|
|
// --- SPARQL injection hardening (F1) --------------------------------------
|
|
//
|
|
// A malicious username must NOT be able to break out of the literal / IRI it
|
|
// lands in and inject arbitrary triples into the shim (the account→doc trust
|
|
// root). We inspect the exact SPARQL string the registry hands to sparql_update.
|
|
|
|
/** The raw INSERT DATA string produced by ensureAccount for `username`. */
|
|
async function insertFor(username: string): Promise<string> {
|
|
await ensureAccount(username);
|
|
const calls = fake.sparql_update.mock.calls;
|
|
return calls[calls.length - 1]![1] as string;
|
|
}
|
|
|
|
/** Count RAW (un-escaped) double-quotes — i.e. `"` not preceded by a `\`.
|
|
* Strip escaped pairs (`\\`, `\"`, …) first so only delimiter quotes remain. */
|
|
function rawQuoteCount(s: string): number {
|
|
const withoutEscapes = s.replace(/\\./g, "");
|
|
return (withoutEscapes.match(/"/g) ?? []).length;
|
|
}
|
|
|
|
test("injection: username with a quote cannot open extra literals", async () => {
|
|
const evil = 'x" ; <urn:evil> "pwn';
|
|
const update = await insertFor(evil);
|
|
// A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw
|
|
// quotes (the delimiters). The injected `"` must have been escaped, so the
|
|
// count stays 8 — no extra literal was opened.
|
|
expect(rawQuoteCount(update)).toBe(8);
|
|
// The escaped username is present as a single literal value — the injected
|
|
// `<urn:evil>` survives only as INERT text inside that literal (its
|
|
// surrounding quotes are escaped `\"`), never as query syntax.
|
|
expect(update).toContain('"x\\" ; <urn:evil> \\"pwn"');
|
|
});
|
|
|
|
test("injection: username with '>' cannot break out of the account-subject IRI", async () => {
|
|
const evil = "x> <urn:evil";
|
|
const update = await insertFor(evil);
|
|
// The account subject is `<urn:ng-eventually:shim:account:...>` — the encoded
|
|
// username must NOT contain a raw `>` that would close the IRI early.
|
|
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
|
|
expect(subjMatch).not.toBeNull();
|
|
expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded
|
|
expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E
|
|
});
|
|
|
|
test("injection: newline / control chars in username are neutralised", async () => {
|
|
const evil = "a\nb\tc";
|
|
const update = await insertFor(evil);
|
|
// In the literal: escaped to \n / \t (no raw control char).
|
|
expect(update).toContain('"a\\nb\\tc"');
|
|
// In the IRI subject: percent-encoded.
|
|
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
|
|
expect(subjMatch[1]).toContain("%0A");
|
|
expect(subjMatch[1]).toContain("%09");
|
|
});
|
|
|
|
test("injection: '; DELETE'-style payload stays inert inside the literal", async () => {
|
|
const evil = 'x"} ; DELETE WHERE { ?s ?p ?o } ; INSERT DATA { <a> <b> "';
|
|
const update = await insertFor(evil);
|
|
// The whole attack survives, escaped, as ONE literal value — the injected
|
|
// `"}` cannot close the literal/graph, so DELETE/second-INSERT stay text.
|
|
expect(update).toContain(escapeLiteralRef(evil));
|
|
// Quote count stays even (all delimiters balanced; the injected `"` escaped):
|
|
// 4 predicate literals → 8 raw delimiter quotes, nothing extra opened.
|
|
expect(rawQuoteCount(update)).toBe(8);
|
|
// The injected `"}` did not survive as raw syntax (it was escaped to `\"}`).
|
|
expect(update).not.toMatch(/[^\\]"} ; DELETE/);
|
|
});
|
|
|
|
// Local mirror of the lib's escapeLiteral so the assertion is self-checking.
|
|
function escapeLiteralRef(v: string): string {
|
|
return `"${v
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\n/g, "\\n")
|
|
.replace(/\r/g, "\\r")
|
|
.replace(/\t/g, "\\t")}"`;
|
|
}
|
|
|
|
test("injection: a malicious username still round-trips through the shim", async () => {
|
|
const evil = 'eve" ; <urn:evil> "x';
|
|
const rec = await ensureAccount(evil);
|
|
expect(rec.username).toBe(evil);
|
|
resetRegistryCache();
|
|
const map = await loadShim();
|
|
// The stored username came back verbatim (escaping is lossless) under its
|
|
// normalized key, and exactly ONE account exists (no injected extra subject).
|
|
const key = evil.trim().replace(/^@+/, "").toLowerCase();
|
|
expect(map.get(key)?.username).toBe(evil);
|
|
expect(map.size).toBe(1);
|
|
});
|
|
|
|
test("normalizeUser defaults to trim when not provided", async () => {
|
|
const ng = makeFakeNg();
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({ getSession: async () => SESSION });
|
|
resetRegistryCache();
|
|
const a = await ensureAccount(" Ivy ");
|
|
const b = await ensureAccount("Ivy"); // trimmed key matches
|
|
expect(b).toEqual(a);
|
|
expect(ng.doc_create).toHaveBeenCalledTimes(3);
|
|
});
|