88914f50ae
Les 25 modules étaient à plat, nommés d'après ce qu'ils font mécaniquement (`store-registry`, `read-model`, `reach`, `caps`). Rien dans l'arborescence ne disait lesquels DEVIENDRONT le vrai SDK, lesquels tiennent lieu du travail que le verifier fera nativement, et lesquels n'existent que parce qu'un wallet est partagé — trois destins sans rapport. Quatre dossiers, les deux fichiers d'entrée restant à la racine pour que l'`exports` du paquet et le code du consommateur ne bougent pas : - `model/` — le modèle d'adressage de la cible, transcrit : vocabulaire pur, pas d'I/O. Survit comme connaissance. - `surface/` — ce que l'app touche, chaque symbole ayant un pendant cible documenté. Supprimé quand l'alias bascule ; le code de l'app est inchangé. - `emulated-verifier/` — les doublures de ce que le verifier fait nativement : possession, dépôt des caps, frontière, non-livraison, traitement des inbox, registres de branche, ouverture de repo. **C'est le dossier où diverger du modèle est possible.** Le préfixe `emulated-` porte le sens : tient lieu de, jamais est — cette bibliothèque ne réside dans aucune couche de la cible, elle les référence. - `shared-wallet/` — n'existe que parce qu'un wallet héberge toutes les identités. Aucun pendant, rien sur quoi s'aligner ; sa seule loi est de rester invisible depuis `surface/`. S'évapore, remplacé par rien. `store-registry-api.ts` devient `surface/placement.ts` : il faisait déjà à la main ce que la frontière de dossier fait structurellement — c'est la meilleure preuve interne du bien-fondé de ce rangement. Ce commit ne fait que déplacer et recâbler les imports (src, test, e2e). Les scissions des modules à cheval suivent. 157 tests unitaires, typecheck src/test/e2e vert.
108 lines
4.6 KiB
TypeScript
108 lines
4.6 KiB
TypeScript
/**
|
|
* 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 (`<PREFIX:${escapeIri(value)}>`). 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<T extends string>(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;
|
|
}
|