@nightpainters/dsh-ling 1.6.2 → 1.7.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.
@@ -0,0 +1,552 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { constants, realpathSync, statSync } from 'node:fs';
4
+ import { open, realpath, stat } from 'node:fs/promises';
5
+ import { request } from 'node:https';
6
+ import { isIP } from 'node:net';
7
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+ import { performance } from 'node:perf_hooks';
10
+ import { normalizeLibraryLimits, normalizeTextLf, resolveJsonPointer } from './library-store.js';
11
+
12
+ const LIMIT_KEYS = Object.freeze([
13
+ 'sourceMaxBytes', 'selectedTextMaxBytes', 'selectedTextMaxLines', 'checkTimeoutMs', 'checkConcurrency',
14
+ ]);
15
+ const CONFIG_KEYS = new Set(['codeRoots', 'officialUrls', 'limits', 'resolver', 'requester', ...LIMIT_KEYS]);
16
+ const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
17
+ const plain = (value) => value !== null && typeof value === 'object'
18
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
19
+ const sha256 = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
20
+
21
+ class SourceFailure extends Error {
22
+ constructor(code, cancelled = false) {
23
+ super(code);
24
+ this.code = code;
25
+ this.cancelled = cancelled;
26
+ }
27
+ }
28
+ const refuse = (reason, extra = {}) => ({ ok: false, state: 'unknown', recorded: false, reason, ...extra });
29
+ const stop = (signal, deadline) => {
30
+ if (signal.aborted) throw signal.reason;
31
+ if (deadline !== undefined && performance.now() >= deadline) throw new SourceFailure('timeout');
32
+ };
33
+
34
+ // A trusted injected hook cannot hold a check open by ignoring cancellation.
35
+ function abortable(promise, signal) {
36
+ if (signal.aborted) return Promise.reject(signal.reason);
37
+ return new Promise((resolvePromise, reject) => {
38
+ const onAbort = () => reject(signal.reason);
39
+ signal.addEventListener('abort', onAbort, { once: true });
40
+ Promise.resolve(promise).then(resolvePromise, reject).finally(() => {
41
+ signal.removeEventListener('abort', onAbort);
42
+ });
43
+ });
44
+ }
45
+
46
+ // ⚠️ 下面三个是**导出**的(2026-10-15):SQLite 接入通道(library-sqlite.js)要用**同一套**根校验。
47
+ // 复制第二份的代价是"将来会漂"——根校验只该有一套实现(选项文档 §11.6 B.2 拍板:导出复用)。
48
+ export function contained(root, candidate) {
49
+ const rest = relative(root, candidate);
50
+ return rest === '' || (!isAbsolute(rest) && rest !== '..' && !rest.startsWith(`..${sep}`));
51
+ }
52
+
53
+ export function localPath(value) {
54
+ if (typeof value !== 'string' || value === '' || value.includes('\0') || !isAbsolute(value)) return false;
55
+ if (value.split(/[\\/]/).includes('..')) return false;
56
+ if (process.platform === 'win32') {
57
+ if (!/^[a-z]:[\\/]/i.test(value) || value.slice(2).includes(':')) return false;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ export function normalizeRoots(raw = []) {
63
+ if (!Array.isArray(raw) || raw.length > 256) throw new TypeError('codeRoots must be an array of at most 256 absolute local directories');
64
+ return Object.freeze(raw.map((entry) => {
65
+ if (!localPath(entry)) throw new TypeError('codeRoots must contain absolute local paths without traversal');
66
+ const path = resolve(entry);
67
+ // Use the same native resolution as fs.promises.realpath (not the JS
68
+ // realpathSync fallback, which can preserve Windows junction spelling).
69
+ const real = realpathSync.native(path);
70
+ if (!statSync(real).isDirectory()) throw new TypeError('codeRoots entries must be existing directories');
71
+ return Object.freeze({ path, real });
72
+ }));
73
+ }
74
+
75
+ function ipv6Words(address) {
76
+ const sides = address.split('::');
77
+ const left = sides[0] ? sides[0].split(':') : [];
78
+ const right = sides.length === 2 && sides[1] ? sides[1].split(':') : [];
79
+ const words = sides.length === 2 ? [...left, ...Array(8 - left.length - right.length).fill('0'), ...right] : left;
80
+ return words.map((part) => Number.parseInt(part, 16));
81
+ }
82
+
83
+ function publicAddress(address) {
84
+ if (typeof address !== 'string' || address.includes('%')) return false;
85
+ const family = isIP(address);
86
+ if (family === 4) {
87
+ const [a, b, c] = address.split('.').map(Number);
88
+ return !(a === 0 || a === 10 || a === 127 || a >= 224
89
+ || (a === 100 && b >= 64 && b <= 127)
90
+ || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
91
+ || (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)) || (b === 88 && c === 99)))
92
+ || (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100)))
93
+ || (a === 203 && b === 0 && c === 113));
94
+ }
95
+ if (family === 6 && !address.includes('.')) {
96
+ const [a, b] = ipv6Words(address);
97
+ // Only global unicast, excluding special-use, documentation and 6to4 ranges.
98
+ return a >= 0x2000 && a <= 0x3fff && a !== 0x2002
99
+ && !(a === 0x2001 && (b < 0x0200 || b === 0x0db8))
100
+ && !(a === 0x3fff && b < 0x1000);
101
+ }
102
+ return false;
103
+ }
104
+
105
+ function authorizedUrl(value) {
106
+ if (typeof value !== 'string' || value === '' || Buffer.byteLength(value) > 4096
107
+ || /[\s\\\u0000-\u001f\u007f]/.test(value)) throw new SourceFailure('invalid_url');
108
+ let url;
109
+ try { url = new URL(value); } catch { throw new SourceFailure('invalid_url'); }
110
+ if (url.protocol !== 'https:') throw new SourceFailure('https_required');
111
+ if (url.username || url.password) throw new SourceFailure('url_credentials_forbidden');
112
+ const host = url.hostname.replace(/^\[|\]$/g, '');
113
+ if (!host || host.endsWith('.') || host === 'localhost' || host.endsWith('.localhost')
114
+ || host.endsWith('.local') || host.endsWith('.internal')) throw new SourceFailure('private_target');
115
+ if (isIP(host) && !publicAddress(host)) throw new SourceFailure('private_target');
116
+ return url;
117
+ }
118
+
119
+ function normalizeUrls(raw = []) {
120
+ if (!Array.isArray(raw) || raw.length > 1000) throw new TypeError('officialUrls must be an array of at most 1000 exact HTTPS URLs');
121
+ return new Set(raw.map((value) => {
122
+ try { return authorizedUrl(value).href; } catch (error) { throw new TypeError(`officialUrls: ${error.code}`); }
123
+ }));
124
+ }
125
+
126
+ function normalizeConfig(store, config) {
127
+ if (!plain(config)) throw new TypeError('checker config must be a plain object');
128
+ for (const key of Object.keys(config)) {
129
+ if (!CONFIG_KEYS.has(key)) throw new RangeError(`unknown checker config key: ${key}`);
130
+ }
131
+ const overrides = config.limits ?? {};
132
+ if (!plain(overrides)) throw new TypeError('checker limits must be a plain object');
133
+ for (const key of Object.keys(overrides)) {
134
+ if (!LIMIT_KEYS.includes(key)) throw new RangeError(`unknown checker limit: ${key}`);
135
+ }
136
+ const supplied = { ...overrides };
137
+ for (const key of LIMIT_KEYS) {
138
+ if (!own(config, key)) continue;
139
+ if (own(supplied, key)) throw new RangeError(`duplicate checker limit: ${key}`);
140
+ supplied[key] = config[key];
141
+ }
142
+ const normalized = normalizeLibraryLimits({ ...store.limits, ...supplied });
143
+ const limits = Object.freeze(Object.fromEntries(LIMIT_KEYS.map((key) => [key, normalized[key]])));
144
+ for (const key of ['resolver', 'requester']) {
145
+ if (config[key] !== undefined && typeof config[key] !== 'function') throw new TypeError(`${key} must be a function`);
146
+ }
147
+ return {
148
+ limits, roots: normalizeRoots(config.codeRoots), urls: normalizeUrls(config.officialUrls),
149
+ resolver: config.resolver ?? defaultResolver, requester: config.requester ?? defaultRequester,
150
+ injected: config.resolver !== undefined || config.requester !== undefined,
151
+ };
152
+ }
153
+
154
+ /** Validate administrator configuration before any database is opened. */
155
+ export function validateLibraryCheckerConfig(config = {}, limits = {}) {
156
+ normalizeConfig({ limits }, config);
157
+ }
158
+
159
+ async function defaultResolver(hostname) {
160
+ return lookup(hostname, { all: true, verbatim: true });
161
+ }
162
+
163
+ function comparableAddress(address) {
164
+ const lower = address.toLowerCase();
165
+ if (lower.startsWith('::ffff:') && isIP(lower.slice(7)) === 4) return lower.slice(7);
166
+ return isIP(lower) === 6 ? ipv6Words(lower).join(':') : lower;
167
+ }
168
+
169
+ // No agent pooling, redirects, proxy environment handling or second DNS lookup.
170
+ function defaultRequester(url, { address, family, signal }) {
171
+ return new Promise((resolvePromise, reject) => {
172
+ const req = request(url, {
173
+ method: 'GET', agent: false, signal,
174
+ headers: { Accept: 'application/json, text/plain, text/markdown, text/csv', 'Accept-Encoding': 'identity' },
175
+ lookup: (_host, options, callback) => {
176
+ if (options?.all) callback(null, [{ address, family }]);
177
+ else callback(null, address, family);
178
+ },
179
+ }, (response) => {
180
+ resolvePromise({ statusCode: response.statusCode, headers: response.headers, body: response, url: url.href });
181
+ });
182
+ req.once('error', reject);
183
+ req.on('socket', (socket) => {
184
+ socket.once('secureConnect', () => {
185
+ const remote = socket.remoteAddress;
186
+ if (!remote || comparableAddress(remote) !== comparableAddress(address)) {
187
+ req.destroy(new SourceFailure('connection_address_mismatch'));
188
+ }
189
+ });
190
+ });
191
+ req.end();
192
+ });
193
+ }
194
+
195
+ function sameFile(left, right) {
196
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size
197
+ && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
198
+ }
199
+
200
+ async function verifyFilePath(path, handle, before, root, signal, deadline) {
201
+ stop(signal, deadline);
202
+ const now = await realpath(path);
203
+ if (now !== path || !contained(root.real, now) || !sameFile(before, await stat(now))
204
+ || !sameFile(before, await handle.stat())) throw new SourceFailure('source_changed');
205
+ stop(signal, deadline);
206
+ }
207
+
208
+ async function readCode(source, config, evidence, signal, deadline) {
209
+ if (!localPath(source)) throw new SourceFailure('absolute_local_path_required');
210
+ const lexical = resolve(source);
211
+ const roots = config.roots.filter((root) => contained(root.path, lexical) || contained(root.real, lexical));
212
+ if (!roots.length) throw new SourceFailure('code_root_not_allowed');
213
+ const actual = await realpath(lexical);
214
+ stop(signal, deadline);
215
+ const root = roots.find((entry) => contained(entry.real, actual));
216
+ if (!root) throw new SourceFailure('symlink_escape');
217
+ evidence.actual_source = actual;
218
+ const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
219
+ const handle = await open(actual, flags);
220
+ try {
221
+ const before = await handle.stat();
222
+ if (!before.isFile()) throw new SourceFailure('source_not_regular_file');
223
+ if (before.size > config.limits.sourceMaxBytes) throw new SourceFailure('source_too_large');
224
+ await verifyFilePath(actual, handle, before, root, signal, deadline);
225
+ const chunks = [];
226
+ let total = 0;
227
+ while (true) {
228
+ stop(signal, deadline);
229
+ const buffer = Buffer.allocUnsafe(Math.min(65536, config.limits.sourceMaxBytes - total + 1));
230
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
231
+ stop(signal, deadline);
232
+ if (bytesRead === 0) break;
233
+ total += bytesRead;
234
+ if (total > config.limits.sourceMaxBytes) throw new SourceFailure('source_too_large');
235
+ chunks.push(buffer.subarray(0, bytesRead));
236
+ }
237
+ await verifyFilePath(actual, handle, before, root, signal, deadline);
238
+ return Buffer.concat(chunks, total);
239
+ } finally {
240
+ await handle.close();
241
+ }
242
+ }
243
+
244
+ function header(headers, name) {
245
+ if (!plain(headers)) throw new SourceFailure('invalid_response_headers');
246
+ const value = Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];
247
+ if (value === undefined) return null;
248
+ if (typeof value !== 'string') throw new SourceFailure('invalid_response_headers');
249
+ return value;
250
+ }
251
+
252
+ function safeMime(value) {
253
+ if (!value) return false;
254
+ const parts = value.split(';').map((part) => part.trim().toLowerCase());
255
+ const type = parts.shift();
256
+ if (!['application/json', 'text/plain', 'text/markdown', 'text/csv'].includes(type)) return false;
257
+ let charset = null;
258
+ for (const part of parts) {
259
+ const match = /^charset\s*=\s*(?:"(utf-8|utf8)"|(utf-8|utf8))$/.exec(part);
260
+ if (!match || charset !== null) return false;
261
+ charset = match[1] ?? match[2];
262
+ }
263
+ return charset !== null || type === 'application/json' || type === 'text/plain';
264
+ }
265
+
266
+ async function resolveAddress(url, config, signal, deadline) {
267
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
268
+ if (isIP(hostname)) return { address: hostname, family: isIP(hostname) };
269
+ let records;
270
+ try { records = await abortable(config.resolver(hostname, { signal }), signal); }
271
+ catch (error) { if (signal.aborted) throw signal.reason; throw new SourceFailure('dns_failure'); }
272
+ stop(signal, deadline);
273
+ if (!Array.isArray(records) || records.length === 0 || records.length > 64) throw new SourceFailure('dns_invalid_result');
274
+ for (const record of records) {
275
+ if (!plain(record) || !publicAddress(record.address) || isIP(record.address) !== record.family) {
276
+ throw new SourceFailure('dns_private_or_invalid_address');
277
+ }
278
+ }
279
+ return { address: records[0].address, family: records[0].family };
280
+ }
281
+
282
+ function discardBody(response) {
283
+ if (response?.body && typeof response.body.destroy === 'function') response.body.destroy();
284
+ }
285
+
286
+ async function readOfficial(source, config, evidence, signal, deadline) {
287
+ const url = authorizedUrl(source);
288
+ if (!config.urls.has(url.href)) throw new SourceFailure('official_url_not_allowed');
289
+ const address = await resolveAddress(url, config, signal, deadline);
290
+ stop(signal, deadline);
291
+ evidence.actual_source = url.href;
292
+ const pending = Promise.resolve(config.requester(new URL(url.href), {
293
+ ...address, signal, maxBytes: config.limits.sourceMaxBytes, timeoutMs: config.limits.checkTimeoutMs,
294
+ }));
295
+ pending.then((response) => { if (signal.aborted) discardBody(response); }, () => {});
296
+ let response;
297
+ try {
298
+ response = await abortable(pending, signal);
299
+ stop(signal, deadline);
300
+ if (!response || typeof response !== 'object') throw new SourceFailure('invalid_http_response');
301
+ if (response.url !== undefined && response.url !== url.href) {
302
+ throw new SourceFailure('unexpected_response_url');
303
+ }
304
+ if (response.statusCode >= 300 && response.statusCode < 400) throw new SourceFailure('redirect_forbidden');
305
+ if (response.statusCode !== 200) throw new SourceFailure('http_status_not_ok');
306
+ if (!safeMime(header(response.headers, 'content-type'))) throw new SourceFailure('unsupported_mime');
307
+ const encoding = header(response.headers, 'content-encoding');
308
+ if (encoding && encoding.trim().toLowerCase() !== 'identity') throw new SourceFailure('unsupported_content_encoding');
309
+ const length = header(response.headers, 'content-length');
310
+ if (length !== null && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)))) {
311
+ throw new SourceFailure('invalid_content_length');
312
+ }
313
+ if (length !== null && Number(length) > config.limits.sourceMaxBytes) throw new SourceFailure('source_too_large');
314
+ if (!response.body || typeof response.body[Symbol.asyncIterator] !== 'function') throw new SourceFailure('invalid_response_body');
315
+ const chunks = [];
316
+ let total = 0;
317
+ const iterator = response.body[Symbol.asyncIterator]();
318
+ try {
319
+ while (true) {
320
+ stop(signal, deadline);
321
+ const next = await abortable(iterator.next(), signal);
322
+ stop(signal, deadline);
323
+ if (next.done) break;
324
+ const chunk = next.value;
325
+ if (!(chunk instanceof Uint8Array)) throw new SourceFailure('invalid_response_chunk');
326
+ total += chunk.byteLength;
327
+ if (total > config.limits.sourceMaxBytes) throw new SourceFailure('source_too_large');
328
+ chunks.push(Buffer.from(chunk));
329
+ }
330
+ } finally {
331
+ // Do not await an uncooperative injected iterator's return during cancellation.
332
+ if (typeof iterator.return === 'function') {
333
+ try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* cleanup only */ }
334
+ }
335
+ }
336
+ stop(signal, deadline);
337
+ if (length !== null && total !== Number(length)) throw new SourceFailure('incomplete_source');
338
+ return Buffer.concat(chunks, total);
339
+ } finally {
340
+ discardBody(response);
341
+ }
342
+ }
343
+
344
+ function selectedBytes(value) {
345
+ let text;
346
+ try {
347
+ text = typeof value === 'string' ? value : JSON.stringify(value, (_key, member) => {
348
+ if (typeof member === 'number' && !Number.isFinite(member)) throw new SourceFailure('nonfinite_json_value');
349
+ if (Object.is(member, -0)) throw new SourceFailure('non_roundtrippable_json_value');
350
+ if (member === undefined || ['bigint', 'function', 'symbol'].includes(typeof member)) {
351
+ throw new SourceFailure('invalid_selected_value');
352
+ }
353
+ return member;
354
+ });
355
+ } catch (error) {
356
+ if (error instanceof SourceFailure) throw error;
357
+ throw new SourceFailure('invalid_selected_value');
358
+ }
359
+ if (typeof text !== 'string') throw new SourceFailure('invalid_selected_value');
360
+ return Buffer.byteLength(text);
361
+ }
362
+
363
+ function boundedSelection(value, limits) {
364
+ if (selectedBytes(value) > limits.selectedTextMaxBytes) throw new SourceFailure('selection_too_large');
365
+ if (typeof value === 'string') {
366
+ let count = 1;
367
+ for (const _match of value.matchAll(/\r\n|\r|\n/g)) {
368
+ count += 1;
369
+ if (count > limits.selectedTextMaxLines) throw new SourceFailure('selection_too_many_lines');
370
+ }
371
+ }
372
+ }
373
+
374
+ function lines(text, locator) {
375
+ let line = 1;
376
+ let start = locator.start === 1 ? 0 : null;
377
+ for (const match of text.matchAll(/\r\n|\r|\n/g)) {
378
+ if (line === locator.end) return text.slice(start, match.index);
379
+ line += 1;
380
+ if (line === locator.start) start = match.index + match[0].length;
381
+ }
382
+ if (line === locator.end && start !== null) return text.slice(start);
383
+ throw new SourceFailure('missing_lines');
384
+ }
385
+
386
+ function validateSpec(spec, limits) {
387
+ if (spec === null || spec === undefined) throw new SourceFailure('no_check_spec');
388
+ if (!plain(spec) || Object.keys(spec).some((key) => !['locator', 'expected', 'comparison'].includes(key))
389
+ || !own(spec, 'expected') || !['exact', 'text-lf'].includes(spec.comparison) || !plain(spec.locator)) {
390
+ throw new SourceFailure('invalid_check_spec');
391
+ }
392
+ const loc = spec.locator;
393
+ if (loc.kind === 'lines') {
394
+ if (Object.keys(loc).some((key) => !['kind', 'start', 'end'].includes(key))
395
+ || !Number.isSafeInteger(loc.start) || !Number.isSafeInteger(loc.end)
396
+ || loc.start < 1 || loc.end < loc.start || typeof spec.expected !== 'string') {
397
+ throw new SourceFailure('invalid_lines_locator');
398
+ }
399
+ if (loc.end - loc.start + 1 > limits.selectedTextMaxLines) throw new SourceFailure('selection_too_many_lines');
400
+ } else if (loc.kind === 'json-pointer') {
401
+ if (Object.keys(loc).some((key) => !['kind', 'pointer'].includes(key))
402
+ || typeof loc.pointer !== 'string' || (loc.pointer !== '' && !loc.pointer.startsWith('/'))
403
+ || /~(?![01])/.test(loc.pointer)) throw new SourceFailure('invalid_json_pointer');
404
+ } else throw new SourceFailure('unsupported_locator');
405
+ boundedSelection(spec.expected, limits);
406
+ }
407
+
408
+ function observe(bytes, spec, limits) {
409
+ let text;
410
+ try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
411
+ catch { throw new SourceFailure('invalid_utf8'); }
412
+ let value;
413
+ if (spec.locator.kind === 'lines') value = lines(text, spec.locator);
414
+ else {
415
+ let doc;
416
+ try { doc = JSON.parse(text); } catch { throw new SourceFailure('invalid_json'); }
417
+ const result = resolveJsonPointer(doc, spec.locator.pointer);
418
+ if (!result.found) throw new SourceFailure(result.invalid ? 'invalid_json_pointer' : 'missing_json_pointer');
419
+ value = result.value;
420
+ if (typeof value === 'number' && !Number.isFinite(value)) throw new SourceFailure('nonfinite_json_value');
421
+ }
422
+ boundedSelection(value, limits);
423
+ return value;
424
+ }
425
+
426
+ function evidenceFor(item) {
427
+ return {
428
+ requested_source: item.source_ref, actual_source: null, source_hash: null,
429
+ locator: item.check_spec?.locator ?? null, expected: item.check_spec?.expected ?? null,
430
+ observed: null, comparison: item.check_spec?.comparison ?? null,
431
+ checked_at: null, state: 'unknown', reason: null,
432
+ };
433
+ }
434
+
435
+ /** Standalone checker. Resolver/requester hooks are trusted administrator code, never item payload. */
436
+ export class LibraryChecker {
437
+ #store;
438
+ #config;
439
+ #active = new Map();
440
+ #closed = false;
441
+ #closing = null;
442
+
443
+ constructor(store, config = {}) {
444
+ if (!store || typeof store.get !== 'function' || typeof store.recordCheck !== 'function') {
445
+ throw new TypeError('LibraryChecker requires store.get and store.recordCheck');
446
+ }
447
+ this.#store = store;
448
+ this.#config = normalizeConfig(store, config);
449
+ }
450
+
451
+ status() {
452
+ return {
453
+ closed: this.#closed, activeCount: this.#active.size, limits: { ...this.#config.limits },
454
+ codeRootCount: this.#config.roots.length, officialUrlCount: this.#config.urls.size,
455
+ transport: this.#config.injected ? 'trusted-injected' : 'native-https',
456
+ };
457
+ }
458
+
459
+ async check(id, options = {}) {
460
+ if (this.#closed) return refuse('closed');
461
+ if (!plain(options) || Object.keys(options).some((key) => key !== 'signal')) return refuse('invalid_options');
462
+ const signal = options.signal;
463
+ if (signal !== undefined && !(signal instanceof AbortSignal)) return refuse('invalid_signal');
464
+ if (signal?.aborted) return refuse('aborted', { cancelled: true });
465
+ // Store failures and exceptions stay store failures and exceptions.
466
+ const found = this.#store.get(id);
467
+ if (!found.ok) return { ...found, state: 'unknown', recorded: false };
468
+ const item = found.item;
469
+ const key = item.id;
470
+ if (this.#active.has(key)) return refuse('in_flight', { item_id: key });
471
+ if (this.#active.size >= this.#config.limits.checkConcurrency) return refuse('concurrency_limit', { item_id: key });
472
+ const controller = new AbortController();
473
+ const entry = { controller, done: null };
474
+ this.#active.set(key, entry);
475
+ entry.done = this.#run(item, signal, controller).finally(() => this.#active.delete(key));
476
+ return entry.done;
477
+ }
478
+
479
+ async #run(item, callerSignal, controller) {
480
+ const evidence = evidenceFor(item);
481
+ const onAbort = () => controller.abort(new SourceFailure('aborted', true));
482
+ callerSignal?.addEventListener('abort', onAbort, { once: true });
483
+ if (callerSignal?.aborted) onAbort();
484
+ const deadline = performance.now() + this.#config.limits.checkTimeoutMs;
485
+ const timer = setTimeout(() => controller.abort(new SourceFailure('timeout')), this.#config.limits.checkTimeoutMs);
486
+ try {
487
+ try {
488
+ await abortable(this.#evaluate(item, evidence, controller.signal, deadline), controller.signal);
489
+ stop(controller.signal, deadline);
490
+ } catch (error) {
491
+ const failure = controller.signal.aborted ? controller.signal.reason : error;
492
+ evidence.state = 'unknown';
493
+ evidence.reason = failure instanceof SourceFailure ? failure.code : 'source_io_failure';
494
+ }
495
+ // Timers cannot interrupt synchronous decoding/comparison: check the monotonic
496
+ // deadline immediately before certification and cancel any residual I/O.
497
+ if (!controller.signal.aborted && performance.now() >= deadline) {
498
+ controller.abort(new SourceFailure('timeout'));
499
+ }
500
+ if (controller.signal.aborted) {
501
+ evidence.state = 'unknown';
502
+ evidence.reason = controller.signal.reason?.code ?? 'aborted';
503
+ }
504
+ evidence.checked_at = new Date().toISOString();
505
+ if (controller.signal.aborted && controller.signal.reason?.cancelled) {
506
+ return refuse(evidence.reason, { cancelled: true, item_id: item.id, item_hash: item.content_hash, evidence });
507
+ }
508
+ const record = this.#store.recordCheck(item.id, item.content_hash, evidence);
509
+ if (!record.ok) return { ...record, state: evidence.state, recorded: false, evidence, record };
510
+ return {
511
+ ok: true, item_id: item.id, item_hash: item.content_hash, state: evidence.state,
512
+ reason: evidence.reason, recorded: true, adopted: record.adopted, evidence, record,
513
+ };
514
+ } finally {
515
+ clearTimeout(timer);
516
+ callerSignal?.removeEventListener('abort', onAbort);
517
+ }
518
+ }
519
+
520
+ async #evaluate(item, evidence, signal, deadline) {
521
+ stop(signal, deadline);
522
+ validateSpec(item.check_spec, this.#config.limits);
523
+ stop(signal, deadline);
524
+ let bytes;
525
+ if (item.source_kind === 'our-code') bytes = await readCode(item.source_ref, this.#config, evidence, signal, deadline);
526
+ else if (item.source_kind === 'official-doc') bytes = await readOfficial(item.source_ref, this.#config, evidence, signal, deadline);
527
+ else throw new SourceFailure('unsupported_source_kind');
528
+ stop(signal, deadline);
529
+ evidence.source_hash = sha256(bytes);
530
+ const observed = observe(bytes, item.check_spec, this.#config.limits);
531
+ evidence.observed = observed;
532
+ let equal;
533
+ if (item.check_spec.comparison === 'text-lf') {
534
+ if (typeof observed !== 'string' || typeof item.check_spec.expected !== 'string') {
535
+ throw new SourceFailure('comparison_requires_text');
536
+ }
537
+ equal = normalizeTextLf(observed) === normalizeTextLf(item.check_spec.expected);
538
+ } else equal = isDeepStrictEqual(observed, item.check_spec.expected);
539
+ stop(signal, deadline);
540
+ evidence.state = equal ? 'ok' : 'wrong';
541
+ evidence.reason = equal ? null : 'declared_comparison_mismatch';
542
+ }
543
+
544
+ close() {
545
+ if (this.#closing) return this.#closing;
546
+ this.#closed = true;
547
+ const entries = [...this.#active.values()];
548
+ for (const entry of entries) entry.controller.abort(new SourceFailure('closed', true));
549
+ this.#closing = Promise.allSettled(entries.map((entry) => entry.done)).then(() => ({ ok: true, closed: true }));
550
+ return this.#closing;
551
+ }
552
+ }