wai: reader limits that bound memory (manifest values, text decode), and the codec wrappers in CI #231

Merged
dcharlot merged 13 commits from wai/security-gaps into main 2026-10-13 06:36:49 -04:00
Owner

Closes the two memory gaps wai/SECURITY.md listed, and puts the tests that prove it, and the codec wrappers' fuzz smoke, into CI. Also adds the changelog entry the integer-operator pull requests left out.

1. A manifest parse is bounded by its values, not only its length

A manifest at the 2²⁷-byte length limit made of small values took several gigabytes to parse: a parsed value takes more memory than its text.

  • SPEC §2, Reader limits: a reader need accept at most 2²² JSON values in a manifest; one more may be refused (manifest_too_many_values).
  • The reference readers count the values before they build anything (check_manifest_text), refuse past the limit, then build each array and object at the size counted and move the members out of that one tree. On a 64-bit target a read allocates at most 128 bytes a value and one a byte: at most 640 MiB for a WAI1 manifest at both limits.
  • Elsewhere: wai.h gains MANIFEST_MAX_VALUES; the four stdlib Python verifiers refuse the same manifests; the HTTP corpus gains a case at the limit and one past it for two objects, with a falsification patch and a mutation that must fail them.
  • Tests: tests/manifest_parse_memory.rs measures each case with a counting allocator; tests/manifest_reader_equivalence.rs holds the new readers to the derived reader's result for every committed envelope and for each with every member removed or retyped. Neither file was named by any workflow step, so no job ran them: both now run in the release step of the cargo-class job.

2. zstd and xz decode within a bound

wai.text.zstd and wai.text.xz decoded the whole stream into memory, so a few kilobytes could ask for any amount.

  • SPEC §2: a text payload decodes to at most 2²⁷ bytes and declares at most 2²⁷ bytes of window (Zstandard) or dictionary (LZMA2). A reader may refuse more: text_decoded_too_long, text_window_too_large.
  • The reference decoders write into a buffer that is never sized past the limit, refuse a Zstandard frame that declares a longer content size before decoding anything, and give the decoding library the window limit, so a declaration past it is refused from the stream's header. The encoders refuse input past the length limit. The C entry points are these decoders.
  • Tests: tests/text_decode_bounds.rs checks each limit at its edge, on streams made in the test that sit at the limit and one step past it; tests/text_decode_memory.rs measures that refusing a payload that would expand to four times a limit takes no more output buffer than the limit.

Planned break for writers. A conforming encoder writes no manifest past the value limit and no text payload past either text limit. No committed envelope or vector is past any of them, and no digest changes.

3. The codec wrappers run in CI

codecs binds C and C++ libraries no container image of the fleet carries, so no job built it, and tests/fuzz_smoke.rs ran only by hand. wai-conformance.yml gains codec-wrappers on the macos runner: fuzz smoke, the text decode limits and the C entry points. Each of those test files is gated on the feature, and a build without it reports "0 passed" and exits 0, so the step fails unless every one of its four binaries ran at least one test.

4. One existing test changed

tests/staged_resources.rs bounded a refused layer's peak allocation by twice the envelope's length. Reading a manifest within the new bound takes more than that on a small manifest, so the check failed; and a peak can no longer show a unit decoded after the read, because the read's own peak is higher. The check now measures reading the same envelope and bounds what the refusal allocates beyond it. It was falsified again: allocating one unit's output before the refusal fails it.

5. Changelog

Two entries: this work, and the integer-operator crate, bounds pass and certificates, which their own pull requests did not record.

Evidence

Run on the build host; the text checks and the Python verifier at the pushed head:

check result
landing gate (build, library tests, carriage, 21 corpora, fast kernels, byte-exact, wai-web wasm32, neural gates) green
codec-wrappers step, exactly as written passes; with the feature removed, exits 1 ("0 did")
text decode: six mutations (output unbounded; window limit raised; no xz memory limit; declared size unchecked; buffer grown past the limit; writers not refusing) each turns its test red; restored, green
manifest values: the falsification patch applied, which drops the value limit two of the memory test's five cases go red (refused-before-built, and one-past-the-limit); reversed, green
HTTP corpus: wai_http_vectors verify; http_verify.py; the new patch through falsify/run.sh; the new mutation; --check-shards both verifiers pass; the patch and the mutation each fail exactly the two cases they name; the shards cover every patch and mutation once
all falsification patches still apply after merging main yes
comparative prose, vanity metrics, public names (with the private list), section references, lockfiles, workflow concurrency all pass

