wai: reader limits for the extension formats' durations #258

Merged
dcharlot merged 5 commits from wai/extension-duration-limits into main 2026-10-15 01:04:12 -04:00
42 changed files with 3910 additions and 297 deletions

View file

@ -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

61
.github/workflows/wai-fuzz.yml vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -67,6 +67,12 @@ mandatory render target; richer layouts (`5.1`, `binaural@<hrtf-id>`)
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:

View file

@ -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

189
wai/wai-rs/Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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

4
wai/wai-rs/fuzz/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target/
corpus/
artifacts/
coverage/

1820
wai/wai-rs/fuzz/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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);
}
});

View file

@ -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);
});

View file

@ -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<Vec<wai_fuzz::IntEntry>> = 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<String, &[u8]> = 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, &params);
});

View file

@ -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);
}
});

60
wai/wai-rs/fuzz/run.sh Executable file
View file

@ -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/<target>/, 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 <target> <artifact>`,
# 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"

View file

@ -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<PathBuf>) {
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<u8> = (0..16 * 16 * 3).map(|i| (i * 7 % 256) as u8).collect();
let tone: Vec<f32> = (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<u8>)> = 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()
);
}

106
wai/wai-rs/fuzz/src/lib.rs Normal file
View file

@ -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<String, Vec<u8>>,
pub payloads: Vec<Vec<u8>>,
}
/// 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<IntEntry> {
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<IntEntry> = 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)),
}
}

View file

@ -30,6 +30,25 @@
#include <stdint.h>
#include <stdlib.h>
/**
* 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,

View file

@ -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<u8> {
fn parse_object_table(bytes: &[u8]) -> Result<Vec<AudioObject>, 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<u8> {
fn parse_assets(bytes: &[u8]) -> Result<Vec<AssetEntry>, 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()?;

View file

@ -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}")),

View file

@ -18,6 +18,17 @@ impl<E: std::fmt::Display> From<E> for AudioError {
pub type Result<T> = std::result::Result<T, AudioError>;
/// 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<Vec<u8>> {
// 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<Vec<u8>
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<f32>, 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<f32>, 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<f32>, 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<f32>, 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<f32> = Vec::new();
let mut last_granule: u64 = 0;
loop {
@ -119,17 +149,26 @@ pub fn opus_decode(bytes: &[u8]) -> Result<(Vec<f32>, 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<f32>, u32)> {
// libFLAC bindings for encode; pure-Rust claxon for decode.
pub fn flac_encode(samples: &[f32], sr: u32) -> Result<Vec<u8>> {
// 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<i32> = samples.iter()
@ -161,14 +204,33 @@ pub fn flac_encode(samples: &[f32], sr: u32) -> Result<Vec<u8>> {
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<f32>, 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<f32>, 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))
}

View file

@ -4,6 +4,11 @@
//! Each codec exposes `encode(rgb, h, w, quality) -> Vec<u8>` 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<E: std::fmt::Display> From<E> for ImageError {
pub type Result<T> = std::result::Result<T, ImageError>;
/// 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<u8>, 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<Vec<u8>> {
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<Vec<u8>> {
}
pub fn png_decode(bytes: &[u8]) -> Result<(Vec<u8>, 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<Vec<u8>> {
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<Vec<u8>> {
}
pub fn jpeg_decode(bytes: &[u8]) -> Result<(Vec<u8>, 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<Vec<u8>> {
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<Vec<u8>> {
}
pub fn avif_decode(bytes: &[u8]) -> Result<(Vec<u8>, 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<f32>) -> Result<Vec<u8>> {
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<f32>) -> Result<Ve
Ok(r.data)
}
pub fn jxl_decode(bytes: &[u8]) -> Result<(Vec<u8>, 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::<jpegxl_sys::metadata::codestream_header::JxlBasicInfo>::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<u8>, 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::<u8>(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))
}

View file

@ -21,6 +21,66 @@ impl<E: std::fmt::Display> From<E> for VideoError {
pub type Result<T> = std::result::Result<T, VideoError>;
/// 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<Av1Header> {
// 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::<LittleEndian>()?;
let h = rd.read_u32::<LittleEndian>()?;
let w = rd.read_u32::<LittleEndian>()?;
let fps_num = rd.read_u32::<LittleEndian>()?;
let fps_den = rd.read_u32::<LittleEndian>()?;
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<T> = std::result::Result<T, VideoError>;
/// quality there).
pub fn av1_encode(frames: &[Vec<u8>], h: u32, w: u32, fps_num: u32,
fps_den: u32, lossless: bool, quality: u8) -> Result<Vec<u8>> {
// 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::<LittleEndian>(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<u
}
}
/// Decode a WAI AV1 payload back to a sequence of RGB frames.
/// Decodes an AV1 payload one frame at a time, handing each to `each` as 8-bit RGB
/// with its width and height, and returns the payload's header.
///
/// Defensive: every value read from the header is sanity-checked before
/// being used so malformed input can't trigger dav1d's C-level abort()
/// (which would bypass Rust's catch_unwind). All errors return a clean
/// `VideoError`; we never feed a zero-length or out-of-bounds packet to
/// dav1d_send_data.
pub fn av1_decode(bytes: &[u8]) -> Result<(Vec<Vec<u8>>, 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<Av1Header> {
let hd = av1_header(bytes)?;
let mut rd = Cursor::new(bytes);
let nframes = rd.read_u32::<LittleEndian>()? as usize;
let h = rd.read_u32::<LittleEndian>()?;
let w = rd.read_u32::<LittleEndian>()?;
let fps_num = rd.read_u32::<LittleEndian>()?;
let fps_den = rd.read_u32::<LittleEndian>()?;
// 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<Vec<u8>>, 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<u8> {
/// 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<Vec<u8>>, 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<Vec<u8>>, 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<Vec<u8>> {
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<f32> {
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<u8> {
out[p + 2] = b.clamp(0.0, 255.0).round() as u8;
}
}
out
Ok(out)
}
#[cfg(test)]

View file

@ -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)]

View file

@ -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<u8> {

View file

@ -113,9 +113,12 @@ pub(crate) fn pose_bytes(p: &CamPose) -> Vec<u8> {
}
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.

View file

@ -127,7 +127,9 @@ fn parse_camkeys(b: &[u8], p: &mut usize) -> Result<Vec<CamKey>, 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<u64> = 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<Vec<Shot>, ReelError> {
let rd = |q: usize| -> Result<u64, ReelError> {
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<Vec<Shot>, 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<u64> {
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).

View file

@ -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<Vec<Vec<HapticKey>>, 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;

View file

@ -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")]

20
wai/wai-rs/src/limits.rs Normal file
View file

@ -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")
}

View file

@ -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<Vec<Note>, 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)?);

View file

@ -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<Vec<Vec<GaussKey>>, 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;

View file

@ -217,7 +217,9 @@ fn decode_assets(bytes: &[u8]) -> Result<Vec<AssetEntry>, 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);

View file

@ -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<u8> {
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<u8>)> {
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<u8> {
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");
}
}

View file

@ -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<u8> {
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<u8>)> {
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<u8>)]) -> Vec<u8> {
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<String, serde_json::Value>)) -> Vec<u8> {
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<String, serde_json::Value>) {
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<E: std::fmt::Debug>(what: &str, r: Result<impl Sized, E>) {
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);
}

View file

@ -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<T: std::fmt::Debug, E: std::fmt::Debug>(r: Result<T, E>) -> String {
format!("{:?}", r.expect_err("the payload must be refused"))
}
fn grey(h: u32, w: u32) -> Vec<u8> {
(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::<u8, u8>(&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<f32> {
(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<u8> {
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<i32> = 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<u8>]) -> Vec<u8> {
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<Vec<u8>> {
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<Vec<u8>> = (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<Vec<u8>> = (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<u8> {
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<u16> = 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]);
}