@factoidal/core 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,18 @@
13
13
  //
14
14
  // No Node built-ins: the browser imports this file as it is.
15
15
 
16
+ import { checkEngineStamp } from './engine-integrity.mjs';
17
+
18
+ // version.json next to the engine, or null (absent, unreadable, not JSON).
19
+ async function stampOf(url) {
20
+ try {
21
+ const r = await fetch(url);
22
+ return r.ok ? await r.json() : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
16
28
  async function instantiate(url, imports) {
17
29
  try {
18
30
  return await WebAssembly.instantiateStreaming(fetch(url), imports);
@@ -59,7 +71,22 @@ export function browserEngine(name, defaultAssetsUrl) {
59
71
  // newer loader (the loader's own locateFile does the same).
60
72
  const version = loader.wasmVersion ? `?v=${loader.wasmVersion}` : '';
61
73
  const wasmUrl = new URL(`${name}.wasm`, assetsUrl).href + version;
62
- return loader.loadL4({ instantiateWasm: (imports) => instantiate(wasmUrl, imports) });
74
+ // The release stamp check (engine-integrity.mjs, issue 1106): with a
75
+ // stamped version.json next to the full engine, its bytes are hashed
76
+ // before they are compiled (no streaming compile then). Without one,
77
+ // the streaming path as before.
78
+ const stamp = name === 'l4factoidal'
79
+ ? await stampOf(new URL('version.json', assetsUrl).href + version) : null;
80
+ if (!stamp || stamp.stamped !== true) {
81
+ return loader.loadL4({ instantiateWasm: (imports) => instantiate(wasmUrl, imports) });
82
+ }
83
+ const response = await fetch(wasmUrl);
84
+ if (!response.ok) {
85
+ throw new Error(`factoidal: fetching ${wasmUrl} failed: ${response.status} ${response.statusText}`);
86
+ }
87
+ const bytes = new Uint8Array(await response.arrayBuffer());
88
+ await checkEngineStamp(bytes, stamp, { where: wasmUrl });
89
+ return loader.loadL4({ instantiateWasm: (imports) => WebAssembly.instantiate(bytes, imports) });
63
90
  })();
64
91
  enginePromise.catch(() => { enginePromise = null; });
65
92
  return enginePromise;
@@ -0,0 +1,77 @@
1
+ // The Node and Deno host of cidRetrieveVerificationMethod (lib/cid.mjs,
2
+ // https://github.com/danbri/factoidal/issues/1100 item 9): name resolution,
3
+ // and a GET that connects to the one address the engine chose, so a second
4
+ // answer of a name server cannot move the request (the authorization
5
+ // server's fetch, authz/server.mjs). Pure I/O: the engine decides which
6
+ // URL, which address, and what the answer means
7
+ // (formal/lean4/L4Factoidal/CID/Fetch.lean). No redirect is followed.
8
+
9
+ const FETCH_TIMEOUT_MS = 10000;
10
+ const MAX_DOCUMENT_BYTES = 1048576;
11
+
12
+ /** Every address of a name, as the system resolver gives them. */
13
+ async function resolve(host) {
14
+ try {
15
+ const dns = await import('node:dns');
16
+ const found = await dns.promises.lookup(host, { all: true, verbatim: true });
17
+ return found.map((a) => a.address);
18
+ } catch (_error) {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ // A Response-like answer: status, headers.get, text().
24
+ function answer(status, contentType, body) {
25
+ return {
26
+ status,
27
+ headers: { get: (name) => (String(name).toLowerCase() === 'content-type' ? contentType : null) },
28
+ text: async () => body,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * GET `url` from `init.address`, sending `init.headers`. With no address
34
+ * (the engine named none), the system resolver connects.
35
+ */
36
+ async function fetch(url, init) {
37
+ let target;
38
+ try {
39
+ target = new URL(url);
40
+ } catch (_error) {
41
+ return answer(0, '', '');
42
+ }
43
+ const mod = target.protocol === 'https:' ? await import('node:https') : await import('node:http');
44
+ const address = init && typeof init.address === 'string' ? init.address : null;
45
+ const family = address && address.indexOf(':') >= 0 ? 6 : 4;
46
+ const options = { method: 'GET', headers: (init && init.headers) || {}, timeout: FETCH_TIMEOUT_MS };
47
+ if (address) {
48
+ options.lookup = (_host, opts, callback) => {
49
+ if (opts && opts.all) callback(null, [{ address, family }]);
50
+ else callback(null, address, family);
51
+ };
52
+ }
53
+ return new Promise((done) => {
54
+ const request = mod.request(target, options, (response) => {
55
+ const chunks = [];
56
+ let size = 0;
57
+ response.on('data', (chunk) => {
58
+ size += chunk.length;
59
+ if (size > MAX_DOCUMENT_BYTES) {
60
+ response.destroy();
61
+ done(answer(0, '', ''));
62
+ return;
63
+ }
64
+ chunks.push(chunk);
65
+ });
66
+ response.on('end', () => done(answer(response.statusCode || 0,
67
+ String(response.headers['content-type'] || ''), Buffer.concat(chunks).toString('utf8'))));
68
+ response.on('error', () => done(answer(0, '', '')));
69
+ });
70
+ request.on('timeout', () => request.destroy(new Error('timeout')));
71
+ request.on('error', () => done(answer(0, '', '')));
72
+ request.end();
73
+ });
74
+ }
75
+
76
+ /** The host index.mjs passes to the API. */
77
+ export const nodeCidHost = Object.freeze({ resolve, fetch });
package/lib/cid.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // Controlled Identifiers v1.0 (https://www.w3.org/TR/cid-1.0/):
2
+ // cidValidate and cidRetrieveVerificationMethod
3
+ // (https://github.com/danbri/factoidal/issues/1100, items 7 and 9).
4
+ //
5
+ // The engine decides everything (formal/lean4/L4Factoidal/CID/Validate.lean,
6
+ // CID/Fetch.lean, over the ops cidValidate and cidRetrieve of
7
+ // formal/lean4/Wasm/Ops/Cid.lean): the §2 checks; which URL to fetch; whether
8
+ // its scheme and addresses may be fetched (the private-address guard of the
9
+ // authorization server); whether the status and the Content-Type of the
10
+ // answer are accepted; §3.3; expiry and revocation with `now`. This file
11
+ // moves bytes: it resolves the host name the engine names, GETs the URL the
12
+ // engine names from the address the engine names, and passes the status,
13
+ // the Content-Type and the body back.
14
+ //
15
+ // ES module, no Node built-ins: the browser loads this file as it is. The
16
+ // Node and Deno host functions (name resolution, a GET pinned to one
17
+ // address) are lib/cid-node.mjs, which index.mjs passes in.
18
+
19
+ import { FactoidalError } from './errors.mjs';
20
+
21
+ // The engine names at most one resolution and one fetch; a longer loop is
22
+ // a defect, not a document.
23
+ const MAX_STEPS = 4;
24
+
25
+ /**
26
+ * The browser host: no name resolution (a page cannot resolve names; the
27
+ * engine then checks only IP literals and localhost names), and the page's
28
+ * fetch, which does not follow redirects and sends no credentials.
29
+ */
30
+ export const browserCidHost = Object.freeze({
31
+ resolve: null,
32
+ fetch: (url, init) => globalThis.fetch(url, {
33
+ method: 'GET',
34
+ headers: init.headers,
35
+ redirect: 'manual',
36
+ credentials: 'omit',
37
+ }),
38
+ });
39
+
40
+ // The status, Content-Type and body of a fetch answer: a WHATWG Response,
41
+ // or an object of the same shape.
42
+ async function answerOf(url, response) {
43
+ const status = Number(response && response.status) || 0;
44
+ const headers = response && response.headers;
45
+ const contentType = headers && typeof headers.get === 'function'
46
+ ? (headers.get('content-type') || '') : '';
47
+ const body = response && typeof response.text === 'function' ? String(await response.text()) : '';
48
+ return { url, status, contentType, body };
49
+ }
50
+
51
+ function nowSeconds(now) {
52
+ if (now === undefined || now === null) return undefined;
53
+ if (now instanceof Date) return Math.floor(now.getTime() / 1000);
54
+ if (typeof now === 'number' && Number.isFinite(now)) return Math.floor(now);
55
+ throw new TypeError('cidRetrieveVerificationMethod: options.now must be a Date or a number of seconds');
56
+ }
57
+
58
+ /**
59
+ * The two functions over an entry (lib/lean-entry.mjs) and a host.
60
+ * @param {object} deps
61
+ * @param {() => Promise<object>} deps.entry
62
+ * @param {(jsonText: string, what: string) => object} deps.entryResult
63
+ * @param {(e: object, fn: string, what: string) => void} deps.requireEntryFn
64
+ * @param {{resolve: ((host: string) => Promise<string[]>)|null, fetch: Function}} [deps.host]
65
+ */
66
+ export function cidFunctions({ entry, entryResult, requireEntryFn, host }) {
67
+ const defaults = host || browserCidHost;
68
+
69
+ /**
70
+ * CID 1.0 §2: check a controlled identifier document.
71
+ * @param {string|object} doc JSON text, or a value JSON.stringify writes
72
+ * @returns {Promise<{valid: boolean, errors: Array<{code: string, message: string, path: string}>}>}
73
+ */
74
+ async function cidValidate(doc) {
75
+ const text = typeof doc === 'string' ? doc : JSON.stringify(doc);
76
+ if (typeof text !== 'string') throw new TypeError('cidValidate: doc must be JSON text or a JSON value');
77
+ const e = await entry();
78
+ requireEntryFn(e, 'cidValidate', 'CID 1.0 document check');
79
+ const r = entryResult(e.cidValidate(text), 'cidValidate');
80
+ return { valid: r.valid === true, errors: Array.isArray(r.errors) ? r.errors : [] };
81
+ }
82
+
83
+ /**
84
+ * CID 1.0 §3.3 Retrieve Verification Method, with the fetch done here.
85
+ * @param {string} url the verification method URL
86
+ * @param {string} relationship a verification relationship of §2.3
87
+ * @param {object} [options]
88
+ * @returns {Promise<{ok: true, verificationMethod: object, controllerDocument: string}
89
+ * | {ok: false, code: string, problem: {type: string, title: string, detail: string}}>}
90
+ */
91
+ async function cidRetrieveVerificationMethod(url, relationship, options) {
92
+ if (typeof url !== 'string' || typeof relationship !== 'string') {
93
+ throw new TypeError('cidRetrieveVerificationMethod: url and relationship must be strings');
94
+ }
95
+ const o = options || {};
96
+ const fetchFn = typeof o.fetch === 'function' ? o.fetch : defaults.fetch;
97
+ // `resolve: null` says the caller cannot resolve names.
98
+ const resolveFn = 'resolve' in o ? o.resolve : defaults.resolve;
99
+ const request = {
100
+ vm: url,
101
+ relationship,
102
+ allowHttp: o.allowHttp === true,
103
+ allowPrivateFetch: o.allowPrivateFetch === true,
104
+ resolver: typeof resolveFn === 'function',
105
+ resolved: [],
106
+ fetched: [],
107
+ };
108
+ const now = nowSeconds(o.now);
109
+ if (now !== undefined) request.now = now;
110
+ const e = await entry();
111
+ requireEntryFn(e, 'cidRetrieve', 'CID 1.0 Retrieve Verification Method');
112
+ for (let i = 0; i < MAX_STEPS; i += 1) {
113
+ const r = entryResult(e.cidRetrieve(JSON.stringify(request)), 'cidRetrieve');
114
+ if (r.step === 'method') {
115
+ return { ok: true, verificationMethod: r.verificationMethod, controllerDocument: r.controllerDocument };
116
+ }
117
+ if (r.step === 'error') return { ok: false, code: r.code, problem: r.problem };
118
+ if (r.step === 'resolve') {
119
+ let addresses = [];
120
+ try {
121
+ const got = await resolveFn(r.host);
122
+ addresses = Array.isArray(got) ? got.map(String) : [];
123
+ } catch (_error) {
124
+ addresses = [];
125
+ }
126
+ request.resolved.push({ host: r.host, addresses });
127
+ } else if (r.step === 'fetch') {
128
+ let answer;
129
+ try {
130
+ const init = { method: 'GET', headers: { accept: r.accept }, redirect: 'manual' };
131
+ if (r.address !== null && r.address !== undefined) init.address = r.address;
132
+ answer = await answerOf(r.url, await fetchFn(r.url, init));
133
+ } catch (_error) {
134
+ answer = { url: r.url, status: 0, contentType: '', body: '' };
135
+ }
136
+ request.fetched.push(answer);
137
+ } else {
138
+ break;
139
+ }
140
+ }
141
+ throw new FactoidalError('cidRetrieveVerificationMethod: the engine asked for more steps than one retrieval needs',
142
+ { code: 'op.failed' });
143
+ }
144
+
145
+ return { cidValidate, cidRetrieveVerificationMethod };
146
+ }
@@ -0,0 +1,126 @@
1
+ // cipher-host.mjs — the JavaScript host side of the AES call-out
2
+ // (https://github.com/danbri/factoidal/issues/1081,
3
+ // https://github.com/danbri/factoidal/issues/677; design record
4
+ // docs/designissues/2026-10-27-xml-encryption-and-jwe.md, section "The AES
5
+ // call-out on JavaScript hosts"; request format
6
+ // formal/lean4/L4Factoidal/Crypto/CipherCall.lean).
7
+ //
8
+ // The engine (Lean, in the wasm) makes every decision: the algorithm, the key,
9
+ // the IV, the lengths (`Req.wellFormed`), the document structure. When it needs
10
+ // AES it answers `{need: {op, key, iv, aad, data}}` (bytes as base64). This
11
+ // module runs exactly that one WebCrypto operation and gives back
12
+ // `{ok: base64}` or `{refused: true}`. It reads no document, chooses no
13
+ // algorithm and checks no length. Every failure becomes the same refusal with
14
+ // no message, so no error text of the host's crypto library reaches the caller
15
+ // (padding oracle, Jager and Somorovsky 2011).
16
+ //
17
+ // Only `globalThis.crypto.subtle` is used, so the same code runs in Node 22,
18
+ // Deno and browsers (Node: OpenSSL; Chromium: BoringSSL; Firefox: NSS).
19
+ //
20
+ // PBKDF2 (https://github.com/danbri/factoidal/issues/1108, the login of
21
+ // `factoidal webdav-serve` and `factoidal webdav-user`; owner, 2026-10-28:
22
+ // PBKDF2-HMAC-SHA256 from WebCrypto, as AES): the engine answers
23
+ // `{need: {op: 'pbkdf2-hmac-sha256', key, data, iterations, bits}}` with the
24
+ // password as `key` and the salt as `data`. This module runs exactly that
25
+ // `deriveBits` call; Lean chose the salt, the count and the output size
26
+ // (formal/lean4/L4Factoidal/WebDAV/Login.lean) and compares the result.
27
+ //
28
+ // Key Wrap: WebCrypto wraps a CryptoKey, not bytes. The bytes to wrap are
29
+ // imported as an extractable raw HMAC key (any byte length), wrapped with
30
+ // `wrapKey('raw', ...)`; an unwrap gives an HMAC key whose raw export is the
31
+ // unwrapped bytes.
32
+
33
+ const subtle = () => globalThis.crypto.subtle;
34
+
35
+ /** The most requests one engine operation may make (CipherCall.maxRequests). */
36
+ export const MAX_CIPHER_REQUESTS = 64;
37
+
38
+ function fromBase64(s) {
39
+ const bin = atob(s ?? '');
40
+ const out = new Uint8Array(bin.length);
41
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
42
+ return out;
43
+ }
44
+
45
+ function toBase64(buf) {
46
+ const b = new Uint8Array(buf);
47
+ let bin = '';
48
+ for (let i = 0; i < b.length; i += 0x8000) {
49
+ bin += String.fromCharCode.apply(null, b.subarray(i, i + 0x8000));
50
+ }
51
+ return btoa(bin);
52
+ }
53
+
54
+ const HMAC = { name: 'HMAC', hash: 'SHA-256' };
55
+
56
+ // One entry per op name of CipherCall.Op.name: the WebCrypto call, nothing more.
57
+ const OPS = {
58
+ 'aes-gcm-encrypt': async (k, iv, aad, d) => {
59
+ const key = await subtle().importKey('raw', k, 'AES-GCM', false, ['encrypt']);
60
+ return subtle().encrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, key, d);
61
+ },
62
+ 'aes-gcm-decrypt': async (k, iv, aad, d) => {
63
+ const key = await subtle().importKey('raw', k, 'AES-GCM', false, ['decrypt']);
64
+ return subtle().decrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, key, d);
65
+ },
66
+ 'aes-kw-wrap': async (k, _iv, _aad, d) => {
67
+ const kek = await subtle().importKey('raw', k, 'AES-KW', false, ['wrapKey']);
68
+ const inner = await subtle().importKey('raw', d, HMAC, true, ['sign']);
69
+ return subtle().wrapKey('raw', inner, kek, 'AES-KW');
70
+ },
71
+ 'aes-kw-unwrap': async (k, _iv, _aad, d) => {
72
+ const kek = await subtle().importKey('raw', k, 'AES-KW', false, ['unwrapKey']);
73
+ const inner = await subtle().unwrapKey('raw', d, kek, 'AES-KW', HMAC, true, ['sign']);
74
+ return subtle().exportKey('raw', inner);
75
+ },
76
+ 'aes-cbc-encrypt': async (k, iv, _aad, d) => {
77
+ const key = await subtle().importKey('raw', k, 'AES-CBC', false, ['encrypt']);
78
+ return subtle().encrypt({ name: 'AES-CBC', iv }, key, d);
79
+ },
80
+ 'aes-cbc-decrypt': async (k, iv, _aad, d) => {
81
+ const key = await subtle().importKey('raw', k, 'AES-CBC', false, ['decrypt']);
82
+ return subtle().decrypt({ name: 'AES-CBC', iv }, key, d);
83
+ },
84
+ 'pbkdf2-hmac-sha256': async (k, _iv, _aad, d, req) => {
85
+ const key = await subtle().importKey('raw', k, 'PBKDF2', false, ['deriveBits']);
86
+ return subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: d, iterations: req.iterations }, key, req.bits);
87
+ },
88
+ };
89
+
90
+ /**
91
+ * Answer one cipher request of the engine (AES, or the PBKDF2 of a login).
92
+ * @param {{op: string, key: string, iv?: string, aad?: string, data: string, iterations?: number, bits?: number}} req bytes as base64
93
+ * @returns {Promise<{ok: string} | {refused: true}>}
94
+ */
95
+ export async function answerCipherRequest(req) {
96
+ try {
97
+ const run = Object.prototype.hasOwnProperty.call(OPS, req?.op) ? OPS[req.op] : null;
98
+ if (!run) return { refused: true };
99
+ const out = await run(fromBase64(req.key), fromBase64(req.iv), fromBase64(req.aad), fromBase64(req.data), req);
100
+ return { ok: toBase64(out) };
101
+ } catch {
102
+ return { refused: true };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Run an engine operation that may ask for AES. `callOp(args)` calls the wasm
108
+ * op (sync or async) with `args` plus `answers`; while its result has `need`,
109
+ * the request is answered and the op is called again with the same inputs and
110
+ * the answer list extended by `{request, answer}`. The engine's format code is
111
+ * deterministic, so each call gets one request further.
112
+ * @param {(args: object) => any} callOp
113
+ * @param {object} args
114
+ */
115
+ export async function runWithCipher(callOp, args) {
116
+ const answers = [];
117
+ for (let asked = 0; ; asked++) {
118
+ const res = await callOp({ ...args, answers: answers.slice() });
119
+ if (!res || res.need === undefined) return res;
120
+ // The request bound is the one check the host makes (design record step 4).
121
+ if (asked === MAX_CIPHER_REQUESTS) {
122
+ throw new Error(`runWithCipher: more than ${MAX_CIPHER_REQUESTS} cipher requests`);
123
+ }
124
+ answers.push({ request: res.need, answer: await answerCipherRequest(res.need) });
125
+ }
126
+ }
package/lib/dataset.mjs CHANGED
@@ -405,22 +405,44 @@ class Dataset {
405
405
  termToJson(graph)]);
406
406
  }
407
407
 
408
- /** RDF/JS DatasetCore.has: one indexed lookup in the engine. */
409
- has(quad) {
408
+ // The options argument of has, match and delete: `{match: 'term'|'value'}`,
409
+ // checked by the engine (op apiCheck). '' for the term match, which every
410
+ // engine serves with the two-argument ops.
411
+ _matchMode(who, options) {
412
+ if (options === undefined || options === null) return '';
413
+ checkOptions(this._ctx.e, `Dataset.${who}`, options);
414
+ if (options.match === undefined || options.match === null) return '';
415
+ const mode = String(options.match).toLowerCase();
416
+ return mode === 'term' ? '' : JSON.stringify({ match: mode });
417
+ }
418
+
419
+ /**
420
+ * RDF/JS DatasetCore.has: one indexed lookup in the engine. With
421
+ * `{match: 'value'}` a literal object matches every object literal of the
422
+ * same value (sameValue(); https://github.com/danbri/factoidal/issues/1052).
423
+ */
424
+ has(quad, options) {
410
425
  this._open('has');
411
- const r = envelope(this._ctx.e.datasetMatchCount(this._handle,
412
- this._pattern(quad.subject, quad.predicate, quad.object, quad.graph || { termType: 'DefaultGraph' })), 'has');
426
+ const mode = this._matchMode('has', options);
427
+ const pattern = this._pattern(quad.subject, quad.predicate, quad.object, quad.graph || { termType: 'DefaultGraph' });
428
+ const r = envelope(mode
429
+ ? this._ctx.e.datasetMatchCount(this._handle, pattern, mode)
430
+ : this._ctx.e.datasetMatchCount(this._handle, pattern), 'has');
413
431
  return r.count > 0;
414
432
  }
415
433
 
416
434
  /**
417
435
  * RDF/JS DatasetCore.match: the matching quads as a new Dataset, found
418
- * through the engine's indexes. `null`/`undefined` matches any term.
436
+ * through the engine's indexes. `null`/`undefined` matches any term. With
437
+ * `{match: 'value'}` a literal object matches by value (as has()).
419
438
  */
420
- match(subject, predicate, object, graph) {
439
+ match(subject, predicate, object, graph, options) {
421
440
  this._open('match');
422
- const r = envelope(this._ctx.e.datasetMatch(this._handle,
423
- this._pattern(subject, predicate, object, graph)), 'match');
441
+ const mode = this._matchMode('match', options);
442
+ const pattern = this._pattern(subject, predicate, object, graph);
443
+ const r = envelope(mode
444
+ ? this._ctx.e.datasetMatch(this._handle, pattern, mode)
445
+ : this._ctx.e.datasetMatch(this._handle, pattern), 'match');
424
446
  return this._ctx.wrap(r, this._prefixes);
425
447
  }
426
448
 
@@ -438,15 +460,77 @@ class Dataset {
438
460
  return this;
439
461
  }
440
462
 
441
- /** RDF/JS DatasetCore.delete: remove one quad. */
442
- delete(quad) {
463
+ /**
464
+ * RDF/JS DatasetCore.delete: remove one quad. With `{match: 'value'}` a
465
+ * literal object removes every quad of the same subject, predicate and
466
+ * graph whose object has the same value (as has()).
467
+ */
468
+ delete(quad, options) {
443
469
  this._open('delete');
444
- const r = envelope(this._ctx.e.datasetDeleteQuadsJson(this._handle,
445
- JSON.stringify([quadToJson(quad)])), 'delete');
470
+ const mode = this._matchMode('delete', options);
471
+ const quads = JSON.stringify([quadToJson(quad)]);
472
+ const r = envelope(mode
473
+ ? this._ctx.e.datasetDeleteQuadsJson(this._handle, quads, mode)
474
+ : this._ctx.e.datasetDeleteQuadsJson(this._handle, quads), 'delete');
446
475
  this._changed(r.count);
447
476
  return this;
448
477
  }
449
478
 
479
+ // RDF lists and containers (https://github.com/danbri/factoidal/issues/1051
480
+ // item 6). The engine walks and builds them (L4Factoidal.RDF.Collections).
481
+ // `graph`: null/undefined or the DefaultGraph for the default graph, else
482
+ // the graph name.
483
+ _collectionOp(op, who) {
484
+ this._open(who);
485
+ if (typeof this._ctx.e[op] !== 'function') {
486
+ throw new FactoidalError(`Dataset.${who}: the loaded engine has no op '${op}'; it is older than this package`,
487
+ { code: 'op.unknown' });
488
+ }
489
+ return this._ctx.e[op].bind(this._ctx.e);
490
+ }
491
+
492
+ _graphArg(graph) {
493
+ return graph === undefined || graph === null ? '' : JSON.stringify(termToJson(graph));
494
+ }
495
+
496
+ /**
497
+ * The items of the RDF list (rdf:first / rdf:rest) at `head`, in order.
498
+ * rdf:nil is the empty list. A list that is not well formed (a node with no
499
+ * or two rdf:first or rdf:rest, or a cycle) is a FactoidalError (op.failed)
500
+ * whose message names the reason and the node.
501
+ */
502
+ listItems(head, graph) {
503
+ const op = this._collectionOp('datasetListItems', 'listItems');
504
+ const r = envelope(op(this._handle, JSON.stringify(termToJson(head)), this._graphArg(graph)), 'listItems');
505
+ return r.items.map(termFromSrj);
506
+ }
507
+
508
+ /**
509
+ * The members rdf:_1, rdf:_2, ... of `node`, sorted by n, and its kind
510
+ * (rdf:Bag, rdf:Seq or rdf:Alt from its rdf:type, or null).
511
+ * @returns {{kind: NamedNode|null, items: Array<{ordinal: number, term: Term}>}}
512
+ */
513
+ containerItems(node, graph) {
514
+ const op = this._collectionOp('datasetContainerItems', 'containerItems');
515
+ const r = envelope(op(this._handle, JSON.stringify(termToJson(node)), this._graphArg(graph)), 'containerItems');
516
+ return {
517
+ kind: r.kind ? termFromSrj(r.kind) : null,
518
+ items: r.items.map((m) => ({ ordinal: m.ordinal, term: termFromSrj(m.term) })),
519
+ };
520
+ }
521
+
522
+ /**
523
+ * Add a new RDF list of `items` (new blank nodes, fresh in this Dataset)
524
+ * and answer its head; the empty list is rdf:nil and adds nothing.
525
+ */
526
+ addList(items, graph) {
527
+ const op = this._collectionOp('datasetAddList', 'addList');
528
+ const r = envelope(op(this._handle, JSON.stringify([...items].map((t) => termToJson(t))),
529
+ this._graphArg(graph)), 'addList');
530
+ this._changed(r.count);
531
+ return termFromSrj(r.head);
532
+ }
533
+
450
534
  /** Release the engine copy. Idempotent. */
451
535
  dispose() {
452
536
  if (this._disposed) return;
@@ -0,0 +1,56 @@
1
+ // The load-time check of the engine against its release stamp
2
+ // (https://github.com/danbri/factoidal/issues/1106, layer 1).
3
+ //
4
+ // A release stamps l4-assets/version.json with the SHA-256 and the size of
5
+ // l4factoidal.wasm (tools/stamp-l4-version.mjs). Before an engine is
6
+ // instantiated, its bytes are hashed and compared with the stamp: a stamped
7
+ // copy whose bytes differ does not load. An unstamped copy (a source
8
+ // checkout, a local build that did not stamp) loads as before.
9
+ //
10
+ // This catches accidents (a stale wasm next to a new loader, a partial
11
+ // upgrade, a hand edit). It is not a defence against someone who can edit
12
+ // the package: they can edit this file too. See issue 1106 for the checks
13
+ // made from outside the package.
14
+ //
15
+ // ES module, no Node built-ins: Node, Deno and the browser load it as it is.
16
+ // The digest is Web Crypto's SHA-256 (globalThis.crypto.subtle).
17
+
18
+ /** Lowercase hex SHA-256 of `bytes` (a Uint8Array or an ArrayBuffer). */
19
+ export async function sha256Hex(bytes) {
20
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
21
+ if (!subtle) throw new Error('factoidal: no Web Crypto SHA-256 to check the engine with');
22
+ const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
23
+ let hex = '';
24
+ for (const b of digest) hex += b.toString(16).padStart(2, '0');
25
+ return hex;
26
+ }
27
+
28
+ /**
29
+ * Check engine bytes against a version.json stamp.
30
+ * @param {Uint8Array|ArrayBuffer} bytes the wasm
31
+ * @param {object|null} stamp the parsed version.json, or null when absent
32
+ * @param {{where?: string, allowModified?: boolean, warn?: (msg: string) => void}} [opts]
33
+ * @returns {Promise<{checked: boolean, sha256?: string}>} `checked` false for
34
+ * an absent or unstamped version.json
35
+ */
36
+ export async function checkEngineStamp(bytes, stamp, opts = {}) {
37
+ if (!stamp || stamp.stamped !== true || typeof stamp.wasmSha256 !== 'string') {
38
+ return { checked: false };
39
+ }
40
+ const size = bytes.byteLength;
41
+ const sha256 = await sha256Hex(bytes);
42
+ const sizeOk = !Number.isInteger(stamp.wasmBytes) || stamp.wasmBytes === size;
43
+ if (sha256 === stamp.wasmSha256.toLowerCase() && sizeOk) return { checked: true, sha256 };
44
+ const where = opts.where || 'l4factoidal.wasm';
45
+ const msg = `factoidal: ${where} is not the engine of this release: version.json ` +
46
+ `(build ${stamp.release ?? stamp.gitSha ?? '?'}) says sha256 ${stamp.wasmSha256}, ${stamp.wasmBytes ?? '?'} bytes; ` +
47
+ `the file has sha256 ${sha256}, ${size} bytes. Reinstall @factoidal/core, ` +
48
+ 'or set FACTOIDAL_ALLOW_MODIFIED_ENGINE=1 to load a changed engine on purpose.';
49
+ if (opts.allowModified) {
50
+ (opts.warn || (() => {}))(`${msg} Loading it because FACTOIDAL_ALLOW_MODIFIED_ENGINE is set.`);
51
+ return { checked: false, sha256 };
52
+ }
53
+ const err = new Error(msg);
54
+ err.code = 'engine.modified';
55
+ throw err;
56
+ }
@@ -63,22 +63,40 @@ function failEnvelope(err) {
63
63
  return JSON.stringify({ ok: false, code: 'op.failed', error: String((err && err.message) || err) });
64
64
  }
65
65
 
66
+ /**
67
+ * A JavaScript value tagged with its JavaScript type, {js, v}, for the engine
68
+ * (Wasm/Ops/ExtFns.lean, Wasm/Ops/LiteralValue.lean). This copies the value
69
+ * as text; the engine chooses the datatype and the lexical form
70
+ * (L4Factoidal.XSD.JsValue.jsLiteral, issue 894 item 3, issue 1051 item 1).
71
+ * A number is its Number::toString text ('-0' for negative zero); a Date is
72
+ * the text of getTime() ('NaN' for an invalid Date). Any other value: null.
73
+ * @param {unknown} value
74
+ * @returns {{js: string, v: string|boolean} | null}
75
+ */
76
+ export function encodeJsValue(value) {
77
+ switch (typeof value) {
78
+ case 'boolean': return { js: 'boolean', v: value };
79
+ case 'number': return { js: 'number', v: Object.is(value, -0) ? '-0' : String(value) };
80
+ case 'bigint': return { js: 'bigint', v: value.toString() };
81
+ case 'string': return { js: 'string', v: value };
82
+ case 'object':
83
+ if (value instanceof Date) return { js: 'date', v: String(value.getTime()) };
84
+ return null;
85
+ default: return null;
86
+ }
87
+ }
88
+
66
89
  // One answer for Wasm/Ops/ExtFns.lean: the JavaScript value tagged with its
67
- // JavaScript type ({js, v}; a number as its Number::toString text), or a term
68
- // object. The engine chooses the datatype and the lexical form
69
- // (L4Factoidal.XSD.JsValue.jsLiteral, issue 894 item 3). '' means "no
70
- // value" (the section 17.6 error).
90
+ // JavaScript type (encodeJsValue), or a term object. '' means "no value" (the
91
+ // section 17.6 error).
71
92
  function encodeExtAnswer(out) {
72
93
  if (out === null || out === undefined || out === EXT_PENDING_MARKER) return '';
73
- switch (typeof out) {
74
- case 'boolean': return JSON.stringify({ js: 'boolean', v: out });
75
- case 'number': return JSON.stringify({ js: 'number', v: Object.is(out, -0) ? '-0' : String(out) });
76
- case 'bigint': return JSON.stringify({ js: 'bigint', v: out.toString() });
77
- case 'string': return JSON.stringify({ js: 'string', v: out });
78
- case 'object':
79
- try { return JSON.stringify(appTermToJson(out)); } catch (_error) { return ''; }
80
- default: return '';
94
+ const tagged = encodeJsValue(out);
95
+ if (tagged) return JSON.stringify(tagged);
96
+ if (typeof out === 'object') {
97
+ try { return JSON.stringify(appTermToJson(out)); } catch (_error) { return ''; }
81
98
  }
99
+ return '';
82
100
  }
83
101
 
84
102
  // The Lean module asks its host through ONE global,