🤖 Generated with Claude Code

Closes the two memory gaps `wai/SECURITY.md` listed, and puts the tests that prove it, and the codec wrappers' fuzz smoke, into CI. Also adds the changelog entry the integer-operator pull requests left out. ## 1. A manifest parse is bounded by its values, not only its length A manifest at the 2²⁷-byte length limit made of small values took several gigabytes to parse: a parsed value takes more memory than its text. - **SPEC §2, *Reader limits*:** a reader need accept at most 2²² JSON values in a manifest; one more may be refused (`manifest_too_many_values`). - **The reference readers** count the values before they build anything (`check_manifest_text`), refuse past the limit, then build each array and object at the size counted and move the members out of that one tree. On a 64-bit target a read allocates at most 128 bytes a value and one a byte: at most 640 MiB for a `WAI1` manifest at both limits. - **Elsewhere:** `wai.h` gains `MANIFEST_MAX_VALUES`; the four stdlib Python verifiers refuse the same manifests; the HTTP corpus gains a case at the limit and one past it for two objects, with a falsification patch and a mutation that must fail them. - **Tests:** `tests/manifest_parse_memory.rs` measures each case with a counting allocator; `tests/manifest_reader_equivalence.rs` holds the new readers to the derived reader's result for every committed envelope and for each with every member removed or retyped. Neither file was named by any workflow step, so no job ran them: both now run in the release step of the cargo-class job. ## 2. zstd and xz decode within a bound `wai.text.zstd` and `wai.text.xz` decoded the whole stream into memory, so a few kilobytes could ask for any amount. - **SPEC §2:** a text payload decodes to at most 2²⁷ bytes and declares at most 2²⁷ bytes of window (Zstandard) or dictionary (LZMA2). A reader may refuse more: `text_decoded_too_long`, `text_window_too_large`. - **The reference decoders** write into a buffer that is never sized past the limit, refuse a Zstandard frame that declares a longer content size before decoding anything, and give the decoding library the window limit, so a declaration past it is refused from the stream's header. The encoders refuse input past the length limit. The C entry points are these decoders. - **Tests:** `tests/text_decode_bounds.rs` checks each limit at its edge, on streams made in the test that sit at the limit and one step past it; `tests/text_decode_memory.rs` measures that refusing a payload that would expand to four times a limit takes no more output buffer than the limit. **Planned break for writers.** A conforming encoder writes no manifest past the value limit and no text payload past either text limit. No committed envelope or vector is past any of them, and no digest changes. ## 3. The codec wrappers run in CI `codecs` binds C and C++ libraries no container image of the fleet carries, so no job built it, and `tests/fuzz_smoke.rs` ran only by hand. `wai-conformance.yml` gains `codec-wrappers` on the `macos` runner: fuzz smoke, the text decode limits and the C entry points. Each of those test files is gated on the feature, and a build without it reports "0 passed" and exits 0, so the step fails unless every one of its four binaries ran at least one test. ## 4. One existing test changed `tests/staged_resources.rs` bounded a refused layer's peak allocation by twice the envelope's length. Reading a manifest within the new bound takes more than that on a small manifest, so the check failed; and a peak can no longer show a unit decoded after the read, because the read's own peak is higher. The check now measures reading the same envelope and bounds what the refusal allocates beyond it. It was falsified again: allocating one unit's output before the refusal fails it. ## 5. Changelog Two entries: this work, and the integer-operator crate, bounds pass and certificates, which their own pull requests did not record. ## Evidence Run on the build host; the text checks and the Python verifier at the pushed head: | check | result | |---|---| | landing gate (build, library tests, carriage, 21 corpora, fast kernels, byte-exact, wai-web wasm32, neural gates) | green | | `codec-wrappers` step, exactly as written | passes; with the feature removed, exits 1 ("0 did") | | text decode: six mutations (output unbounded; window limit raised; no xz memory limit; declared size unchecked; buffer grown past the limit; writers not refusing) | each turns its test red; restored, green | | manifest values: the falsification patch applied, which drops the value limit | two of the memory test's five cases go red (refused-before-built, and one-past-the-limit); reversed, green | | HTTP corpus: `wai_http_vectors verify`; `http_verify.py`; the new patch through `falsify/run.sh`; the new mutation; `--check-shards` | both verifiers pass; the patch and the mutation each fail exactly the two cases they name; the shards cover every patch and mutation once | | all falsification patches still apply after merging main | yes | | comparative prose, vanity metrics, public names (with the private list), section references, lockfiles, workflow concurrency | all pass | 🤖 Generated with [Claude Code](https://claude.com/claude-code)
check_manifest_text: one pass refusing a member named twice and more than
2^22 values (manifest_too_many_values); the readers and writers use it.
The memory probe is scratch and will become the measured test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
read_manifest and read_manifest_v2 count the manifest's values in the first
pass (refusing past 2^22, manifest_too_many_values), then build one JSON
tree at the sizes counted and move its members into the manifest types, so
no member is held twice as serde's flatten holds it. Every reference reader
(Wai, WaiMulti, the HTTP head) reads through them; the writers refuse what
they refuse.

