@zakkster/lite-bake-stream 1.1.0 → 1.3.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.
@@ -25,12 +25,25 @@
25
25
  // Errors (stable E_* codes):
26
26
  // E_TRUNCATED - end of input reached mid-record (depth != 0 or in-string)
27
27
  // E_RECORD_TOO_LARGE - single record exceeds maxRecordBytes (default: none)
28
+ // E_ENDED - feed()/end() after end()
29
+ // E_POISONED - feed()/end() after a thrown error
30
+ // E_UNKNOWN_OPTION - unknown constructor option key
31
+ // E_OPTION_VALUE - constructor option value out of domain
28
32
 
29
- export const VERSION = '1.1.0';
33
+ import { checkOpts } from './Opts.js';
34
+
35
+ export const VERSION = '1.3.0';
36
+
37
+ const U32_MAX = 4294967295;
38
+ const PRESERVE_TOKENIZER_OPTS = {
39
+ framing: { t: 'enum', values: ['ndjson', 'array', 'auto'] },
40
+ maxRecordBytes: { t: 'int', min: 0, max: U32_MAX },
41
+ };
30
42
 
31
43
  export class PreserveTokenizerError extends Error {
32
44
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveTokenizerError'; }
33
45
  }
46
+ function raisePreserveTok(code, msg) { throw new PreserveTokenizerError(code, msg); }
34
47
 
35
48
  // Byte values used by the scanner (named so the hot loop reads cleanly).
36
49
  const BYTE_LF = 0x0A;
