diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 281ab55f5..080668132 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -220,6 +220,14 @@ 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`), + # 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 --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. # The feature list above leaves out audio_int, video_int, world_int and @@ -974,18 +982,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/.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 1e4c0229e..90ad9a908 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,83 @@ 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: +- `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 +(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. + ### Vector graphics (Draft): a title card and credit roll in the corpus `wai/vector-conformance/` gains `motion/stroke-titles`: a 1280 × 720 document at 25 fps diff --git a/wai/SECURITY.md b/wai/SECURITY.md index 11205c26d..6b77c8767 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. @@ -158,15 +181,43 @@ 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; +- `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: -- **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 - 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. +- **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. ## Module authorisation 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/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.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..7386f6328 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 @@ -82,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, @@ -116,7 +116,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 +1131,21 @@ 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 = "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/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/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..f24ded952 --- /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/bin/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/bin/seeds.rs b/wai/wai-rs/fuzz/src/bin/seeds.rs new file mode 100644 index 000000000..32fa0d424 --- /dev/null +++ b/wai/wai-rs/fuzz/src/bin/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/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/include/wai.h b/wai/wai-rs/include/wai.h index 01ce30f7d..eff448920 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` @@ -80,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 @@ -521,6 +569,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 +582,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 +628,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 +643,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 +691,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 +708,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 +720,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 +740,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 +752,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 +769,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 +780,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 +836,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 +855,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 +881,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/audio_scene/container.rs b/wai/wai-rs/src/audio_scene/container.rs index e7b04bfe2..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")?, }) } @@ -110,7 +118,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 +154,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/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/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 23972d405..078f94d30 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,10 @@ 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)); + let mut total = 0u64; for _ in 0..n { let frames = rd(p)?; let start_tick = rd(p + 8)?; @@ -166,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 }); @@ -198,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 2970a06d3..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 }) } } @@ -226,7 +232,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/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 462941f0a..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, }) } } @@ -107,7 +113,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..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, }) } } @@ -117,7 +122,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"); + } +} 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); +} 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]); +}