Skip to content

Harden compressed ops.bin loader against non-canonical zstd framing and declared-count allocation #12

Description

@welttowelt

Problem:
eval_circuit::load_ops switched ops.bin to a plaintext header plus zstd-compressed fixed-width records. The loader intends to reject trailing data and bound untrusted input, but two audit findings remain:

  1. A file with a valid header and two concatenated empty zstd frames is accepted by load_ops; it then fails later at register validation instead of failing as non-canonical trailing data.
  2. The loader allocates Vec::with_capacity(n) directly from the declared untrusted op count after only checking n <= MAX_OPS, where MAX_OPS = 4_000_000_000. That cap is far above a predictable memory budget for Vec<Op> and happens before the body proves it contains that many records.

Evidence:
Checked on origin/main / da51a4807f92e4dd6df60262a9258aa751adbb58 in a clean detached worktree.

Relevant code:

src/bin/eval_circuit.rs:96  fn load_ops(path: &str) -> Result<Vec<Op>, String>
src/bin/eval_circuit.rs:108 if n > MAX_OPS { ... }
src/bin/eval_circuit.rs:121 let mut ops = Vec::with_capacity(n);
src/bin/eval_circuit.rs:160 // Reject trailing data: exactly n records must decompress, no more.
src/bin/eval_circuit.rs:162 match dec.read(&mut extra) { ... }

Reproducer for concatenated empty frames:

cargo build --release --locked --bin eval_circuit
rm -f ops.bin score.json results.tsv
python3 - <<'PY'
from pathlib import Path
Path('ops.bin').write_bytes(b'QECCOPSZ' + (0).to_bytes(8, 'little'))
PY
printf '' | zstd -q -c >> ops.bin
printf '' | zstd -q -c >> ops.bin
./target/release/eval_circuit --note zero-twoframes

Observed output:

=== quantum_ecc: eval_circuit (trusted stage) ===

  loaded ops  : 0

!! eval FAILED: expected 4 registers, got 0

For comparison, appending raw bytes after the empty frame is rejected by the loader:

!! could not load ops.bin: ops.bin: error checking for trailing data: Unknown frame descriptor

Effect:
The trusted stage does not enforce a canonical single-frame compressed body. Separately, a forged header can steer allocation shape before the compressed body is validated. Neither issue changes the current accepted score, but both weaken the loader's fail-closed story and make malformed input handling harder to reason about.

Smallest useful fix:
Use a single-frame zstd decode mode or explicitly verify EOF on the underlying reader after the frame finishes, so concatenated empty frames fail in load_ops. Replace Vec::with_capacity(n) with a checked reservation path and a memory-derived MAX_OPS cap, or stream records through validation/counting before committing to a full vector-sized allocation.

Gate:
Add loader tests that cover:

  • valid zero-record frame (then downstream register validation can fail as today),
  • raw trailing bytes rejected in load_ops,
  • concatenated empty zstd frames rejected in load_ops,
  • declared counts that exceed the memory-derived cap rejected before allocation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions