Skip to content

Commit bfdd4ce

Browse files
eliahilseclaude
andauthored
Python implementation + pydantic adapter (#11)
* feat: Python implementation + pydantic adapter Full port of wire v0 and plan columnar@2 — row and columnar layouts, packed string columns via stdlib zlib raw-deflate, the same decoder limits, the same error codes. The test suite runs the exact spec/vectors files the TypeScript reference runs: all golden vectors both plans, all invalid inputs by error code, the stored-block packed decode, and every fingerprint vector reproduced byte-for-byte from an independent canonical serializer. hyperfly.pydantic.compile maps pydantic v2 models to the same IR the zod adapter produces (Optional[T] becomes the nullable field flag; bounds from annotated-types; Literal/StrEnum to enum), so a shared schema fingerprints identically from either language. Cross-language hardening that surfaced while porting: the scaled-decimal mantissa is now pinned to sign(v)*floor(|v|*10^s + 0.5) in pure IEEE ops — Math.round and Python's banker's round disagree on half values, which would have made encoders in different languages pick different bytes. Spec updated; vectors unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_hf1 * fix(vectors): portable lone-surrogate marker serde_json (correctly) refuses to parse a raw \ud800 escape because Rust strings cannot hold lone surrogates. The vector now carries a {"$surrogate"} marker: JS and Python revive it, implementations whose strings cannot represent one skip it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_hf1 * fix: address codex retro-review findings across all three implementations Conformance and safety fixes surfaced by an adversarial gate review, plus the Rust core (which is now conformant with them from the start): Cross-cutting (spec + TS + Python + Rust, with a new golden vector): - Empty nested struct now round-trips. Columnar flattening created nested containers lazily, so a row like {p:{}} with an all-optional nested struct decoded to {} and then failed to re-encode. Containers are now materialized at each leaf's declared position — order-preserving (Rust's ordered decode caught a key-order regression the JS/Python order-insensitive asserts had masked) and empty-safe. - IR strings (literals, enum members, field names) with lone surrogates are rejected with a typed error instead of silently fingerprinting a replacement char (TS) or raising an untyped UnicodeEncodeError (Python). - Columnar decode enforces max_depth at row-equivalent nesting, matching the row plan. - The scaled-decimal mantissa rule now forbids fused multiply-add contraction in the spec, so the two IEEE operations round identically everywhere. Python decoder safety: - Packed inflate is bounded (declared-size cap, no zlib "0 = unlimited" hole) and rejects truncated deflate streams via an explicit eof check. - max_items is enforced on fixed-length arrays before allocation. Adapter parity (the two adapters are now pinned to one identical artifact string in cross-adapter tests): - pydantic emits `optional` for fields with defaults, intersects multiple integer constraints instead of overwriting, rejects extra="allow" models and RootModel. - zod folds exclusiveMinimum/exclusiveMaximum into integer bounds, so gt/lt match pydantic's inclusive conversion. Also lands the Rust core (hyperfly-core) passing every golden vector both plans, and adds a Python version matrix + Rust job to CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_hf1 * fix: second codex gate round — deep conformance and portability hardening Portability by construction: - Field names that are array indices ("0", "10") or "__proto__" are rejected; JS reorders integer-index object keys and traps __proto__ assignment, so these could never round-trip or fingerprint identically across TS/Python/Rust. This also lets the columnar decoder drop an earlier null-prototype workaround. - The zod adapter rejects array-index enum members: zod itself discards their declaration order (e.options returns them re-sorted), so it cannot honour the IR's ordered member array. It also rejects lone surrogates with a typed UnsupportedSchemaError carrying a path, instead of deferring to a generic error. - nullable(literal null) is rejected: it gave one value two wire encodings. - Rust constrains fixed-array length to the safe-integer domain, matching TS/Python. Decoder correctness and safety, verified across all three with new shared vectors: - Columnar depth is enforced only for participating leaves, matching the row plan (an absent optional nested leaf no longer over-rejects); the same check now guards columnar encoding. - Fixed-length arrays enforce maxItems in TS and Rust (previously Python only). - TS TextDecoder preserves a leading U+FEFF (ignoreBOM) as Python/Rust do. - Packed inflaters require exactly one complete DEFLATE stream: bounded output, truncation rejected, and trailing bytes after the final block rejected — via unused_data (Python), full-consumption check (Rust), and a tight-stream probe (TS, working around node's sync inflater silently ignoring trailing bytes). A valid empty-total packed column decodes everywhere. Adapter fidelity: - pydantic maps Optional[T] to a nullable field and never to wire-optional (defaults are always materialized, so exclude_unset dropped default_factory values); supports annotated_types.Interval; routes field names and dumps through serialization aliases so aliased models round-trip under validation. - Both adapters are pinned to one identical artifact string in cross-adapter tests. Spec: the scaled-decimal mantissa forbids FMA contraction; float column mode ties resolve to the lowest mode byte; packing and inflater-strictness are documented as capability-scoped. Hardening: codec deep-clones its IR at compile, header magic no longer routes through the mutable exported constant, and struct encoding snapshots each field once so accessors cannot desync the bitmap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_hf1 * fix: third codex gate round — prototype safety, inflate flags, depth semantics The suite was genuinely red and I had reported it green: turbo served a cached result and the check grepped that cached output. Verified directly from here on. Blocking fixes: - Rust could not decode its own packed columns. inflate_exact passed flags = 0, omitting TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF, so anything larger than a stored block failed. The golden vectors only carried tiny stored blocks, so a self-roundtrip test now covers what cross-implementation vectors cannot. - TS read struct fields through the prototype chain, so a field legitimately named constructor or toString read an inherited value on encode and wrote onto Object.prototype on decode. Encoding now snapshots own properties only, and columnar container resolution uses hasOwnProperty. - The property generators emitted nullable(literal null) schemas, which the previous round made invalid. Conformance: - Columnar depth now mirrors row-plan structure exactly: a nested container at chain position j sits at depth+2+j and is checked whenever rows exist, while the leaf value at depth+1+len(segs) is checked only when it participates. Previously an absent optional leaf let an over-deep nested struct through. - A nullable flag over a null literal is rejected, closing the field-level variant of an ambiguity fixed at node level last round. - Empty columns must carry mode 0x00; a nonzero mode with no payload was accepted and re-encoded differently. - Python's array-index field-name check is length-bounded before int(), which raises on 3.11+ for very long digit strings. - pydantic rejects split validation/serialization alias pairs instead of emitting an IR name that model_dump never produces. - Codecs deep-copy their IR in Python as they already did in TS. spec/wire-v0.md now documents the portable-field-name, well-formed-Unicode and unambiguous-null rules, so an implementation following the spec alone accepts exactly what these three accept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_hf1 * fix: fourth codex gate round — no blockers, four conformance gaps closed - Empty int and float columns now require mode 0x00 on decode in TypeScript; only the string column had the guard, so TS accepted a nonzero mode with no payload that Python and Rust rejected. - Compiled IR is deep-frozen in TypeScript and exposed as a copy in Python, so codec.ir can no longer drift from the fingerprint that names it. - Python columnar float encoding survives the full float64 domain: probing a decimal scale for values near float_info.max overflowed to infinity and raised OverflowError instead of falling back to raw mode. - The pydantic adapter compares the serialization name against the validation name and rejects any field where they differ, including one-sided aliases that previously produced an IR name model_dump never emits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_hf1 * fix: fifth codex gate round — allocation bound, symmetric packing, encoder limits Blocking: a declared array count is now bounded by the bytes still on the wire. Any element carrying payload costs at least one bit, so four malformed bytes claiming 2^24 rows no longer make TS, Python, or Rust allocate sixteen million rows before noticing the body is empty. maxItems remains the ceiling; this is the floor that makes truncation cheap to reject. Also: - A codec that cannot inflate no longer emits packed string columns. Asymmetric hooks let a codec write bytes its own decoder refused as unsupported. - Encoders enforce their codec's maxByteLength and maxItems, so an encoder can no longer produce output that the same codec rejects on the way back in. - The pydantic adapter rejects AliasChoices and AliasPath validation aliases; the previous check only compared strings and let those through with a wire name validation would never accept. Its wrapper also exposes ir as a copy, matching the core codec. - The compiled codec object is frozen in TS, so codec.ir cannot be reassigned to something the fingerprint does not describe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_hf1 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 60cadeb commit bfdd4ce

45 files changed

Lines changed: 5346 additions & 124 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,30 @@ jobs:
2727
- run: bun run check-types
2828

2929
- run: bun run test
30+
31+
python:
32+
runs-on: ubuntu-latest
33+
timeout-minutes: 10
34+
strategy:
35+
matrix:
36+
python-version: ["3.10", "3.11", "3.12", "3.13"]
37+
steps:
38+
- uses: actions/checkout@v4
39+
40+
- uses: actions/setup-python@v5
41+
with:
42+
python-version: ${{ matrix.python-version }}
43+
44+
- run: pip install ./python[test]
45+
46+
- run: pytest python/tests -q
47+
48+
rust:
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 15
51+
steps:
52+
- uses: actions/checkout@v4
53+
54+
- uses: dtolnay/rust-toolchain@stable
55+
56+
- run: cargo test --manifest-path rust/Cargo.toml

‎.gitignore‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,12 @@ next-env.d.ts
2323
.DS_Store
2424
*.pem
2525
npm-debug.log*
26+
27+
# python
28+
__pycache__/
29+
*.pyc
30+
*.egg-info/
31+
.pytest_cache/
32+
33+
# rust
34+
rust/target/

‎apps/bench/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ cd apps/bench && bun run bench
4444
the wire), which is the honest comparison: schema-aware is precisely the
4545
thing being measured against.
4646
- Every binary contender gets the same +br4 stacking option hyperfly gets.
47+
- Two known minor biases, disclosed rather than hidden: the route discriminator
48+
is a schema literal Hyperfly erases to zero bytes but Protobuf sends as a
49+
field (≈6–9 B/message in Hyperfly's favour), and the reported "codec compile"
50+
time covers both Hyperfly plan compilations while Protobuf's `Root.fromJSON`
51+
runs during suite construction outside the timer. Neither moves the byte
52+
ranking; both are why these numbers stay private until a fresh-process,
53+
matched-setup harness replaces them.
4754

4855
## What these numbers are NOT
4956

‎packages/hyperfly/src/codec.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,19 @@ export interface Codec<T = unknown> {
3333
decodeBody(bytes: Uint8Array): T;
3434
}
3535

36+
function deepFreeze<T>(value: T): T {
37+
if (value && typeof value === "object") {
38+
for (const inner of Object.values(value)) deepFreeze(inner);
39+
Object.freeze(value);
40+
}
41+
return value;
42+
}
43+
3644
export function compileIR<T = unknown>(ir: IRNode, options: CompileOptions = {}): Codec<T> {
3745
validateIR(ir);
46+
// isolate from later mutation, caller-side or through codec.ir: the fingerprint is
47+
// fixed at compile time and the schema behind it must not drift
48+
ir = deepFreeze(structuredClone(ir));
3849
const plan: PlanLayout = options.plan ?? "row";
3950
const artifact = serializeArtifact(ir, plan);
4051
const fingerprintBytes = fingerprintOf(artifact);
@@ -45,7 +56,14 @@ export function compileIR<T = unknown>(ir: IRNode, options: CompileOptions = {})
4556

4657
const encodeBody = (value: T): Uint8Array => {
4758
const w = new Writer();
48-
encodeNode(w, ir, value, "$", 0, { maxDepth: limits.maxDepth, columnar, deflate: pack.deflate });
59+
encodeNode(w, ir, value, "$", 0, {
60+
maxDepth: limits.maxDepth,
61+
maxItems: limits.maxItems,
62+
maxByteLength: limits.maxByteLength,
63+
columnar,
64+
deflate: pack.deflate,
65+
canInflate: pack.inflate !== undefined,
66+
});
4967
return w.finish();
5068
};
5169

@@ -56,7 +74,7 @@ export function compileIR<T = unknown>(ir: IRNode, options: CompileOptions = {})
5674
return value as T;
5775
};
5876

59-
return {
77+
return Object.freeze({
6078
ir,
6179
artifact,
6280
fingerprint,
@@ -66,19 +84,20 @@ export function compileIR<T = unknown>(ir: IRNode, options: CompileOptions = {})
6684
encode(value: T): Uint8Array {
6785
const body = encodeBody(value);
6886
const out = new Uint8Array(HEADER_SIZE + body.length);
69-
out.set(MAGIC, 0);
87+
out[0] = 0x68;
88+
out[1] = 0x66;
7089
out[2] = WIRE_VERSION;
7190
out.set(fingerprintBytes, 3);
7291
out.set(body, HEADER_SIZE);
7392
return out;
7493
},
7594
decode(bytes: Uint8Array): T {
7695
if (bytes.length < HEADER_SIZE) throw new DecodeError("header", "shorter than envelope header");
77-
if (bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1]) throw new DecodeError("header", "bad magic");
96+
if (bytes[0] !== 0x68 || bytes[1] !== 0x66) throw new DecodeError("header", "bad magic");
7897
if (bytes[2] !== WIRE_VERSION) throw new DecodeError("header", `unsupported wire major ${bytes[2]}`);
7998
const actual = toHex(bytes.subarray(3, HEADER_SIZE));
8099
if (actual !== fingerprint) throw new FingerprintMismatchError(fingerprint, actual);
81100
return decodeBody(bytes.subarray(HEADER_SIZE));
82101
},
83-
};
102+
});
84103
}