@@ -51,14 +64,18 @@ export class PreserveTokenizer {
51
64
  if (!sink || typeof sink.onRecord !== 'function') {
52
65
  throw new TypeError('PreserveTokenizer: sink must implement onRecord(bytes, from, to)');
53
66
  }
67
+ checkOpts('PreserveTokenizer', opts, PRESERVE_TOKENIZER_OPTS, raisePreserveTok);
54
68
  opts = opts || {};
55
- if (opts.framing && opts.framing !== 'ndjson') {
69
+ if (opts.framing !== undefined && opts.framing !== 'ndjson') {
56
70
  // 'array' framing (top-level `[a, b, c]`) is a future addition.
57
71
  throw new PreserveTokenizerError('E_UNSUPPORTED_FRAMING',
58
72
  'preserve-mode currently supports NDJSON only; got framing=' + opts.framing);
59
73
  }
60
74
  this._sink = sink;
61
- this._maxRecordBytes = typeof opts.maxRecordBytes === 'number' ? opts.maxRecordBytes : 0; // 0 = unlimited
75
+ this._maxRecordBytes = opts.maxRecordBytes !== undefined ? opts.maxRecordBytes : 0; // 0 = unlimited
76
+ // Terminal state (BS-14): once ended or poisoned, the instance is dead.
77
+ this._ended = false;
78
+ this._poisoned = false;
62
79
  // Accumulator: bytes seen since the last emitted record's terminating LF.
63
80
  this._buf = new Uint8Array(INITIAL_BUF);
64
81
  this._bufLen = 0;
@@ -71,22 +88,32 @@ export class PreserveTokenizer {
71
88
  }
72
89
 
73
90
  feed(chunk) {
91
+ if (this._poisoned) throw new PreserveTokenizerError('E_POISONED', 'tokenizer poisoned by a previous error or an in-flight feed(); construct a new instance');
92
+ if (this._ended) throw new PreserveTokenizerError('E_ENDED', 'tokenizer already ended; construct a new instance');
74
93
  if (!(chunk instanceof Uint8Array)) {
75
94
  throw new TypeError('PreserveTokenizer.feed: expected Uint8Array');
76
95
  }
77
- if (chunk.length === 0) return;
96
+ // D3 armed-flag: a sink throw inside _scan unwinds with the flag set,
97
+ // poisoning the instance. Cleared at every clean exit (empty chunk and end).
98
+ this._poisoned = true;
99
+ if (chunk.length === 0) { this._poisoned = false; return; }
78
100
  this._ensureCapacity(this._bufLen + chunk.length);
79
101
  this._buf.set(chunk, this._bufLen);
80
102
  const scanStart = this._bufLen;
81
103
  this._bufLen += chunk.length;
82
104
  this._scan(scanStart);
83
105
  this._compact();
106
+ this._poisoned = false;
84
107
  }
85
108
 
86
109
  end() {
110
+ if (this._poisoned) throw new PreserveTokenizerError('E_POISONED', 'tokenizer poisoned by a previous error or an in-flight feed(); construct a new instance');
111
+ if (this._ended) throw new PreserveTokenizerError('E_ENDED', 'tokenizer already ended; construct a new instance');
112
+ this._poisoned = true; // D3: armed until the clean exit below
87
113
  if (this._bufLen > this._recordStart) {
88
114
  // There's pending content that never terminated.
89
115
  if (this._depth !== 0 || this._inString || this._escape) {
116
+ this._poisoned = true;
90
117
  throw new PreserveTokenizerError('E_TRUNCATED',
91
118
  'input ended mid-record at byte ' + (this._absOffset + this._bufLen) +
92
119
  ' (depth=' + this._depth + ', inString=' + this._inString + ')');
@@ -96,6 +123,8 @@ export class PreserveTokenizer {
96
123
  this._emitRecord(this._recordStart, this._bufLen);
97
124
  this._recordStart = this._bufLen;
98
125
  }
126
+ this._ended = true;
127
+ this._poisoned = false; // clean end; a throwing onRecord above stays poisoned
99
128
  }
100
129
 
101
130
  _scan(from) {
@@ -119,6 +148,7 @@ export class PreserveTokenizer {
119
148
  depth--;
120
149
  if (depth < 0) {
121
150
  this._depth = depth; this._inString = inString; this._escape = escape;
151
+ this._poisoned = true;
122
152
  throw new PreserveTokenizerError('E_UNBALANCED',
123
153
  'closing bracket without matching open at byte ' + (this._absOffset + i));
124
154
  }
@@ -130,6 +160,7 @@ export class PreserveTokenizer {
130
160
  }
131
161
  }
132
162
  if (this._maxRecordBytes > 0 && (i - this._recordStart) > this._maxRecordBytes) {
163
+ this._poisoned = true;
133
164
  throw new PreserveTokenizerError('E_RECORD_TOO_LARGE',
134
165
  'record starting at byte ' + (this._absOffset + this._recordStart) +
135
166
  ' exceeded maxRecordBytes=' + this._maxRecordBytes);
@@ -16,22 +16,35 @@
16
16
  // Reader dispatch happens via the flag bit; a schema-mode Reader refuses this
17
17
  // container with R_WRONG_MODE.
18
18
 
19
- export const VERSION = '1.1.0';
19
+ import { checkOpts } from './Opts.js';
20
+
21
+ export const VERSION = '1.3.0';
20
22
 
21
23
  export class PreserveWriterError extends Error {
22
24
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
23
25
  }
26
+ function raisePreserveWriter(code, msg) { throw new PreserveWriterError(code, msg); }
24
27
 
25
28
  const CONTAINER_HEADER_BYTES = 48;
26
29
  const SHARD_ENTRY_BYTES = 40;
27
30
  const FOOTER_BYTES = 16;
28
31
  const DEFAULT_TARGET_SHARD_BYTES = 8 * 1024 * 1024; // 8 MiB — smaller than schema mode
29
32
  const INITIAL_OFFSETS_CAP = 4096;
33
+ const U32_MAX = 4294967295;
34
+ // maxRecordBytes is a declared, validated, deliberately-unused key here: the
35
+ // documented call serialize(x, {preserve:true, writer:{maxRecordBytes:N}}) reads
36
+ // it out of opts.writer and hands it to PreserveTokenizer, which consumes it.
37
+ // PreserveWriter accepts it so that call shape does not trip E_UNKNOWN_OPTION.
38
+ const PRESERVE_WRITER_OPTS = {
39
+ targetShardBytes: { t: 'int', min: 1, max: U32_MAX },
40
+ maxRecordBytes: { t: 'int', min: 0, max: U32_MAX },
41
+ };
30
42
 
31
43
  export class PreserveWriter {
32
44
  constructor(opts) {
45
+ checkOpts('PreserveWriter', opts, PRESERVE_WRITER_OPTS, raisePreserveWriter);
33
46
  opts = opts || {};
34
- this._targetShardBytes = opts.targetShardBytes || DEFAULT_TARGET_SHARD_BYTES;
47
+ this._targetShardBytes = opts.targetShardBytes !== undefined ? opts.targetShardBytes : DEFAULT_TARGET_SHARD_BYTES;
35
48
  this._shards = [];
36
49
  this._totalRows = 0;
37
50
  this._finalized = false;
@@ -9,9 +9,9 @@
9
9
  // table offsets exist precisely so a client can fetch only what it needs.
10
10
  //
11
11
  // RangeReader implements that story:
12
- // 1. On open, fetch the header (32 bytes) + schema block + shard directory.
13
- // That gives us the whole schema and every shard's byte extents without
14
- // touching a single row.
12
+ // 1. On open, fetch the header (48 bytes) + footer + schema block + shard
13
+ // directory. That gives us the whole schema and every shard's byte extents
14
+ // without touching a single row.
15
15
  // 2. On get(rowIdx, fieldName), locate the shard containing rowIdx, fetch
16
16
  // its payload + local string table in ONE range request (they are
17
17
  // contiguous by SPEC 3.4), cache it, decode.
@@ -32,10 +32,17 @@
32
32
  // R_UNSUPPORTED_LANE / R_SHARD_VERSION_TOO_NEW / R_TRUNCATED / R_UNKNOWN_FIELD
33
33
  // R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested
34
34
  // R_ROW_OUT_OF_RANGE -- rowIdx >= totalRows
35
+ // R_WRONG_MODE -- preserve-mode container fed to the schema RangeReader
36
+ // R_BAD_FLAGS -- unknown header flag bits set
37
+ // R_RESERVED_NONZERO -- a reserved header/descriptor/shard field is non-zero
38
+ // R_BAD_FOOTER -- footer magic_end or footer_len is malformed
39
+ // R_BAD_METADATA -- metadata_off non-zero but the zone-map segment is unparseable
40
+ // R_INVALID -- a structure is internally inconsistent but in-bounds
35
41
 
36
42
  import { StringTable } from './StringTable.js';
43
+ import { checkOpts } from './Opts.js';
37
44
 
38
- export const VERSION = '1.1.0';
45
+ export const VERSION = '1.3.0';
39
46
 
40
47
  const CONTAINER_HEADER_BYTES = 48;
41
48
  const SHARD_ENTRY_BYTES = 40;
@@ -44,10 +51,44 @@ const FIELD_DESCRIPTOR_BYTES = 24;
44
51
  const LANE_F64 = 1;
45
52
  const LANE_U32 = 3;
46
53
  const READER_VERSION = 1;
54
+ const U32_MAX = 4294967295;
55
+
56
+ const RANGE_READER_OPTS = { maxCachedShards: { t: 'int', min: 0, max: U32_MAX } };
57
+ const HTTP_ADAPTER_OPTS = { fetch: { t: 'fn' } };
58
+
59
+ const CONTAINER_FOOTER_BYTES = 16;
60
+
61
+ function laneBytesOf(k) { return k === LANE_F64 ? 8 : (k === LANE_U32 ? 4 : 0); }
47
62
 
48
63
  export class RangeReaderError extends Error {
49
64
  constructor(code, msg) { super(msg); this.code = code; this.name = 'RangeReaderError'; }
50
65
  }
66
+ function raiseRange(code, msg) { throw new RangeReaderError(code, msg); }
67
+
68
+ // Validate a local string table's shape before StringTable.parse casts a
69
+ // Uint32Array over it (T-1..T-4). `bytes` is the already-fetched shard slice;
70
+ // `off` and `len` locate the table within it.
71
+ function validateStringTable(bytes, off, len, label) {
72
+ if (len < 8)
73
+ throw new RangeReaderError('R_TRUNCATED', label + ' string table shorter than its 8-byte header');
74
+ const dv = new DataView(bytes.buffer, bytes.byteOffset + off, len);
75
+ const entryCount = dv.getUint32(0, true);
76
+ const blobLen = dv.getUint32(4, true);
77
+ if (8 + (entryCount + 1) * 4 + blobLen > len)
78
+ throw new RangeReaderError('R_TRUNCATED', label + ' string table claims ' + entryCount +
79
+ ' entries + ' + blobLen + ' blob bytes past its ' + len + '-byte extent');
80
+ let prev = dv.getUint32(8, true);
81
+ if (prev !== 0)
82
+ throw new RangeReaderError('R_INVALID', label + ' string table offsets[0] is ' + prev + ', must be 0');
83
+ for (let i = 1; i <= entryCount; i++) {
84
+ const cur = dv.getUint32(8 + i * 4, true);
85
+ if (cur < prev)
86
+ throw new RangeReaderError('R_INVALID', label + ' string table offsets not monotonic at index ' + i);
87
+ prev = cur;
88
+ }
89
+ if (prev !== blobLen)
90
+ throw new RangeReaderError('R_INVALID', label + ' string table sentinel ' + prev + ' != blob_length ' + blobLen);
91
+ }
51
92
 
52
93
  // ----- Adapters --------------------------------------------------------------
53
94
 
@@ -56,8 +97,9 @@ export class RangeReaderError extends Error {
56
97
  // origin/CDN).
57
98
  export class HTTPRangeAdapter {
58
99
  static async open(url, opts) {
100
+ checkOpts('HTTPRangeAdapter', opts, HTTP_ADAPTER_OPTS, raiseRange);
59
101
  opts = opts || {};
60
- const fetchImpl = opts.fetch || globalThis.fetch;
102
+ const fetchImpl = opts.fetch !== undefined ? opts.fetch : globalThis.fetch;
61
103
  if (typeof fetchImpl !== 'function') {
62
104
  throw new RangeReaderError('R_TRUNCATED', 'no fetch() available in this environment');
63
105
  }
@@ -148,9 +190,10 @@ export class RangeReader {
148
190
  }
149
191
 
150
192
  constructor(adapter, opts) {
193
+ checkOpts('RangeReader', opts, RANGE_READER_OPTS, raiseRange);
151
194
  opts = opts || {};
152
195
  this.adapter = adapter;
153
- this.maxCachedShards = opts.maxCachedShards || 8;
196
+ this.maxCachedShards = opts.maxCachedShards !== undefined ? opts.maxCachedShards : 8;
154
197
  // shard cache: shardIdx -> { payloadBytes, payloadDv, stringTable, lastAccess }
155
198
  this._shardCache = new Map();
156
199
  this._accessCounter = 0;
@@ -161,7 +204,7 @@ export class RangeReader {
161
204
  throw new RangeReaderError('R_TRUNCATED', 'adapter size smaller than header');
162
205
  }
163
206
 
164
- // Step 1: header (32 bytes) tells us schema + shard-dir offsets.
207
+ // Step 1: header (48 bytes) tells us schema + shard-dir offsets.
165
208
  const headerBytes = await this.adapter.fetch(0, CONTAINER_HEADER_BYTES);
166
209
  const hdrDv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
167
210
  if (headerBytes[0] !== 0x4C || headerBytes[1] !== 0x42 || headerBytes[2] !== 0x4B || headerBytes[3] !== 0x31) {
@@ -171,21 +214,48 @@ export class RangeReader {
171
214
  if (version > 1) throw new RangeReaderError('R_UNSUPPORTED_VERSION', 'format_version=' + version);
172
215
  const endian = headerBytes[6];
173
216
  if (endian !== 1) throw new RangeReaderError('R_UNSUPPORTED_ENDIAN', 'BE payloads not implemented in v1 reader');
217
+ const flags = headerBytes[7];
218
+ if (flags & 0x01) {
219
+ throw new RangeReaderError('R_WRONG_MODE',
220
+ 'container is preserve-mode; RangeReader is unsupported in preserve mode (SPEC 3.8) -- use PreserveReader');
221
+ }
222
+ if (flags & ~0x01) {
223
+ throw new RangeReaderError('R_BAD_FLAGS', 'unknown flag bits set in header byte 7: 0x' + flags.toString(16));
224
+ }
225
+ const reserved1 = hdrDv.getUint32(36, true);
226
+ if (reserved1 !== 0)
227
+ throw new RangeReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
174
228
 
175
229
  this._schemaBlockOff = Number(hdrDv.getBigUint64(8, true));
176
230
  this._metadataOff = Number(hdrDv.getBigUint64(16, true));
177
231
  this._shardDirOff = Number(hdrDv.getBigUint64(24, true));
178
232
  this._shardCount = hdrDv.getUint32(32, true);
179
- // 4 bytes reserved at 36
180
233
  this._totalRows = Number(hdrDv.getBigUint64(40, true));
181
234
 
235
+ const size = this.adapter.size;
236
+ if (this._schemaBlockOff < CONTAINER_HEADER_BYTES || this._schemaBlockOff >= size)
237
+ throw new RangeReaderError('R_TRUNCATED', 'schema_block_off ' + this._schemaBlockOff + ' out of range [48, ' + size + ')');
238
+ if (this._shardDirOff < CONTAINER_HEADER_BYTES || this._shardDirOff >= size)
239
+ throw new RangeReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' out of range [48, ' + size + ')');
240
+ if (this._metadataOff !== 0 && (this._metadataOff < CONTAINER_HEADER_BYTES || this._metadataOff >= size))
241
+ throw new RangeReaderError('R_TRUNCATED', 'metadata_off ' + this._metadataOff + ' out of range {0} u [48, ' + size + ')');
242
+ if (this._shardCount * SHARD_ENTRY_BYTES > size - CONTAINER_HEADER_BYTES)
243
+ throw new RangeReaderError('R_TRUNCATED', 'shard_count ' + this._shardCount + ' exceeds the bytes available for a shard directory');
244
+
245
+ // Footer: one 16-byte suffix fetch. Refuse a wrong-footer container before
246
+ // spending fetches on the schema. Gated on a body big enough to hold a
247
+ // header and a footer that do not overlap.
248
+ if (size >= CONTAINER_HEADER_BYTES + CONTAINER_FOOTER_BYTES) await this._loadFooter();
249
+
182
250
  // Step 2: schema block. We know its start but not its length; compute
183
251
  // upper bound: schemaBlockOff .. shardDirOff.
252
+ if (this._shardDirOff <= this._schemaBlockOff)
253
+ throw new RangeReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' not after schema_block_off ' + this._schemaBlockOff);
184
254
  const schemaBlockLen = this._shardDirOff - this._schemaBlockOff;
185
255
  const schemaBytes = await this.adapter.fetch(this._schemaBlockOff, schemaBlockLen);
186
256
  this._parseSchema(schemaBytes);
187
257
 
188
- // Step 3: shard directory (fixed size = shardCount * 32).
258
+ // Step 3: shard directory (fixed size = shardCount * 40).
189
259
  const dirBytes = await this.adapter.fetch(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
190
260
  this._parseShardDirectory(dirBytes);
191
261
 
@@ -202,21 +272,43 @@ export class RangeReader {
202
272
  }
203
273
  }
204
274
 
275
+ async _loadFooter() {
276
+ const size = this.adapter.size;
277
+ const footer = await this.adapter.fetch(size - CONTAINER_FOOTER_BYTES, CONTAINER_FOOTER_BYTES);
278
+ if (footer[8] !== 0x31 || footer[9] !== 0x4B || footer[10] !== 0x42 || footer[11] !== 0x4C)
279
+ throw new RangeReaderError('R_BAD_FOOTER', 'footer magic_end is not 1KBL');
280
+ const fDv = new DataView(footer.buffer, footer.byteOffset, footer.byteLength);
281
+ const footerLen = fDv.getUint32(12, true);
282
+ if (footerLen < CONTAINER_FOOTER_BYTES)
283
+ throw new RangeReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' is less than the minimum ' + CONTAINER_FOOTER_BYTES);
284
+ if (footerLen > size - CONTAINER_HEADER_BYTES)
285
+ throw new RangeReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' exceeds the container body size');
286
+ }
287
+
205
288
  async _loadZoneMaps() {
206
- // Fetch the segment header first (16 bytes) so we can size the rest.
289
+ // Fetch the segment header first (16 bytes) so we can size the rest. A
290
+ // non-zero metadata_off asserts a parseable segment; any failure is
291
+ // corruption (D1), not absence.
292
+ if (this._metadataOff + 16 > this.adapter.size)
293
+ throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment header runs past the container');
207
294
  const hdr = await this.adapter.fetch(this._metadataOff, 16);
208
- if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31) return;
295
+ if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31)
296
+ throw new RangeReaderError('R_BAD_METADATA', 'zone maps magic is not ZM01');
209
297
  const hdrDv = new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength);
210
298
  const shardCount = hdrDv.getUint32(4, true);
211
299
  const T = hdrDv.getUint32(8, true);
212
- if (shardCount !== this._shardCount) {
213
- throw new RangeReaderError('R_TRUNCATED', 'zone maps shard_count mismatch');
214
- }
300
+ const reserved0 = hdrDv.getUint32(12, true);
301
+ if (shardCount !== this._shardCount)
302
+ throw new RangeReaderError('R_BAD_METADATA', 'zone maps shard_count ' + shardCount + ' != header ' + this._shardCount);
303
+ if (reserved0 !== 0)
304
+ throw new RangeReaderError('R_BAD_METADATA', 'zone maps reserved0 must be 0, got ' + reserved0);
215
305
  if (T === 0) return;
216
306
  // Fetch the rest in one range: field table + pad + mins + maxes.
217
307
  const fieldTableLen = T * 2;
218
308
  const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
219
309
  const restLen = fieldTableLen + fieldTablePad + shardCount * T * 8 * 2;
310
+ if (this._metadataOff + 16 + restLen > this.adapter.size)
311
+ throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment runs past the container');
220
312
  const rest = await this.adapter.fetch(this._metadataOff + 16, restLen);
221
313
  const restDv = new DataView(rest.buffer, rest.byteOffset, rest.byteLength);
222
314
  const tracked = new Array(T);
@@ -224,7 +316,7 @@ export class RangeReader {
224
316
  for (let t = 0; t < T; t++) {
225
317
  const schemaFieldIdx = restDv.getUint16(t * 2, true);
226
318
  if (schemaFieldIdx >= this._schema.fields.length) {
227
- throw new RangeReaderError('R_TRUNCATED', 'zone maps references field index ' + schemaFieldIdx);
319
+ throw new RangeReaderError('R_BAD_METADATA', 'zone maps references field index ' + schemaFieldIdx + ' past the schema');
228
320
  }
229
321
  tracked[t] = schemaFieldIdx;
230
322
  fieldToPos.set(this._schema.fields[schemaFieldIdx].name, t);
@@ -242,13 +334,20 @@ export class RangeReader {
242
334
 
243
335
  _parseSchema(bytes) {
244
336
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
337
+ const total = bytes.byteLength;
338
+ if (total < 8) throw new RangeReaderError('R_TRUNCATED', 'schema block shorter than its 8-byte header');
245
339
  const fieldCount = dv.getUint32(0, true);
246
340
  const rowStride = dv.getUint32(4, true);
341
+ if (rowStride < 1) throw new RangeReaderError('R_INVALID', 'schema row_stride is 0');
247
342
  const descOff = 8;
248
343
  const descBytes = fieldCount * FIELD_DESCRIPTOR_BYTES;
344
+ if (descOff + descBytes + 4 > total)
345
+ throw new RangeReaderError('R_TRUNCATED', 'schema descriptors run past the fetched schema block');
249
346
  const nameBlobLenOff = descOff + descBytes;
250
347
  const nameBlobLen = dv.getUint32(nameBlobLenOff, true);
251
348
  const nameBlobOff = nameBlobLenOff + 4;
349
+ if (nameBlobOff + nameBlobLen > total)
350
+ throw new RangeReaderError('R_TRUNCATED', 'schema name blob runs past the fetched schema block');
252
351
  const decoder = new TextDecoder('utf-8');
253
352
  const fields = new Array(fieldCount);
254
353
  for (let i = 0; i < fieldCount; i++) {
@@ -257,10 +356,19 @@ export class RangeReader {
257
356
  const offsetInRow = dv.getUint16(off + 2, true);
258
357
  const laneKind = bytes[off + 4];
259
358
  const flags = bytes[off + 5];
359
+ const reserved2 = dv.getUint16(off + 6, true);
260
360
  const nameStrOff = Number(dv.getBigUint64(off + 8, true));
361
+ const reserved3 = dv.getBigUint64(off + 16, true);
261
362
  if (flags !== 0) throw new RangeReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags');
363
+ if (reserved2 !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved2 must be 0, got ' + reserved2);
364
+ if (reserved3 !== 0n) throw new RangeReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved3 must be 0, got ' + reserved3);
262
365
  if (laneKind !== LANE_F64 && laneKind !== LANE_U32)
263
366
  throw new RangeReaderError('R_UNSUPPORTED_LANE', 'field ' + i + ' lane_kind=' + laneKind);
367
+ if (nameLen > 255) throw new RangeReaderError('R_INVALID', 'field ' + i + ' name_len ' + nameLen + ' exceeds 255');
368
+ if (offsetInRow + laneBytesOf(laneKind) > rowStride)
369
+ throw new RangeReaderError('R_INVALID', 'field ' + i + ' offset_in_row ' + offsetInRow + ' + lane bytes exceeds row_stride ' + rowStride);
370
+ if (nameStrOff + nameLen > nameBlobLen)
371
+ throw new RangeReaderError('R_TRUNCATED', 'field ' + i + ' name range past the name blob');
264
372
  const nb = bytes.subarray(nameBlobOff + nameStrOff, nameBlobOff + nameStrOff + nameLen);
265
373
  fields[i] = { name: decoder.decode(nb), laneKind, offsetInRow };
266
374
  }
@@ -270,6 +378,8 @@ export class RangeReader {
270
378
  _parseShardDirectory(bytes) {
271
379
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
272
380
  const shards = new Array(this._shardCount);
381
+ const size = this.adapter.size;
382
+ const rowStride = this._schema.rowStride;
273
383
  let cumulativeRow = 0;
274
384
  for (let i = 0; i < this._shardCount; i++) {
275
385
  const off = i * SHARD_ENTRY_BYTES;
@@ -277,12 +387,25 @@ export class RangeReader {
277
387
  const payloadLen = dv.getUint32(off + 8, true);
278
388
  const rowCount = dv.getUint32(off + 12, true);
279
389
  const minReaderVer = dv.getUint16(off + 16, true);
280
- // 2 bytes flags at 18, 4 bytes reserved at 20
390
+ const shardFlags = dv.getUint16(off + 18, true);
391
+ const shardReserved = dv.getUint32(off + 20, true);
281
392
  const localStrOff = Number(dv.getBigUint64(off + 24, true));
282
393
  const localStrLen = Number(dv.getBigUint64(off + 32, true));
283
394
  if (minReaderVer > READER_VERSION)
284
395
  throw new RangeReaderError('R_SHARD_VERSION_TOO_NEW',
285
396
  'shard ' + i + ' requires reader version ' + minReaderVer);
397
+ if (shardFlags !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'shard ' + i + ' flags must be 0, got ' + shardFlags);
398
+ if (shardReserved !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'shard ' + i + ' reserved must be 0, got ' + shardReserved);
399
+ if (payloadOff < CONTAINER_HEADER_BYTES)
400
+ throw new RangeReaderError('R_INVALID', 'shard ' + i + ' payload_off ' + payloadOff + ' overlaps the header');
401
+ if (payloadOff + payloadLen > size)
402
+ throw new RangeReaderError('R_TRUNCATED', 'shard ' + i + ' payload past the container size ' + size);
403
+ if (rowCount * rowStride > payloadLen)
404
+ throw new RangeReaderError('R_INVALID', 'shard ' + i + ' row_count ' + rowCount + ' * row_stride ' + rowStride + ' exceeds payload_len ' + payloadLen);
405
+ if (localStrLen === 0 && localStrOff !== 0)
406
+ throw new RangeReaderError('R_INVALID', 'shard ' + i + ' has local_string_len 0 but local_string_off ' + localStrOff);
407
+ if (localStrLen > 0 && localStrOff + localStrLen > size)
408
+ throw new RangeReaderError('R_TRUNCATED', 'shard ' + i + ' string table past the container size ' + size);
286
409
  shards[i] = {
287
410
  payloadOff, payloadLen, rowCount, localStrOff, localStrLen,
288
411
  firstRow: cumulativeRow,
@@ -290,6 +413,8 @@ export class RangeReader {
290
413
  };
291
414
  cumulativeRow += rowCount;
292
415
  }
416
+ if (cumulativeRow !== this._totalRows)
417
+ throw new RangeReaderError('R_INVALID', 'shard row_count sum ' + cumulativeRow + ' != header total_rows ' + this._totalRows);
293
418
  this._shards = shards;
294
419
  }
295
420
 
@@ -346,6 +471,7 @@ export class RangeReader {
346
471
  let stringTable = null;
347
472
  if (s.localStrLen > 0) {
348
473
  const stBytes = combined.subarray(s.payloadLen, s.payloadLen + s.localStrLen);
474
+ validateStringTable(stBytes, 0, s.localStrLen, 'shard ' + shardIdx);
349
475
  stringTable = StringTable.parse(stBytes, 0);
350
476
  }
351
477
  const record = {
@@ -440,6 +566,8 @@ export class RangeReader {
440
566
  // the container has no zone maps or the field is not tracked. Synchronous
441
567
  // -- zone maps were fetched during open().
442
568
  shardBounds(shardIdx, fieldName) {
569
+ if (!this._fieldIndex.has(fieldName))
570
+ throw new RangeReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
443
571
  if (!this._zoneMapsTrackedFields) return null;
444
572
  const t = this._zoneMapsFieldToPos.get(fieldName);
445
573
  if (t === undefined) return null;
@@ -452,6 +580,8 @@ export class RangeReader {
452
580
  // When no zone maps are present or the field is not tracked, returns every
453
581
  // shard (query planner must fall back to full scan).
454
582
  findShards(fieldName, opts) {
583
+ if (!this._fieldIndex.has(fieldName))
584
+ throw new RangeReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
455
585
  const all = () => { const a = new Array(this._shardCount); for (let i = 0; i < this._shardCount; i++) a[i] = i; return a; };
456
586
  if (!this._zoneMapsTrackedFields) return all();
457
587
  const t = this._zoneMapsFieldToPos.get(fieldName);