pikelet-wasm 0.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,561 @@
1
+ 'use strict';
2
+ // Shared by the range and sketch artifact readers/builders: read budgets and
3
+ // range validation, range sources, the result heap, snapshot parsing, and
4
+ // SHA-256 helpers. Not a public entry; pancake-artifact.js re-exports what is public.
5
+ // Split out of pancake-artifact.js (the public entry, which re-exports the
6
+ // three parts); see that file for the module map.
7
+
8
+ const { pancakeError, PANCAKE_ERROR_CODES } = require('./pancake-errors.js');
9
+
10
+ const PANCAKE_MAGIC = 0x504E434B;
11
+ const V1_ENVELOPE_HEADER_SIZE = 24;
12
+ const V2_ENVELOPE_HEADER_SIZE = 20;
13
+ const V3_ENVELOPE_HEADER_SIZE = 32;
14
+ const MAPPING_ENTRY_SIZE = 8;
15
+ const UINT8_HNSW_MAGIC_V1 = 0x49384831;
16
+
17
+ // Read limits are layered, because header fields are untrusted and a hostile
18
+ // count/offset must not turn into a multi-GB read or allocation before the
19
+ // truncation checks run:
20
+ // - MAX_ARTIFACT_READ_BYTES is the absolute backstop on any single read,
21
+ // blocking the 32-bit-overflow class of pathological requests. It is also
22
+ // enforced independently inside NodeFileRangeSource.
23
+ // - Open-path reads (id map, router, resident sketch tier) default to the
24
+ // much tighter DEFAULT_OPEN_READ_BYTES, configurable per open() via
25
+ // options.maxReadBytes — resident segments are small by design, so a read
26
+ // near this limit means a hostile or misconfigured header.
27
+ // - Query-path coalesced ranges are split at MAX_COALESCED_RANGE_BYTES so no
28
+ // gap/parallelism combination can drive one giant fetch.
29
+ // - Whole-segment verification streams in VERIFY_CHUNK_BYTES chunks where a
30
+ // streaming hash exists (Node); WebCrypto cannot stream SHA-256, so other
31
+ // runtimes fall back to a single read bounded by the open budget.
32
+ const MAX_ARTIFACT_READ_BYTES = 2 * 1024 * 1024 * 1024;
33
+ const DEFAULT_OPEN_READ_BYTES = 256 * 1024 * 1024;
34
+ const MAX_COALESCED_RANGE_BYTES = 16 * 1024 * 1024;
35
+ const VERIFY_CHUNK_BYTES = 64 * 1024 * 1024;
36
+
37
+ async function mapLimit(items, limit, fn) {
38
+ const n = !Number.isFinite(limit) ? items.length : Math.min(Math.max(1, Math.trunc(limit)), items.length);
39
+ if (n === 0) return [];
40
+ const out = new Array(items.length);
41
+ let next = 0;
42
+ await Promise.all(Array.from({ length: n }, async () => {
43
+ for (let i = next++; i < items.length; i = next++) {
44
+ out[i] = await fn(items[i], i);
45
+ }
46
+ }));
47
+ return out;
48
+ }
49
+
50
+ // Validate an artifact-derived (offset, length) before issuing source.read.
51
+ // Rejects non-integer/negative/overflowing ranges, anything past the cap, and
52
+ // — when the source reports a numeric size — anything past the source end, so
53
+ // the read is refused instead of attempted. Returns the length for convenience.
54
+ function checkArtifactRange(source, offset, length, label, limit = MAX_ARTIFACT_READ_BYTES) {
55
+ if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length)
56
+ || offset < 0 || length < 0 || !Number.isSafeInteger(offset + length)) {
57
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
58
+ `Artifact ${label} range is out of bounds`, { offset, length });
59
+ }
60
+ const max = Math.min(limit, MAX_ARTIFACT_READ_BYTES);
61
+ if (length > max) {
62
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
63
+ `Artifact ${label} range exceeds the maximum read size`, { length, max });
64
+ }
65
+ if (source && Number.isSafeInteger(source.size) && offset + length > source.size) {
66
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
67
+ `Artifact ${label} range extends past the source size`, { offset, length, size: source.size });
68
+ }
69
+ return length;
70
+ }
71
+
72
+ // Read a checked artifact region: validate (offset, length) before touching
73
+ // the source so a hostile header cannot drive a giant read or allocation.
74
+ async function readChecked(source, offset, length, label, limit) {
75
+ checkArtifactRange(source, offset, length, label, limit);
76
+ return asUint8Array(await source.read(offset, length));
77
+ }
78
+
79
+ // Per-open read budget: undefined keeps the default, Infinity defers to the
80
+ // absolute backstop, anything else must be a positive finite number and is
81
+ // honored strictly — a budget too small for the artifact's resident segments
82
+ // fails the open with a coded error rather than being silently raised.
83
+ function resolveMaxReadBytes(value) {
84
+ if (value === undefined) return DEFAULT_OPEN_READ_BYTES;
85
+ if (value === Infinity) return MAX_ARTIFACT_READ_BYTES;
86
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
87
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT,
88
+ 'maxReadBytes must be a positive number or Infinity', { maxReadBytes: value });
89
+ }
90
+ return value;
91
+ }
92
+ function asUint8Array(bytes) {
93
+ if (bytes instanceof Uint8Array) return bytes;
94
+ if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
95
+ if (ArrayBuffer.isView(bytes)) {
96
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
97
+ }
98
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'Range source returned a non-binary value');
99
+ }
100
+
101
+ // Both readers accept a maxCacheBytes option: undefined keeps the default,
102
+ // Infinity disables the bound, and any other value must be a positive number.
103
+ // Each reader raises small budgets to a floor so the LRU always holds enough
104
+ // records for one search round.
105
+ function resolveMaxCacheBytes(value, floorBytes, defaultBytes) {
106
+ if (value === undefined) return defaultBytes;
107
+ if (value === Infinity) return Infinity;
108
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
109
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'maxCacheBytes must be a positive number or Infinity', { maxCacheBytes: value });
110
+ }
111
+ return Math.max(value, floorBytes);
112
+ }
113
+
114
+ function readU32(view, state) {
115
+ const value = view.getUint32(state.offset, true);
116
+ state.offset += 4;
117
+ return value;
118
+ }
119
+
120
+ function readU16(view, state) {
121
+ const value = view.getUint16(state.offset, true);
122
+ state.offset += 2;
123
+ return value;
124
+ }
125
+
126
+ function readF32(view, state) {
127
+ const value = view.getFloat32(state.offset, true);
128
+ state.offset += 4;
129
+ return value;
130
+ }
131
+
132
+ function compareDistancesAsc(a, b) {
133
+ return a.distance - b.distance || a.id - b.id;
134
+ }
135
+
136
+ class MinHeap {
137
+ constructor(compare) {
138
+ this.items = [];
139
+ this.compare = compare;
140
+ }
141
+
142
+ get size() {
143
+ return this.items.length;
144
+ }
145
+
146
+ push(value) {
147
+ const items = this.items;
148
+ items.push(value);
149
+ this.bubbleUp(items.length - 1);
150
+ }
151
+
152
+ bubbleUp(index) {
153
+ const items = this.items;
154
+ const value = items[index];
155
+ while (index > 0) {
156
+ const parent = (index - 1) >> 1;
157
+ if (this.compare(items[parent], value) <= 0) break;
158
+ items[index] = items[parent];
159
+ index = parent;
160
+ }
161
+ items[index] = value;
162
+ }
163
+
164
+ pop() {
165
+ const items = this.items;
166
+ if (items.length === 0) return undefined;
167
+ const top = items[0];
168
+ const value = items.pop();
169
+ if (items.length > 0) {
170
+ let index = 0;
171
+ while (true) {
172
+ let child = index * 2 + 1;
173
+ if (child >= items.length) break;
174
+ if (child + 1 < items.length && this.compare(items[child + 1], items[child]) < 0) {
175
+ child++;
176
+ }
177
+ if (this.compare(items[child], value) >= 0) break;
178
+ items[index] = items[child];
179
+ index = child;
180
+ }
181
+ items[index] = value;
182
+ }
183
+ return top;
184
+ }
185
+
186
+ peek() {
187
+ return this.items[0];
188
+ }
189
+
190
+ sorted() {
191
+ return [...this.items].sort(this.compare);
192
+ }
193
+ }
194
+
195
+ class NodeFileRangeSource {
196
+ constructor(filePath) {
197
+ const fs = require('fs');
198
+ this.fs = fs;
199
+ this.filePath = filePath;
200
+ this.fd = fs.openSync(filePath, 'r');
201
+ try {
202
+ // fstat on the descriptor we hold: no second path lookup to race,
203
+ // and if it fails the descriptor is released before the throw.
204
+ this.size = fs.fstatSync(this.fd).size;
205
+ } catch (err) {
206
+ fs.closeSync(this.fd);
207
+ this.fd = null;
208
+ throw err;
209
+ }
210
+ this.preferredParallelism = Infinity;
211
+ this.preferredGapBytes = 2048;
212
+ }
213
+
214
+ async read(offset, length) {
215
+ if (this.fd === null) {
216
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'NodeFileRangeSource is closed', { filePath: this.filePath });
217
+ }
218
+ // Defense in depth: the callers validate artifact-derived ranges, but
219
+ // a future caller must not be able to drive a giant Buffer.alloc or a
220
+ // read past the file end through this source directly.
221
+ if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length)
222
+ || offset < 0 || length < 0 || length > MAX_ARTIFACT_READ_BYTES
223
+ || offset + length > this.size) {
224
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
225
+ 'NodeFileRangeSource.read() range is out of bounds', { offset, length, size: this.size });
226
+ }
227
+ const buffer = Buffer.alloc(length);
228
+ let bytesRead = 0;
229
+ while (bytesRead < length) {
230
+ const chunk = this.fs.readSync(this.fd, buffer, bytesRead, length - bytesRead, offset + bytesRead);
231
+ if (chunk === 0) break;
232
+ bytesRead += chunk;
233
+ }
234
+ // Return only the bytes actually read so callers' truncation checks
235
+ // fire instead of parsing an unwritten buffer tail.
236
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
237
+ }
238
+
239
+ async close() {
240
+ if (this.fd !== null) {
241
+ this.fs.closeSync(this.fd);
242
+ this.fd = null;
243
+ }
244
+ }
245
+ }
246
+
247
+ // The artifact readers apply the same query-vector contract as the core
248
+ // engine (pancake-core.js assertNumericVector / validateVectorValues): a
249
+ // plain array must hold real numbers (Float32Array.from would coerce '1',
250
+ // '', true silently), every component must be finite for every metric (an
251
+ // L2 search over NaN would otherwise return distance: NaN results), and a
252
+ // cosine query must have a usable norm.
253
+ function normalizeQuery(query, dim, metric = 0) {
254
+ if (!(query instanceof Float32Array)) {
255
+ if (!Array.isArray(query)) {
256
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR, 'search() query must be a Float32Array or number[]');
257
+ }
258
+ for (let i = 0; i < query.length; i++) {
259
+ if (typeof query[i] !== 'number') {
260
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR,
261
+ `search() query must contain only numbers; found ${typeof query[i]} at index ${i}`, { index: i, actualType: typeof query[i] });
262
+ }
263
+ }
264
+ query = Float32Array.from(query);
265
+ }
266
+ if (query.length !== dim) {
267
+ throw pancakeError(PANCAKE_ERROR_CODES.DIMENSION_MISMATCH, `search() query dimension ${query.length} does not match artifact dimension ${dim}`, { queryDim: query.length, dim });
268
+ }
269
+ let norm = 0;
270
+ for (let i = 0; i < query.length; i++) {
271
+ const value = query[i];
272
+ if (!Number.isFinite(value)) {
273
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR, 'search() query contains non-finite value (NaN or Infinity)', { index: i, reason: 'non_finite' });
274
+ }
275
+ norm += value * value;
276
+ }
277
+ if (metric === 1) {
278
+ norm = Math.sqrt(norm);
279
+ if (!Number.isFinite(norm) || norm <= 1e-30) {
280
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR, 'search() query has invalid cosine norm', { reason: 'invalid_cosine_norm' });
281
+ }
282
+ const normalized = new Float32Array(query.length);
283
+ for (let i = 0; i < query.length; i++) normalized[i] = query[i] / norm;
284
+ return normalized;
285
+ }
286
+ return query;
287
+ }
288
+
289
+ // Positive-integer search arguments (k, rerank, efSearch) shared by both
290
+ // artifact readers: integers only, and bounded by the artifact's row count
291
+ // before they size any allocation — an artifact cannot return more than
292
+ // count results, so a k of 1e9 over 300 rows must not allocate 1e9 slots.
293
+ function resolveSearchK(k, count) {
294
+ if (!Number.isSafeInteger(k) || k < 1) {
295
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'search() k must be a positive integer', { k });
296
+ }
297
+ return Math.min(k, count);
298
+ }
299
+ // undefined, null, and 0 all mean "use the default" — 0 kept its historical
300
+ // `options.rerank || default` meaning so existing callers do not break;
301
+ // anything else must be a positive safe integer.
302
+ function resolveOptionalPositiveInt(value, name, fallback) {
303
+ if (value === undefined || value === null || value === 0) return fallback;
304
+ if (!Number.isSafeInteger(value) || value < 1) {
305
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, `search() ${name} must be a positive integer (0 or undefined selects the default)`, { [name]: value });
306
+ }
307
+ return value;
308
+ }
309
+
310
+ function unwrapSnapshot(bytes) {
311
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
312
+ if (bytes.byteLength < 4 || view.getUint32(0, true) !== PANCAKE_MAGIC) return bytes;
313
+ // Envelope fields are untrusted: every size read from them must be
314
+ // checked against the actual buffer before slicing, so a corrupt
315
+ // envelope fails closed with a coded error instead of a raw RangeError.
316
+ if (bytes.byteLength < 8) {
317
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Pancake envelope is truncated', { byteLength: bytes.byteLength });
318
+ }
319
+ const version = view.getUint32(4, true);
320
+ if (version === 1 || version === 2) {
321
+ const headerSize = version === 1 ? V1_ENVELOPE_HEADER_SIZE : V2_ENVELOPE_HEADER_SIZE;
322
+ if (bytes.byteLength < headerSize) {
323
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Pancake envelope is truncated', { version, byteLength: bytes.byteLength });
324
+ }
325
+ return bytes.subarray(headerSize);
326
+ }
327
+ if (version === 3) {
328
+ if (bytes.byteLength < V3_ENVELOPE_HEADER_SIZE) {
329
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Pancake envelope is truncated', { version, byteLength: bytes.byteLength });
330
+ }
331
+ const mappingCount = view.getUint32(24, true);
332
+ const rawSize = view.getUint32(28, true);
333
+ const rawOffset = V3_ENVELOPE_HEADER_SIZE + mappingCount * MAPPING_ENTRY_SIZE;
334
+ if (rawOffset + rawSize > bytes.byteLength) {
335
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Pancake envelope declares more data than the snapshot contains', { mappingCount, rawSize, byteLength: bytes.byteLength });
336
+ }
337
+ return bytes.subarray(rawOffset, rawOffset + rawSize);
338
+ }
339
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `Unsupported Pancake envelope version ${version}`, { version });
340
+ }
341
+
342
+ function parseUint8Snapshot(bytes) {
343
+ const raw = unwrapSnapshot(asUint8Array(bytes));
344
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
345
+ let offset = 0;
346
+ // Snapshot bytes are untrusted: sizes read from the header drive every
347
+ // subsequent read, so each read checks the remaining buffer and fails
348
+ // closed with a coded error instead of a raw RangeError.
349
+ const truncated = () => pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot is truncated', { offset, byteLength: raw.byteLength });
350
+ const u32 = () => {
351
+ if (raw.byteLength - offset < 4) throw truncated();
352
+ const value = view.getUint32(offset, true);
353
+ offset += 4;
354
+ return value;
355
+ };
356
+ const f32 = () => {
357
+ if (raw.byteLength - offset < 4) throw truncated();
358
+ const value = view.getFloat32(offset, true);
359
+ offset += 4;
360
+ return value;
361
+ };
362
+
363
+ const magic = u32();
364
+ const dim = u32();
365
+ const version = u32();
366
+ const count = u32();
367
+ const entryPoint = u32();
368
+ const maxLevel = u32();
369
+ const M = u32();
370
+ const M0 = u32();
371
+ const metric = u32();
372
+ const efConstruction = u32();
373
+ if (magic !== UINT8_HNSW_MAGIC_V1) {
374
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Search Artifact export currently supports uint8 Pancake snapshots only');
375
+ }
376
+ // Contract §6: reject unknown future versions instead of parsing them
377
+ // as v2 — a changed layout must fail closed, not misparse.
378
+ if (version > 2) {
379
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported uint8 snapshot format version', { version });
380
+ }
381
+ if (metric !== 0 && metric !== 1) {
382
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported uint8 snapshot metric', { metric });
383
+ }
384
+ // Same structural sanity the engine's own deserialize enforces; the
385
+ // level cap mirrors MAX_DESERIALIZE_LEVEL in src/uint8_float_hnsw.hpp.
386
+ if (dim < 1 || count < 1 || entryPoint >= count || maxLevel > 64
387
+ || !Number.isSafeInteger(count * dim)
388
+ || raw.byteLength - offset < count * 8 + count * dim) {
389
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot header is inconsistent', { dim, count, entryPoint, maxLevel, byteLength: raw.byteLength });
390
+ }
391
+
392
+ const scales = new Float32Array(count);
393
+ const offsets = new Float32Array(count);
394
+ for (let i = 0; i < count; i++) scales[i] = f32();
395
+ for (let i = 0; i < count; i++) offsets[i] = f32();
396
+ const qdata = raw.subarray(offset, offset + count * dim);
397
+ offset += qdata.byteLength;
398
+
399
+ // Adjacency bounds mirror the engine's own deserializer: a level's edge
400
+ // count is capped by M0 (layer 0) / M (upper layers), the bytes it claims
401
+ // (4 per id + 4 per serialized distance) must still be in the buffer, and
402
+ // every neighbor id must address a node — all checked before the
403
+ // Uint32Array for that level is allocated, so a crafted count cannot
404
+ // drive a giant allocation that truncation would only catch afterwards.
405
+ // Same envelope as the engine: create() accepts M in [2, 128] and fixes
406
+ // M0 = 2*M, and its own import rejects M outside that range.
407
+ if (!(M >= 2 && M <= 128) || !(M0 >= M && M0 <= 256)) {
408
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot graph parameters are implausible', { M, M0 });
409
+ }
410
+ // Edge layout follows the engine's deserializer (uint8_float_hnsw.hpp):
411
+ // version >= 2 stores {u32 id, f32 distance} per edge, version 1 stores
412
+ // the u32 id only (distances are recomputed on load). The artifact
413
+ // readers never use the stored distance, so both parse to the same graph.
414
+ const edgeBytes = version >= 2 ? 8 : 4;
415
+ const levels = new Uint16Array(count);
416
+ const base = new Array(count);
417
+ const upper = Array.from({ length: count }, () => []);
418
+ for (let id = 0; id < count; id++) {
419
+ const level = u32();
420
+ if (level > maxLevel) {
421
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot node level exceeds header maxLevel', { id, level, maxLevel });
422
+ }
423
+ levels[id] = level;
424
+ for (let l = 0; l <= level; l++) {
425
+ const size = u32();
426
+ const cap = l === 0 ? M0 : M;
427
+ if (size > cap) {
428
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot adjacency exceeds the graph parameter bound', { id, level: l, size, cap });
429
+ }
430
+ if (raw.byteLength - offset < size * edgeBytes) throw truncated();
431
+ const edges = new Uint32Array(size);
432
+ for (let e = 0; e < size; e++) {
433
+ const neighbor = u32();
434
+ if (neighbor >= count) {
435
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot neighbor id is out of range', { id, level: l, neighbor, count });
436
+ }
437
+ edges[e] = neighbor;
438
+ if (version >= 2) offset += 4; // the serialized float edge distance, unused here
439
+ }
440
+ if (l === 0) base[id] = edges;
441
+ else upper[id][l - 1] = edges;
442
+ }
443
+ if (!base[id]) base[id] = new Uint32Array(0);
444
+ }
445
+
446
+ return { kind: 'u8', dim, version, count, entryPoint, maxLevel, M, M0, metric, efConstruction, scales, offsets, qdata, levels, base, upper };
447
+ }
448
+ function sha256Bytes(bytes) {
449
+ const crypto = require('crypto');
450
+ return crypto.createHash('sha256').update(bytes).digest();
451
+ }
452
+
453
+ async function sha256BytesAsync(bytes) {
454
+ if (globalThis.crypto && globalThis.crypto.subtle) {
455
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
456
+ return new Uint8Array(digest);
457
+ }
458
+ try {
459
+ return new Uint8Array(sha256Bytes(bytes));
460
+ } catch {
461
+ return null; // no crypto available in this environment
462
+ }
463
+ }
464
+
465
+ // Verify bytes against an expected 32-byte SHA-256 digest. Verification was
466
+ // requested by the caller, so a missing crypto backend fails closed rather
467
+ // than admitting unverified bytes.
468
+ async function verifySha256(bytes, expected, label) {
469
+ const digest = await sha256BytesAsync(bytes);
470
+ if (!digest) {
471
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
472
+ 'Artifact verification requested but no crypto backend is available; pass verify:false to skip');
473
+ }
474
+ for (let b = 0; b < 32; b++) {
475
+ if (digest[b] !== expected[b]) {
476
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `${label} failed hash verification`);
477
+ }
478
+ }
479
+ }
480
+
481
+ // Verify a whole segment against its digest without buffering it entirely.
482
+ // With a streaming hash backend (Node crypto) the segment is read and hashed
483
+ // in bounded chunks, so peak memory stays at chunkBytes no matter how large
484
+ // the segment. WebCrypto has no incremental SHA-256, so without a streaming
485
+ // backend the verify falls back to one bounded read — and refuses segments
486
+ // larger than the one-shot limit rather than buffering them.
487
+ async function verifySegmentSha256(source, offset, totalBytes, expected, label, options = {}) {
488
+ const chunkBytes = Math.max(4096, Math.trunc(options.chunkBytes || VERIFY_CHUNK_BYTES));
489
+ let hash = null;
490
+ try {
491
+ hash = require('crypto').createHash('sha256');
492
+ } catch {
493
+ hash = null;
494
+ }
495
+ if (hash) {
496
+ let done = 0;
497
+ while (done < totalBytes) {
498
+ const len = Math.min(chunkBytes, totalBytes - done);
499
+ const bytes = await readChecked(source, offset + done, len, label);
500
+ if (bytes.byteLength !== len) {
501
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
502
+ `Artifact ${label} is truncated`, { offset: offset + done, expected: len, actual: bytes.byteLength });
503
+ }
504
+ hash.update(bytes);
505
+ done += len;
506
+ }
507
+ const digest = new Uint8Array(hash.digest());
508
+ for (let b = 0; b < 32; b++) {
509
+ if (digest[b] !== expected[b]) {
510
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `${label} failed hash verification`);
511
+ }
512
+ }
513
+ return;
514
+ }
515
+ const oneShotLimit = options.oneShotLimit || DEFAULT_OPEN_READ_BYTES;
516
+ if (totalBytes > oneShotLimit) {
517
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
518
+ `Artifact ${label} is too large to verify without a streaming crypto backend`,
519
+ { totalBytes, oneShotLimit });
520
+ }
521
+ const bytes = await readChecked(source, offset, totalBytes, label, oneShotLimit);
522
+ if (bytes.byteLength !== totalBytes) {
523
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
524
+ `Artifact ${label} is truncated`, { offset, expected: totalBytes, actual: bytes.byteLength });
525
+ }
526
+ await verifySha256(bytes, expected, label);
527
+ }
528
+
529
+ module.exports = {
530
+ resolveSearchK,
531
+ resolveOptionalPositiveInt,
532
+ PANCAKE_MAGIC,
533
+ V1_ENVELOPE_HEADER_SIZE,
534
+ V2_ENVELOPE_HEADER_SIZE,
535
+ V3_ENVELOPE_HEADER_SIZE,
536
+ MAPPING_ENTRY_SIZE,
537
+ UINT8_HNSW_MAGIC_V1,
538
+ MAX_ARTIFACT_READ_BYTES,
539
+ DEFAULT_OPEN_READ_BYTES,
540
+ MAX_COALESCED_RANGE_BYTES,
541
+ VERIFY_CHUNK_BYTES,
542
+ mapLimit,
543
+ checkArtifactRange,
544
+ readChecked,
545
+ resolveMaxReadBytes,
546
+ asUint8Array,
547
+ resolveMaxCacheBytes,
548
+ readU32,
549
+ readU16,
550
+ readF32,
551
+ compareDistancesAsc,
552
+ MinHeap,
553
+ NodeFileRangeSource,
554
+ normalizeQuery,
555
+ unwrapSnapshot,
556
+ parseUint8Snapshot,
557
+ sha256Bytes,
558
+ sha256BytesAsync,
559
+ verifySha256,
560
+ verifySegmentSha256,
561
+ };