‎packages/hyperfly/src/columnar.ts‎

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { decodeNode, type Inflate } from "./decode.js";
1+
import { boundByInput, decodeNode, readBitmap, type Inflate } from "./decode.js";
22
import { encodeNode, typeAcceptsNull, utf8Bytes, writeBitmap, type EncodeCtx } from "./encode.js";
33
import { DecodeError, EncodeError } from "./errors.js";
44
import type { IRField, IRNode } from "./ir.js";
5-
import { readBitmap } from "./decode.js";
65
import type { Reader } from "./reader.js";
76
import { INT_MAX, INT_MIN, readUleb, ulebLen, unzigzag, writeUleb, zigzag } from "./varint.js";
87
import type { Writer } from "./writer.js";
@@ -102,7 +101,10 @@ function decodeIntColumn(r: Reader, node: IntNode, count: number, path: string):
102101
const mode = r.u8();
103102
if (mode > 1) throw new DecodeError("marker", `${path}: invalid int column mode 0x${mode.toString(16)}`);
104103
const out: number[] = new Array<number>(count);
105-
if (count === 0) return out;
104+
if (count === 0) {
105+
if (mode !== 0) throw new DecodeError("marker", `${path}: empty column must use mode 0x00`);
106+
return out;
107+
}
106108

