From f9215b5e013d0162a442db1d5412e44725f6abf4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 14 Oct 2026 10:08:25 -0400 Subject: [PATCH 1/4] wai: reader limits for picture, audio and video payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image, audio and AV1 decoders allocated what a payload declared, within whatever their libraries allowed: the gap SECURITY.md listed first. SPEC §2 now sets reader limits for them as it does for manifests and text: a picture up to 65 536 on a side and 2^27 pixels, audio up to 2^27 samples counting every channel, AV1 up to 2^20 temporal units of such pictures. A reader may refuse past them (image_too_large, audio_decoded_too_long, video_too_large), from what the payload declares; the writers refuse the same. SPEC §5 now states the payload framing of the AV1, FLAC and Ogg Opus capabilities, which only packaging §7 described for AV1. Defects found and fixed on the way: - flac_decode reserved STREAMINFO's declared total: 2^36 - 1 samples asked the allocator for 256 GiB before a sample was decoded. - A 24-bit FLAC decoded to about ±256: the scale assumed 16 bits. - opus_decode refused a stream whose source rate was not an Opus rate (44.1 kHz); RFC 7845 makes that field informational. Its output had no bound at all. - png_decode and jpeg_decode guessed the format, so a PNG payload holding WebP decoded. - jxl_decode refused deeper than 8 bits and returned 1 or 4 channels for grey or alpha. - av1_decode decoded every packet and only then truncated to n; panicked on monochrome (empty chroma planes); misread 4:2:2, 4:4:4 and high bit depth; and let dav1d allocate whatever a sequence header declared. dav1d is now held to the header's h × w, a frame wider or taller than declared is refused, and av1_decode_each streams one frame at a time; the whole-output entry points refuse past 4 GiB from the header. 8-bit 4:2:0 frames decode byte for byte as before. AVIF decodes through libavif directly with the reader limits as its decoder limits (libavif-image and the rav1e build inside libavif are gone from the lockfile). include/wai.h is regenerated, which also brings in the `# Safety` lines #241 added to the sources. tests/media_decode_bounds.rs (14 tests, run by codec-wrappers) checks every limit at its edge and one past it, on real payloads and on headers rewritten to declare more than they hold. Falsified: the unbounded FLAC reserve, the 4:2:0-only conversion, format guessing, an off-by-one pixel limit and the missing dav1d limit each fail it. Green on the build host: those 14, fuzz_smoke, ffi_robustness, the text bounds and memory tests, corpus_provenance, video_receipt, and the 193 lib tests with codecs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 11 +- wai/CHANGELOG.md | 35 +++ wai/SECURITY.md | 33 ++- wai/SPEC.md | 42 +++ wai/wai-rs/Cargo.lock | 189 +------------ wai/wai-rs/Cargo.toml | 10 +- wai/wai-rs/build.rs | 2 +- wai/wai-rs/include/wai.h | 138 +++++++++ wai/wai-rs/src/bin/wai.rs | 23 +- wai/wai-rs/src/codecs/audio.rs | 70 ++++- wai/wai-rs/src/codecs/image.rs | 176 ++++++++++-- wai/wai-rs/src/codecs/video.rs | 255 +++++++++++++---- wai/wai-rs/src/ffi_codecs.rs | 36 +++ wai/wai-rs/tests/media_decode_bounds.rs | 357 ++++++++++++++++++++++++ 14 files changed, 1104 insertions(+), 273 deletions(-) create mode 100644 wai/wai-rs/tests/media_decode_bounds.rs diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 3f79cce22..d66659c1d 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -937,18 +937,19 @@ jobs: # Every one of these test files is `#![cfg(feature = "codecs")]`: built without # the feature each reports "0 passed" and exits 0. So the step names the feature, - # and then requires that each of the four binaries ran at least one test. - - name: Fuzz smoke, the text decode limits and the C entry points + # and then requires that each of the five binaries ran at least one test. + - name: Fuzz smoke, the text and media decode limits and the C entry points working-directory: wai/wai-rs shell: bash run: | set -o pipefail cargo test --locked --features codecs \ - --test fuzz_smoke --test text_decode_bounds --test text_decode_memory --test ffi_robustness \ + --test fuzz_smoke --test text_decode_bounds --test text_decode_memory \ + --test media_decode_bounds --test ffi_robustness \ 2>&1 | tee "$RUNNER_TEMP/codec-wrappers.log" ran=$(grep -cE '^test result: ok\. [1-9][0-9]* passed' "$RUNNER_TEMP/codec-wrappers.log" || true) - if [ "$ran" -ne 4 ]; then - echo "::error::expected four test binaries that each ran tests; $ran did" + if [ "$ran" -ne 5 ]; then + echo "::error::expected five test binaries that each ran tests; $ran did" exit 1 fi diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 8d4f99bf5..715ecc391 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,41 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Reader limits for picture, audio and video payloads + +The last decoders that allocated what a payload declared now hold it to limits +(SPEC §2), as manifests and text payloads already were. + +- **The limits.** A reader must accept a picture up to 65 536 pixels on a side and + 2²⁷ pixels, audio up to 2²⁷ samples counting every channel, and AV1 up to 2²⁰ + temporal units of such pictures. It may refuse past them (`image_too_large`, + `audio_decoded_too_long`, `video_too_large`), from what the payload declares wherever + it declares it. The writers refuse the same. +- **Payload framing** (SPEC §5). The AV1 framing that the reference has always used, + and packaging §7 writes, is now in the standard itself. So are the FLAC and Ogg Opus + forms, and the rule that an image payload decodes only as its own format. +- **Defects fixed in the reference decoders.** + - A FLAC `STREAMINFO` declaring 2³⁶ − 1 samples made the decoder ask for 256 GiB up + front. + - A 24-bit FLAC decoded to about ±256 instead of ±1. + - An Ogg Opus stream whose source rate was not an Opus rate (44.1 kHz) was refused. + - `png_decode` and `jpeg_decode` decoded any format the bytes resembled. + - `jxl_decode` refused a deeper-than-8-bit picture and returned one or four channels + where it promised three. + - The AV1 decoder decoded every packet before truncating to `n`. It panicked on + monochrome, misread 4:2:2, 4:4:4 and 10- and 12-bit frames, and let dav1d allocate + whatever a sequence header declared. +- **Streaming AV1.** `av1_decode_each` hands on one frame at a time. `av1_decode` and + `wai_video_av1_decode`, which return every frame at once, refuse past 4 GiB of output + from the header (`video_decoded_too_large`). `wai decode` streams frames to its file. +- **Unchanged.** Every 8-bit 4:2:0 frame decodes byte for byte as before. The AVIF path + now calls libavif directly with the reader limits as its decoder limits (it drops + `libavif-image` and the rav1e build inside libavif), and `include/wai.h` is + regenerated. +- **Tests.** `tests/media_decode_bounds.rs` checks each limit at its edge and one past + it, and each defect above. CI runs it in `codec-wrappers`. Five mutations, each + reintroducing one defect, fail it. + ### `wai.neural.int_synth` through the integer operator set The first capability whose reference decoder decodes through `wai-ops/1`. diff --git a/wai/SECURITY.md b/wai/SECURITY.md index 11205c26d..88999bf28 100644 --- a/wai/SECURITY.md +++ b/wai/SECURITY.md @@ -117,12 +117,35 @@ counting allocator, that refusing a payload of a few kilobytes that would expand four times a limit takes no more output buffer than the limit. The C entry points `wai_text_zstd_decode` and `wai_text_xz_decode` are these decoders. +The picture, audio and video payloads of the floor and the recommended set have reader +limits as well (SPEC §2): a picture at most 65 536 pixels wide and tall and 2²⁷ pixels +in all, audio of at most 2²⁷ samples counting every channel, and AV1 of at most 2²⁰ +temporal units whose frames are pictures within those limits. The reference decoders +(`codecs/image.rs`, `codecs/audio.rs`, `codecs/video.rs`) refuse a picture from the +size its header declares, before they decode it (`image_too_large`): PNG and JPEG +through the `image` crate's header read, AVIF through libavif's own decoder limits, +which it checks against the `ispe` of every image item while it parses the container +and again against the AV1 sequence header, and JPEG XL through a pass of libjxl that +reads only the basic information. Each decodes only its own format. The FLAC decoder +refuses a `STREAMINFO` whose declared total passes the limit before it decodes, and +never sizes its output from the declaration past the limit; both audio decoders count +their output as it grows and refuse it past the limit (`audio_decoded_too_long`). The +AV1 decoder refuses a framing header past the limits (`video_too_large`), more temporal +units than the header declares, and a frame larger than it declares, and decodes every +layout and depth AV1 allows rather than assuming 8-bit 4:2:0. `av1_decode_each` hands +on one frame at a time, so its memory is one frame and dav1d's reference frames whatever +the payload's length; `av1_decode` and `wai_video_av1_decode`, which return every frame +at once, refuse a payload whose frames would take more than 4 GiB together, from its +header (`video_decoded_too_large`). The encoders refuse what a reader may refuse. +`tests/media_decode_bounds.rs` checks each limit at its edge and one past it, on real +payloads and on headers rewritten to declare more than their data holds. + ## Native codec libraries and sandboxed decode The default `codecs` feature binds C and C++ libraries for the floor and the recommended set: libzstd and liblzma for `wai.text.zstd` and `wai.text.xz`, libjxl -for JPEG XL, libavif for AVIF decode, libopus, dav1d for AV1 decode, and libFLAC for -FLAC encode. PNG, JPEG and FLAC decode run in Rust (the `image` and `claxon` crates). +for JPEG XL, libavif for AVIF decode (with dav1d inside it), libopus, dav1d for AV1 +decode, and libFLAC for FLAC encode. PNG, JPEG and FLAC decode run in Rust (the `image` and `claxon` crates). The `neural` feature binds ONNX Runtime for the float neural decoders. These libraries parse payload bytes directly, and they are the usual place a memory-safety defect in a media stack lives. @@ -136,8 +159,8 @@ run it in a WebAssembly runtime. What does not touch them: - Most CI builds. One job builds `codecs`: `codec-wrappers` in `wai-conformance.yml`, - on the macOS runner, which runs the wrappers' fuzz smoke, the text decode limits and - the C entry points through the libraries. `wai-neural-gates.yml` and + on the macOS runner, which runs the wrappers' fuzz smoke, the text and media decode + limits and the C entry points through the libraries. `wai-neural-gates.yml` and `wai-neural-e2e.yml` build the `neural` feature, which links ONNX Runtime and libzstd, and run the float neural decoders through them. No workflow enables `semantic`, and no other workflow's build links any of these libraries. @@ -160,8 +183,6 @@ element beside each kernel and over every committed integer-decoder vector Known gaps: -- **The image, audio and video decoders set no output limit of their own.** They - allocate what the bitstream declares, within the libraries' own limits. - **Fuzzing is shallow.** `tests/fuzz_smoke.rs` feeds seeded random bytes to each codec wrapper and to `Wai::from_bytes` and requires that nothing panics. It runs in CI on one platform (`codec-wrappers` in `wai-conformance.yml`). There is no diff --git a/wai/SPEC.md b/wai/SPEC.md index 7330ce110..2a3762c10 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -132,6 +132,33 @@ the other, and the decoder's fixed state. As for a manifest, a conforming encode no payload past either limit, and the reference writers refuse one; bytes longer than the length limit are carried as more than one object. +A picture, audio or video payload of the mandatory floor or the recommended set (§5) +has reader limits too, since its format bounds what it may ask of a decoder loosely or +not at all. A reader MUST NOT refuse a picture (a `wai.image.png`, `wai.image.jpeg`, +`wai.image.avif` or `wai.image.jxl` payload, or a frame of a `wai.video.av1` or +`wai.video.av1.lossless` one) for its size while it is at most 65 536 pixels wide and +tall and holds at most 134 217 728 pixels (2²⁷, a 16 384 × 8 192 picture). It MUST NOT +refuse a `wai.audio.flac` or `wai.audio.opus` payload for its length while it decodes to +at most 134 217 728 samples (2²⁷), counting every channel: 46 minutes of one channel at +48 kHz. It MUST NOT refuse a `wai.video.av1` or `wai.video.av1.lossless` payload for its +length while its framing header (§5, *Payload framing*) declares at most 1 048 576 +temporal units (2²⁰), 4.9 hours at 60 frames a second. It MAY refuse one past any of +these: a picture (`image_too_large`), audio (`audio_decoded_too_long`) or video +(`video_too_large`). A reader that refuses a picture does so from the size its payload +declares, before it decodes a pixel: the PNG `IHDR`, the JPEG frame header, the `ispe` +property of each image item of an AVIF, the JPEG XL size header, the AV1 framing +header's `h` and `w`. One that refuses audio does so when its output would pass the +limit, in a buffer it never sizes past the limit, and where a FLAC `STREAMINFO` declares +a total past the limit, from the declaration, before it decodes. One that refuses video +does so from the framing header. A picture or audio decode then takes its output and +the decoder's state; a video decode that hands on each frame as it decodes takes one +frame and the decoder's reference frames, whatever the payload's length. (A reader that +keeps every frame at once needs n · h · w pixels; the reference library's whole-output +entry points refuse a payload past a budget, 4 GiB by default, and decode one frame at +a time otherwise; informative, `SECURITY.md`.) As for text, a conforming encoder writes +no payload past these limits, and the reference writers refuse one; longer audio and +video are carried as more than one object. + An envelope delivered over HTTP is one object of the proposed media type `application/wai`, named by its receipt content hash, and fetched whole or, for a `WAI2`, one verified component at a time @@ -603,6 +630,21 @@ Every conforming sink SHOULD implement: | `wai.video.av1.lossless` | AV1 (AOMedia), `quantizer=0` | mathematically lossless YUV (lossy in RGB due to color conversion) | | `wai.text.xz` | XZ/LZMA2 | maximum classical text ratio | +**Payload framing.** A `wai.image.*` payload is one file of its format as the named +specification defines it, and a reader decodes it as that format only: PNG bytes are +not a `wai.image.jpeg` payload. A `wai.audio.flac` payload is a FLAC stream (RFC 9639), +its `fLaC` marker and `STREAMINFO` first. A `wai.audio.opus` payload is an Ogg Opus stream +(RFC 7845); its `OpusHead` input sample rate is informational (RFC 7845 §5.1), so a +reader decodes at a rate of its own choosing when the declared one is not an Opus rate. +A `wai.video.av1` or `wai.video.av1.lossless` payload is five `u32` little-endian +values, `n`, `h`, `w`, `fps_num` and `fps_den` (the number of temporal units, the +largest frame's height and width, and the frame rate as `fps_num` / `fps_den` frames a +second), then each temporal unit as a `u32` little-endian length and its bytes: the +temporal unit in the low-overhead bitstream format (AV1 §5), every OBU with its size +field. A payload holding more temporal units than `n`, or a frame larger than `h` × `w`, +is malformed. [packaging](extensions/packaging.md) §7 writes its floor objects in +this framing. A sink decodes AV1 in any profile, layout and depth AV1 allows. + ### Neural capabilities (OPTIONAL, sink advertises if its ML runtime supports them) Each names a model the sink MAY have installed. Payload is a compact diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index e8a84c0b1..bd180d3a4 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -332,12 +332,6 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -[[package]] -name = "bitstream-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" - [[package]] name = "bitstream-io" version = "4.10.0" @@ -486,12 +480,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "built" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" - [[package]] name = "built" version = "0.8.1" @@ -693,16 +681,6 @@ dependencies = [ "slab", ] -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon 0.12.16", -] - [[package]] name = "cfg-expr" version = "0.20.9" @@ -710,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" dependencies = [ "smallvec", - "target-lexicon 0.13.5", + "target-lexicon", ] [[package]] @@ -1078,7 +1056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3c91aea6668645415331133ed6f8ddf0e7f40160cd97a12d59e68716a58704b" dependencies = [ "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -1873,15 +1851,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2073,25 +2042,6 @@ dependencies = [ "spin", ] -[[package]] -name = "libavif" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b710faf0de92da0e32a9a9c89bbf849e37277fd447b4f17b0f0f798796ae1f2" -dependencies = [ - "libavif-sys", -] - -[[package]] -name = "libavif-image" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0a6b5ea7c9d9260fa57daa8f238992de5d9c7a3c82a78e8dbc4d1367e7c28be" -dependencies = [ - "image", - "libavif", -] - [[package]] name = "libavif-sys" version = "0.17.0+libavif.1.0.4" @@ -2101,7 +2051,6 @@ dependencies = [ "cmake", "libc", "libdav1d-sys", - "rav1e 0.7.1", ] [[package]] @@ -2304,15 +2253,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "nasm-rs" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4d98d0065f4b1daf164b3eafb11974c94662e5e2396cf03f32d0bb5c17da51" -dependencies = [ - "rayon", -] - [[package]] name = "ndarray" version = "0.17.2" @@ -3166,43 +3106,6 @@ dependencies = [ "rasn", ] -[[package]] -name = "rav1e" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" -dependencies = [ - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av1-grain", - "bitstream-io 2.6.0", - "built 0.7.7", - "cc", - "cfg-if", - "interpolate_name", - "itertools 0.12.1", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "nasm-rs", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "once_cell", - "paste", - "profiling", - "rand 0.8.8", - "rand_chacha 0.3.1", - "scan_fmt", - "simd_helpers", - "system-deps 6.2.2", - "thiserror 1.0.69", - "v_frame", -] - [[package]] name = "rav1e" version = "0.8.1" @@ -3215,8 +3118,8 @@ dependencies = [ "arrayvec", "av-scenechange", "av1-grain", - "bitstream-io 4.10.0", - "built 0.8.1", + "bitstream-io", + "built", "cfg-if", "interpolate_name", "itertools 0.14.0", @@ -3248,7 +3151,7 @@ dependencies = [ "imgref", "loop9", "quick-error", - "rav1e 0.8.1", + "rav1e", "rgb", ] @@ -3543,12 +3446,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scan_fmt" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248" - [[package]] name = "schannel" version = "0.1.29" @@ -3705,15 +3602,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -4004,26 +3892,13 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr 0.15.8", - "heck", - "pkg-config", - "toml 0.8.23", - "version-compare", -] - [[package]] name = "system-deps" version = "7.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" dependencies = [ - "cfg-expr 0.20.9", + "cfg-expr", "heck", "pkg-config", "toml 1.1.4+spec-1.1.0", @@ -4036,12 +3911,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - [[package]] name = "target-lexicon" version = "0.13.5" @@ -4165,18 +4034,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit", -] - [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -4185,7 +4042,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned 1.1.1", + "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -4200,22 +4057,13 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned 1.1.1", + "serde_spanned", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.4", ] -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -4234,19 +4082,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "winnow 0.7.15", -] - [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -4438,14 +4273,15 @@ dependencies = [ "image", "jpeg-encoder", "jpegxl-rs", - "libavif-image", + "jpegxl-sys", + "libavif-sys", "ndarray", "num-bigint", "ogg", "opus", "ort", "pkg-config", - "rav1e 0.8.1", + "rav1e", "rustls", "rustls-platform-verifier", "serde", @@ -4971,9 +4807,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index f86f980a7..3b820b907 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -40,8 +40,9 @@ serde_json = { version = "1", features = ["preserve_order", "raw_value"] } # Image image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "avif"], optional = true } # PNG, JPEG decode, AVIF encode (via ravif) jpeg-encoder = { version = "0.7", optional = true } # pure-Rust JPEG with optimized Huffman tables -libavif-image = { version = "0.14", optional = true } # AVIF decode via system libavif (fixes silent-empty decode bug) +libavif-sys = { version = "0.17", optional = true, default-features = false, features = ["codec-dav1d"] } # AVIF decode through libavif itself, with the reader limits as its decoder limits jpegxl-rs = { version = "0.14", optional = true } # libjxl bindings (links system libjxl; 0.11.x and 0.12.x both work) +jpegxl-sys = { version = "0.12", optional = true } # libjxl itself, to read a payload's declared size before it decodes # Audio opus = { version = "0.3", optional = true } # libopus bindings (lossy) ogg = { version = "0.9", optional = true } # Ogg container for standard .opus interop @@ -116,7 +117,7 @@ default = ["codecs"] # Disable for targets where the C codec chain can't follow (wasm32) — # the envelope, manifest, and `world` engine build without it. codecs = [ - "dep:image", "dep:jpeg-encoder", "dep:libavif-image", "dep:jpegxl-rs", + "dep:image", "dep:jpeg-encoder", "dep:libavif-sys", "dep:jpegxl-rs", "dep:jpegxl-sys", "dep:opus", "dep:ogg", "dep:claxon", "dep:flac-bound", "dep:rav1e", "dep:dav1d", "dep:zstd", "dep:xz2", "dep:byteorder", @@ -1131,6 +1132,11 @@ name = "text_decode_bounds" path = "tests/text_decode_bounds.rs" required-features = ["codecs"] +[[test]] +name = "media_decode_bounds" +path = "tests/media_decode_bounds.rs" +required-features = ["codecs"] + [[test]] name = "text_decode_memory" path = "tests/text_decode_memory.rs" diff --git a/wai/wai-rs/build.rs b/wai/wai-rs/build.rs index 6005f710c..8041ad259 100644 --- a/wai/wai-rs/build.rs +++ b/wai/wai-rs/build.rs @@ -1,7 +1,7 @@ //! Cross-platform linker paths for the C libraries we wrap. //! //! Most of our codec deps (`opus`, `jpegxl-rs`, `dav1d`, `zstd`, `xz2`, -//! `libavif-image`) already have build.rs files that probe pkg-config +//! `libavif-sys`) already have build.rs files that probe pkg-config //! themselves. `flac-bound` does NOT — it just links `-lflac` and //! expects the linker to find it. On Homebrew macOS that lives under //! `/opt/homebrew/opt/flac/lib`, which Cargo doesn't probe. We use the diff --git a/wai/wai-rs/include/wai.h b/wai/wai-rs/include/wai.h index 01ce30f7d..058ad5794 100644 --- a/wai/wai-rs/include/wai.h +++ b/wai/wai-rs/include/wai.h @@ -30,6 +30,25 @@ #include #include +/** + * The most samples a reader must accept from one audio payload (SPEC §2, *Reader + * limits*), counting every channel: 2^27, 46 minutes of one channel at 48 kHz. Decoded + * to `f32` it is 512 MiB. Longer audio is carried as more than one object. + */ +#define AUDIO_DECODED_MAX_SAMPLES (1 << 27) + +/** + * The widest and the tallest picture a reader must accept (SPEC §2, *Reader limits*): + * 65 536 pixels, the largest side an AV1 sequence header can declare. + */ +#define IMAGE_MAX_SIDE (1 << 16) + +/** + * The most pixels a reader must accept in one picture (SPEC §2, *Reader limits*): + * 2^27, a 16 384 × 8 192 picture. Decoded to 8-bit RGB it is 384 MiB. + */ +#define IMAGE_MAX_PIXELS (1 << 27) + /** * The most bytes a `wai.text.zstd` or `wai.text.xz` payload may decode to that a reader * must accept (SPEC §2, *Reader limits*): 2^27. The decoders here write no more than @@ -54,6 +73,23 @@ */ #define TEXT_WINDOW_MAX_LEN (1 << TEXT_WINDOW_MAX_LOG) +/** + * The most temporal units a reader must accept in one AV1 payload (SPEC §2, *Reader + * limits*): 2^20, 4.9 hours at 60 frames a second. Each frame is held to the picture + * limits of [`crate::codecs::image::check_dimensions`]. + */ +#define VIDEO_MAX_TEMPORAL_UNITS (1 << 20) + +/** + * What the frames [`av1_decode`] returns may take together: 4 GiB of RGB. A payload + * within the reader limits can declare far more (2^20 frames of 2^27 pixels), and the + * whole-output entry points keep every frame at once, so they refuse one past their + * budget (`video_decoded_too_large`) from its header, before they decode. + * [`av1_decode_each`] keeps one frame at a time and decodes any payload within the + * reader limits. + */ +#define AV1_DECODE_DEFAULT_MAX_LEN 4294967296 + /** * The longest manifest, in bytes, a reader must accept (SPEC §2, *Reader limits*): * 2^27 (128 MiB). It is sized to the largest manifest a conforming layer has: a `prev` @@ -521,6 +557,9 @@ int wai_int_payload_declared(const char *capability, * *Candidate capabilities*), and a payload that is not the format SPEC §5 pins its * integer capability or fallback to (*Payloads of the WAI-native integer codecs*): * it emits through `Wai::to_emit_bytes`. + * + * # Safety + * `manifest_json` NUL-terminated; `payload` valid for `payload_len` bytes; `out` writable. */ int wai_envelope_pack(const char *manifest_json, @@ -531,6 +570,9 @@ int wai_envelope_pack(const char *manifest_json, /** * Detect WAI version from a byte stream. Returns 1 (WAI1), 2 (WAI2), * or 0 with an error message in `wai_last_error` for unknown magic. + * + * # Safety + * `bytes` valid for `len` bytes. */ int wai_envelope_detect_version(const uint8_t *bytes, uintptr_t len); @@ -574,6 +616,9 @@ int wai_envelope_pack_multi(const char *manifest_json, * This avoids exposing a C struct array (and the ownership pain that * comes with it). Any JSON parser the consumer already has decodes * the rendition table in two lines. + * + * # Safety + * `bytes` valid for `len` bytes; `out_manifest` writable; `out_renditions_json` writable; `out_payload_block` writable. */ int wai_envelope_unpack_multi(const uint8_t *bytes, @@ -586,6 +631,9 @@ int wai_envelope_unpack_multi(const uint8_t *bytes, * Parses a WAI envelope. Writes the manifest JSON exactly as the envelope * carries it to `out_manifest` (so `security`, and members this library does not * model, survive) and the raw payload to `out_payload`. + * + * # Safety + * `bytes` valid for `len` bytes; `out_manifest` writable; `out_payload` writable. */ int wai_envelope_unpack(const uint8_t *bytes, @@ -631,6 +679,15 @@ int wai_image_png_encode(const uint8_t *rgb, /** * Decode a PNG byte stream to RGB. Returns `(rgb, h, w)`. + * + * A picture past the reader limits of SPEC §2 (wider or taller than + * [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than + * [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with + * `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins + * `image_too_large`. + * + * # Safety + * `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable. */ int wai_image_png_decode(const uint8_t *bytes, @@ -639,6 +696,10 @@ int wai_image_png_decode(const uint8_t *bytes, uint32_t *out_h, uint32_t *out_w); +/** + * # Safety + * `rgb` valid for `len` bytes; `out` writable. + */ int wai_image_avif_encode(const uint8_t *rgb, uintptr_t len, @@ -647,6 +708,16 @@ int wai_image_avif_encode(const uint8_t *rgb, uint8_t quality, struct wai_buffer_t *out); +/** + * A picture past the reader limits of SPEC §2 (wider or taller than + * [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than + * [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with + * `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins + * `image_too_large`. + * + * # Safety + * `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable. + */ int wai_image_avif_decode(const uint8_t *bytes, uintptr_t len, @@ -657,6 +728,9 @@ int wai_image_avif_decode(const uint8_t *bytes, /** * `quality = 0` ⇒ lossless. Otherwise butteraugli distance proxy * (higher quality = smaller distance internally). + * + * # Safety + * `rgb` valid for `len` bytes; `out` writable. */ int wai_image_jxl_encode(const uint8_t *rgb, @@ -666,6 +740,16 @@ int wai_image_jxl_encode(const uint8_t *rgb, uint8_t quality, struct wai_buffer_t *out); +/** + * A picture past the reader limits of SPEC §2 (wider or taller than + * [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than + * [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with + * `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins + * `image_too_large`. + * + * # Safety + * `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable. + */ int wai_image_jxl_decode(const uint8_t *bytes, uintptr_t len, @@ -673,6 +757,10 @@ int wai_image_jxl_decode(const uint8_t *bytes, uint32_t *out_h, uint32_t *out_w); +/** + * # Safety + * `samples` valid for `n` `f32`s; `out` writable. + */ int wai_audio_opus_encode(const float *samples, uintptr_t n, @@ -680,24 +768,50 @@ int wai_audio_opus_encode(const float *samples, int32_t bitrate_bps, struct wai_buffer_t *out); +/** + * Output past the reader limit of SPEC §2, + * [`AUDIO_DECODED_MAX_SAMPLES`](crate::codecs::audio::AUDIO_DECODED_MAX_SAMPLES) + * samples, is refused with `WAI_ERR_CODEC`; the message begins + * `audio_decoded_too_long`. + * + * # Safety + * `bytes` valid for `len` bytes; `out` writable; `out_sr` writable. + */ int wai_audio_opus_decode(const uint8_t *bytes, uintptr_t len, struct wai_buffer_t *out, uint32_t *out_sr); +/** + * # Safety + * `samples` valid for `n` `f32`s; `out` writable. + */ int wai_audio_flac_encode(const float *samples, uintptr_t n, uint32_t sr, struct wai_buffer_t *out); +/** + * Output past the reader limit of SPEC §2, + * [`AUDIO_DECODED_MAX_SAMPLES`](crate::codecs::audio::AUDIO_DECODED_MAX_SAMPLES) + * samples, is refused with `WAI_ERR_CODEC`; the message begins + * `audio_decoded_too_long`. + * + * # Safety + * `bytes` valid for `len` bytes; `out` writable; `out_sr` writable. + */ int wai_audio_flac_decode(const uint8_t *bytes, uintptr_t len, struct wai_buffer_t *out, uint32_t *out_sr); +/** + * # Safety + * `data` valid for `len` bytes; `out` writable. + */ int wai_text_zstd_encode(const uint8_t *data, uintptr_t len, @@ -710,9 +824,16 @@ int wai_text_zstd_encode(const uint8_t *data, * and [`TEXT_WINDOW_MAX_LEN`](crate::codecs::text::TEXT_WINDOW_MAX_LEN) bytes of * window. A payload past either is refused with `WAI_ERR_CODEC`; the message begins * `text_decoded_too_long` or `text_window_too_large`. + * + * # Safety + * `bytes` valid for `len` bytes; `out` writable. */ int wai_text_zstd_decode(const uint8_t *bytes, uintptr_t len, struct wai_buffer_t *out); +/** + * # Safety + * `data` valid for `len` bytes; `out` writable. + */ int wai_text_xz_encode(const uint8_t *data, uintptr_t len, @@ -722,11 +843,17 @@ int wai_text_xz_encode(const uint8_t *data, /** * Decode a `wai.text.xz` payload within the same limits as * [`wai_text_zstd_decode`], the window limit applying to the dictionary. + * + * # Safety + * `bytes` valid for `len` bytes; `out` writable. */ int wai_text_xz_decode(const uint8_t *bytes, uintptr_t len, struct wai_buffer_t *out); /** * Encode a sequence of `n` RGB frames as AV1. + * + * # Safety + * `rgb` valid for `len` bytes; `out` writable. */ int wai_video_av1_encode(const uint8_t *rgb, @@ -742,6 +869,17 @@ int wai_video_av1_encode(const uint8_t *rgb, /** * Decode an AV1 WAI payload to a packed RGB buffer `n*h*w*3` bytes. + * + * A payload past the reader limits of SPEC §2 (more than + * [`VIDEO_MAX_TEMPORAL_UNITS`](crate::codecs::video::VIDEO_MAX_TEMPORAL_UNITS) + * temporal units, or frames past the picture limits) is refused from its header; + * the message begins `video_too_large`. Since the buffer holds every frame, so is + * one whose frames would take more than + * [`AV1_DECODE_DEFAULT_MAX_LEN`](crate::codecs::video::AV1_DECODE_DEFAULT_MAX_LEN) + * bytes together (`video_decoded_too_large`), and one whose frames differ in size. + * + * # Safety + * `bytes` valid for `len` bytes; `out` writable; `out_n` writable; `out_h` writable; `out_w` writable; `out_fps_num` writable; `out_fps_den` writable. */ int wai_video_av1_decode(const uint8_t *bytes, diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index ee67dc9a2..e0e931cdb 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -318,14 +318,23 @@ fn decode(input: &str, out: &str) -> Result<(), String> { fs::write(out, &d).map_err(|e| e.to_string())?; } c if c == CAP_VIDEO_AV1 || c == CAP_VIDEO_AV1_LOSSLESS => { - let (frames, h, w, fps_num, fps_den) = - video::av1_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - let n = frames.len() as u32; - let mut packed = Vec::with_capacity(frames.len() * (h * w * 3) as usize); - for f in &frames { packed.extend_from_slice(f); } - fs::write(out, &packed).map_err(|e| e.to_string())?; + // One frame at a time to the file: a payload within the reader limits can + // decode to far more than fits in memory at once. + use std::io::Write as _; + let hd = video::av1_header(&wai.payload).map_err(|e| format!("{:?}", e))?; + let mut file = std::io::BufWriter::new(fs::File::create(out).map_err(|e| e.to_string())?); + let mut n = 0u32; + video::av1_decode_each(&wai.payload, |rgb, w, h| { + if (w, h) != (hd.w, hd.h) { + return Err(video::VideoError(format!( + "a {w}×{h} frame in a payload of {}×{} frames; the packed output holds one size", hd.w, hd.h))); + } + n += 1; + file.write_all(rgb).map_err(|e| video::VideoError(e.to_string())) + }).map_err(|e| format!("{:?}", e))?; + file.flush().map_err(|e| e.to_string())?; fs::write(format!("{out}.meta"), - format!("{n} {h} {w} {fps_num} {fps_den}\n")) + format!("{n} {} {} {} {}\n", hd.h, hd.w, hd.fps_num, hd.fps_den)) .map_err(|e| e.to_string())?; } other => return Err(format!("unsupported capability for CLI decode: {other}")), diff --git a/wai/wai-rs/src/codecs/audio.rs b/wai/wai-rs/src/codecs/audio.rs index a2cc76380..260ec57a6 100644 --- a/wai/wai-rs/src/codecs/audio.rs +++ b/wai/wai-rs/src/codecs/audio.rs @@ -18,6 +18,17 @@ impl From for AudioError { pub type Result = std::result::Result; +/// The most samples a reader must accept from one audio payload (SPEC §2, *Reader +/// limits*), counting every channel: 2^27, 46 minutes of one channel at 48 kHz. Decoded +/// to `f32` it is 512 MiB. Longer audio is carried as more than one object. +pub const AUDIO_DECODED_MAX_SAMPLES: usize = 1 << 27; + +fn too_long(what: &str, max: usize) -> AudioError { + AudioError(format!( + "audio_decoded_too_long: the payload {what} more than {max} samples, the most this reader accepts (SPEC §2)" + )) +} + // ---- Opus (lossy) --------------------------------------------------- // Opus only supports specific sample rates (8/12/16/24/48 kHz) and // fixed frame sizes. We frame at 20 ms (the Opus default) and resample @@ -33,6 +44,10 @@ fn opus_frame_size(sr: u32) -> usize { (sr as usize) * OPUS_FRAME_MS / 1000 } /// libopus only accepts 8/12/16/24/48 kHz. Other rates MUST be /// resampled upstream. pub fn opus_encode(samples: &[f32], sr: u32, bitrate_bps: i32) -> Result> { + // A conforming encoder writes no payload a conforming reader may refuse (SPEC §2). + if samples.len() > AUDIO_DECODED_MAX_SAMPLES { + return Err(too_long("would decode to", AUDIO_DECODED_MAX_SAMPLES)); + } if !matches!(sr, 8_000 | 12_000 | 16_000 | 24_000 | 48_000) { return Err(AudioError(format!( "opus only supports sr in {{8000,12000,16000,24000,48000}} \ @@ -93,7 +108,15 @@ pub fn opus_encode(samples: &[f32], sr: u32, bitrate_bps: i32) -> Result Ok(out.into_inner()) } +/// Decodes an Ogg Opus payload (RFC 7845) to mono `f32` samples within the reader +/// limit of SPEC §2, [`AUDIO_DECODED_MAX_SAMPLES`]. pub fn opus_decode(bytes: &[u8]) -> Result<(Vec, u32)> { + opus_decode_within(bytes, AUDIO_DECODED_MAX_SAMPLES) +} + +/// [`opus_decode`] with the sample limit the caller gives (`audio_decoded_too_long`). +/// While it decodes, it holds at most that many samples, the pre-skip and one packet. +pub fn opus_decode_within(bytes: &[u8], max_samples: usize) -> Result<(Vec, u32)> { let mut rd = PacketReader::new(Cursor::new(bytes)); // First packet: OpusHead — parse for sample-rate + pre-skip let head_pkt = rd.read_packet_expected() @@ -102,7 +125,13 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec, u32)> { return Err(AudioError("not an Ogg-Opus stream".into())); } let pre_skip = u16::from_le_bytes(head_pkt.data[10..12].try_into().unwrap()) as usize; - let sr = u32::from_le_bytes(head_pkt.data[12..16].try_into().unwrap()); + // OpusHead's input sample rate is informational (RFC 7845 §5.1): it names the rate + // the source had, not one the stream needs. Decode at it when libopus can, and at + // 48 kHz otherwise, so a stream whose source was 44.1 kHz still decodes. + let sr = match u32::from_le_bytes(head_pkt.data[12..16].try_into().unwrap()) { + r @ (8_000 | 12_000 | 16_000 | 24_000 | 48_000) => r, + _ => 48_000, + }; // Second packet: OpusTags — skip its content let _ = rd.read_packet_expected() .map_err(|e| AudioError(format!("ogg read OpusTags: {e:?}")))?; @@ -110,6 +139,7 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec, u32)> { let mut dec = Decoder::new(sr, Channels::Mono)?; let frame = opus_frame_size(sr); let mut buf = vec![0f32; frame * 6]; + let drop = (pre_skip as u64 * sr as u64 / 48_000) as usize; let mut out: Vec = Vec::new(); let mut last_granule: u64 = 0; loop { @@ -119,17 +149,26 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec, u32)> { None => break, }; let got = dec.decode_float(&pkt.data, &mut buf, false)?; + // A packet of a few bytes decodes to up to 120 ms, so the output is bounded + // here, as it grows, and not by the payload's length. What is decoded runs + // ahead of what is kept by the pre-skip and the last packet's padding, so the + // bound allows those; the kept samples are held to the limit below. + if out.len() + got > max_samples.saturating_add(drop).saturating_add(buf.len()) { + return Err(too_long("decodes to", max_samples)); + } out.extend_from_slice(&buf[..got]); last_granule = pkt.absgp_page(); } // Trim pre-skip (algorithmic delay padding) and any trailing pad // implied by the final granule position. Granule is in 48 kHz; convert // back to input-rate samples. - let drop = (pre_skip as u64 * sr as u64 / 48_000) as usize; if drop < out.len() { out.drain(..drop); } else { out.clear(); } let total_at_sr = (last_granule * sr as u64 / 48_000) as usize; let total_at_sr = total_at_sr.saturating_sub(drop); if total_at_sr < out.len() { out.truncate(total_at_sr); } + if out.len() > max_samples { + return Err(too_long("decodes to", max_samples)); + } Ok((out, sr)) } @@ -137,6 +176,10 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec, u32)> { // libFLAC bindings for encode; pure-Rust claxon for decode. pub fn flac_encode(samples: &[f32], sr: u32) -> Result> { + // A conforming encoder writes no payload a conforming reader may refuse (SPEC §2). + if samples.len() > AUDIO_DECODED_MAX_SAMPLES { + return Err(too_long("would decode to", AUDIO_DECODED_MAX_SAMPLES)); + } use flac_bound::{FlacEncoder, WriteWrapper}; // FLAC takes int32 samples; widen i16-equivalent quant to i32 in 16-bit let i32_samples: Vec = samples.iter() @@ -161,14 +204,33 @@ pub fn flac_encode(samples: &[f32], sr: u32) -> Result> { Ok(out) } +/// Decodes a FLAC payload (RFC 9639) to `f32` samples within the reader limit of +/// SPEC §2, [`AUDIO_DECODED_MAX_SAMPLES`], counting every channel. pub fn flac_decode(bytes: &[u8]) -> Result<(Vec, u32)> { + flac_decode_within(bytes, AUDIO_DECODED_MAX_SAMPLES) +} + +/// [`flac_decode`] with the sample limit the caller gives (`audio_decoded_too_long`). +pub fn flac_decode_within(bytes: &[u8], max_samples: usize) -> Result<(Vec, u32)> { let mut rdr = claxon::FlacReader::new(Cursor::new(bytes)) .map_err(|e| AudioError(format!("flac open: {e}")))?; let info = rdr.streaminfo(); let sr = info.sample_rate; - let mut out = Vec::with_capacity(info.samples.unwrap_or(0) as usize); + // STREAMINFO's total is a 36-bit count of inter-channel samples, from the payload: + // refuse one past the limit from the declaration, and never size the output from + // it past the limit (a 40-byte stream may declare 2^36). + let declared = info.samples.map(|n| n.saturating_mul(info.channels as u64)); + if declared.is_some_and(|n| n > max_samples as u64) { + return Err(too_long("declares", max_samples)); + } + let mut out = Vec::with_capacity(declared.unwrap_or(0) as usize); + // Samples come scaled to the stream's own bit depth (4 to 32 bits). + let scale = 1.0 / (1u64 << (info.bits_per_sample.clamp(1, 32) - 1)) as f32; for s in rdr.samples() { - out.push((s.map_err(|e| AudioError(format!("flac sample: {e}")))? as f32) / 32768.0); + if out.len() == max_samples { + return Err(too_long("decodes to", max_samples)); + } + out.push((s.map_err(|e| AudioError(format!("flac sample: {e}")))? as f32) * scale); } Ok((out, sr)) } diff --git a/wai/wai-rs/src/codecs/image.rs b/wai/wai-rs/src/codecs/image.rs index 3098e5933..5b7fb2a4b 100644 --- a/wai/wai-rs/src/codecs/image.rs +++ b/wai/wai-rs/src/codecs/image.rs @@ -4,6 +4,11 @@ //! Each codec exposes `encode(rgb, h, w, quality) -> Vec` and //! `decode(bytes) -> (rgb, h, w)`. RGB is row-major, 8-bit, 3 channels. //! `quality` is in 1..=100 where applicable. +//! +//! Every decoder holds a picture to the reader limits of SPEC §2: at most +//! [`IMAGE_MAX_SIDE`] wide and tall and at most [`IMAGE_MAX_PIXELS`] pixels. It refuses +//! one past them (`image_too_large`) from the dimensions the payload declares, before it +//! decodes a pixel, and decodes only the format its capability names. use std::io::Cursor; @@ -20,8 +25,47 @@ impl From for ImageError { pub type Result = std::result::Result; +/// The widest and the tallest picture a reader must accept (SPEC §2, *Reader limits*): +/// 65 536 pixels, the largest side an AV1 sequence header can declare. +pub const IMAGE_MAX_SIDE: u32 = 1 << 16; + +/// The most pixels a reader must accept in one picture (SPEC §2, *Reader limits*): +/// 2^27, a 16 384 × 8 192 picture. Decoded to 8-bit RGB it is 384 MiB. +pub const IMAGE_MAX_PIXELS: u64 = 1 << 27; + +/// Refuses a picture past the reader limits of SPEC §2 (`image_too_large`), given the +/// width and height its payload declares. The writers refuse the same pictures: a +/// conforming encoder writes no payload a conforming reader may refuse. +pub fn check_dimensions(w: u64, h: u64) -> Result<()> { + if w > IMAGE_MAX_SIDE as u64 || h > IMAGE_MAX_SIDE as u64 || w * h > IMAGE_MAX_PIXELS { + return Err(ImageError(format!( + "image_too_large: the payload declares a {w} × {h} picture; a reader need accept at most {IMAGE_MAX_SIDE} on a side and {IMAGE_MAX_PIXELS} pixels (SPEC §2)" + ))); + } + Ok(()) +} + +/// Decodes a PNG or a JPEG payload, refusing any other format and any picture past +/// the reader limits, which it reads from the header before it decodes. +fn decode_as(bytes: &[u8], format: image::ImageFormat) -> Result<(Vec, u32, u32)> { + let (w, h) = image::ImageReader::with_format(Cursor::new(bytes), format).into_dimensions()?; + check_dimensions(w as u64, h as u64)?; + let mut reader = image::ImageReader::with_format(Cursor::new(bytes), format); + // The decoder's own buffer is at most 16-bit RGBA, 8 bytes a pixel; the default + // allocation limit (512 MiB) would refuse such a picture within the reader limits. + let mut limits = image::Limits::default(); + limits.max_image_width = Some(IMAGE_MAX_SIDE); + limits.max_image_height = Some(IMAGE_MAX_SIDE); + limits.max_alloc = Some(IMAGE_MAX_PIXELS * 8 + (1 << 24)); + reader.limits(limits); + let img = reader.decode()?.to_rgb8(); + let (w, h) = img.dimensions(); + Ok((img.into_raw(), h, w)) +} + // ---- PNG (lossless, universal) ---- pub fn png_encode(rgb: &[u8], h: u32, w: u32) -> Result> { + check_dimensions(w as u64, h as u64)?; let mut out = Vec::new(); image::codecs::png::PngEncoder::new(&mut out) .write_image(rgb, w, h, image::ExtendedColorType::Rgb8)?; @@ -29,10 +73,7 @@ pub fn png_encode(rgb: &[u8], h: u32, w: u32) -> Result> { } pub fn png_decode(bytes: &[u8]) -> Result<(Vec, u32, u32)> { - let img = image::ImageReader::new(Cursor::new(bytes)) - .with_guessed_format()?.decode()?.to_rgb8(); - let (w, h) = img.dimensions(); - Ok((img.into_raw(), h, w)) + decode_as(bytes, image::ImageFormat::Png) } // ---- JPEG (lossy) ---- @@ -58,6 +99,7 @@ fn remap_q_to_jpeg_encoder(public_q: u8) -> u8 { } pub fn jpeg_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result> { + check_dimensions(w as u64, h as u64)?; let mut out = Vec::new(); let q = remap_q_to_jpeg_encoder(quality); let enc = jpeg_encoder::Encoder::new(&mut out, q); @@ -68,14 +110,12 @@ pub fn jpeg_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result> { } pub fn jpeg_decode(bytes: &[u8]) -> Result<(Vec, u32, u32)> { - let img = image::ImageReader::new(Cursor::new(bytes)) - .with_guessed_format()?.decode()?.to_rgb8(); - let (w, h) = img.dimensions(); - Ok((img.into_raw(), h, w)) + decode_as(bytes, image::ImageFormat::Jpeg) } // ---- AVIF (modern lossy, AV1-based) ---- pub fn avif_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result> { + check_dimensions(w as u64, h as u64)?; let mut out = Vec::new(); image::codecs::avif::AvifEncoder::new_with_speed_quality(&mut out, 6, quality) .write_image(rgb, w, h, image::ExtendedColorType::Rgb8)?; @@ -83,18 +123,63 @@ pub fn avif_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result> { } pub fn avif_decode(bytes: &[u8]) -> Result<(Vec, u32, u32)> { - // Use libavif-image (links the system libavif). The `image` crate's - // own AVIF decoder is encode-only in the default build; relying on - // it silently returned empty buffers (bench harness caught this). - let img = libavif_image::read(bytes) - .map_err(|e| ImageError(format!("libavif decode: {e:?}")))? - .to_rgb8(); - let (w, h) = img.dimensions(); - Ok((img.into_raw(), h, w)) + // libavif directly. The `image` crate's own AVIF decoder is encode-only in the + // default build; relying on it silently returned empty buffers (bench harness + // caught this). libavif checks the size every image item declares (its `ispe`) + // against the decoder's limits while it parses the container, before it decodes, + // and checks the AV1 sequence header against them again, so the limits are set to + // the reader limits rather than libavif's defaults (whose 32 768 side would refuse + // a picture SPEC §2 says a reader must accept). + use libavif_sys as sys; + struct Decoder(*mut sys::avifDecoder); + impl Drop for Decoder { + fn drop(&mut self) { unsafe { sys::avifDecoderDestroy(self.0) } } + } + struct Image(*mut sys::avifImage); + impl Drop for Image { + fn drop(&mut self) { unsafe { sys::avifImageDestroy(self.0) } } + } + // SAFETY: each pointer libavif returns is checked for null before use and freed by + // its guard; `bytes` outlives the decode that reads it; `out` is sized for the RGB + // image libavif writes (`rowBytes` × `height`) before libavif writes it. + unsafe { + let dec = Decoder(sys::avifDecoderCreate()); + let img = Image(sys::avifImageCreateEmpty()); + if dec.0.is_null() || img.0.is_null() { + return Err(ImageError("libavif decode: out of memory".into())); + } + (*dec.0).imageSizeLimit = IMAGE_MAX_PIXELS as u32; + (*dec.0).imageDimensionLimit = IMAGE_MAX_SIDE; + let r = sys::avifDecoderReadMemory(dec.0, img.0, bytes.as_ptr(), bytes.len()); + if r != sys::AVIF_RESULT_OK { + let diag = std::ffi::CStr::from_ptr((*dec.0).diag.error.as_ptr()).to_string_lossy(); + if diag.contains("too large") { + return Err(ImageError(format!( + "image_too_large: {diag}; a reader need accept at most {IMAGE_MAX_SIDE} on a side and {IMAGE_MAX_PIXELS} pixels (SPEC §2)" + ))); + } + return Err(ImageError(format!("libavif decode: result {r}: {diag}"))); + } + let (w, h) = ((*img.0).width, (*img.0).height); + check_dimensions(w as u64, h as u64)?; + let mut rgb = sys::avifRGBImage::default(); + sys::avifRGBImageSetDefaults(&mut rgb, img.0); + rgb.format = sys::AVIF_RGB_FORMAT_RGB; + rgb.depth = 8; + rgb.rowBytes = w * 3; + let mut out = vec![0u8; w as usize * h as usize * 3]; + rgb.pixels = out.as_mut_ptr(); + let r = sys::avifImageYUVToRGB(img.0, &mut rgb); + if r != sys::AVIF_RESULT_OK { + return Err(ImageError(format!("libavif YUV to RGB: result {r}"))); + } + Ok((out, h, w)) + } } // ---- JPEG-XL (lossless or lossy) ---- pub fn jxl_encode(rgb: &[u8], h: u32, w: u32, quality: Option) -> Result> { + check_dimensions(w as u64, h as u64)?; // `quality=None` ⇒ mathematically lossless (the `.lossless(true)` // flag is what enables this; distance=0 alone is *not* sufficient). // `quality=Some(q)` ⇒ lossy with butteraugli distance derived from q. @@ -117,14 +202,59 @@ pub fn jxl_encode(rgb: &[u8], h: u32, w: u32, quality: Option) -> Result Result<(Vec, u32, u32)> { - let dec = decoder_builder().build()?; - let r = dec.decode(bytes)?; - let (info, pixels) = r; - let pixels = match pixels { - jpegxl_rs::decode::Pixels::Uint8(v) => v, - _ => return Err(ImageError("jxl: non-u8 pixel buffer".into())), +/// The width and height a JPEG XL payload declares, read by libjxl from its header +/// alone (the decoder is asked for nothing past the basic information). +fn jxl_dimensions(bytes: &[u8]) -> Result<(u32, u32)> { + use jpegxl_sys::decode::{ + JxlDecoderCloseInput, JxlDecoderCreate, JxlDecoderDestroy, JxlDecoderGetBasicInfo, + JxlDecoderProcessInput, JxlDecoderSetInput, JxlDecoderStatus, JxlDecoderSubscribeEvents, }; + struct Decoder(*mut jpegxl_sys::decode::JxlDecoder); + impl Drop for Decoder { + fn drop(&mut self) { unsafe { JxlDecoderDestroy(self.0) } } + } + // SAFETY: the decoder is checked for null and freed by its guard; `bytes` outlives + // every call that reads it (input is closed and the decoder destroyed before this + // returns); `info` is written by libjxl only when it reports success. + unsafe { + let dec = Decoder(JxlDecoderCreate(std::ptr::null())); + if dec.0.is_null() { + return Err(ImageError("jxl: out of memory".into())); + } + if JxlDecoderSubscribeEvents(dec.0, JxlDecoderStatus::BasicInfo as i32) != JxlDecoderStatus::Success + || JxlDecoderSetInput(dec.0, bytes.as_ptr(), bytes.len()) != JxlDecoderStatus::Success + { + return Err(ImageError("jxl: decoder setup failed".into())); + } + JxlDecoderCloseInput(dec.0); + if JxlDecoderProcessInput(dec.0) != JxlDecoderStatus::BasicInfo { + return Err(ImageError("jxl: no header to read".into())); + } + let mut info = std::mem::MaybeUninit::::zeroed(); + if JxlDecoderGetBasicInfo(dec.0, info.as_mut_ptr()) != JxlDecoderStatus::Success { + return Err(ImageError("jxl: no header to read".into())); + } + let info = info.assume_init(); + Ok((info.xsize, info.ysize)) + } +} + +pub fn jxl_decode(bytes: &[u8]) -> Result<(Vec, u32, u32)> { + let (w, h) = jxl_dimensions(bytes)?; + check_dimensions(w as u64, h as u64)?; + // Three 8-bit channels whatever the source holds: libjxl converts a grey picture to + // RGB, drops alpha and rounds a deeper one to 8 bits, so every JPEG XL payload + // decodes to the RGB this module promises. + let dec = decoder_builder() + .pixel_format(jpegxl_rs::decode::PixelFormat { num_channels: 3, ..Default::default() }) + .build()?; + let (info, pixels) = dec.decode_with::(bytes)?; + check_dimensions(info.width as u64, info.height as u64)?; + if pixels.len() != info.width as usize * info.height as usize * 3 { + return Err(ImageError(format!( + "jxl: {} bytes decoded for a {} × {} picture", pixels.len(), info.width, info.height + ))); + } Ok((pixels, info.height, info.width)) } diff --git a/wai/wai-rs/src/codecs/video.rs b/wai/wai-rs/src/codecs/video.rs index 363bea4e5..5cf3f04eb 100644 --- a/wai/wai-rs/src/codecs/video.rs +++ b/wai/wai-rs/src/codecs/video.rs @@ -21,6 +21,66 @@ impl From for VideoError { pub type Result = std::result::Result; +/// The most temporal units a reader must accept in one AV1 payload (SPEC §2, *Reader +/// limits*): 2^20, 4.9 hours at 60 frames a second. Each frame is held to the picture +/// limits of [`crate::codecs::image::check_dimensions`]. +pub const VIDEO_MAX_TEMPORAL_UNITS: u32 = 1 << 20; + +/// What the frames [`av1_decode`] returns may take together: 4 GiB of RGB. A payload +/// within the reader limits can declare far more (2^20 frames of 2^27 pixels), and the +/// whole-output entry points keep every frame at once, so they refuse one past their +/// budget (`video_decoded_too_large`) from its header, before they decode. +/// [`av1_decode_each`] keeps one frame at a time and decodes any payload within the +/// reader limits. +// A literal, not `1 << 32`: cbindgen copies the expression into include/wai.h, where +// it would shift a C `int` by its width. +pub const AV1_DECODE_DEFAULT_MAX_LEN: u64 = 4_294_967_296; + +/// The framing header of an AV1 payload (SPEC §5; extensions/packaging §7.2): the +/// number of temporal units, the largest frame's height and width, and the timebase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Av1Header { + pub n: u32, + pub h: u32, + pub w: u32, + pub fps_num: u32, + pub fps_den: u32, +} + +/// Reads and checks an AV1 payload's framing header, refusing one past the reader +/// limits of SPEC §2 (`video_too_large`). +pub fn av1_header(bytes: &[u8]) -> Result { + // The fixed header is 20 bytes (5×u32). + if bytes.len() < 20 { + return Err(VideoError(format!( + "payload too short for AV1 header: {} < 20", bytes.len()))); + } + let mut rd = Cursor::new(bytes); + let n = rd.read_u32::()?; + let h = rd.read_u32::()?; + let w = rd.read_u32::()?; + let fps_num = rd.read_u32::()?; + let fps_den = rd.read_u32::()?; + if n == 0 || h == 0 || w == 0 { + return Err(VideoError(format!("empty AV1 payload: {n} temporal units of {w}×{h}"))); + } + if fps_num == 0 || fps_den == 0 { + return Err(VideoError("fps_num/fps_den must be non-zero".into())); + } + if n > VIDEO_MAX_TEMPORAL_UNITS { + return Err(VideoError(format!( + "video_too_large: the payload declares {n} temporal units; a reader need accept at most {VIDEO_MAX_TEMPORAL_UNITS} (SPEC §2)" + ))); + } + if crate::codecs::image::check_dimensions(w as u64, h as u64).is_err() { + use crate::codecs::image::{IMAGE_MAX_PIXELS, IMAGE_MAX_SIDE}; + return Err(VideoError(format!( + "video_too_large: the payload declares frames of up to {w} × {h}; a reader need accept at most {IMAGE_MAX_SIDE} on a side and {IMAGE_MAX_PIXELS} pixels a frame (SPEC §2)" + ))); + } + Ok(Av1Header { n, h, w, fps_num, fps_den }) +} + /// Encode a sequence of RGB frames as AV1. /// /// `frames`: each is row-major H×W×3 u8 RGB; all the same dimensions. @@ -35,6 +95,17 @@ pub type Result = std::result::Result; /// quality there). pub fn av1_encode(frames: &[Vec], h: u32, w: u32, fps_num: u32, fps_den: u32, lossless: bool, quality: u8) -> Result> { + // A conforming encoder writes no payload a conforming reader may refuse (SPEC §2): + // the header it writes is held to the same limits the reader holds it to. + if frames.len() > VIDEO_MAX_TEMPORAL_UNITS as usize { + return Err(VideoError(format!( + "video_too_large: {} frames; a reader need accept at most {VIDEO_MAX_TEMPORAL_UNITS} (SPEC §2)", frames.len()))); + } + let mut header = Vec::with_capacity(20); + for v in [frames.len().max(1) as u32, h, w, fps_num.max(1), fps_den.max(1)] { + header.write_u32::(v)?; + } + av1_header(&header)?; let mut enc_cfg = EncoderConfig::with_speed_preset(6); enc_cfg.width = w as usize; enc_cfg.height = h as usize; @@ -151,40 +222,44 @@ fn rgb_to_yuv420_into(rgb: &[u8], h: usize, w: usize, frame: &mut rav1e::Frame Result<(Vec>, u32, u32, u32, u32)> { - // The fixed header is 20 bytes (5×u32). - if bytes.len() < 20 { - return Err(VideoError(format!( - "payload too short for AV1 header: {} < 20", bytes.len()))); - } +/// Defensive: every value read from the header is checked before it is used, so +/// malformed input cannot reach dav1d's C-level abort() (which would bypass Rust's +/// catch_unwind), and every packet fed to `dav1d_send_data` is non-empty and in bounds. +/// It refuses a payload past the reader limits of SPEC §2 from its header, more temporal +/// units than the header declares, and a frame larger than it declares. It keeps one +/// frame and dav1d's reference frames, whatever the payload's length. +pub fn av1_decode_each( + bytes: &[u8], + mut each: impl FnMut(&[u8], u32, u32) -> Result<()>, +) -> Result { + let hd = av1_header(bytes)?; let mut rd = Cursor::new(bytes); - let nframes = rd.read_u32::()? as usize; - let h = rd.read_u32::()?; - let w = rd.read_u32::()?; - let fps_num = rd.read_u32::()?; - let fps_den = rd.read_u32::()?; - - // sanity bounds — reject malformed/adversarial headers. - if nframes == 0 || nframes > 1_000_000 { - return Err(VideoError(format!("nframes out of range: {nframes}"))); - } - if h == 0 || w == 0 || h > 32_768 || w > 32_768 { - return Err(VideoError(format!("dimensions out of range: {w}×{h}"))); - } - if fps_num == 0 || fps_den == 0 { - return Err(VideoError("fps_num/fps_den must be non-zero".into())); - } - - let mut dec = dav1d::Decoder::new() + rd.set_position(20); + // dav1d allocates a frame's buffers from the AV1 sequence header, before a picture + // reaches the size check below, so it is given the header's frame size as its own + // limit: a sequence header declaring more is refused before it allocates. + let mut settings = dav1d::Settings::new(); + settings.set_frame_size_limit((hd.h as u64 * hd.w as u64) as u32); + let mut dec = dav1d::Decoder::with_settings(&settings) .map_err(|e| VideoError(format!("dav1d new: {e:?}")))?; - let mut frames = Vec::with_capacity(nframes); + let (mut units, mut frames) = (0u32, 0u32); + let mut emit = |pic: dav1d::Picture| -> Result<()> { + frames += 1; + if frames > hd.n { + return Err(VideoError(format!("the payload shows more frames than its {} temporal units", hd.n))); + } + if pic.width() > hd.w || pic.height() > hd.h { + return Err(VideoError(format!( + "a {}×{} frame in a payload whose frames are at most {}×{}", + pic.width(), pic.height(), hd.w, hd.h + ))); + } + let rgb = picture_to_rgb(&pic)?; + each(&rgb, pic.width(), pic.height()) + }; loop { if rd.position() as usize >= bytes.len() { @@ -205,37 +280,123 @@ pub fn av1_decode(bytes: &[u8]) -> Result<(Vec>, u32, u32, u32, u32)> { return Err(VideoError(format!( "packet size {sz} exceeds {remaining} remaining bytes"))); } + units += 1; + if units > hd.n { + return Err(VideoError(format!("the payload holds more than the {} temporal units its header declares", hd.n))); + } let mut pkt = vec![0u8; sz]; rd.read_exact(&mut pkt)?; dec.send_data(pkt, None, None, None) .map_err(|e| VideoError(format!("dav1d send: {e:?}")))?; while let Ok(pic) = dec.get_picture() { - frames.push(picture_to_rgb(&pic)); + emit(pic)?; } } dec.send_pending_data().ok(); while let Ok(pic) = dec.get_picture() { - frames.push(picture_to_rgb(&pic)); + emit(pic)?; } - frames.truncate(nframes); - Ok((frames, h, w, fps_num, fps_den)) + Ok(hd) } -fn picture_to_rgb(pic: &dav1d::Picture) -> Vec { +/// Decode a WAI AV1 payload back to a sequence of RGB frames, every one kept at once, +/// within [`AV1_DECODE_DEFAULT_MAX_LEN`] bytes of output. +pub fn av1_decode(bytes: &[u8]) -> Result<(Vec>, u32, u32, u32, u32)> { + av1_decode_within(bytes, AV1_DECODE_DEFAULT_MAX_LEN) +} + +/// [`av1_decode`] with the output budget the caller gives. It refuses a payload whose +/// frames would take more than `max_len` bytes together (`video_decoded_too_large`) +/// from its header, before it decodes, and one whose frames are not all the size its +/// header declares (the frames it returns share one height and width). +pub fn av1_decode_within(bytes: &[u8], max_len: u64) -> Result<(Vec>, u32, u32, u32, u32)> { + let hd = av1_header(bytes)?; + let need = hd.n as u64 * hd.h as u64 * hd.w as u64 * 3; + if need > max_len { + return Err(VideoError(format!( + "video_decoded_too_large: the payload's {} frames of {}×{} take {need} bytes of RGB together, past this reader's {max_len}; av1_decode_each decodes one frame at a time", + hd.n, hd.w, hd.h + ))); + } + let mut frames = Vec::with_capacity(hd.n as usize); + av1_decode_each(bytes, |rgb, w, h| { + if (w, h) != (hd.w, hd.h) { + return Err(VideoError(format!( + "a {w}×{h} frame in a payload of {}×{} frames; av1_decode_each decodes frames of varying size", + hd.w, hd.h + ))); + } + frames.push(rgb.to_vec()); + Ok(()) + })?; + Ok((frames, hd.h, hd.w, hd.fps_num, hd.fps_den)) +} + +/// One decoded frame as 8-bit RGB (BT.601, full range), from any layout and depth AV1 +/// allows: 4:2:0, 4:2:2, 4:4:4 and monochrome, 8, 10 and 12 bits. +fn picture_to_rgb(pic: &dav1d::Picture) -> Result> { + use dav1d::{PixelLayout, PlanarImageComponent as C}; let (w, h) = (pic.width() as usize, pic.height() as usize); - let y_plane = pic.plane(dav1d::PlanarImageComponent::Y); - let u_plane = pic.plane(dav1d::PlanarImageComponent::U); - let v_plane = pic.plane(dav1d::PlanarImageComponent::V); - let y_stride = pic.stride(dav1d::PlanarImageComponent::Y) as usize; - let u_stride = pic.stride(dav1d::PlanarImageComponent::U) as usize; - let v_stride = pic.stride(dav1d::PlanarImageComponent::V) as usize; + let y_plane = pic.plane(C::Y); + let y_stride = pic.stride(C::Y) as usize; let mut out = vec![0u8; w * h * 3]; + if pic.bit_depth() == 8 && pic.pixel_layout() == PixelLayout::I420 { + // The 8-bit 4:2:0 path, exactly as every committed vector was decoded. + let u_plane = pic.plane(C::U); + let v_plane = pic.plane(C::V); + let u_stride = pic.stride(C::U) as usize; + let v_stride = pic.stride(C::V) as usize; + for i in 0..h { + for j in 0..w { + let y = y_plane.as_ref()[i * y_stride + j] as f32; + let ci = i / 2; let cj = j / 2; + let cb = u_plane.as_ref()[ci * u_stride + cj] as f32 - 128.0; + let cr = v_plane.as_ref()[ci * v_stride + cj] as f32 - 128.0; + let r = y + 1.402 * cr; + let g = y - 0.344136 * cb - 0.714136 * cr; + let b = y + 1.772 * cb; + let p = (i * w + j) * 3; + out[p] = r.clamp(0.0, 255.0).round() as u8; + out[p + 1] = g.clamp(0.0, 255.0).round() as u8; + out[p + 2] = b.clamp(0.0, 255.0).round() as u8; + } + } + return Ok(out); + } + // Every other layout and depth. A sample at depth d is brought to the 8-bit scale + // by 2^(d-8); planes deeper than 8 bits hold one native-endian u16 a sample. + let wide = pic.bit_depth() > 8; + let bits = pic.bits_per_component().map_or(8, |b| b.0); + if !(8..=12).contains(&bits) { + return Err(VideoError(format!("AV1 frame of {bits} bits per component"))); + } + let scale = 1.0 / (1u32 << (bits - 8)) as f32; + let at = |plane: &[u8], stride: usize, i: usize, j: usize| -> Result { + let v = if wide { + let k = i * stride + 2 * j; + plane.get(k..k + 2).map(|b| u16::from_ne_bytes([b[0], b[1]]) as f32) + } else { + plane.get(i * stride + j).map(|&b| b as f32) + }; + v.map(|v| v * scale).ok_or_else(|| VideoError("AV1 frame plane shorter than its geometry".into())) + }; + let (sub_x, sub_y) = match pic.pixel_layout() { + PixelLayout::I420 => (1, 1), + PixelLayout::I422 => (1, 0), + PixelLayout::I444 | PixelLayout::I400 => (0, 0), + }; + let mono = pic.pixel_layout() == PixelLayout::I400; + let (u_plane, v_plane) = (pic.plane(C::U), pic.plane(C::V)); + let (u_stride, v_stride) = (pic.stride(C::U) as usize, pic.stride(C::V) as usize); for i in 0..h { for j in 0..w { - let y = y_plane.as_ref()[i * y_stride + j] as f32; - let ci = i / 2; let cj = j / 2; - let cb = u_plane.as_ref()[ci * u_stride + cj] as f32 - 128.0; - let cr = v_plane.as_ref()[ci * v_stride + cj] as f32 - 128.0; + let y = at(y_plane.as_ref(), y_stride, i, j)?; + let (cb, cr) = if mono { + (0.0, 0.0) + } else { + let (ci, cj) = (i >> sub_y, j >> sub_x); + (at(u_plane.as_ref(), u_stride, ci, cj)? - 128.0, at(v_plane.as_ref(), v_stride, ci, cj)? - 128.0) + }; let r = y + 1.402 * cr; let g = y - 0.344136 * cb - 0.714136 * cr; let b = y + 1.772 * cb; @@ -245,7 +406,7 @@ fn picture_to_rgb(pic: &dav1d::Picture) -> Vec { out[p + 2] = b.clamp(0.0, 255.0).round() as u8; } } - out + Ok(out) } #[cfg(test)] diff --git a/wai/wai-rs/src/ffi_codecs.rs b/wai/wai-rs/src/ffi_codecs.rs index 8a306661c..18dec3a07 100644 --- a/wai/wai-rs/src/ffi_codecs.rs +++ b/wai/wai-rs/src/ffi_codecs.rs @@ -45,6 +45,12 @@ pub unsafe extern "C" fn wai_image_png_encode( /// Decode a PNG byte stream to RGB. Returns `(rgb, h, w)`. /// +/// A picture past the reader limits of SPEC §2 (wider or taller than +/// [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than +/// [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with +/// `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins +/// `image_too_large`. +/// /// # Safety /// `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable. #[unsafe(no_mangle)] @@ -113,6 +119,12 @@ pub unsafe extern "C" fn wai_image_avif_encode( }) } +/// A picture past the reader limits of SPEC §2 (wider or taller than +/// [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than +/// [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with +/// `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins +/// `image_too_large`. +/// /// # Safety /// `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable. #[unsafe(no_mangle)] @@ -158,6 +170,12 @@ pub unsafe extern "C" fn wai_image_jxl_encode( }) } +/// A picture past the reader limits of SPEC §2 (wider or taller than +/// [`IMAGE_MAX_SIDE`](crate::codecs::image::IMAGE_MAX_SIDE) or holding more than +/// [`IMAGE_MAX_PIXELS`](crate::codecs::image::IMAGE_MAX_PIXELS) pixels) is refused with +/// `WAI_ERR_CODEC` from the size it declares, before it decodes; the message begins +/// `image_too_large`. +/// /// # Safety /// `bytes` valid for `len` bytes; `out` writable; `out_h` writable; `out_w` writable. #[unsafe(no_mangle)] @@ -195,6 +213,11 @@ pub unsafe extern "C" fn wai_audio_opus_encode( }) } +/// Output past the reader limit of SPEC §2, +/// [`AUDIO_DECODED_MAX_SAMPLES`](crate::codecs::audio::AUDIO_DECODED_MAX_SAMPLES) +/// samples, is refused with `WAI_ERR_CODEC`; the message begins +/// `audio_decoded_too_long`. +/// /// # Safety /// `bytes` valid for `len` bytes; `out` writable; `out_sr` writable. #[unsafe(no_mangle)] @@ -246,6 +269,11 @@ pub unsafe extern "C" fn wai_audio_flac_encode( }) } +/// Output past the reader limit of SPEC §2, +/// [`AUDIO_DECODED_MAX_SAMPLES`](crate::codecs::audio::AUDIO_DECODED_MAX_SAMPLES) +/// samples, is refused with `WAI_ERR_CODEC`; the message begins +/// `audio_decoded_too_long`. +/// /// # Safety /// `bytes` valid for `len` bytes; `out` writable; `out_sr` writable. #[unsafe(no_mangle)] @@ -380,6 +408,14 @@ pub unsafe extern "C" fn wai_video_av1_encode( /// Decode an AV1 WAI payload to a packed RGB buffer `n*h*w*3` bytes. /// +/// A payload past the reader limits of SPEC §2 (more than +/// [`VIDEO_MAX_TEMPORAL_UNITS`](crate::codecs::video::VIDEO_MAX_TEMPORAL_UNITS) +/// temporal units, or frames past the picture limits) is refused from its header; +/// the message begins `video_too_large`. Since the buffer holds every frame, so is +/// one whose frames would take more than +/// [`AV1_DECODE_DEFAULT_MAX_LEN`](crate::codecs::video::AV1_DECODE_DEFAULT_MAX_LEN) +/// bytes together (`video_decoded_too_large`), and one whose frames differ in size. +/// /// # Safety /// `bytes` valid for `len` bytes; `out` writable; `out_n` writable; `out_h` writable; `out_w` writable; `out_fps_num` writable; `out_fps_den` writable. #[unsafe(no_mangle)] diff --git a/wai/wai-rs/tests/media_decode_bounds.rs b/wai/wai-rs/tests/media_decode_bounds.rs new file mode 100644 index 000000000..3d14017e2 --- /dev/null +++ b/wai/wai-rs/tests/media_decode_bounds.rs @@ -0,0 +1,357 @@ +//! The image, audio and video decoders hold a payload to the reader limits of SPEC §2: +//! a picture past the limits, an audio payload that declares or decodes to more samples +//! than the limit, and a video payload past them are refused by name, from what the +//! payload declares wherever it declares it, and one at a limit is not refused for it. +//! +//! - Pictures: `check_dimensions` at and one past each limit; real PNG, JPEG XL and +//! AVIF payloads one pixel past the side limit; PNG, JPEG and AVIF headers that +//! declare more pixels than the limit with sides within it. Each decoder decodes only +//! the format its capability names, and JPEG XL decodes to three channels whatever +//! the source holds. The writers refuse what a reader may refuse. +//! - Audio: a FLAC stream whose STREAMINFO declares 2^36 - 1 samples is refused from the +//! declaration (it used to ask the allocator for 256 GiB up front, which aborts the +//! process wherever the allocator refuses that); the declared count is +//! taken over every channel; a 24-bit stream decodes to [-1, 1]; both decoders refuse +//! output past a limit the caller gives, and not at it. +//! - Video: the framing header past each limit; more temporal units than the header +//! declares; a frame larger than it declares; the whole-output budget, at and one +//! past it; monochrome and 10-bit 4:4:4 streams, which the decoder used to panic on +//! or misread. +//! +//! Exercises the codec wrappers, so it requires the `codecs` feature. +#![cfg(feature = "codecs")] + +use wai::codecs::audio::{ + flac_decode, flac_decode_within, flac_encode, opus_decode_within, opus_encode, + AUDIO_DECODED_MAX_SAMPLES, +}; +use wai::codecs::image::{ + avif_decode, avif_encode, check_dimensions, jpeg_decode, jpeg_encode, jxl_decode, + jxl_encode, png_decode, png_encode, IMAGE_MAX_PIXELS, IMAGE_MAX_SIDE, +}; +use wai::codecs::video::{ + av1_decode, av1_decode_each, av1_decode_within, av1_encode, av1_header, + VIDEO_MAX_TEMPORAL_UNITS, +}; + +fn why(r: Result) -> String { + format!("{:?}", r.expect_err("the payload must be refused")) +} + +fn grey(h: u32, w: u32) -> Vec { + (0..h * w * 3).map(|i| (i % 251) as u8).collect() +} + +// ---- pictures ------------------------------------------------------------------ + +#[test] +fn picture_limits_hold_at_and_one_past_each_bound() { + let side = IMAGE_MAX_SIDE as u64; + assert_eq!(IMAGE_MAX_PIXELS, 1 << 27); + for (w, h) in [(side, 1), (1, side), (16_384, 8_192), (side, IMAGE_MAX_PIXELS / side)] { + check_dimensions(w, h).unwrap_or_else(|e| panic!("{w} × {h} is within the limits: {e:?}")); + } + for (w, h) in [(side + 1, 1), (1, side + 1), (16_384, 8_193), (side, IMAGE_MAX_PIXELS / side + 1)] { + let e = why(check_dimensions(w, h)); + assert!(e.contains("image_too_large:"), "{w} × {h}: {e}"); + } +} + +#[test] +fn png_and_jxl_one_pixel_past_the_side_limit_are_refused() { + // Made with the libraries themselves: the reference writers refuse such a picture. + let w = IMAGE_MAX_SIDE + 1; + let rgb = grey(1, w); + let mut png = Vec::new(); + image::ImageEncoder::write_image( + image::codecs::png::PngEncoder::new(&mut png), &rgb, w, 1, image::ExtendedColorType::Rgb8, + ) + .unwrap(); + assert!(why(png_decode(&png)).contains("image_too_large:")); + let mut enc = jpegxl_rs::encoder_builder().lossless(true).uses_original_profile(true).build().unwrap(); + let jxl = enc.encode::(&rgb, w, 1).unwrap().data; + assert!(why(jxl_decode(&jxl)).contains("image_too_large:")); + // At the side limit both decode. + let rgb = grey(1, IMAGE_MAX_SIDE); + let (out, h, w) = png_decode(&png_encode(&rgb, 1, IMAGE_MAX_SIDE).unwrap()).unwrap(); + assert_eq!((out.len(), h, w), (rgb.len(), 1, IMAGE_MAX_SIDE)); + let (out, h, w) = jxl_decode(&jxl_encode(&rgb, 1, IMAGE_MAX_SIDE, None).unwrap()).unwrap(); + assert_eq!((out, h, w), (rgb, 1, IMAGE_MAX_SIDE)); +} + +/// CRC-32 (ISO 3309), for PNG chunks. +fn crc32(bytes: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for &b in bytes { + c ^= b as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c +} + +#[test] +fn png_and_jpeg_headers_past_the_pixel_limit_are_refused_before_decoding() { + // 16 384 × 8 193: each side within the limit, one row of pixels past it. Only the + // header says so; the picture data is an 8 × 8's, so a decoder that read it first + // would fail for another reason. + let rgb = grey(8, 8); + let mut png = png_encode(&rgb, 8, 8).unwrap(); + assert_eq!(&png[12..16], b"IHDR"); + png[16..20].copy_from_slice(&16_384u32.to_be_bytes()); + png[20..24].copy_from_slice(&8_193u32.to_be_bytes()); + let crc = crc32(&png[12..29]); + png[29..33].copy_from_slice(&crc.to_be_bytes()); + assert!(why(png_decode(&png)).contains("image_too_large:")); + + let mut jpg = jpeg_encode(&rgb, 8, 8, 90).unwrap(); + let sof = jpg.windows(2).position(|m| m == [0xFF, 0xC0]).expect("a baseline SOF0"); + jpg[sof + 5..sof + 7].copy_from_slice(&8_193u16.to_be_bytes()); + jpg[sof + 7..sof + 9].copy_from_slice(&16_384u16.to_be_bytes()); + assert!(why(jpeg_decode(&jpg)).contains("image_too_large:")); +} + +#[test] +fn avif_declarations_past_the_limits_are_refused_and_a_small_one_decodes() { + let rgb = grey(16, 16); + let avif = avif_encode(&rgb, 16, 16, 80).unwrap(); + let (out, h, w) = avif_decode(&avif).unwrap(); + assert_eq!((out.len(), h, w), (rgb.len(), 16, 16)); + let ispe = avif.windows(4).position(|b| b == b"ispe").expect("an ispe property"); + for (w, h) in [(IMAGE_MAX_SIDE + 1, 1u32), (16_384, 8_193)] { + let mut a = avif.clone(); + a[ispe + 8..ispe + 12].copy_from_slice(&w.to_be_bytes()); + a[ispe + 12..ispe + 16].copy_from_slice(&h.to_be_bytes()); + let e = why(avif_decode(&a)); + assert!(e.contains("image_too_large:"), "{w} × {h}: {e}"); + } +} + +#[test] +fn each_image_decoder_decodes_only_its_own_format() { + let rgb = grey(8, 8); + let png = png_encode(&rgb, 8, 8).unwrap(); + let jpg = jpeg_encode(&rgb, 8, 8, 90).unwrap(); + assert!(png_decode(&jpg).is_err(), "png_decode read a JPEG"); + assert!(jpeg_decode(&png).is_err(), "jpeg_decode read a PNG"); + assert!(png_decode(&png).is_ok() && jpeg_decode(&jpg).is_ok()); +} + +#[test] +fn the_writers_refuse_what_a_reader_may_refuse() { + let w = IMAGE_MAX_SIDE + 1; + let rgb = grey(1, w); + assert!(why(png_encode(&rgb, 1, w)).contains("image_too_large:")); + assert!(why(jpeg_encode(&rgb, 1, w, 90)).contains("image_too_large:")); + assert!(why(avif_encode(&rgb, 1, w, 80)).contains("image_too_large:")); + assert!(why(jxl_encode(&rgb, 1, w, None)).contains("image_too_large:")); + let long = vec![0f32; AUDIO_DECODED_MAX_SAMPLES + 1]; + assert!(why(flac_encode(&long, 48_000)).contains("audio_decoded_too_long:")); + assert!(why(opus_encode(&long, 48_000, 64_000)).contains("audio_decoded_too_long:")); + drop(long); + let frame = grey(1, w); + assert!(why(av1_encode(&[frame], 1, w, 30, 1, false, 60)).contains("video_too_large:")); +} + +// ---- audio --------------------------------------------------------------------- + +fn tone(n: usize, amp: f32) -> Vec { + (0..n).map(|i| amp * (i as f32 * 0.05).sin()).collect() +} + +/// Rewrites a FLAC stream's STREAMINFO: its channel count and its 36-bit total. +fn declare(flac: &[u8], channels: u8, total: u64) -> Vec { + let mut f = flac.to_vec(); + assert_eq!(&f[..4], b"fLaC"); + let si = 8; // after the marker and the STREAMINFO block header + // Byte 12 of STREAMINFO: the low bit of the sample rate, three bits of channels - 1, + // the high bit of bits per sample - 1. Byte 13: four bits of bps - 1, then the + // total's top four bits; bytes 14..18 the rest of it. + f[si + 12] = (f[si + 12] & 0xF1) | ((channels - 1) << 1); + f[si + 13] = (f[si + 13] & 0xF0) | ((total >> 32) as u8 & 0x0F); + f[si + 14..si + 18].copy_from_slice(&(total as u32).to_be_bytes()); + f +} + +#[test] +fn a_flac_stream_that_declares_too_many_samples_is_refused_from_the_declaration() { + let flac = flac_encode(&tone(4_096, 0.5), 48_000).unwrap(); + let max = AUDIO_DECODED_MAX_SAMPLES as u64; + // 2^36 - 1, the largest total STREAMINFO can carry: the reader used to reserve that + // many samples, 256 GiB, before decoding one. Now it refuses, having allocated + // nothing for it. + for (ch, total) in [(1, (1u64 << 36) - 1), (1, max + 1), (2, max / 2 + 1)] { + let e = why(flac_decode(&declare(&flac, ch, total))); + assert!(e.contains("audio_decoded_too_long:") && e.contains("declares"), "{ch} × {total}: {e}"); + } + // At the limit the declaration is not refused (the stream then decodes what it holds). + let at = declare(&flac, 1, max); + let (out, _) = flac_decode(&at).unwrap(); + assert_eq!(out.len(), 4_096); +} + +#[test] +fn flac_decodes_a_24_bit_stream_to_unit_scale() { + use flac_bound::{FlacEncoder, WriteWrapper}; + let x = tone(4_800, 0.5); + let ints: Vec = x.iter().map(|&s| (s * 8_388_607.0).round() as i32).collect(); + let mut bytes = Vec::new(); + { + let mut ww = WriteWrapper(&mut bytes); + let mut enc = FlacEncoder::new().unwrap().channels(1).bits_per_sample(24) + .sample_rate(48_000).init_write(&mut ww).unwrap(); + enc.process_interleaved(&ints, ints.len() as u32).unwrap(); + enc.finish().unwrap(); + } + let (out, _) = flac_decode(&bytes).unwrap(); + let peak = out.iter().fold(0f32, |m, &v| m.max(v.abs())); + assert!((0.49..=0.51).contains(&peak), "a 0.5 tone decoded to a peak of {peak}"); +} + +#[test] +fn the_audio_decoders_refuse_output_past_the_callers_limit_and_not_at_it() { + let x = tone(9_600, 0.5); + let flac = flac_encode(&x, 48_000).unwrap(); + // A stream that declares its total is refused from the declaration; one whose total + // is 0 (unknown) is refused from the count as samples decode. + assert!(why(flac_decode_within(&declare(&flac, 1, 9_600), 9_599)).contains("declares")); + let unknown = declare(&flac, 1, 0); + assert!(why(flac_decode_within(&unknown, 9_599)).contains("decodes to")); + assert_eq!(flac_decode_within(&unknown, 9_600).unwrap().0.len(), 9_600); + + let opus = opus_encode(&x, 48_000, 64_000).unwrap(); + let n = opus_decode_within(&opus, usize::MAX).unwrap().0.len(); + assert!(n > 9_000, "decoded {n} samples of 9 600"); + assert_eq!(opus_decode_within(&opus, n).unwrap().0.len(), n); + assert!(why(opus_decode_within(&opus, n - 1)).contains("audio_decoded_too_long:")); +} + +// ---- video --------------------------------------------------------------------- + +fn framing(n: u32, h: u32, w: u32, units: &[Vec]) -> Vec { + let mut out = Vec::new(); + for v in [n, h, w, 30, 1] { + out.extend_from_slice(&v.to_le_bytes()); + } + for u in units { + out.extend_from_slice(&(u.len() as u32).to_le_bytes()); + out.extend_from_slice(u); + } + out +} + +/// The temporal units of an AV1 payload, after its framing header. +fn units_of(payload: &[u8]) -> Vec> { + let mut at = 20; + let mut out = Vec::new(); + while at < payload.len() { + let n = u32::from_le_bytes(payload[at..at + 4].try_into().unwrap()) as usize; + out.push(payload[at + 4..at + 4 + n].to_vec()); + at += 4 + n; + } + out +} + +#[test] +fn the_av1_framing_header_is_held_to_the_limits() { + let n = VIDEO_MAX_TEMPORAL_UNITS; + let side = IMAGE_MAX_SIDE; + assert!(av1_header(&framing(n, 2_048, side, &[])).is_ok(), "at every limit"); + for (n, h, w) in [(n + 1, 64, 64), (1, 1, side + 1), (1, 8_193, 16_384)] { + let e = why(av1_header(&framing(n, h, w, &[]))); + assert!(e.contains("video_too_large:"), "{n} × {w}×{h}: {e}"); + } +} + +#[test] +fn av1_refuses_more_units_than_declared_and_frames_larger_than_declared() { + let clip: Vec> = (0..3).map(|_| grey(64, 64)).collect(); + let payload = av1_encode(&clip, 64, 64, 30, 1, false, 60).unwrap(); + let units = units_of(&payload); + assert_eq!(units.len(), 3, "one temporal unit a frame"); + assert_eq!(av1_decode(&payload).unwrap().0.len(), 3); + let e = why(av1_decode(&framing(2, 64, 64, &units))); + assert!(e.contains("more than the 2 temporal units"), "{e}"); + // A frame with more pixels than the header declares never reaches a picture: dav1d + // is held to h × w pixels (the test below). One with as many pixels, but wider than + // the header says, is decoded and then refused by its shape. + assert!(av1_decode(&framing(3, 32, 32, &units)).is_err()); + let wide = av1_unit(128, 16, rav1e::color::ChromaSampling::Cs420, 8, [100, 128, 128]); + let e = why(av1_decode(&framing(1, 32, 64, &[wide]))); + assert!(e.contains("128×16 frame in a payload whose frames are at most 64×32"), "{e}"); +} + +#[test] +fn the_whole_output_budget_refuses_from_the_header_and_streaming_does_not_need_one() { + let clip: Vec> = (0..4).map(|_| grey(64, 64)).collect(); + let payload = av1_encode(&clip, 64, 64, 30, 1, false, 60).unwrap(); + let need = 4 * 64 * 64 * 3; + assert_eq!(av1_decode_within(&payload, need).unwrap().0.len(), 4); + assert!(why(av1_decode_within(&payload, need - 1)).contains("video_decoded_too_large:")); + let mut seen = 0; + let hd = av1_decode_each(&payload, |rgb, w, h| { + assert_eq!((rgb.len(), w, h), (64 * 64 * 3, 64, 64)); + seen += 1; + Ok(()) + }) + .unwrap(); + assert_eq!((seen, hd.n), (4, 4)); +} + +#[test] +fn a_sequence_header_larger_than_the_framing_header_is_refused_before_it_allocates() { + // A 256×256 stream in a framing header that says 16×16: dav1d is held to 16×16 + // pixels, so it refuses the stream's sequence header instead of allocating its + // frames and leaving the size check to the decoded picture. + let unit = av1_unit(256, 256, rav1e::color::ChromaSampling::Cs420, 8, [100, 128, 128]); + let e = why(av1_decode(&framing(1, 16, 16, &[unit.clone()]))); + assert!(!e.contains("256×256 frame"), "the frame was decoded before it was refused: {e}"); + assert!(av1_decode(&framing(1, 256, 256, &[unit])).is_ok()); +} + +/// One AV1 temporal unit of a `w`×`h` picture in the given layout and depth, every +/// sample of plane p equal to `fill[p]`, made with rav1e. +fn av1_unit(w: usize, h: usize, cs: rav1e::color::ChromaSampling, depth: usize, fill: [u16; 3]) -> Vec { + use rav1e::{Config, EncoderConfig, EncoderStatus}; + let mut cfg = EncoderConfig::with_speed_preset(10); + cfg.width = w; + cfg.height = h; + cfg.chroma_sampling = cs; + cfg.bit_depth = depth; + cfg.still_picture = true; + cfg.quantizer = 0; + cfg.min_quantizer = 0; + let mut ctx: rav1e::Context = Config::new().with_encoder_config(cfg).new_context().unwrap(); + let mut f = ctx.new_frame(); + let planes = if cs == rav1e::color::ChromaSampling::Cs400 { 1 } else { 3 }; + for (p, plane) in f.planes.iter_mut().enumerate().take(planes) { + for v in plane.data_origin_mut().iter_mut() { + *v = fill[p]; + } + } + ctx.send_frame(f).unwrap(); + ctx.flush(); + let mut out = Vec::new(); + loop { + match ctx.receive_packet() { + Ok(p) => out.extend_from_slice(&p.data), + Err(EncoderStatus::Encoded) => continue, + Err(_) => break, + } + } + out +} + +#[test] +fn av1_decodes_monochrome_and_10_bit_4_4_4() { + use rav1e::color::ChromaSampling::{Cs400, Cs444}; + // Monochrome, 8 bits: Y = 100 everywhere, so every RGB sample is 100. + let mono = framing(1, 16, 16, &[av1_unit(16, 16, Cs400, 8, [100, 0, 0])]); + let (frames, ..) = av1_decode(&mono).unwrap(); + assert!(frames[0].iter().all(|&v| v == 100), "monochrome: {:?}", &frames[0][..6]); + // 10-bit 4:4:4: Y = 400, Cb = Cr = 512 (neutral), so every sample is 400 / 4 = 100. + let deep = framing(1, 16, 16, &[av1_unit(16, 16, Cs444, 10, [400, 512, 512])]); + let (frames, ..) = av1_decode(&deep).unwrap(); + assert!(frames[0].iter().all(|&v| v == 100), "10-bit 4:4:4: {:?}", &frames[0][..6]); +} -- 2.54.0 (Apple Git-157) From 3a6963ea0dcc2aa04fa6a8286f7585be6c983e82 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 14 Oct 2026 10:52:15 -0400 Subject: [PATCH 2/4] wai: coverage-guided fuzzing of the reader, and the container reservations it found SECURITY.md's remaining known gap: fuzzing was a seeded-random smoke test, with no coverage guidance for the manifest parser, the integer payload parsers or the codec wrappers. wai-rs/fuzz/ adds four libFuzzer targets (cargo-fuzz, AddressSanitizer): - envelope: version detection, the WAI1 and WAI2 readers, the listing form; - int_payload: int_payload::decode_int against each (capability, parameter set) pair the integer conformance corpus commits, the first byte choosing the pair; - media: the picture, audio, video and text decoders of the floor and the recommended set, the first byte choosing the decoder; - containers: the extension containers (world, causal op, film, reel, score, haptic, splat4d, audio scene). `seeds` builds the corpora from the repository: every committed envelope, integer payload and container, and a valid payload per decoder from the reference writers. `run.sh` runs each target for a given time, tells a find (exit 1, input kept in fuzz/artifacts) from a tool failure (exit 2), and builds with --no-cfg-fuzzing because rav1e depends on libfuzzer-sys under cfg(fuzzing), which left wai's cdylib with an undefined symbol. wai-fuzz.yml runs each target for 15 minutes nightly on the macOS runner (which has the codec libraries), never per pull request, keeping the corpus on the CI volume between nights. wai-rs's one multi-line inline table (c2pa) is put on one line: cargo-fuzz's TOML parser rejects it. Found in the containers target's first seconds: the audio scene reader reserved a u32 count of entries read from the input before reading one, so a few bytes asked for 48 GiB. The world, reel, score, haptic and splat4d readers did the same. Each now reserves no more than its remaining bytes could hold. tests/container_reservations.rs rewrites a committed vector of each to declare 2^32 - 1 entries in four bytes and requires a refusal with no single allocation past a few megabytes, through an allocator that records the largest request and refuses past 256 MiB; on the old readers the world asset table alone asked for 256 GiB (falsified). wai-conformance runs it on every leg. SECURITY.md now says what is fuzzed, and keeps two gaps: the C libraries are not instrumented, and the extension formats' declared durations have no reader limit yet (rendering allocates in proportion; reading is bounded). Smoke runs on the build host: envelope, int_payload and media 120 s each, nothing found; containers found the above, then ran 300 s clean once fixed. Green there: the new test, and the 275 lib tests with the container features. Clean: tests-in-CI, spec and section refs, workflow concurrency, lockfiles, vanity metrics, comparative prose, public names. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 7 + .github/workflows/wai-fuzz.yml | 61 + wai/CHANGELOG.md | 21 + wai/SECURITY.md | 32 +- wai/wai-rs/Cargo.toml | 8 +- wai/wai-rs/fuzz/.gitignore | 4 + wai/wai-rs/fuzz/Cargo.lock | 1820 +++++++++++++++++++ wai/wai-rs/fuzz/Cargo.toml | 64 + wai/wai-rs/fuzz/fuzz_targets/containers.rs | 11 + wai/wai-rs/fuzz/fuzz_targets/envelope.rs | 11 + wai/wai-rs/fuzz/fuzz_targets/int_payload.rs | 25 + wai/wai-rs/fuzz/fuzz_targets/media.rs | 10 + wai/wai-rs/fuzz/run.sh | 60 + wai/wai-rs/fuzz/src/lib.rs | 106 ++ wai/wai-rs/fuzz/src/seeds.rs | 90 + wai/wai-rs/src/audio_scene/container.rs | 8 +- wai/wai-rs/src/film/reel.rs | 8 +- wai/wai-rs/src/haptic/mod.rs | 4 +- wai/wai-rs/src/score/container.rs | 4 +- wai/wai-rs/src/splat4d/container.rs | 4 +- wai/wai-rs/src/world/container.rs | 4 +- wai/wai-rs/tests/container_reservations.rs | 133 ++ 22 files changed, 2480 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/wai-fuzz.yml create mode 100644 wai/wai-rs/fuzz/.gitignore create mode 100644 wai/wai-rs/fuzz/Cargo.lock create mode 100644 wai/wai-rs/fuzz/Cargo.toml create mode 100644 wai/wai-rs/fuzz/fuzz_targets/containers.rs create mode 100644 wai/wai-rs/fuzz/fuzz_targets/envelope.rs create mode 100644 wai/wai-rs/fuzz/fuzz_targets/int_payload.rs create mode 100644 wai/wai-rs/fuzz/fuzz_targets/media.rs create mode 100755 wai/wai-rs/fuzz/run.sh create mode 100644 wai/wai-rs/fuzz/src/lib.rs create mode 100644 wai/wai-rs/fuzz/src/seeds.rs create mode 100644 wai/wai-rs/tests/container_reservations.rs diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index d66659c1d..e168f495c 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -195,6 +195,13 @@ jobs: cargo clippy --lib --no-default-features --features neural_int -- -A clippy::all cargo test --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film --test jpegai_decode_symbols + # Each extension container refuses a count its bytes cannot hold, without reserving + # it first (found by the coverage-guided fuzzer, wai-rs/fuzz/, target `containers`). + - name: Extension containers reserve no more than their bytes can hold + shell: bash + working-directory: wai/wai-rs + run: cargo test --no-default-features --features world,audio_scene,haptic,splat4d,score,film --test container_reservations + # The integer decoders run fast kernels (src/int_kernels.rs) that must give # the same integer as the scalar reference kernels for every output element. # The feature list above leaves out audio_int, video_int, world_int and diff --git a/.github/workflows/wai-fuzz.yml b/.github/workflows/wai-fuzz.yml new file mode 100644 index 000000000..105c3b1b9 --- /dev/null +++ b/.github/workflows/wai-fuzz.yml @@ -0,0 +1,61 @@ +# Coverage-guided fuzzing of the WAI reader, every night. +# +# `wai/wai-rs/fuzz/run.sh` runs four libFuzzer targets (cargo-fuzz, AddressSanitizer): +# envelope version detection, the WAI1 and WAI2 readers, the listing form +# int_payload int_payload::decode_int against every committed parameter set +# media the picture, audio, video and text decoders of the floor and the +# recommended set +# containers the extension containers: world, causal op, film, reel, score, +# haptic, splat4d, audio scene +# each for 15 minutes, seeded from the repository's own envelopes, integer payloads and +# containers, and a small valid payload per decoder. +# +# Nightly and never per pull request: the runners are the bottleneck, and fifteen +# minutes a target is worth running only where it can grow a corpus. The corpus is kept +# on the runner host's CI volume, so each night starts where the last left off. +# +# A find fails the job (exit 1) and keeps its input as an artifact; a target that does +# not build or run fails it too (exit 2). Turn a find into a regression test as the +# header of run.sh says. +name: WAI fuzz (coverage-guided, nightly) + +on: + schedule: + - cron: '17 4 * * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + name: envelope, integer payloads, media decoders and containers · 15 min each + # The macOS runner's host has the C codec libraries the media target links. + runs-on: macos + timeout-minutes: 120 + steps: + - uses: actions/checkout@v5 + + - name: Install a pinned nightly and cargo-fuzz + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + rustup toolchain install nightly-2026-09-28 --profile minimal + command -v cargo-fuzz >/dev/null || cargo install cargo-fuzz --locked + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Fuzz + working-directory: wai/wai-rs/fuzz + shell: bash + env: + WAI_FUZZ_TOOLCHAIN: nightly-2026-09-28 + WAI_FUZZ_CORPUS: /Volumes/macos_4TB_external_1/ci/wai-fuzz-corpus + run: ./run.sh 900 + + - name: Keep what it found + if: failure() + uses: actions/upload-artifact@v3 + with: + name: wai-fuzz-artifacts + path: wai/wai-rs/fuzz/artifacts + if-no-files-found: ignore diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 715ecc391..529ffe94f 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,27 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Coverage-guided fuzzing of the reader + +`wai-rs/fuzz/` adds four libFuzzer targets, built by cargo-fuzz with AddressSanitizer: +- `envelope`: the `WAI1` and `WAI2` readers and the listing form; +- `int_payload`: every integer capability, against the committed parameter sets; +- `media`: the decoders of the floor and the recommended set; +- `containers`: the extension containers. + +They are seeded from the repository's own vectors. `wai-fuzz.yml` runs each target for +15 minutes every night and keeps the corpus between runs; it never runs on a pull +request. `fuzz/run.sh` runs the same targets anywhere a nightly toolchain is installed. +SECURITY.md now says what is fuzzed and what is not. + +- **Found and fixed.** In its first seconds, `containers` found the audio scene reader + reserving entries for a `u32` count read from the input, before it read one: 48 GiB + asked for by a few bytes. The world, reel, score, haptic and splat4d readers did the + same. Each now reserves no more than its remaining bytes could hold. + `tests/container_reservations.rs` rewrites a committed vector of each to declare + 2³² − 1 entries in four bytes and requires a refusal with no allocation past a few + megabytes; on the old readers the world asset table alone asked for 256 GiB. + ### Reader limits for picture, audio and video payloads The last decoders that allocated what a payload declared now hold it to limits diff --git a/wai/SECURITY.md b/wai/SECURITY.md index 88999bf28..5e755226b 100644 --- a/wai/SECURITY.md +++ b/wai/SECURITY.md @@ -181,13 +181,35 @@ model-checked. Equality tests hold them to the scalar reference kernels, element element beside each kernel and over every committed integer-decoder vector (`tests/int_kernels_vectors.rs`), on every platform leg of `wai-conformance.yml`. +The reader is fuzzed with coverage guidance. `wai-rs/fuzz/` holds four libFuzzer +targets, built by cargo-fuzz with AddressSanitizer: +- `envelope`: version detection, the `WAI1` and `WAI2` readers and the listing form; +- `int_payload`: `int_payload::decode_int` against each parameter set the integer + conformance corpus commits; +- `media`: the picture, audio, video and text decoders of the floor and the recommended + set; +- `containers`: the extension containers (world, causal op, film, reel, score, haptic, + splat4d, audio scene). + +They are seeded from the repository's own envelopes, integer payloads and containers, +and from a valid payload per decoder. `wai-fuzz.yml` runs each for 15 minutes every +night on the macOS runner and keeps the corpus between nights. A find fails the job +and keeps its input; `fuzz/run.sh` says how to turn one into a regression test. On every +change, `tests/fuzz_smoke.rs` still runs seeded random bytes through each wrapper and +`Wai::from_bytes`. + Known gaps: -- **Fuzzing is shallow.** `tests/fuzz_smoke.rs` feeds seeded random bytes to each - codec wrapper and to `Wai::from_bytes` and requires that nothing panics. It runs in - CI on one platform (`codec-wrappers` in `wai-conformance.yml`). There is no - coverage-guided fuzzing of the manifest parser, the integer payload parsers or the - codec wrappers. +- **The C libraries are not instrumented.** AddressSanitizer covers the Rust code. The + codec libraries run as the system builds them, so a memory error inside one is seen + only when it crashes the process or exceeds a limit. +- **The extension formats' durations have no reader limit.** A score, haptic signal, + audio scene, splat4d clip or film declares its duration in its contract, and + rendering it allocates in proportion. Nothing bounds the declaration yet, so a small + container can ask a renderer for more memory than it has. Reading them is bounded; + rendering them is not. +- **The float neural decoders are not fuzzed.** They need the `neural` feature and its + runtime. The integer decoders are fuzzed. ## Module authorisation diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 3b820b907..f663947b1 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -83,8 +83,7 @@ sha2 = "0.10" # Writes an actual signed manifest carrying the energy + reconstruction # bindings, rather than only defining their shapes. Off by default: the SDK # is a large dependency chain and most consumers only need the types. -c2pa = { version = "0.90", optional = true, default-features = false, - features = ["rust_native_crypto"] } +c2pa = { version = "0.90", optional = true, default-features = false, features = ["rust_native_crypto"] } # ---- interactive worlds (optional, behind the `world` feature) ---- # extensions/interactive-worlds: WWLD container, signed CausalOps, @@ -1137,6 +1136,11 @@ name = "media_decode_bounds" path = "tests/media_decode_bounds.rs" required-features = ["codecs"] +[[test]] +name = "container_reservations" +path = "tests/container_reservations.rs" +required-features = ["world", "audio_scene", "film", "score", "haptic", "splat4d"] + [[test]] name = "text_decode_memory" path = "tests/text_decode_memory.rs" diff --git a/wai/wai-rs/fuzz/.gitignore b/wai/wai-rs/fuzz/.gitignore new file mode 100644 index 000000000..fe68c971b --- /dev/null +++ b/wai/wai-rs/fuzz/.gitignore @@ -0,0 +1,4 @@ +target/ +corpus/ +artifacts/ +coverage/ diff --git a/wai/wai-rs/fuzz/Cargo.lock b/wai/wai-rs/fuzz/Cargo.lock new file mode 100644 index 000000000..8a9b903ae --- /dev/null +++ b/wai/wai-rs/fuzz/Cargo.lock @@ -0,0 +1,1820 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-data" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca67ba5d317924c02180c576157afd54babe48a76ebc66ce6d34bb8ba08308e" +dependencies = [ + "byte-slice-cast", + "bytes", + "num-derive", + "num-rational", + "num-traits", +] + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bon" +version = "3.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2" +dependencies = [ + "bon-macros", +] + +[[package]] +name = "bon-macros" +version = "3.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "claxon" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dav1d" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee89cb860616069c67520dcd66cacdb900b57c799f634a0eb6d91f6e2a82b61" +dependencies = [ + "av-data", + "bitflags", + "dav1d-sys", + "static_assertions", +] + +[[package]] +name = "dav1d-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c91aea6668645415331133ed6f8ddf0e7f40160cd97a12d59e68716a58704b" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flac-bound" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e907092e9fba0d718cad77433321f524836e90a2c4cc7ab67e8fb5244047ad2" +dependencies = [ + "flac-sys", +] + +[[package]] +name = "flac-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5ccca1896065c6c3927147467ab3d042565607c01194e36560e84344a02c5c0" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "ravif", + "rgb", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "jpeg-encoder" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6" + +[[package]] +name = "jpegxl-rs" +version = "0.14.0+libjxl-0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7d16fe73fb6d2e16b986392b716409c6ae6fabd35769d66bea391b016cdbc9" +dependencies = [ + "bon", + "byteorder", + "half", + "image", + "jpegxl-sys", + "thiserror", +] + +[[package]] +name = "jpegxl-sys" +version = "0.12.1+libjxl-0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00cb4f7ffb45ee4327e9ec6ca1a732f3abef470ee008f7d4be3afb414075cd7d" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libavif-sys" +version = "0.17.0+libavif.1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490ca74b61e773140bebb086c81facb23273a99364a9ab0c92ab1532b90ade35" +dependencies = [ + "cmake", + "libc", + "libdav1d-sys", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdav1d-sys" +version = "0.7.1+libdav1d.1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d875e9669d116a603412a126de599b7bf47789a365b79fcf461fbf9c18d141f" +dependencies = [ + "libc", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ogg" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdab8dcd8d4052eaacaf8fb07a3ccd9a6e26efadb42878a413c68fc4af1dee2b" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.9", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.8+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wai" +version = "0.3.0" +dependencies = [ + "blake3", + "byteorder", + "cbindgen", + "claxon", + "dav1d", + "ed25519-dalek", + "flac-bound", + "image", + "jpeg-encoder", + "jpegxl-rs", + "jpegxl-sys", + "libavif-sys", + "ogg", + "opus", + "pkg-config", + "rav1e", + "serde", + "serde_json", + "sha2", + "wai-ops", + "xz2", + "zstd", +] + +[[package]] +name = "wai-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "serde_json", + "wai", +] + +[[package]] +name = "wai-ops" +version = "0.1.0" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.1+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/wai/wai-rs/fuzz/Cargo.toml b/wai/wai-rs/fuzz/Cargo.toml new file mode 100644 index 000000000..142047f82 --- /dev/null +++ b/wai/wai-rs/fuzz/Cargo.toml @@ -0,0 +1,64 @@ +# Coverage-guided fuzzing of the reader: the envelope and manifest parsers, the integer +# payload parsers against the committed parameter sets, every media decoder, and the +# extension containers. +# `run.sh` builds the seed corpora and runs each target; see the header there. +[package] +name = "wai-fuzz" +version = "0.0.0" +edition = "2024" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +serde_json = "1" +wai = { path = "..", features = ["codecs", "video_int", "audio_int", "splat_int", "world", "film", "score", "haptic", "splat4d", "audio_scene"] } + +# A workspace of its own: cargo-fuzz builds with sanitizer and coverage flags that +# wai-rs's own builds must not share. +[workspace] +members = ["."] + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "envelope" +path = "fuzz_targets/envelope.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "int_payload" +path = "fuzz_targets/int_payload.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "media" +path = "fuzz_targets/media.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "containers" +path = "fuzz_targets/containers.rs" +test = false +doc = false +bench = false + +# Writes the seed corpora (run.sh runs it); not a fuzz target. +[[bin]] +name = "seeds" +path = "src/seeds.rs" +test = false +doc = false +bench = false + +[profile.release] +debug = 1 diff --git a/wai/wai-rs/fuzz/fuzz_targets/containers.rs b/wai/wai-rs/fuzz/fuzz_targets/containers.rs new file mode 100644 index 000000000..830cc5e7a --- /dev/null +++ b/wai/wai-rs/fuzz/fuzz_targets/containers.rs @@ -0,0 +1,11 @@ +//! The extension containers' readers (world, causal op, film, reel, score, haptic, +//! splat4d, audio scene): the first byte picks one (`wai_fuzz::CONTAINERS`), the rest +//! is its bytes. +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Some((&pick, bytes)) = data.split_first() { + wai_fuzz::container_read(pick, bytes); + } +}); diff --git a/wai/wai-rs/fuzz/fuzz_targets/envelope.rs b/wai/wai-rs/fuzz/fuzz_targets/envelope.rs new file mode 100644 index 000000000..4bf1d6524 --- /dev/null +++ b/wai/wai-rs/fuzz/fuzz_targets/envelope.rs @@ -0,0 +1,11 @@ +//! The envelope readers: version detection, `WAI1` and `WAI2` (container, manifest, +//! rendition table) and the parameter-set listing form. +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = wai::container::detect_version(data); + let _ = wai::Wai::from_bytes(data); + let _ = wai::container::WaiMulti::from_bytes(data); + let _ = wai::pin::parse_listing(data); +}); diff --git a/wai/wai-rs/fuzz/fuzz_targets/int_payload.rs b/wai/wai-rs/fuzz/fuzz_targets/int_payload.rs new file mode 100644 index 000000000..12767202b --- /dev/null +++ b/wai/wai-rs/fuzz/fuzz_targets/int_payload.rs @@ -0,0 +1,25 @@ +//! The integer payload decoders (`int_payload::decode_int`), each against a parameter +//! set the conformance corpus commits: the first byte picks the (capability, set) pair, +//! the rest is the payload. +#![no_main] +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use libfuzzer_sys::fuzz_target; +use wai::int_payload::{IntParams, decode_int, resolve}; +use wai::pin::HeldBytes; + +fn entries() -> &'static [wai_fuzz::IntEntry] { + static E: OnceLock> = OnceLock::new(); + E.get_or_init(wai_fuzz::int_entries) +} + +fuzz_target!(|data: &[u8]| { + let Some((&pick, payload)) = data.split_first() else { return }; + let es = entries(); + let e = &es[pick as usize % es.len()]; + let view: BTreeMap = e.set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let params = resolve(&e.cap, None, &[HeldBytes::Set { listing: b"", files: &view }], None) + .unwrap_or_else(|_| IntParams::none()); + let _ = decode_int(&e.cap, payload, ¶ms); +}); diff --git a/wai/wai-rs/fuzz/fuzz_targets/media.rs b/wai/wai-rs/fuzz/fuzz_targets/media.rs new file mode 100644 index 000000000..3ddfef5bd --- /dev/null +++ b/wai/wai-rs/fuzz/fuzz_targets/media.rs @@ -0,0 +1,10 @@ +//! The picture, audio, video and text decoders of the floor and the recommended set: +//! the first byte picks the decoder (`wai_fuzz::MEDIA`), the rest is the payload. +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Some((&pick, payload)) = data.split_first() { + wai_fuzz::media_decode(pick, payload); + } +}); diff --git a/wai/wai-rs/fuzz/run.sh b/wai/wai-rs/fuzz/run.sh new file mode 100755 index 000000000..3a167146c --- /dev/null +++ b/wai/wai-rs/fuzz/run.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# run.sh — coverage-guided fuzzing of the reader (libFuzzer through cargo-fuzz). +# +# run.sh [seconds-per-target] [target ...] +# (default 600; envelope int_payload media containers) +# +# Targets: +# envelope version detection, the WAI1 and WAI2 readers, the listing form +# int_payload int_payload::decode_int against every committed parameter set +# media the picture, audio, video and text decoders of the floor and the +# recommended set +# containers the extension containers: world, causal op, film, reel, score, +# haptic, splat4d, audio scene +# +# Builds the seed corpora from the repository (`seeds`), then runs each target for +# the time given, adding what it finds to the corpus. The corpus lives in +# $WAI_FUZZ_CORPUS (default fuzz/corpus): point it at a directory that persists between +# runs and each run starts where the last left off. A crash, a timeout (60 s) or an +# input that takes more than 4 GiB stops that target; its input lands in +# fuzz/artifacts//, the script carries on with the next target, and exits 1. +# A target that fails without leaving an input (it did not build, or cargo-fuzz could +# not run) is a tool failure, not a find: exit 2. +# Turn a find into a regression test: `cargo +nightly fuzz tmin `, +# then add the minimised input to tests/fuzz_smoke.rs. +# +# Needs a nightly toolchain and cargo-fuzz (`cargo install cargo-fuzz`). Heavy: run it +# on the build host, under memguard, never on the dev Mac. +set -uo pipefail +cd "$(dirname "$0")" +SECS=${1:-600} +shift || true +TARGETS=${*:-envelope int_payload media containers} +CORPUS=${WAI_FUZZ_CORPUS:-corpus} +TOOLCHAIN=${WAI_FUZZ_TOOLCHAIN:-nightly} + +cargo +"$TOOLCHAIN" run --quiet --release --bin seeds -- "$CORPUS" || exit 2 +found=() broken=() +for t in $TARGETS; do + echo "== $t: ${SECS}s" + mkdir -p "$CORPUS/$t" "artifacts/$t" + before=$(ls "artifacts/$t" | wc -l) + # --no-cfg-fuzzing: rav1e (the AV1 encoder wai links) depends on libfuzzer-sys under + # cfg(fuzzing), which would leave wai's cdylib with an undefined libFuzzer symbol. The + # targets use libfuzzer-sys directly and need no cfg. + if ! cargo +"$TOOLCHAIN" fuzz run --no-cfg-fuzzing "$t" "$CORPUS/$t" -- \ + -max_total_time="$SECS" -timeout=60 -rss_limit_mb=4096 -malloc_limit_mb=4096 \ + -max_len=1048576 -print_final_stats=1; then + if [ "$(ls "artifacts/$t" | wc -l)" -gt "$before" ]; then found+=("$t"); else broken+=("$t"); fi + fi +done +if [ ${#broken[@]} -gt 0 ]; then + echo "run.sh: did not run: ${broken[*]} (see the output above)" >&2 +fi +if [ ${#found[@]} -gt 0 ]; then + echo "run.sh: found something in: ${found[*]}; inputs in fuzz/artifacts/" >&2 + exit 1 +fi +[ ${#broken[@]} -eq 0 ] || exit 2 +echo "run.sh: ${TARGETS} ran ${SECS}s each and found nothing" diff --git a/wai/wai-rs/fuzz/src/lib.rs b/wai/wai-rs/fuzz/src/lib.rs new file mode 100644 index 000000000..15848334d --- /dev/null +++ b/wai/wai-rs/fuzz/src/lib.rs @@ -0,0 +1,106 @@ +//! What the fuzz targets and the seed writer share: the integer-payload entries, read +//! from the committed corpus, and the media decoders and extension containers by +//! selector byte. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// The `wai/` directory. +pub fn wai_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// One capability with one parameter set, as `int-payload-conformance/vectors.json` +/// pairs them, and the payloads committed for that pair. +pub struct IntEntry { + pub cap: String, + pub set: BTreeMap>, + pub payloads: Vec>, +} + +/// The distinct (capability, parameter set) pairs of the committed corpus, in the +/// order the index first names them. A fuzz input's first byte picks one. +pub fn int_entries() -> Vec { + let dir = wai_dir(); + let read = |rel: &str| std::fs::read(dir.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")); + let idx: serde_json::Value = + serde_json::from_slice(&read("int-payload-conformance/vectors.json")).expect("the index parses"); + let mut out: Vec = Vec::new(); + let mut keys: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for list in ["vectors", "refusals"] { + for e in idx[list].as_array().into_iter().flatten() { + let cap = e["capability"].as_str().expect("a capability").to_string(); + let set: Vec<(String, String)> = e["set"] + .as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.clone(), v.as_str().expect("a path").to_string())) + .collect(); + let key = (cap.clone(), set.clone()); + let i = match keys.iter().position(|k| *k == key) { + Some(i) => i, + None => { + keys.push(key); + out.push(IntEntry { + cap, + set: set.iter().map(|(k, p)| (k.clone(), read(p))).collect(), + payloads: Vec::new(), + }); + out.len() - 1 + } + }; + if let Some(p) = e["payload"].as_str() { + out[i].payloads.push(read(p)); + } + } + } + assert!(!out.is_empty() && out.len() <= 256, "{} entries: one selector byte must address them", out.len()); + out +} + +/// The media decoders, in selector order. +pub const MEDIA: [&str; 9] = ["png", "jpeg", "avif", "jxl", "flac", "opus", "av1", "zstd", "xz"]; + +/// Decodes `p` with the decoder `pick` selects, discarding the result: every input must +/// come back as a value or a refusal, never as a crash, a hang or unbounded memory. +pub fn media_decode(pick: u8, p: &[u8]) { + use wai::codecs::{audio, image, text, video}; + match pick as usize % MEDIA.len() { + 0 => drop(image::png_decode(p)), + 1 => drop(image::jpeg_decode(p)), + 2 => drop(image::avif_decode(p)), + 3 => drop(image::jxl_decode(p)), + 4 => drop(audio::flac_decode(p)), + 5 => drop(audio::opus_decode(p)), + // One frame at a time: the whole-output form would only add an allocation. + 6 => drop(video::av1_decode_each(p, |_, _, _| Ok(()))), + 7 => drop(text::zstd_decode(p)), + _ => drop(text::xz_decode(p)), + } +} + +/// The extension containers, in selector order, with the magic each begins with. +pub const CONTAINERS: [(&str, &[u8; 4]); 8] = [ + ("world", b"WWLD"), + ("causal op", b"WWLD"), + ("film", b"WFLM"), + ("reel", b"WREL"), + ("score", b"WSCR"), + ("haptic", b"WHAP"), + ("splat4d", b"W4DS"), + ("audio scene", b"WSCN"), +]; + +/// Reads `p` as the container `pick` selects, discarding the result. +pub fn container_read(pick: u8, p: &[u8]) { + match pick as usize % CONTAINERS.len() { + 0 => drop(wai::world::WorldContainer::from_bytes(p)), + 1 => drop(wai::world::op::CausalOp::from_bytes(p)), + 2 => drop(wai::film::Film::from_bytes(p)), + 3 => drop(wai::film::Reel::from_bytes(p)), + 4 => drop(wai::score::Score::from_bytes(p)), + 5 => drop(wai::haptic::HapticSignal::from_bytes(p)), + 6 => drop(wai::splat4d::Splat4dClipData::from_bytes(p)), + _ => drop(wai::audio_scene::SpatialScene::from_bytes(p)), + } +} diff --git a/wai/wai-rs/fuzz/src/seeds.rs b/wai/wai-rs/fuzz/src/seeds.rs new file mode 100644 index 000000000..32fa0d424 --- /dev/null +++ b/wai/wai-rs/fuzz/src/seeds.rs @@ -0,0 +1,90 @@ +//! Writes the seed corpora into the directory given (default `corpus`), one file per +//! seed, named by its FNV-1a hash so a rerun adds nothing it already holds: +//! +//! - `envelope/`: every committed `.wai` under `wai/` of at most 512 KiB; +//! - `int_payload/`: every payload the integer corpus commits, after its entry's +//! selector byte; +//! - `media/`: one small valid payload for each decoder, made with the reference +//! writers, after its selector byte; +//! - `containers/`: every committed file that begins with an extension container's +//! magic, after that container's selector byte. +use std::path::{Path, PathBuf}; + +fn put(dir: &Path, bytes: &[u8]) { + let mut h = 0xcbf2_9ce4_8422_2325u64; + for &b in bytes { + h = (h ^ b as u64).wrapping_mul(0x0100_0000_01b3); + } + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join(format!("{h:016x}")), bytes).unwrap(); +} + +/// Every file under `dir` of at most 512 KiB, skipping build and dependency trees. +fn files(dir: &Path, out: &mut Vec) { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + let name = e.file_name(); + if p.is_dir() { + if !matches!(name.to_str(), Some("target" | "node_modules" | "fuzz" | ".git")) { + files(&p, out); + } + } else if e.metadata().is_ok_and(|m| m.len() <= 512 * 1024) { + out.push(p); + } + } +} + +fn main() { + let root = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "corpus".into())); + + let mut all = Vec::new(); + files(&wai_fuzz::wai_dir(), &mut all); + let (mut n_env, mut n_con) = (0, 0); + for f in &all { + if f.extension().is_some_and(|x| x == "wai") { + put(&root.join("envelope"), &std::fs::read(f).unwrap()); + n_env += 1; + } + // A committed file that begins with a container's magic seeds that container. + let Ok(bytes) = std::fs::read(f) else { continue }; + for (i, (_, magic)) in wai_fuzz::CONTAINERS.iter().enumerate() { + if bytes.starts_with(&magic[..]) { + put(&root.join("containers"), &[&[i as u8][..], &bytes].concat()); + n_con += 1; + } + } + } + + let entries = wai_fuzz::int_entries(); + let mut n_int = 0; + for (i, e) in entries.iter().enumerate() { + for p in &e.payloads { + put(&root.join("int_payload"), &[&[i as u8][..], p].concat()); + n_int += 1; + } + } + + use wai::codecs::{audio, image, text, video}; + let rgb: Vec = (0..16 * 16 * 3).map(|i| (i * 7 % 256) as u8).collect(); + let tone: Vec = (0..4_800).map(|i| 0.4 * (i as f32 * 0.03).sin()).collect(); + let words = b"a WAI text payload, repeated: a WAI text payload, repeated.".repeat(8); + let media: Vec<(usize, Vec)> = vec![ + (0, image::png_encode(&rgb, 16, 16).unwrap()), + (1, image::jpeg_encode(&rgb, 16, 16, 80).unwrap()), + (2, image::avif_encode(&rgb, 16, 16, 80).unwrap()), + (3, image::jxl_encode(&rgb, 16, 16, None).unwrap()), + (3, image::jxl_encode(&rgb, 16, 16, Some(80.0)).unwrap()), + (4, audio::flac_encode(&tone, 48_000).unwrap()), + (5, audio::opus_encode(&tone, 48_000, 32_000).unwrap()), + (6, video::av1_encode(&[rgb.clone(), rgb.clone()], 16, 16, 30, 1, false, 60).unwrap()), + (7, text::zstd_encode(&words, 3).unwrap()), + (8, text::xz_encode(&words, 6).unwrap()), + ]; + for (pick, p) in &media { + put(&root.join("media"), &[&[*pick as u8][..], p].concat()); + } + println!( + "seeds: {n_env} envelopes, {n_int} integer payloads over {} entries, {} media payloads, {n_con} containers, in {}", + entries.len(), media.len(), root.display() + ); +} diff --git a/wai/wai-rs/src/audio_scene/container.rs b/wai/wai-rs/src/audio_scene/container.rs index e7b04bfe2..7219737e4 100644 --- a/wai/wai-rs/src/audio_scene/container.rs +++ b/wai/wai-rs/src/audio_scene/container.rs @@ -110,7 +110,9 @@ pub fn object_table_bytes(objects: &[AudioObject]) -> Vec { fn parse_object_table(bytes: &[u8]) -> Result, AudioError> { let mut c = Cur { b: bytes, p: 0 }; let n = c.u32()? as usize; - let mut objs = Vec::with_capacity(n); + // Reserve no more entries than the bytes left could hold: the count is the input's, + // and a reservation sized by it alone let a few bytes ask for gigabytes. + let mut objs = Vec::with_capacity(n.min(bytes.len() / (32 + 8 + 2))); for _ in 0..n { let source_hash = c.arr::<32>()?; let gain = Fx::from_bits(c.i64()?); @@ -144,7 +146,9 @@ fn encode_assets(a: &[AssetEntry]) -> Vec { fn parse_assets(bytes: &[u8]) -> Result, AudioError> { let mut c = Cur { b: bytes, p: 0 }; let n = c.u32()? as usize; - let mut out = Vec::with_capacity(n); + // Reserve no more entries than the bytes left could hold: the count is the input's, + // and a reservation sized by it alone let a few bytes ask for gigabytes. + let mut out = Vec::with_capacity(n.min(bytes.len() / (32 + 1 + 4))); for _ in 0..n { let hash = c.arr::<32>()?; let role = c.u8()?; diff --git a/wai/wai-rs/src/film/reel.rs b/wai/wai-rs/src/film/reel.rs index 23972d405..b794430e4 100644 --- a/wai/wai-rs/src/film/reel.rs +++ b/wai/wai-rs/src/film/reel.rs @@ -127,7 +127,9 @@ fn parse_camkeys(b: &[u8], p: &mut usize) -> Result, ReelError> { }; let n = u32::from_le_bytes(rd(*p, 4)?.try_into().unwrap()) as usize; *p += 4; - let mut keys = Vec::with_capacity(n); + // Reserve no more entries than the bytes left could hold: the count is the input's, + // and a reservation sized by it alone let a few bytes ask for gigabytes. + let mut keys = Vec::with_capacity(n.min(b.len().saturating_sub(*p) / CAMKEY_BYTES)); let mut last: Option = None; for _ in 0..n { let tick = u64::from_le_bytes(rd(*p, 8)?.try_into().unwrap()); @@ -158,7 +160,9 @@ fn parse_shots(b: &[u8]) -> Result, ReelError> { let rd = |q: usize| -> Result { if q + 8 > b.len() { Err(ReelError::Truncated) } else { Ok(u64::from_le_bytes(b[q..q + 8].try_into().unwrap())) } }; - let mut shots = Vec::with_capacity(n); + // Reserve no more entries than the bytes left could hold: the count is the input's, + // and a reservation sized by it alone let a few bytes ask for gigabytes. + let mut shots = Vec::with_capacity(n.min(b.len() / 24)); for _ in 0..n { let frames = rd(p)?; let start_tick = rd(p + 8)?; diff --git a/wai/wai-rs/src/haptic/mod.rs b/wai/wai-rs/src/haptic/mod.rs index 2970a06d3..4311c4f51 100644 --- a/wai/wai-rs/src/haptic/mod.rs +++ b/wai/wai-rs/src/haptic/mod.rs @@ -226,7 +226,9 @@ fn parse_channels(b: &[u8]) -> Result>, HapticError> { }; let nc = u32::from_le_bytes(rd(0, 4)?.try_into().unwrap()) as usize; let mut p = 4usize; - let mut chans = Vec::with_capacity(nc); + // Reserve no more entries than the bytes left could hold: the count is the input's, + // and a reservation sized by it alone let a few bytes ask for gigabytes. + let mut chans = Vec::with_capacity(nc.min(b.len() / 2)); for _ in 0..nc { let nk = u16::from_le_bytes(rd(p, 2)?.try_into().unwrap()) as usize; p += 2; diff --git a/wai/wai-rs/src/score/container.rs b/wai/wai-rs/src/score/container.rs index 462941f0a..35c324979 100644 --- a/wai/wai-rs/src/score/container.rs +++ b/wai/wai-rs/src/score/container.rs @@ -107,7 +107,9 @@ fn parse_notes(b: &[u8]) -> Result, ScoreError> { let i64a = |s: &[u8]| i64::from_le_bytes(s.try_into().unwrap()); let nn = u32::from_le_bytes(rd(0, 4)?.try_into().unwrap()) as usize; let mut p = 4usize; - let mut notes = Vec::with_capacity(nn); + // Reserve no more entries than the bytes left could hold: the count is the input's, + // and a reservation sized by it alone let a few bytes ask for gigabytes. + let mut notes = Vec::with_capacity(nn.min(b.len() / 65)); for _ in 0..nn { let start = u64a(rd(p, 8)?); let dur = u64a(rd(p + 8, 8)?); diff --git a/wai/wai-rs/src/splat4d/container.rs b/wai/wai-rs/src/splat4d/container.rs index 073ce4de5..739ac1afe 100644 --- a/wai/wai-rs/src/splat4d/container.rs +++ b/wai/wai-rs/src/splat4d/container.rs @@ -117,7 +117,9 @@ fn parse_tracks(b: &[u8]) -> Result>, Splat4dError> { }; let ng = u32::from_le_bytes(rd(0, 4)?.try_into().unwrap()) as usize; let mut p = 4usize; - let mut gaussians = Vec::with_capacity(ng); + // Reserve no more entries than the bytes left could hold: the count is the input's, + // and a reservation sized by it alone let a few bytes ask for gigabytes. + let mut gaussians = Vec::with_capacity(ng.min(b.len() / 2)); for _ in 0..ng { let nk = u16::from_le_bytes(rd(p, 2)?.try_into().unwrap()) as usize; p += 2; diff --git a/wai/wai-rs/src/world/container.rs b/wai/wai-rs/src/world/container.rs index 05eadeb98..3a8e7a407 100644 --- a/wai/wai-rs/src/world/container.rs +++ b/wai/wai-rs/src/world/container.rs @@ -217,7 +217,9 @@ fn decode_assets(bytes: &[u8]) -> Result, WorldError> { } let n = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize; let mut pos = 4usize; - let mut out = Vec::with_capacity(n); + // Reserve no more entries than the bytes left could hold: the count is the input's, + // and a reservation sized by it alone let a few bytes ask for gigabytes. + let mut out = Vec::with_capacity(n.min(bytes.len() / 37)); for _ in 0..n { if pos + 37 > bytes.len() { return Err(WorldError::Truncated); diff --git a/wai/wai-rs/tests/container_reservations.rs b/wai/wai-rs/tests/container_reservations.rs new file mode 100644 index 000000000..f816dacd4 --- /dev/null +++ b/wai/wai-rs/tests/container_reservations.rs @@ -0,0 +1,133 @@ +//! A container's reader reserves no more entries than its bytes could hold. +//! +//! Each extension container (world, audio scene, reel, score, haptic, splat4d) reads a +//! `u32` count of entries from its input and used to reserve that many before reading +//! one: a few bytes could ask the allocator for gigabytes. The coverage-guided fuzzer +//! (`fuzz/`, target `containers`) found it in the audio scene's object table, asking +//! for 48 GiB. +//! +//! For each container a committed vector is rewritten so that the section holding a +//! count declares 2^32 - 1 entries and holds nothing after the count. The reader must +//! refuse it having asked for no single allocation past a few megabytes. The allocator +//! here records the largest request, and refuses any past 256 MiB, so a reader that +//! still sizes its reservation from the count fails here instead of reserving. +#![cfg(all( + feature = "world", + feature = "audio_scene", + feature = "film", + feature = "score", + feature = "haptic", + feature = "splat4d" +))] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + +/// The system allocator, recording the largest single request. +struct Largest; +static LARGEST: AtomicUsize = AtomicUsize::new(0); +const REFUSE_PAST: usize = 256 << 20; + +unsafe impl GlobalAlloc for Largest { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + LARGEST.fetch_max(l.size(), Relaxed); + if l.size() > REFUSE_PAST { std::ptr::null_mut() } else { unsafe { System.alloc(l) } } + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + LARGEST.fetch_max(l.size(), Relaxed); + if l.size() > REFUSE_PAST { std::ptr::null_mut() } else { unsafe { System.alloc_zeroed(l) } } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + LARGEST.fetch_max(new, Relaxed); + if new > REFUSE_PAST { std::ptr::null_mut() } else { unsafe { System.realloc(p, l, new) } } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static A: Largest = Largest; + +fn vector(rel: &str) -> Vec { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} + +/// The sections of a container in the layout every one of these shares: a magic, a +/// `u16` count, a table of `(kind u8, offset u32, length u32)`, then the bodies. +fn sections(b: &[u8]) -> Vec<(u8, Vec)> { + let n = u16::from_le_bytes([b[4], b[5]]) as usize; + let body = &b[6 + 9 * n..]; + (0..n) + .map(|i| { + let e = &b[6 + 9 * i..6 + 9 * (i + 1)]; + let off = u32::from_le_bytes(e[1..5].try_into().unwrap()) as usize; + let len = u32::from_le_bytes(e[5..9].try_into().unwrap()) as usize; + (e[0], body[off..off + len].to_vec()) + }) + .collect() +} + +/// The container rebuilt with section `kind`'s body replaced by a count of 2^32 - 1. +fn declaring_too_many(b: &[u8], kind: u8) -> Vec { + let mut secs = sections(b); + let s = secs.iter_mut().find(|(k, _)| *k == kind).expect("the vector has the section"); + s.1 = u32::MAX.to_le_bytes().to_vec(); + let mut out = b[..4].to_vec(); + out.extend_from_slice(&(secs.len() as u16).to_le_bytes()); + let mut off = 0u32; + for (k, body) in &secs { + out.push(*k); + out.extend_from_slice(&off.to_le_bytes()); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + off += body.len().div_ceil(8) as u32 * 8; + } + for (_, body) in &secs { + out.extend_from_slice(body); + out.resize(out.len() + (body.len().div_ceil(8) * 8 - body.len()), 0); + } + out +} + +#[test] +fn each_container_refuses_a_count_its_bytes_cannot_hold_without_reserving_it() { + let cases: [(&str, &str, u8, fn(&[u8]) -> bool); 7] = [ + ("world assets", "wai-web/demo/vectors/gallery.wwld", 0x03, |b| wai::world::WorldContainer::from_bytes(b).is_err()), + ("audio scene objects", "wai-web/demo/audio/moving.wscn", 0x02, |b| wai::audio_scene::SpatialScene::from_bytes(b).is_err()), + ("audio scene assets", "wai-web/demo/audio/moving.wscn", 0x03, |b| wai::audio_scene::SpatialScene::from_bytes(b).is_err()), + ("reel shots", "film-conformance/vectors/coverage.wrel", 0x03, |b| wai::film::Reel::from_bytes(b).is_err()), + ("score notes", "score-conformance/vectors/arp.wscr", 0x02, |b| wai::score::Score::from_bytes(b).is_err()), + ("haptic channels", "haptic-conformance/vectors/ramp.whap", 0x02, |b| wai::haptic::HapticSignal::from_bytes(b).is_err()), + ("splat4d tracks", "volumetric-conformance/vectors/morph.w4ds", 0x02, |b| wai::splat4d::Splat4dClipData::from_bytes(b).is_err()), + ]; + for (what, rel, kind, refused) in cases { + let original = vector(rel); + let bytes = if sections(&original).iter().any(|(k, _)| *k == kind) { + declaring_too_many(&original, kind) + } else { + // The vector carries no such section (the audio scene's assets are optional): + // add one, as the last section. + let mut with = original.clone(); + let n = u16::from_le_bytes([with[4], with[5]]); + with[4..6].copy_from_slice(&(n + 1).to_le_bytes()); + let secs = sections(&original); + let end: usize = secs.iter().map(|(_, b)| b.len().div_ceil(8) * 8).sum(); + let mut entry = vec![kind]; + entry.extend_from_slice(&(end as u32).to_le_bytes()); + entry.extend_from_slice(&4u32.to_le_bytes()); + let table_end = 6 + 9 * n as usize; + with.splice(table_end..table_end, entry); + let body_start = table_end + 9; + with.truncate(body_start + end); + with.resize(body_start + end, 0); + with.extend_from_slice(&u32::MAX.to_le_bytes()); + with + }; + LARGEST.store(0, Relaxed); + assert!(refused(&bytes), "{what}: 2^32 - 1 entries in four bytes were accepted"); + let largest = LARGEST.load(Relaxed); + assert!(largest <= 8 << 20, "{what}: the reader asked for {largest} bytes at once"); + } +} -- 2.54.0 (Apple Git-157) From 161d147767da2175b442f612c4d5b7079c2e4390 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 14 Oct 2026 11:07:16 -0400 Subject: [PATCH 3/4] wai: reader limits for the extension formats' durations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A score, haptic signal, audio scene, splat4d clip or film declares its length in its contract, and rendering allocates in proportion: a score's mixdown allocated `duration` samples outright, a haptic channel `duration` × 2 bytes, a scene's stereo mix 20 bytes a sample, and the splat4d and film receipts a hash a frame. Nothing bounded the declaration, so a few bytes could ask a renderer for more memory than any machine has. SECURITY.md recorded it as a gap when the fuzzing PR found the reader-side reservations. Each extension's contract section (score, haptics, spatial-audio, volumetric, linear-film and its reel) now has a reader limit shared with SPEC §2, through a new always-on module `wai::limits`: - score and haptics: `duration × channels` at most 2^27 samples; - audio scene: `duration` at most 2^26, so the stereo floor is at most 2^27 samples; - splat4d `n_frames`, film `frames`, a reel's shots together: at most 2^20 frames. A reader may refuse past them (`duration_too_long`) when it reads the contract, before anything is rendered; a conforming encoder writes none. The film schedule's tick, `start_tick + f × ticks_per_frame`, wrapped in release builds and panicked in debug ones. A reader must now refuse a schedule whose last tick passes 2^64 - 1 (a film's, and each shot's of a reel); `Film::frame_tick` saturates and `Reel::frame_tick` uses checked arithmetic. tests/extension_duration_limits.rs rewrites a committed vector of each format to declare exactly its limit (it reads) and one past it (refused by name), and checks the film schedule ending at 2^64 - 1 (reads) and past it (refused). Falsified: with the readers as they were, all three tests fail. The world, audio, haptic, splat4d, score and film corpora verify unchanged; the 275 lib tests with the container features pass. CI runs the test beside container_reservations on every leg. SECURITY.md drops the gap and keeps the one this exposes: replaying a world to a far tick costs time in proportion (memory is bounded, time is not). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 7 +- wai/CHANGELOG.md | 21 +++ wai/SECURITY.md | 18 +- wai/extensions/haptics.md | 5 + wai/extensions/linear-film.md | 9 +- wai/extensions/score.md | 5 + wai/extensions/spatial-audio.md | 6 + wai/extensions/volumetric.md | 5 + wai/wai-rs/Cargo.toml | 5 + wai/wai-rs/include/wai.h | 12 ++ wai/wai-rs/src/audio_scene/container.rs | 10 +- wai/wai-rs/src/film/container.rs | 17 +- wai/wai-rs/src/film/mod.rs | 7 +- wai/wai-rs/src/film/reel.rs | 13 +- wai/wai-rs/src/haptic/mod.rs | 8 +- wai/wai-rs/src/lib.rs | 3 + wai/wai-rs/src/limits.rs | 20 +++ wai/wai-rs/src/score/container.rs | 8 +- wai/wai-rs/src/splat4d/container.rs | 7 +- wai/wai-rs/tests/extension_duration_limits.rs | 165 ++++++++++++++++++ 20 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 wai/wai-rs/src/limits.rs create mode 100644 wai/wai-rs/tests/extension_duration_limits.rs diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index e168f495c..e38c675c7 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -196,11 +196,12 @@ jobs: cargo test --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film --test jpegai_decode_symbols # Each extension container refuses a count its bytes cannot hold, without reserving - # it first (found by the coverage-guided fuzzer, wai-rs/fuzz/, target `containers`). - - name: Extension containers reserve no more than their bytes can hold + # it first (found by the coverage-guided fuzzer, wai-rs/fuzz/, target `containers`), + # and a declared duration past the reader limits it shares with SPEC §2. + - name: Extension containers reserve no more than their bytes hold, and are held to their duration limits shell: bash working-directory: wai/wai-rs - run: cargo test --no-default-features --features world,audio_scene,haptic,splat4d,score,film --test container_reservations + run: cargo test --no-default-features --features world,audio_scene,haptic,splat4d,score,film --test container_reservations --test extension_duration_limits # The integer decoders run fast kernels (src/int_kernels.rs) that must give # the same integer as the scalar reference kernels for every output element. diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 529ffe94f..984c5c06c 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,27 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Reader limits for the extension formats' durations + +A score, haptic signal, audio scene, splat4d clip or film declares its length in its +contract, and rendering it allocates in proportion. Nothing bounded the declaration +(found while fuzzing: a score's `mixdown` allocated `duration` samples outright). + +- **The limits** (each extension's §2.1, `wai::limits`), shared with SPEC §2: + - score and haptics: `duration × channels` at most 2²⁷ samples; + - audio scene: `duration` at most 2²⁶, so its stereo floor is at most 2²⁷; + - splat4d (`n_frames`), film (`frames`) and a reel's shots together: at most 2²⁰ + frames. + + A reader may refuse past a limit (`duration_too_long`), when it reads the contract. +- **Film tick arithmetic.** `start_tick + f × ticks_per_frame` wrapped in release + builds and panicked in debug ones. A reader now refuses a schedule whose last tick + passes 2⁶⁴ − 1, and `frame_tick` saturates rather than wraps. +- **Tests.** `tests/extension_duration_limits.rs` rewrites a committed vector of each + format to sit exactly at its limit (it reads) and one past it (refused by name), and + checks the film schedule at and past 2⁶⁴ − 1. The old readers fail all three tests, + and every committed corpus verifies unchanged. + ### Coverage-guided fuzzing of the reader `wai-rs/fuzz/` adds four libFuzzer targets, built by cargo-fuzz with AddressSanitizer: diff --git a/wai/SECURITY.md b/wai/SECURITY.md index 5e755226b..6b77c8767 100644 --- a/wai/SECURITY.md +++ b/wai/SECURITY.md @@ -181,6 +181,14 @@ model-checked. Equality tests hold them to the scalar reference kernels, element element beside each kernel and over every committed integer-decoder vector (`tests/int_kernels_vectors.rs`), on every platform leg of `wai-conformance.yml`. +The extension formats are held to the reader limits they share with SPEC §2 +(`wai::limits`): a score or haptic signal renders at most 2²⁷ samples counting every +channel, an audio scene's stereo floor the same, and a splat4d clip, a film or a reel's +shots together at most 2²⁰ frames. Each reader refuses a contract past them when it +reads it (`duration_too_long`), before anything is rendered, and refuses a film or reel +schedule whose last tick a `u64` does not hold (the tick arithmetic used to wrap). +`tests/extension_duration_limits.rs` checks each at the limit and one past it. + The reader is fuzzed with coverage guidance. `wai-rs/fuzz/` holds four libFuzzer targets, built by cargo-fuzz with AddressSanitizer: - `envelope`: version detection, the `WAI1` and `WAI2` readers and the listing form; @@ -203,11 +211,11 @@ Known gaps: - **The C libraries are not instrumented.** AddressSanitizer covers the Rust code. The codec libraries run as the system builds them, so a memory error inside one is seen only when it crashes the process or exceeds a limit. -- **The extension formats' durations have no reader limit.** A score, haptic signal, - audio scene, splat4d clip or film declares its duration in its contract, and - rendering it allocates in proportion. Nothing bounds the declaration yet, so a small - container can ask a renderer for more memory than it has. Reading them is bounded; - rendering them is not. +- **Replaying a world costs CPU in proportion to the tick it is asked for.** A film or + reel samples its embedded world at the ticks its schedule names, and the reference + replay steps every tick from genesis. The schedule's frames are bounded and its last + tick must fit a `u64`, but no limit yet bounds how far a schedule may reach, so a + small film can ask a renderer for a long replay. Memory is bounded; time is not. - **The float neural decoders are not fuzzed.** They need the `neural` feature and its runtime. The integer decoders are fuzzed. diff --git a/wai/extensions/haptics.md b/wai/extensions/haptics.md index 3deb781f1..a03624964 100644 --- a/wai/extensions/haptics.md +++ b/wai/extensions/haptics.md @@ -50,6 +50,11 @@ authoritative. Haptic rates are low (≈1 kHz); `duration` is in samples. +**Reader limit.** A reader MUST NOT refuse a signal for its length while `duration × +channels` is at most 134 217 728 (2²⁷, the audio limit of SPEC §2), and MAY refuse one +past it (`duration_too_long`), when it reads the contract, before it reconstructs +anything. A conforming encoder writes no signal past it. + ### 2.2 Channel signals `n_channels u32`, then per channel: `n_keys u16`, then diff --git a/wai/extensions/linear-film.md b/wai/extensions/linear-film.md index 1081bbdd7..ef08a0ade 100644 --- a/wai/extensions/linear-film.md +++ b/wai/extensions/linear-film.md @@ -73,6 +73,12 @@ sampled states, but the verified quantity is the schedule above. `fps` is **not** in the contract — it is playback speed (presentation), not part of the film's identity. +**Reader limit.** A reader MUST NOT refuse a film for its length while `frames` is at +most 1 048 576 (2²⁰, the video limit of SPEC §2), and MAY refuse one past it +(`duration_too_long`), when it reads the contract, before it renders anything. It MUST +refuse a contract whose last tick, `start_tick + (frames − 1) × ticks_per_frame`, is +past 2⁶⁴ − 1: that schedule names no tick. A conforming encoder writes neither. + ### 2.2 Embedded world The exact bytes of a sealed `wai.world.replay` (`WWLD`) container. The film @@ -157,7 +163,8 @@ Container `WREL`: `0x01` contract (canonical JSON, `{ "numeric", "shots" }`), `0x02` embedded sealed world (`WWLD`), `0x03` shots — `n_shots u32`, then per shot `frames u64 | start_tick u64 | ticks_per_frame u64 | camera track`. Each shot's keys MUST be strictly ascending by tick; a shot's local frame `j` -samples world tick `start_tick + j * ticks_per_frame`. +samples world tick `start_tick + j * ticks_per_frame`. The frame limit of §2.1 holds +for the sum of the shots' frames, and each shot's last tick is held as a film's is. Conformance is **reel-equivalence**, film-equivalence extended over cuts: the global frame `f` belongs to a shot, samples the world at that shot's tick, is diff --git a/wai/extensions/score.md b/wai/extensions/score.md index 211b70689..42fc345e8 100644 --- a/wai/extensions/score.md +++ b/wai/extensions/score.md @@ -48,6 +48,11 @@ runs in the `wai.det.fixed64` floor; `media` takes the value `"audio"`; `duration` is the rendered mixdown length in samples. +**Reader limit.** A reader MUST NOT refuse a score for its length while `duration × +channels` is at most 134 217 728 (2²⁷, the audio limit of SPEC §2), and MAY refuse one +past it (`duration_too_long`), when it reads the contract, before it renders anything. +A conforming encoder writes no score past it. + ### 2.2 Notes `n_notes u32`, then per note (65 bytes): diff --git a/wai/extensions/spatial-audio.md b/wai/extensions/spatial-audio.md index 54ccb6fd5..518de3a87 100644 --- a/wai/extensions/spatial-audio.md +++ b/wai/extensions/spatial-audio.md @@ -67,6 +67,12 @@ mandatory render target; richer layouts (`5.1`, `binaural@`) are OPTIONAL and, for binaural, perceptual (the HRTF is a requirement-of-existence, not bit-exact across HRTFs). +**Reader limit.** A reader MUST NOT refuse a scene for its length while `duration` is +at most 67 108 864 (2²⁶): its stereo floor mixdown is then at most 2²⁷ samples, the audio +limit of SPEC §2. It MAY refuse one past it (`duration_too_long`), when it reads the +contract, before it renders anything. A richer layout is a render the sink chooses, at +its own cost. A conforming encoder writes no scene past the limit. + ### 2.2 Object table `n_objects u32`, then per object: diff --git a/wai/extensions/volumetric.md b/wai/extensions/volumetric.md index 65be50d33..6ed3eb73c 100644 --- a/wai/extensions/volumetric.md +++ b/wai/extensions/volumetric.md @@ -51,6 +51,11 @@ informational value `"volumetric"`; `wai.splat.4d` is authoritative. "numeric": "wai.det.fixed64" } ``` +**Reader limit.** A reader MUST NOT refuse a clip for its length while `n_frames` is at +most 1 048 576 (2²⁰, the video limit of SPEC §2), and MAY refuse one past it +(`duration_too_long`), when it reads the contract, before it reconstructs anything. A +conforming encoder writes no clip past it. + ### 2.2 Gaussian tracks `n_gaussians u32`, then per Gaussian: `n_keys u16`, then diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index f663947b1..7386f6328 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -1141,6 +1141,11 @@ name = "container_reservations" path = "tests/container_reservations.rs" required-features = ["world", "audio_scene", "film", "score", "haptic", "splat4d"] +[[test]] +name = "extension_duration_limits" +path = "tests/extension_duration_limits.rs" +required-features = ["world", "audio_scene", "film", "score", "haptic", "splat4d"] + [[test]] name = "text_decode_memory" path = "tests/text_decode_memory.rs" diff --git a/wai/wai-rs/include/wai.h b/wai/wai-rs/include/wai.h index 058ad5794..eff448920 100644 --- a/wai/wai-rs/include/wai.h +++ b/wai/wai-rs/include/wai.h @@ -116,6 +116,18 @@ */ #define MANIFEST_MAX_VALUES (1 << 22) +/** + * The most samples, counting every channel, a reader must be willing to render from + * one object: 2^27, the audio limit of SPEC §2 (46 minutes of one channel at 48 kHz). + */ +#define RENDER_MAX_SAMPLES (1 << 27) + +/** + * The most frames a reader must be willing to render from one object: 2^20, the + * video limit of SPEC §2 (4.9 hours at 60 frames a second). + */ +#define RENDER_MAX_FRAMES (1 << 20) + #define WAI_OK 0 #define WAI_ERR_INVALID_ARG 1 diff --git a/wai/wai-rs/src/audio_scene/container.rs b/wai/wai-rs/src/audio_scene/container.rs index 7219737e4..05894321b 100644 --- a/wai/wai-rs/src/audio_scene/container.rs +++ b/wai/wai-rs/src/audio_scene/container.rs @@ -45,12 +45,20 @@ impl SceneContract { if sample_rate == 0 || sample_rate > u32::MAX as u64 { return Err(AudioError::BadContract("sample_rate out of range".into())); } + // The mandatory floor is a stereo mixdown: `duration` samples on each of two + // channels. Richer layouts are renders the sink chooses. + let duration = u("duration")?; + let samples = duration.saturating_mul(2); + if samples > crate::limits::RENDER_MAX_SAMPLES { + return Err(AudioError::BadContract(crate::limits::duration_too_long( + "samples in its stereo floor (duration × 2)", samples, crate::limits::RENDER_MAX_SAMPLES))); + } Ok(SceneContract { sample_rate: sample_rate as u32, numeric: s("numeric")?, pan_law: s("pan_law")?, distance: s("distance")?, - duration: u("duration")?, + duration, floor_layout: s("floor_layout")?, }) } diff --git a/wai/wai-rs/src/film/container.rs b/wai/wai-rs/src/film/container.rs index 9dbfcd76d..7b45ee733 100644 --- a/wai/wai-rs/src/film/container.rs +++ b/wai/wai-rs/src/film/container.rs @@ -92,12 +92,27 @@ impl FilmContract { .and_then(|x| x.as_str()) .map(str::to_owned) .ok_or_else(|| FilmError::BadContract("missing `numeric`".into()))?, - start_tick: u("start_tick")?, + start_tick: last_tick(frames, u("start_tick")?, ticks_per_frame)?.0, ticks_per_frame, }) } } +/// Checks a frame schedule: at most [`crate::limits::RENDER_MAX_FRAMES`] frames +/// (`duration_too_long`), and a last tick, `start_tick + (frames - 1) × ticks_per_frame`, +/// that a `u64` holds. Returns `(start_tick, last tick)`. +pub(super) fn last_tick(frames: u64, start_tick: u64, ticks_per_frame: u64) -> Result<(u64, u64), FilmError> { + if frames > crate::limits::RENDER_MAX_FRAMES { + return Err(FilmError::BadContract(crate::limits::duration_too_long( + "frames", frames, crate::limits::RENDER_MAX_FRAMES))); + } + let last = (frames.max(1) - 1) + .checked_mul(ticks_per_frame) + .and_then(|t| t.checked_add(start_tick)) + .ok_or_else(|| FilmError::BadContract("the frame schedule's last tick passes 2^64 - 1".into()))?; + Ok((start_tick, last)) +} + /// Camera-track bytes: `n_keys u32`, then per key `tick u64 | pos[3] i64 | /// rot[4] i64 | fov i64` (all little-endian; the `Fx` values as raw bits). pub fn camera_bytes(keys: &[CamKey]) -> Vec { diff --git a/wai/wai-rs/src/film/mod.rs b/wai/wai-rs/src/film/mod.rs index d7ff9797f..6144bd4b9 100644 --- a/wai/wai-rs/src/film/mod.rs +++ b/wai/wai-rs/src/film/mod.rs @@ -113,9 +113,12 @@ pub(crate) fn pose_bytes(p: &CamPose) -> Vec { } impl Film { - /// The world tick frame `f` samples: `start_tick + f * ticks_per_frame`. + /// The world tick frame `f` samples: `start_tick + f * ticks_per_frame`. Within the + /// film's frames it cannot overflow: the contract's reader refuses a schedule whose + /// last tick a `u64` does not hold. Past them it saturates rather than wrap to an + /// earlier tick. pub fn frame_tick(&self, f: u64) -> u64 { - self.contract.start_tick + f * self.contract.ticks_per_frame + f.saturating_mul(self.contract.ticks_per_frame).saturating_add(self.contract.start_tick) } /// The frame ticks, ascending — what the world is sampled at. diff --git a/wai/wai-rs/src/film/reel.rs b/wai/wai-rs/src/film/reel.rs index b794430e4..078f94d30 100644 --- a/wai/wai-rs/src/film/reel.rs +++ b/wai/wai-rs/src/film/reel.rs @@ -163,6 +163,7 @@ fn parse_shots(b: &[u8]) -> Result, ReelError> { // Reserve no more entries than the bytes left could hold: the count is the input's, // and a reservation sized by it alone let a few bytes ask for gigabytes. let mut shots = Vec::with_capacity(n.min(b.len() / 24)); + let mut total = 0u64; for _ in 0..n { let frames = rd(p)?; let start_tick = rd(p + 8)?; @@ -170,6 +171,16 @@ fn parse_shots(b: &[u8]) -> Result, ReelError> { if frames == 0 || ticks_per_frame == 0 { return Err(ReelError::BadShots("shot frames and ticks_per_frame must be >= 1".into())); } + // The reel renders every shot's frames: their sum is held to the frame limit, and + // each shot's last tick to what a u64 holds. + total = total.saturating_add(frames); + if total > crate::limits::RENDER_MAX_FRAMES { + return Err(ReelError::BadShots(crate::limits::duration_too_long( + "frames across its shots", total, crate::limits::RENDER_MAX_FRAMES))); + } + if (frames - 1).checked_mul(ticks_per_frame).and_then(|t| t.checked_add(start_tick)).is_none() { + return Err(ReelError::BadShots("a shot's last tick passes 2^64 - 1".into())); + } p += 24; let camera = parse_camkeys(b, &mut p)?; shots.push(Shot { frames, start_tick, ticks_per_frame, camera }); @@ -202,7 +213,7 @@ impl Reel { pub fn frame_tick(&self, f: u64) -> Option { let (i, local) = self.locate(f)?; let s = &self.shots[i]; - Some(s.start_tick + local * s.ticks_per_frame) + local.checked_mul(s.ticks_per_frame).and_then(|t| t.checked_add(s.start_tick)) } /// The camera pose at global frame `f` (its shot's track, at `f`'s tick). diff --git a/wai/wai-rs/src/haptic/mod.rs b/wai/wai-rs/src/haptic/mod.rs index 4311c4f51..4d8360792 100644 --- a/wai/wai-rs/src/haptic/mod.rs +++ b/wai/wai-rs/src/haptic/mod.rs @@ -67,7 +67,13 @@ impl HapticContract { return Err(HapticError::BadContract("sample_rate out of range".into())); } let ch = u("channels")?; - Ok(HapticContract { sample_rate: sr as u32, numeric: s("numeric")?, channels: ch as u32, duration: u("duration")? }) + let duration = u("duration")?; + let samples = duration.saturating_mul(ch.max(1)); + if samples > crate::limits::RENDER_MAX_SAMPLES { + return Err(HapticError::BadContract(crate::limits::duration_too_long( + "samples (duration × channels)", samples, crate::limits::RENDER_MAX_SAMPLES))); + } + Ok(HapticContract { sample_rate: sr as u32, numeric: s("numeric")?, channels: ch as u32, duration }) } } diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index db4614d8d..07f49bf58 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -36,6 +36,9 @@ pub mod bench_env; // Parameter-set pins (SPEC §3.1): the listing form, and verifying a pin against // the sets a sink holds. Pure Rust over bytes, so it is always on. pub mod pin; +// The reader limits the extension formats share with SPEC §2: how many samples, counting +// every channel, and how many frames a reader must be willing to render. Always on. +pub mod limits; #[cfg(feature = "ffi")] pub mod ffi; #[cfg(feature = "codecs")] diff --git a/wai/wai-rs/src/limits.rs b/wai/wai-rs/src/limits.rs new file mode 100644 index 000000000..85139d551 --- /dev/null +++ b/wai/wai-rs/src/limits.rs @@ -0,0 +1,20 @@ +//! Reader limits the extension formats share with SPEC §2. +//! +//! A score, haptic signal or audio scene declares how many samples it renders, and a +//! splat4d clip, film or reel how many frames; rendering allocates in proportion. Each +//! extension's reader refuses a contract past these limits when it reads the contract +//! (`duration_too_long`), before anything is rendered, as SPEC §2 does for audio and +//! video payloads. + +/// The most samples, counting every channel, a reader must be willing to render from +/// one object: 2^27, the audio limit of SPEC §2 (46 minutes of one channel at 48 kHz). +pub const RENDER_MAX_SAMPLES: u64 = 1 << 27; + +/// The most frames a reader must be willing to render from one object: 2^20, the +/// video limit of SPEC §2 (4.9 hours at 60 frames a second). +pub const RENDER_MAX_FRAMES: u64 = 1 << 20; + +/// The refusal for a contract past either limit, as its message. +pub fn duration_too_long(what: &str, declared: u64, limit: u64) -> String { + format!("duration_too_long: the contract declares {what} {declared}, past the {limit} a reader need render") +} diff --git a/wai/wai-rs/src/score/container.rs b/wai/wai-rs/src/score/container.rs index 35c324979..ad865f0df 100644 --- a/wai/wai-rs/src/score/container.rs +++ b/wai/wai-rs/src/score/container.rs @@ -62,6 +62,12 @@ impl ScoreContract { return Err(ScoreError::BadContract("sample_rate out of range".into())); } let ch = u("channels")?; + let duration = u("duration")?; + let samples = duration.saturating_mul(ch.max(1)); + if samples > crate::limits::RENDER_MAX_SAMPLES { + return Err(ScoreError::BadContract(crate::limits::duration_too_long( + "samples (duration × channels)", samples, crate::limits::RENDER_MAX_SAMPLES))); + } Ok(ScoreContract { sample_rate: sr as u32, numeric: o @@ -70,7 +76,7 @@ impl ScoreContract { .map(str::to_owned) .ok_or_else(|| ScoreError::BadContract("missing `numeric`".into()))?, channels: ch as u32, - duration: u("duration")?, + duration, }) } } diff --git a/wai/wai-rs/src/splat4d/container.rs b/wai/wai-rs/src/splat4d/container.rs index 739ac1afe..7e61a1a25 100644 --- a/wai/wai-rs/src/splat4d/container.rs +++ b/wai/wai-rs/src/splat4d/container.rs @@ -73,11 +73,16 @@ impl Splat4dContract { if ng > u32::MAX as u64 { return Err(Splat4dError::BadContract("n_gaussians out of range".into())); } + let n_frames = u("n_frames")?; + if n_frames > crate::limits::RENDER_MAX_FRAMES { + return Err(Splat4dError::BadContract(crate::limits::duration_too_long( + "frames", n_frames, crate::limits::RENDER_MAX_FRAMES))); + } Ok(Splat4dContract { frame_rate: fr as u32, numeric: s("numeric")?, n_gaussians: ng as u32, - n_frames: u("n_frames")?, + n_frames, }) } } diff --git a/wai/wai-rs/tests/extension_duration_limits.rs b/wai/wai-rs/tests/extension_duration_limits.rs new file mode 100644 index 000000000..06614882f --- /dev/null +++ b/wai/wai-rs/tests/extension_duration_limits.rs @@ -0,0 +1,165 @@ +//! Each extension format's reader holds its declared duration to the reader limits it +//! shares with SPEC §2 (`wai::limits`): samples, counting every channel, for a score, +//! a haptic signal and an audio scene's stereo floor; frames for a splat4d clip, a film +//! and a reel's shots together. Rendering allocates in proportion to the declaration, +//! and nothing bounded it: a few bytes could ask a renderer for more memory than any +//! machine has. +//! +//! For each, a committed vector's contract is rewritten to declare exactly the limit, +//! which must still read, and one past it, which must be refused by name +//! (`duration_too_long`). A film whose last tick passes 2^64 - 1 is refused too: its +//! tick arithmetic used to wrap in release builds and panic in debug ones. +#![cfg(all( + feature = "world", + feature = "audio_scene", + feature = "film", + feature = "score", + feature = "haptic", + feature = "splat4d" +))] + +use std::path::PathBuf; + +use wai::limits::{RENDER_MAX_FRAMES, RENDER_MAX_SAMPLES}; + +fn vector(rel: &str) -> Vec { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} + +/// The sections of a container in the layout these formats share: a magic, a `u16` +/// count, a table of `(kind u8, offset u32, length u32)`, then the bodies. +fn sections(b: &[u8]) -> Vec<(u8, Vec)> { + let n = u16::from_le_bytes([b[4], b[5]]) as usize; + let body = &b[6 + 9 * n..]; + (0..n) + .map(|i| { + let e = &b[6 + 9 * i..6 + 9 * (i + 1)]; + let off = u32::from_le_bytes(e[1..5].try_into().unwrap()) as usize; + let len = u32::from_le_bytes(e[5..9].try_into().unwrap()) as usize; + (e[0], body[off..off + len].to_vec()) + }) + .collect() +} + +fn build(magic: &[u8], secs: &[(u8, Vec)]) -> Vec { + let mut out = magic[..4].to_vec(); + out.extend_from_slice(&(secs.len() as u16).to_le_bytes()); + let mut off = 0u32; + for (k, body) in secs { + out.push(*k); + out.extend_from_slice(&off.to_le_bytes()); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + off += body.len().div_ceil(8) as u32 * 8; + } + for (_, body) in secs { + out.extend_from_slice(body); + out.resize(out.len() + (body.len().div_ceil(8) * 8 - body.len()), 0); + } + out +} + +/// The container with its contract (section 0x01) rewritten by `edit`. +fn with_contract(b: &[u8], edit: impl Fn(&mut serde_json::Map)) -> Vec { + let mut secs = sections(b); + let c = secs.iter_mut().find(|(k, _)| *k == 0x01).expect("a contract section"); + let mut v: serde_json::Value = serde_json::from_slice(&c.1).expect("the contract parses"); + edit(v.as_object_mut().unwrap()); + c.1 = serde_json::to_vec(&v).unwrap(); + build(b, &secs) +} + +fn set(key: &'static str, value: u64) -> impl Fn(&mut serde_json::Map) { + move |o| { + o.insert(key.into(), value.into()); + } +} + +fn channels(b: &[u8]) -> u64 { + let secs = sections(b); + let c = &secs.iter().find(|(k, _)| *k == 0x01).unwrap().1; + let v: serde_json::Value = serde_json::from_slice(c).unwrap(); + v["channels"].as_u64().unwrap_or(1).max(1) +} + +fn refused(what: &str, r: Result) { + let e = format!("{:?}", r.err().unwrap_or_else(|| panic!("{what}: past the limit, and read"))); + assert!(e.contains("duration_too_long:"), "{what}: refused for another reason: {e}"); +} + +#[test] +fn audio_formats_are_held_to_the_sample_limit_counting_every_channel() { + let score = vector("score-conformance/vectors/arp.wscr"); + let at = RENDER_MAX_SAMPLES / channels(&score); + wai::score::Score::from_bytes(&with_contract(&score, set("duration", at))).expect("a score at the limit reads"); + refused("score", wai::score::Score::from_bytes(&with_contract(&score, set("duration", at + 1)))); + + let haptic = vector("haptic-conformance/vectors/ramp.whap"); + let at = RENDER_MAX_SAMPLES / channels(&haptic); + wai::haptic::HapticSignal::from_bytes(&with_contract(&haptic, set("duration", at))).expect("a signal at the limit reads"); + refused("haptic", wai::haptic::HapticSignal::from_bytes(&with_contract(&haptic, set("duration", at + 1)))); + + // A scene's floor is stereo: two samples for every one of `duration`. + let scene = vector("wai-web/demo/audio/moving.wscn"); + let at = RENDER_MAX_SAMPLES / 2; + wai::audio_scene::SpatialScene::from_bytes(&with_contract(&scene, set("duration", at))).expect("a scene at the limit reads"); + refused("audio scene", wai::audio_scene::SpatialScene::from_bytes(&with_contract(&scene, set("duration", at + 1)))); +} + +#[test] +fn frame_formats_are_held_to_the_frame_limit() { + let clip = vector("volumetric-conformance/vectors/morph.w4ds"); + wai::splat4d::Splat4dClipData::from_bytes(&with_contract(&clip, set("n_frames", RENDER_MAX_FRAMES))) + .expect("a clip at the limit reads"); + refused("splat4d", wai::splat4d::Splat4dClipData::from_bytes(&with_contract(&clip, set("n_frames", RENDER_MAX_FRAMES + 1)))); + + let film = vector("film-conformance/vectors/rise.wflm"); + wai::film::Film::from_bytes(&with_contract(&film, set("frames", RENDER_MAX_FRAMES))).expect("a film at the limit reads"); + refused("film", wai::film::Film::from_bytes(&with_contract(&film, set("frames", RENDER_MAX_FRAMES + 1)))); + + // The reel: the first shot's frames (the first u64 after the shot count) set so the + // shots together are exactly at the limit, then one past it. + let reel = vector("film-conformance/vectors/coverage.wrel"); + let secs = sections(&reel); + let shots = &secs.iter().find(|(k, _)| *k == 0x03).expect("a shots section").1; + let n = u32::from_le_bytes(shots[..4].try_into().unwrap()) as usize; + let mut others = 0u64; + let mut p = 4; + let mut firsts = Vec::new(); + for i in 0..n { + let frames = u64::from_le_bytes(shots[p..p + 8].try_into().unwrap()); + firsts.push(p); + if i > 0 { + others += frames; + } + let keys = u32::from_le_bytes(shots[p + 24..p + 28].try_into().unwrap()) as usize; + p += 24 + 4 + keys * 72; + } + let with_first = |frames: u64| { + let mut s = secs.clone(); + let body = &mut s.iter_mut().find(|(k, _)| *k == 0x03).unwrap().1; + body[firsts[0]..firsts[0] + 8].copy_from_slice(&frames.to_le_bytes()); + build(&reel, &s) + }; + wai::film::Reel::from_bytes(&with_first(RENDER_MAX_FRAMES - others)).expect("a reel at the limit reads"); + refused("reel", wai::film::Reel::from_bytes(&with_first(RENDER_MAX_FRAMES - others + 1))); +} + +#[test] +fn a_film_whose_last_tick_passes_u64_is_refused() { + let film = vector("film-conformance/vectors/rise.wflm"); + let past = with_contract(&film, |o| { + o.insert("frames".into(), 3u64.into()); + o.insert("start_tick".into(), (u64::MAX - 1).into()); + o.insert("ticks_per_frame".into(), 1u64.into()); + }); + let e = format!("{:?}", wai::film::Film::from_bytes(&past).err().expect("refused")); + assert!(e.contains("last tick passes"), "{e}"); + let at = with_contract(&film, |o| { + o.insert("frames".into(), 2u64.into()); + o.insert("start_tick".into(), (u64::MAX - 1).into()); + o.insert("ticks_per_frame".into(), 1u64.into()); + }); + let f = wai::film::Film::from_bytes(&at).expect("a schedule ending at 2^64 - 1 reads"); + assert_eq!(f.frame_tick(1), u64::MAX); +} -- 2.54.0 (Apple Git-157) From b07ce38a6b35da70e76a1a0c632215fde0d85f6d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 14 Oct 2026 19:53:40 -0400 Subject: [PATCH 4/4] fuzz: the seed writer lives under src/bin, where cargo and the orphan check find it `no-vanity-metrics / No new orphan source files` flagged wai/wai-rs/fuzz/src/seeds.rs: a [[bin]] declared by an explicit path under src/ is reached by no `mod`, so the check, which knows cargo's auto-discovered roots (src/bin/*.rs among them), counted it as never compiled. It is the `seeds` binary run.sh builds; under src/bin/ it is both. The check now reports no new orphans (25 pinned, 25 expected). Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/fuzz/Cargo.toml | 2 +- wai/wai-rs/fuzz/src/{ => bin}/seeds.rs | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename wai/wai-rs/fuzz/src/{ => bin}/seeds.rs (100%) diff --git a/wai/wai-rs/fuzz/Cargo.toml b/wai/wai-rs/fuzz/Cargo.toml index 142047f82..f24ded952 100644 --- a/wai/wai-rs/fuzz/Cargo.toml +++ b/wai/wai-rs/fuzz/Cargo.toml @@ -55,7 +55,7 @@ bench = false # Writes the seed corpora (run.sh runs it); not a fuzz target. [[bin]] name = "seeds" -path = "src/seeds.rs" +path = "src/bin/seeds.rs" test = false doc = false bench = false diff --git a/wai/wai-rs/fuzz/src/seeds.rs b/wai/wai-rs/fuzz/src/bin/seeds.rs similarity index 100% rename from wai/wai-rs/fuzz/src/seeds.rs rename to wai/wai-rs/fuzz/src/bin/seeds.rs -- 2.54.0 (Apple Git-157)