tests/manifest_parse_memory.rs measures the peak with a counting allocator
against 128 bytes a value plus one a byte; tests/manifest_reader_equivalence.rs
holds the reader to serde's derived reader over every committed envelope and
1488 mutations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
SPEC §2 states the value limit (2^22, manifest_too_many_values) beside the
length and nesting limits, and what the two bound together. staged_verify,
claim_verify, http_verify and pack_verify refuse past it and check both edges
in memory; the HTTP corpus gains parse_head cases at the limit and one past it
(pad_values), verified by wai_http_vectors and http_verify.py alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai.text.zstd and wai.text.xz decoded the whole stream into memory, so a few
kilobytes could ask for any amount. Both decoders now write at most 2^27 bytes
(text_decoded_too_long) into a buffer that is never sized past that, and give
the decoder at most 2^27 bytes of window or dictionary (text_window_too_large),
refused from the stream's declaration. A zstd frame that declares a longer
content size is refused before anything is decoded. The writers refuse what a
reader may refuse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
SPEC §2 gains the reader limits for wai.text.zstd and wai.text.xz: 2^27 decoded
bytes and 2^27 bytes of window or dictionary, with their refusal reasons.
SECURITY.md now states what the manifest readers and the text decoders bound
and which tests measure it, in place of the two gaps it listed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai-conformance.yml gains codec-wrappers on the macOS runner: fuzz smoke, the
text decode limits and the C entry points, built with the codecs feature, which
no job built before. The step fails when a test binary ran no test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The check bounded a refused layer's peak by twice the envelope's length. The
manifest reader now builds its tree at the size counted, within the bound SPEC
§2 states, and that read is the larger part of what a refusal takes, so the old
bound failed and a peak could no longer show a unit decoded after the read. The
check now measures reading the same envelope and bounds what the refusal
allocates beyond it. Falsified: one unit's output allocated before the refusal
fails it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Neither test file was named by any step, so no job ran them. They join the
release step of the cargo-class job on every platform leg.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
wai changelog: record the integer-operator crate, the bounds pass and its certificates
All checks were successful
WAI energy receipts / energy receipts · linux (pull_request) Successful in 2m10s
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Successful in 4s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 1m21s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 1m18s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 4s
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness · MSRV (pull_request) Successful in 3m8s
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Successful in 1m53s
WAI section references / cited sections exist (pull_request) Successful in 11s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 18m19s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 12s
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Successful in 5m21s
workflow hygiene / every workflow supersedes its own queue (pull_request) Successful in 5s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 2m58s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 1m16s
wai-pack / wai-pack · macos (pull_request) Successful in 1m10s
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Successful in 40m1s
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Successful in 38m46s
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Successful in 5m0s
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Successful in 5m58s
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Successful in 5m43s
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Successful in 4s
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Successful in 2m23s
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Successful in 1m5s
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Successful in 40m37s
wai-pack / wai-pack · linux (pull_request) Successful in 1m19s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 20s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 1m23s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 4m9s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 2m26s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 4m25s
a8d2ba297e
The two pull requests that added wai-ops, wai-verify, the operator corpus, the
independent certifier and load step 8 wrote no changelog entry. This is it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!231
No description provided.