107109
const fromForm = (form: bigint): bigint =>
108110
node.min !== undefined ? form + BigInt(node.min) : unzigzag(form);
@@ -147,13 +149,20 @@ function sigBytes(x: bigint): number {
147149
const POW10 = [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000];
148150
const MAX_SCALE = POW10.length - 1;
149151

152+
/** Spec-pinned mantissa recovery: sign(v) * floor(|v|*10^s + 0.5), pure IEEE ops. */
153+
function decimalMantissa(v: number, pow: number): number {
154+
if (v > 0) return Math.floor(v * pow + 0.5);
155+
if (v < 0) return -Math.floor(-v * pow + 0.5);
156+
return 0;
157+
}
158+
150159
/** Smallest s with every value exactly m/10^s for a safe integer m, or null. */
151160
function decimalScale(values: number[]): number | null {
152161
for (let s = 0; s <= MAX_SCALE; s++) {
153162
const pow = POW10[s]!;
154163
let ok = true;
155164
for (const v of values) {
156-
const m = Math.round(v * pow);
165+
const m = decimalMantissa(v, pow);
157166
if (!Number.isSafeInteger(m) || m / pow !== v) {
158167
ok = false;
159168
break;
@@ -190,7 +199,7 @@ function encodeFloatColumn(w: Writer, values: number[], path: string): void {
190199
let scaledRawCost = Infinity;
191200
let mantissas: bigint[] = [];
192201
if (scale !== null) {
193-
mantissas = canon.map((v) => BigInt(Math.round(v * POW10[scale]!)));
202+
mantissas = canon.map((v) => BigInt(decimalMantissa(v, POW10[scale]!)));
194203
scaledRawCost = 1 + mantissas.reduce((n, m) => n + ulebLen(zigzag(m)), 0);
195204
scaledDeltaCost = 1 + ulebLen(zigzag(mantissas[0]!));
196205
for (let i = 1; i < mantissas.length; i++) {
@@ -232,7 +241,10 @@ function decodeFloatColumn(r: Reader, count: number, path: string): number[] {
232241
const mode = r.u8();
233242
if (mode > 3) throw new DecodeError("marker", `${path}: invalid float column mode 0x${mode.toString(16)}`);
234243
const out: number[] = new Array<number>(count);
235-
if (count === 0) return out;
244+
if (count === 0) {
245+
if (mode !== 0) throw new DecodeError("marker", `${path}: empty column must use mode 0x00`);
246+
return out;
247+
}
236248

237249
if (mode >= 2) {
238250
const scale = r.u8();
@@ -294,7 +306,7 @@ function encodeStringColumn(w: Writer, values: unknown[], path: string, ctx: Enc
294306

295307
let packed: Uint8Array | null = null;
296308
let packedCost = Infinity;
297-
if (ctx.deflate) {
309+
if (ctx.deflate && ctx.canInflate) {
298310
const total = bytes.reduce((n, b) => n + b.length, 0);
299311
const concat = new Uint8Array(total);
300312
let offset = 0;
@@ -323,11 +335,12 @@ function encodeStringColumn(w: Writer, values: unknown[], path: string, ctx: Enc
323335
}
324336
}
325337

326-
const utf8Strict = new TextDecoder("utf-8", { fatal: true });
338+
const utf8Strict = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
327339

328340
function decodeStringColumn(r: Reader, count: number, path: string, inflate?: Inflate): string[] {
329341
const mode = r.u8();
330342
if (mode > 1) throw new DecodeError("marker", `${path}: invalid string column mode 0x${mode.toString(16)}`);
343+
if (count === 0 && mode !== 0) throw new DecodeError("marker", `${path}: empty column must use mode 0x00`);
331344
const out: string[] = new Array<string>(count);
332345
if (count === 0) return out;
333346

@@ -431,7 +444,8 @@ export function encodeColumnarArray(
431444
const containerOf = (row: Record<string, unknown>, segs: readonly string[], i: number): Record<string, unknown> => {
432445
let obj: Record<string, unknown> = row;
433446
for (let d = 0; d < segs.length - 1; d++) {
434-
const v = obj[segs[d]!];
447+
const key = segs[d]!;
448+
const v = Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
435449
if (typeof v !== "object" || v === null || Array.isArray(v)) {
436450
throw new EncodeError(
437451
v === undefined ? "required" : "type",
@@ -448,7 +462,10 @@ export function encodeColumnarArray(
448462
const leafName = leaf.segs[leaf.segs.length - 1]!;
449463
const dotted = leaf.segs.join(".");
450464
const fieldPath = `${path}[].${dotted}`;
451-
const values: unknown[] = rows.map((row, i) => containerOf(row, leaf.segs, i)[leafName]);
465+
const values: unknown[] = rows.map((row, i) => {
466+
const holder = containerOf(row, leaf.segs, i);
467+
return Object.prototype.hasOwnProperty.call(holder, leafName) ? holder[leafName] : undefined;
468+
});
452469
const states: RowState[] = values.map((v, i) => {
453470
const absent = v === undefined;
454471
if (absent && !field.optional) {
@@ -471,6 +488,15 @@ export function encodeColumnarArray(
471488
participating.push(values[i]);
472489
}
473490

491+
// row-equivalent depths: a nested container at chain position j sits at depth+2+j,
492+
// the leaf value at depth+1+segs.length. Containers always exist; the leaf only when present.
493+
if (rows.length > 0 && depth + leaf.segs.length > ctx.maxDepth) {
494+
throw new EncodeError("depth", `${fieldPath}: nesting deeper than ${ctx.maxDepth}`);
495+
}
496+
if (participating.length > 0 && depth + 1 + leaf.segs.length > ctx.maxDepth) {
497+
throw new EncodeError("depth", `${fieldPath}: nesting deeper than ${ctx.maxDepth}`);
498+
}
499+
474500
switch (field.type.kind) {
475501
case "int":
476502
encodeIntColumn(
@@ -514,6 +540,10 @@ export function decodeColumnarArray(
514540
}
515541
count = Number(raw);
516542
}
543+
if (count > r.limits.maxItems) {
544+
throw new DecodeError("limit", `${path}: array count ${count} exceeds limit ${r.limits.maxItems}`);
545+
}
546+
boundByInput(r, count, element, path);
517547

518548
const out: Record<string, unknown>[] = Array.from({ length: count }, () => ({}));
519549
const leaves = flattenLeaves(element)!;
@@ -522,7 +552,8 @@ export function decodeColumnarArray(
522552
let obj = row;
523553
for (let d = 0; d < segs.length - 1; d++) {
524554
const seg = segs[d]!;
525-
obj = (obj[seg] ??= {}) as Record<string, unknown>;
555+
if (!Object.prototype.hasOwnProperty.call(obj, seg)) obj[seg] = {};
556+
obj = obj[seg] as Record<string, unknown>;
526557
}
527558
return obj;
528559
};
@@ -531,6 +562,12 @@ export function decodeColumnarArray(
531562
const field = leaf.field;
532563
const leafName = leaf.segs[leaf.segs.length - 1]!;
533564
const fieldPath = `${path}[].${leaf.segs.join(".")}`;
565+
// nested structs are required and non-nullable: materialize the container chain at
566+
// this leaf's declared position for every row, so an all-absent nested struct still
567+
// round-trips and keys stay in declared order across implementations
568+
if (leaf.segs.length > 1) {
569+
for (let i = 0; i < count; i++) containerOf(out[i]!, leaf.segs);
570+
}
534571
const presence = field.optional ? readBitmap(r, count, fieldPath) : null;
535572
const nulls = field.nullable ? readBitmap(r, count, fieldPath) : null;
536573

@@ -549,6 +586,13 @@ export function decodeColumnarArray(
549586
slots.push(i);
550587
}
551588

589+
if (count > 0 && depth + leaf.segs.length > r.limits.maxDepth) {
590+
throw new DecodeError("depth", `${fieldPath}: nesting deeper than ${r.limits.maxDepth}`);
591+
}
592+
if (slots.length > 0 && depth + 1 + leaf.segs.length > r.limits.maxDepth) {
593+
throw new DecodeError("depth", `${fieldPath}: nesting deeper than ${r.limits.maxDepth}`);
594+
}
595+
552596
switch (field.type.kind) {
553597
case "int": {
554598
const values = decodeIntColumn(r, field.type as IntNode, slots.length, fieldPath);

‎packages/hyperfly/src/decode.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { columnarEligible, decodeColumnarArray } from "./columnar.js";
22
import { DecodeError } from "./errors.js";
3-
import type { IRNode } from "./ir.js";
3+
import { hasPayload, type IRNode } from "./ir.js";
44
import type { Reader } from "./reader.js";
55
import { INT_MAX, INT_MIN, readUleb, unzigzag } from "./varint.js";
66

7-
const utf8 = new TextDecoder("utf-8", { fatal: true });
7+
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
88

99
function fail(code: "type" | "range" | "utf8" | "float" | "marker" | "bitmap" | "depth" | "limit", path: string, message: string): never {
1010
throw new DecodeError(code, `${path}: ${message}`);
@@ -45,6 +45,19 @@ export function readBitmap(r: Reader, count: number, path: string): boolean[] {
4545

4646
export type Inflate = (data: Uint8Array, maxOutputLength: number) => Uint8Array;
4747

48+
/**
49+
* A declared count must be payable by the bytes still on the wire: every element that
50+
* carries any payload costs at least one bit, so a truncated body can never make a
51+
* decoder allocate for millions of rows it will never read.
52+
*/
53+
export function boundByInput(r: Reader, count: number, element: IRNode, path: string): void {
54+
if (count === 0 || !hasPayload(element)) return;
55+
const affordable = r.remaining() * 8;
56+
if (count > affordable) {
57+
throw new DecodeError("limit", `${path}: declared ${count} items but only ${r.remaining()} byte(s) remain`);
58+
}
59+
}
60+
4861
export function decodeNode(
4962
r: Reader,
5063
node: IRNode,
@@ -103,6 +116,10 @@ export function decodeNode(
103116
return decodeColumnarArray(r, node, path, depth, inflate);
104117
}
105118
const count = node.length ?? readCount(r, r.limits.maxItems, "array count", path);
119+
if (count > r.limits.maxItems) {
120+
fail("limit", path, `array count ${count} exceeds limit ${r.limits.maxItems}`);
121+
}
122+
boundByInput(r, count, node.element, path);
106123
const out = new Array<unknown>(count);
107124
for (let i = 0; i < count; i++) out[i] = decodeNode(r, node.element, `${path}[${i}]`, depth + 1, columnar, inflate);
108125
return out;
@@ -114,6 +131,8 @@ export function decodeNode(
114131
const nulls = readBitmap(r, nullableCount, path);
115132
let pi = 0;
116133
let ni = 0;
134+
// a field may legitimately be named constructor/toString/valueOf; assigning those on a
135+
// prototypeful object would hit inherited accessors instead of creating own properties
117136
const out: Record<string, unknown> = {};
118137
for (const field of node.fields) {
119138
const present = field.optional ? presence[pi++]! : true;

0 commit comments

Comments
 (0)