@zakkster/lite-bake-stream 1.4.1 → 1.6.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.
package/src/Reader.js CHANGED
@@ -21,16 +21,31 @@
21
21
  // R_BAD_METADATA - metadata_off is non-zero but the zone-map segment is unparseable
22
22
  // R_INVALID - a structure is internally inconsistent but in-bounds
23
23
  // R_UNKNOWN_FIELD - get()/findShards()/shardBounds() called with an unknown field name
24
+ // R_OFFSET_TOO_LARGE - a u64 header/schema/directory offset exceeds 2^53-1
25
+ // (Number() would lose precision; fail closed, BS-05)
26
+ // R_ROW_OUT_OF_RANGE - get(rowIdx) with rowIdx negative, fractional, NaN,
27
+ // or >= totalRows (BS-32)
28
+ //
29
+ // Row-index policy (BS-32): get() range-checks rowIdx; the SHARD-index escape
30
+ // hatches (shardPayload, shardF64, shardStringTable) do NOT -- their contract is
31
+ // raw indexed access with no per-call checking. shardBounds returns null for an
32
+ // out-of-range shard by decided policy.
24
33
 
25
34
  import { StringTable } from './StringTable.js';
26
35
  import { toContainerBuffer } from './Views.js';
36
+ import { checkOpts } from './Opts.js';
37
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
27
38
 
28
- export const VERSION = '1.4.1';
39
+ export const VERSION = '1.6.0';
29
40
 
30
41
  const CONTAINER_HEADER_BYTES = 48;
31
42
  const SHARD_ENTRY_BYTES = 40;
32
43
  const FIELD_DESCRIPTOR_BYTES = 24;
33
44
  const FOOTER_BYTES = 16;
45
+ const CRC_ABSENT = 0xFFFFFFFF;
46
+
47
+ const READER_OPTS = { verifyCrc: { t: 'bool' } };
48
+ function raiseReaderOpt(code, msg) { throw new ReaderError(code, msg); }
34
49
 
35
50
  const LANE_F64 = 1;
36
51
  const LANE_U32 = 3;
@@ -42,6 +57,19 @@ export class ReaderError extends Error {
42
57
  constructor(code, msg) { super(msg); this.code = code; this.name = 'ReaderError'; }
43
58
  }
44
59
 
