/** * SPARQL string-building safety helpers — shared by every module that builds * SPARQL by interpolation (inbox, store-registry). * * These exist because of SPARQL injection. When an untrusted value (an identity * id, a payload) is spliced verbatim into a query, a `"` closes a literal and a * `>` closes an IRI, letting the value inject arbitrary triples (or corrupt the * shim graph, the trust root mapping accounts → document NURIs). Every value that * reaches a query passes through one of these helpers first. * * Two positions, two strategies: * - Literal position (`"..."`): {@link escapeLiteral}. Escape rather than reject, * because literals legitimately carry arbitrary text (JSON payloads, display * names). Escaping is lossless and reversible. * - IRI position (`<...>`): two cases. * · Trusted-shaped NURIs coming back from `ng` (`did:ng:...`): validate with * {@link assertNuri} — they should never contain IRI-breaking chars; if one * does, something upstream is wrong, so it throws rather than silently * building a broken/injected query. * · Untrusted values embedded into an IRI (an identity id used to mint an * account-subject IRI): {@link escapeIri} percent-encodes every IRI-hostile * character. Encode rather than reject so any id (spaces, unicode, * punctuation) stays usable, while `<`, `>`, `"`, whitespace and control * chars can never break out of the IRI. */ /** * Escape a value for embedding inside a SPARQL string literal (`"..."`). * Escapes backslash, double-quote and the C0 whitespace controls that would * otherwise terminate or corrupt the literal. Lossless / reversible. */ export function escapeLiteral(value: string): string { return value .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/\t/g, "\\t"); } /** * Delimiter characters that must never appear raw inside a SPARQL/Turtle IRI * ref (`<...>`): the space plus `< > " { } | ^ backtick \`. Whitespace beyond * the space and all C0/C1 control characters are handled by the code-point * check in {@link isIriForbidden}. Any of these would let a value break out of * the `<...>` and inject arbitrary syntax. */ const IRI_FORBIDDEN_DELIMS = /[<>"{}|^`\\ ]/; /** True if `ch` (a single code point) may not appear raw inside an IRI ref. */ function isIriForbidden(ch: string): boolean { const code = ch.codePointAt(0)!; return IRI_FORBIDDEN_DELIMS.test(ch) || code < 0x20 || code === 0x7f; } /** * Percent-encode every IRI-hostile character in `value` so it is safe to embed * inside a SPARQL IRI ref (``). Use this for * untrusted values (e.g. an identity id minted into an account-subject IRI): * encoding keeps every id usable while making breakout impossible. * * NOTE: this encodes only the delimiter/whitespace/control set, so ordinary * printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass * through unchanged and the resulting IRI stays human-readable. */ export function escapeIri(value: string): string { let out = ""; for (const ch of value) { if (isIriForbidden(ch)) { // Percent-encode each UTF-8 byte of the offending character. Also encode // the chars encodeURIComponent leaves alone but which are IRI-hostile. out += encodeURIComponent(ch).replace( /[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(), ); } else { out += ch; } } return out; } /** * Assert that `nuri` is safe to embed verbatim inside a SPARQL IRI ref. NURIs * that come back from `ng` are trusted-SHAPED (`did:ng:...` or `urn:...`) and * should never carry IRI-breaking characters; if one does, we throw rather than * emit a query that could be malformed or injected. Returns the value unchanged * so it can be used inline: `<${assertNuri(doc)}>`. * * Generic in its argument so the caller's type flows THROUGH: passing a `Nuri` * gives back a `Nuri`, not a widened `string`. This function checks characters, * not the `did:ng:` shape (it legitimately accepts `urn:…` IRIs too), so it must * not be the thing that mints a `Nuri` — that is {@link isNuri}'s job. */ export function assertNuri(nuri: T): T { if (typeof nuri !== "string" || nuri.length === 0) { throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`); } for (const ch of nuri) { if (isIriForbidden(ch)) { throw new Error( `[sparql] NURI contains IRI-forbidden characters, refusing to embed: ${JSON.stringify(nuri)}`, ); } } return nuri; }