60
+ // Read a u64 header/directory/schema field and narrow it to a JS number, failing
61
+ // closed if it exceeds Number.MAX_SAFE_INTEGER (2^53-1). Past that a Number()
62
+ // cast loses precision and every downstream bounds check reads a corrupted
63
+ // offset -- an in-bounds lie is worse than a loud refusal (BS-05). Cold: called
64
+ // only during header/schema/directory parse, never per row.
65
+ function u64(dv, off, what) {
66
+ const v = dv.getBigUint64(off, true);
67
+ if (v > 9007199254740991n)
68
+ throw new ReaderError('R_OFFSET_TOO_LARGE',
69
+ what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
70
+ return Number(v);
71
+ }
72
+
45
73
  // Validate a local string table's shape BEFORE StringTable.parse casts a
46
74
  // Uint32Array over it (T-1..T-4): the offsets array must be in bounds, the
47
75
  // count/blob must fit, offsets must be non-decreasing, and the trailing
@@ -71,11 +99,12 @@ function validateStringTable(bytes, off, len, label) {
71
99
  }
72
100
 
73
101
  export class Reader {
74
- static fromBuffer(input) {
75
- return new Reader(toContainerBuffer(input, 'Reader.fromBuffer'));
102
+ static fromBuffer(input, opts) {
103
+ return new Reader(toContainerBuffer(input, 'Reader.fromBuffer'), opts);
76
104
  }
77
105
 
78
- constructor(buffer) {
106
+ constructor(buffer, opts) {
107
+ checkOpts('Reader', opts, READER_OPTS, raiseReaderOpt);
79
108
  this._buffer = buffer;
80
109
  this._dv = new DataView(buffer);
81
110
  this._bytes = new Uint8Array(buffer);
@@ -85,6 +114,27 @@ export class Reader {
85
114
  this._parseShardDirectory();
86
115
  this._parseZoneMaps(); // M7 -- no-op if metadata_off is 0
87
116
  this._buildFieldIndex();
117
+ // Optional open-time verification (fail closed on BOTH mismatch AND absence:
118
+ // the caller demanded verification, so an unverifiable container is an
119
+ // unverified state -- null is not zero).
120
+ if (opts && opts.verifyCrc === true) {
121
+ const status = this.verifyCrc();
122
+ if (status === 'absent')
123
+ throw new ReaderError('R_CRC_ABSENT', 'verifyCrc:true but the container carries no CRC (footer CRC is absent, 0xFFFFFFFF)');
124
+ }
125
+ }
126
+
127
+ // SPEC 3.7 integrity: recompute CRC-32C over [0, footer_off) and compare to the
128
+ // stored footer CRC. Returns 'ok' when they match, 'absent' when the container
129
+ // carries no CRC (footer CRC == 0xFFFFFFFF). A MISMATCH throws R_BAD_CRC.
130
+ verifyCrc() {
131
+ const footerOff = this._buffer.byteLength - FOOTER_BYTES;
132
+ const stored = this._dv.getUint32(footerOff, true) >>> 0;
133
+ if (stored === CRC_ABSENT) return 'absent';
134
+ const actual = crc32cFinal(crc32cUpdate(crc32cInit(), this._bytes, 0, footerOff));
135
+ if (actual !== stored)
136
+ throw new ReaderError('R_BAD_CRC', 'container CRC mismatch: stored 0x' + stored.toString(16) + ' != computed 0x' + actual.toString(16));
137
+ return 'ok';
88
138
  }
89
139
 
90
140
  _parseHeader() {
@@ -118,11 +168,11 @@ export class Reader {
118
168
  if (reserved1 !== 0)
119
169
  throw new ReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
120
170
 
121
- this._schemaBlockOff = Number(this._dv.getBigUint64(8, true));
122
- this._metadataOff = Number(this._dv.getBigUint64(16, true)); // 0 = no metadata block; M7+ zone maps
123
- this._shardDirOff = Number(this._dv.getBigUint64(24, true));
171
+ this._schemaBlockOff = u64(this._dv, 8, 'schema_block_off');
172
+ this._metadataOff = u64(this._dv, 16, 'metadata_off'); // 0 = no metadata block; M7+ zone maps
173
+ this._shardDirOff = u64(this._dv, 24, 'shard_directory_off');
124
174
  this._shardCount = this._dv.getUint32(32, true);
125
- this._totalRows = Number(this._dv.getBigUint64(40, true));
175
+ this._totalRows = u64(this._dv, 40, 'total_rows');
126
176
  this._formatVersion = version;
127
177
 
128
178
  const len = this._buffer.byteLength;
@@ -180,7 +230,10 @@ export class Reader {
180
230
  const laneKind = this._bytes[descOff + 4];
181
231
  const flags = this._bytes[descOff + 5];
182
232
  const reserved2 = this._dv.getUint16(descOff + 6, true);
183
- const nameStrOff = Number(this._dv.getBigUint64(descOff + 8, true));
233
+ const nameStrOff = u64(this._dv, descOff + 8, 'field ' + i + ' name_str_off');
234
+ // reserved3 is compared as BigInt vs 0n below and never Number()-cast, so
235
+ // it needs no u64() narrowing guard -- a value past 2^53 is caught by the
236
+ // reserved-must-be-zero check, not by precision loss (BS-05).
184
237
  const reserved3 = this._dv.getBigUint64(descOff + 16, true);
185
238
 
186
239
  if (flags !== 0) throw new ReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags (v2+ reserved)');
@@ -212,14 +265,14 @@ export class Reader {
212
265
  let rowSum = 0;
213
266
  for (let i = 0; i < this._shardCount; i++) {
214
267
  const entryOff = off + i * SHARD_ENTRY_BYTES;
215
- const payloadOff = Number(this._dv.getBigUint64(entryOff + 0, true));
268
+ const payloadOff = u64(this._dv, entryOff + 0, 'shard ' + i + ' payload_off');
216
269
  const payloadLen = this._dv.getUint32(entryOff + 8, true);
217
270
  const rowCount = this._dv.getUint32(entryOff + 12, true);
218
271
  const minReaderVer = this._dv.getUint16(entryOff + 16, true);
219
272
  const shardFlags = this._dv.getUint16(entryOff + 18, true);
220
273
  const shardReserved = this._dv.getUint32(entryOff + 20, true);
221
- const localStrOff = Number(this._dv.getBigUint64(entryOff + 24, true));
222
- const localStrLen = Number(this._dv.getBigUint64(entryOff + 32, true));
274
+ const localStrOff = u64(this._dv, entryOff + 24, 'shard ' + i + ' local_string_off');
275
+ const localStrLen = u64(this._dv, entryOff + 32, 'shard ' + i + ' local_string_len');
223
276
  if (minReaderVer > READER_VERSION) {
224
277
  throw new ReaderError('R_SHARD_VERSION_TOO_NEW',
225
278
  'shard ' + i + ' requires reader version ' + minReaderVer);
@@ -396,13 +449,24 @@ export class Reader {
396
449
  return i;
397
450
  }
398
451
 
452
+ // Cold: the BS-32 row-range refusal body, kept out of get()'s hot frame.
453
+ _badRow(rowIdx) {
454
+ throw new ReaderError('R_ROW_OUT_OF_RANGE',
455
+ 'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
456
+ }
457
+
399
458
  // Random-access get across all shards. Returns the field's decoded value:
400
459
  // F64 lanes -> number
401
460
  // U32 lanes -> JS string (resolved via the shard's local string table)
402
461
  // Not zero-alloc; intended for testing/introspection.
403
462
  get(rowIdx, fieldName) {
404
- let remaining = rowIdx;
405
463
  const fieldIdx = this.fieldIndex(fieldName);
464
+ // BS-32 row-range door. `(rowIdx >>> 0) !== rowIdx` rejects negative,
465
+ // fractional, NaN and >= 2^32 in one compare; a whole ArrayBuffer cannot hold
466
+ // 2^32 rows, so the totalRows compare fires first for every reachable
467
+ // container. Throw body hoisted to the cold _badRow helper.
468
+ if ((rowIdx >>> 0) !== rowIdx || rowIdx >= this._totalRows) this._badRow(rowIdx);
469
+ let remaining = rowIdx;
406
470
  const field = this._schema.fields[fieldIdx];
407
471
  for (let s = 0; s < this._shards.length; s++) {
408
472
  const shard = this._shards[s];
package/src/Split.js CHANGED
@@ -36,8 +36,9 @@ import { Writer, WriterError } from './Writer.js';
36
36
  import { Reader, ReaderError } from './Reader.js';
37
37
  import { StringTable } from './StringTable.js';
38
38
  import { checkOpts } from './Opts.js';
39
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
39
40
 
40
- export const VERSION = '1.4.1';
41
+ export const VERSION = '1.6.0';
41
42
 
42
43
  const LF = 0x0A;
43
44
  const CONTAINER_HEADER_BYTES = 48;
@@ -219,6 +220,11 @@ export function mergeContainers(containers) {
219
220
  // Compute the target layout. Schema block is copied from container 0 verbatim.
220
221
  // Reader gives us schemaBlockOff and shardDirOff; the schema-block byte range
221
222
  // is [schemaBlockOff, shardDirOff). We reuse this slice.
223
+ // These Number(getBigUint64) reads carry NO u64 narrowing guard by design
224
+ // (BS-05 dominance): `new Reader(...)` at :193 above parsed every part first,
225
+ // and Reader's own guarded u64() over the same header bytes (schema_block_off,
226
+ // shard_directory_off) already threw R_OFFSET_TOO_LARGE for any over-2^53
227
+ // value. Split mints nothing here -- an untriggerable code would be dead.
222
228
  const src0 = parts[0];
223
229
  const dv0 = new DataView(src0.buffer, src0.byteOffset, src0.byteLength);
224
230
  const schemaBlockOff = Number(dv0.getBigUint64(8, true));
@@ -350,9 +356,20 @@ export function mergeContainers(containers) {
350
356
  }
351
357
  }
352
358
 
353
- // Footer
359
+ // Footer. CRC policy (D4): recompute a fresh CRC-32C over the merged body iff
360
+ // EVERY input carried one; if any input's CRC is absent the merged CRC is
361
+ // absent too (an integrity guarantee only the inputs all shared can be
362
+ // honestly re-asserted -- never fabricated over an unverified part).
354
363
  const footerOff = totalBytes - FOOTER_BYTES;
355
- outDv.setUint32(footerOff + 0, 0xFFFFFFFF, true); // CRC absent
364
+ let mergedCrc = 0xFFFFFFFF;
365
+ let allHaveCrc = true;
366
+ for (const p of parts) {
367
+ const pDv = new DataView(p.buffer, p.byteOffset, p.byteLength);
368
+ const pCrc = pDv.getUint32(p.byteLength - FOOTER_BYTES, true) >>> 0;
369
+ if (pCrc === 0xFFFFFFFF) { allHaveCrc = false; break; }
370
+ }
371
+ if (allHaveCrc) mergedCrc = crc32cFinal(crc32cUpdate(crc32cInit(), out, 0, footerOff));
372
+ outDv.setUint32(footerOff + 0, mergedCrc >>> 0, true);
356
373
  outDv.setUint32(footerOff + 4, 0, true);
357
374
  out[footerOff + 8] = 0x31;
358
375
  out[footerOff + 9] = 0x4B;
@@ -383,7 +400,10 @@ function _schemasEqual(a, b) {
383
400
  return true;
384
401
  }
385
402
 
386
- // Look up the source string-table byte length for a shard.
403
+ // Look up the source string-table byte length for a shard. No u64 narrowing
404
+ // guard here (BS-05 dominance): `reader` is a fully-parsed Reader, so Reader's
405
+ // guarded u64() over this same local_string_len field already threw
406
+ // R_OFFSET_TOO_LARGE for any over-2^53 value during construction.
387
407
  function _sourceStringTableLen(reader, shardIdx) {
388
408
  const dv = new DataView(reader.buffer);
389
409
  const entryOff = reader.shardDirectoryOffset + shardIdx * SHARD_ENTRY_BYTES;
@@ -19,8 +19,26 @@
19
19
  // Entry 0 is always the empty string: the table reserves it in the constructor
20
20
  // and at every reset(), so an absent U32 row cell (which is 0) decodes as ""
21
21
  // rather than aliasing the shard's first-interned string (SPEC 3.3, SPEC 7).
22
+ //
23
+ // Serialization ceilings (BS-31). serialize() writes entry_count and
24
+ // blob_length as u32; a table that grew past either would silently wrap on
25
+ // setUint32 and mint a corrupt-but-in-bounds container. Two cold guards in
26
+ // _insertNew fail closed instead:
27
+ // ST_BLOB_OVERFLOW - the blob would exceed the u32 blob_length ceiling, OR
28
+ // the entry count would exceed the entry_count ceiling
29
+ // (one code, two messages -- both name their ceiling).
30
+ // The doubling clamps in _growBlob / _growOffsets cap allocation at the
31
+ // ceiling so a crossing write ALWAYS re-enters the grow branch where the guard
32
+ // lives; guard placement is then provably complete (no path reaches serialize
33
+ // with an out-of-range count/blob). Zero hot cost: the guards read only inside
34
+ // the cold grow arms.
35
+ //
36
+ // __setStringTableLimits({blobBytes, entryCount}) lowers the ceilings for a
37
+ // cheap gated crossing on the real grow path and returns the previous pair.
38
+ // TEST-ONLY: not re-exported from index.js, absent from the .d.ts and docs,
39
+ // and carrying no semver guarantee.
22
40
 
23
- export const VERSION = '1.4.1';
41
+ export const VERSION = '1.6.0';
24
42
 
25
43
  const EMPTY_SLOT = 0xFFFFFFFF; // MUST be unsigned; typed-array reads are unsigned
26
44
  const INITIAL_BLOB_BYTES = 64 * 1024;
@@ -29,6 +47,26 @@ const INITIAL_HASH_CAP = 2048; // load factor target 50%
29
47
  const HASH_MAX_LOAD_NUM = 1; // 50% load factor: num/den = 1/2
30
48
  const HASH_MAX_LOAD_DEN = 2;
31
49
 
50
+ // u32 serialization ceilings. blob_length is a u32 (max 4294967295). entry_count
51
+ // is a u32, but the offsets array carries entry_count+1 slots (the sentinel), so
52
+ // the last addressable entry_count is 4294967294 to keep offsets_len a valid u32.
53
+ // `let`, not `const`: __setStringTableLimits lowers them for gated crossings.
54
+ let LIMIT_BLOB = 4294967295;
55
+ let LIMIT_ENTRIES = 4294967294;
56
+
57
+ // TEST-ONLY seam (BS-31). Assigns whichever of {blobBytes, entryCount} are
58
+ // present, returns the previous pair. Not part of the public API.
59
+ export function __setStringTableLimits(next) {
60
+ const prev = { blobBytes: LIMIT_BLOB, entryCount: LIMIT_ENTRIES };
61
+ if (next && next.blobBytes !== undefined) LIMIT_BLOB = next.blobBytes;
62
+ if (next && next.entryCount !== undefined) LIMIT_ENTRIES = next.entryCount;
63
+ return prev;
64
+ }
65
+
66
+ export class StringTableError extends Error {
67
+ constructor(code, msg) { super(msg); this.code = code; this.name = 'StringTableError'; }
68
+ }
69
+
32
70
  // Reserved entry 0. A zero-length range: no blob bytes, no growth, one hash
33
71
  // slot. Module-level so reset() reserves without allocating.
34
72
  const EMPTY = new Uint8Array(0);
@@ -94,10 +132,23 @@ export class StringTable {
94
132
 
95
133
  _insertNew(bytes, from, to, slot) {
96
134
  const n = to - from;
97
- // Grow blob if needed
98
- if (this._blobLen + n > this._blob.length) this._growBlob(n);
99
- // Grow offsets if needed (leave room for +1 sentinel)
100
- if (this._count + 1 >= this._offsets.length) this._growOffsets();
135
+ // Grow blob if needed. The clamp keeps _blob.length <= LIMIT_BLOB, so any
136
+ // write pushing past the ceiling necessarily re-enters this branch and is
137
+ // caught by the cold guard (fail closed -- serialize would wrap otherwise).
138
+ if (this._blobLen + n > this._blob.length) {
139
+ if (this._blobLen + n > LIMIT_BLOB)
140
+ throw new StringTableError('ST_BLOB_OVERFLOW',
141
+ 'string blob ' + this._blobLen + ' + ' + n + ' would exceed the u32 blob_length ceiling ' + LIMIT_BLOB);
142
+ this._growBlob(n);
143
+ }
144
+ // Grow offsets if needed (leave room for +1 sentinel). Same completeness
145
+ // argument via the _growOffsets clamp on the entry_count ceiling.
146
+ if (this._count + 1 >= this._offsets.length) {
147
+ if (this._count >= LIMIT_ENTRIES)
148
+ throw new StringTableError('ST_BLOB_OVERFLOW',
149
+ 'string table entry count ' + this._count + ' would exceed the entry_count ceiling ' + LIMIT_ENTRIES);
150
+ this._growOffsets();
151
+ }
101
152
 
102
153
  const idx = this._count;
103
154
  this._offsets[idx] = this._blobLen;
@@ -123,13 +174,18 @@ export class StringTable {
123
174
  _growBlob(needed) {
124
175
  let cap = this._blob.length;
125
176
  while (cap < this._blobLen + needed) cap *= 2;
177
+ // Clamp so allocated length never exceeds the u32 ceiling (see _insertNew).
178
+ if (cap > LIMIT_BLOB) cap = LIMIT_BLOB;
126
179
  const nb = new Uint8Array(cap);
127
180
  nb.set(this._blob);
128
181
  this._blob = nb;
129
182
  }
130
183
 
131
184
  _growOffsets() {
132
- const nb = new Uint32Array(this._offsets.length * 2);
185
+ let len = this._offsets.length * 2;
186
+ // Clamp so the offsets array (entry_count + 1 slots) never exceeds a valid u32.
187
+ if (len > LIMIT_ENTRIES + 1) len = LIMIT_ENTRIES + 1;
188
+ const nb = new Uint32Array(len);
133
189
  nb.set(this._offsets);
134
190
  this._offsets = nb;
135
191
  }
package/src/Tokenizer.js CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { checkOpts } from './Opts.js';
32
32
 
33
- export const VERSION = '1.4.1';
33
+ export const VERSION = '1.6.0';
34
34
 
35
35
  const U32_MAX = 4294967295;
36
36
  const TOKENIZER_OPTS = {
package/src/Views.js CHANGED
@@ -23,3 +23,65 @@ export function toContainerBuffer(input, label) {
23
23
  throw new TypeError(label + ': expected ArrayBuffer or Uint8Array, got ' +
24
24
  (input === null ? 'null' : typeof input));
25
25
  }
26
+
27
+ // ---- streaming sink support (M6) --------------------------------------------
28
+ //
29
+ // A sink is any object with a synchronous write(bytes) method; the streaming
30
+ // (layout:'stream') path additionally needs writeAt(bytes, position) for the
31
+ // single header backpatch. MemorySink is the in-RAM reference implementation
32
+ // that finalize() drives internally so it can return the identical container
33
+ // buffer the classic assembler produced.
34
+
35
+ // Growable in-RAM sink. write() appends; writeAt() places bytes at an absolute
36
+ // position (growing the logical length if needed); toArrayBuffer() returns an
37
+ // EXACT-length ArrayBuffer copy of the bytes written.
38
+ export class MemorySink {
39
+ constructor(hint) {
40
+ const cap = (typeof hint === 'number' && hint > 0) ? hint : 64 * 1024;
41
+ this._buf = new Uint8Array(cap);
42
+ this._len = 0;
43
+ }
44
+ _ensure(need) {
45
+ if (need <= this._buf.length) return;
46
+ let cap = this._buf.length;
47
+ while (cap < need) cap *= 2;
48
+ const next = new Uint8Array(cap);
49
+ next.set(this._buf.subarray(0, this._len));
50
+ this._buf = next;
51
+ }
52
+ write(bytes) {
53
+ this._ensure(this._len + bytes.length);
54
+ this._buf.set(bytes, this._len);
55
+ this._len += bytes.length;
56
+ }
57
+ writeAt(bytes, position) {
58
+ this._ensure(position + bytes.length);
59
+ this._buf.set(bytes, position);
60
+ if (position + bytes.length > this._len) this._len = position + bytes.length;
61
+ }
62
+ toArrayBuffer() {
63
+ return this._buf.buffer.slice(0, this._len);
64
+ }
65
+ }
66
+
67
+ // Cold prologue guard for finalizeToSink (raised as W_BAD_SINK by the writer):
68
+ // the sink must be an object with write(); layout:'stream' also needs writeAt().
69
+ export function validateSink(sink, needsWriteAt, raise) {
70
+ if (sink === null || typeof sink !== 'object') {
71
+ raise('W_BAD_SINK', 'sink must be an object with a write(bytes) method; got ' +
72
+ (sink === null ? 'null' : typeof sink));
73
+ }
74
+ if (typeof sink.write !== 'function') {
75
+ raise('W_BAD_SINK', 'sink is missing a write(bytes) method');
76
+ }
77
+ if (needsWriteAt && typeof sink.writeAt !== 'function') {
78
+ raise('W_BAD_SINK', "sink is missing a writeAt(bytes, position) method required for layout:'stream'");
79
+ }
80
+ }
81
+
82
+ // A sink write that returns a thenable is an async sink; the contract is
83
+ // synchronous, so the writer treats it as a malformed sink (W_BAD_SINK).
84
+ export function isThenable(v) {
85
+ return v !== null && (typeof v === 'object' || typeof v === 'function') &&
86
+ typeof v.then === 'function';
87
+ }