Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e7621d5
test(fuzz): drive the parser entry points on untrusted bytes
justin13888 Sep 10, 2026
f17f229
test(ifd): reject a 65 535-entry directory against the source length
justin13888 Sep 10, 2026
6ccd998
ci(fuzz): run the parser-entry-point targets in the extended lane
justin13888 Sep 10, 2026
8b9a9db
docs(fuzz): warn against force-adding the corpus directory a second time
justin13888 Sep 10, 2026
7f046c4
test(fuzz): replace the tautological differentials with structure pins
justin13888 Sep 10, 2026
67bb97c
test(fuzz): classify a digest check that cannot run
justin13888 Sep 10, 2026
078d785
test(ifd): name the malformed-input case for the assertion it makes
justin13888 Sep 10, 2026
8f170ff
ci(fuzz): build the targets and reconcile the lists on the pull-reque…
justin13888 Sep 10, 2026
7cd2e6d
docs(testing): record the wired fuzz entry points
justin13888 Sep 10, 2026
6725ee8
docs(fuzz): reprice the robustness checks and state the expected-red …
justin13888 Sep 10, 2026
0f8662a
test(fuzz): re-anchor the TIFF decode check on the samples produced
justin13888 Sep 10, 2026
b101b38
docs(fuzz): record the injection that fires each robustness check
justin13888 Sep 10, 2026
6a6f619
ci(fuzz): name a duplicated target entry as a duplicate
justin13888 Sep 10, 2026
8c1ce50
ci(fuzz): run the compile and drift checks through mise tasks
justin13888 Sep 10, 2026
9cd010d
docs(fuzz): reprice the listed checks and answer the job's cadence
justin13888 Sep 10, 2026
1f7a9f2
test(fuzz): check the geometry that arrives, not the count derived fr…
justin13888 Sep 10, 2026
b2d2516
test(fuzz): name the box-cursor and empty-segment assertions as pins
justin13888 Sep 10, 2026
52be3fe
test(fuzz): replace heic_container's duplicated tiling with the promi…
justin13888 Sep 10, 2026
1ddd498
test(fuzz): seed the unread-claim half of the dual-ledger audit
justin13888 Sep 10, 2026
07c4f20
test(fuzz): seed the appended-stream and trailer sides of the accesso…
justin13888 Sep 10, 2026
39176ad
test(fuzz): seed the error path of the annex_b append contract
justin13888 Sep 10, 2026
eea00c3
docs(fuzz): reprice the listed checks against a per-check injection a…
justin13888 Sep 10, 2026
1d4bdde
docs(testing): correct the rule for anchoring a robustness check
justin13888 Sep 10, 2026
d349d92
docs(fuzz): publish the per-check injection audit
justin13888 Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ jobs:
# property depending on OS entropy would make cargo-mutants report CAUGHT or MISSED for the
# same mutant on different runs.
run: mise run check-tests
- name: Fuzz target lists in step
# Three hand-maintained lists describe the same set of fuzz targets: the files under
# tooling/gamut-fuzz/fuzz_targets, the [[bin]] entries in that crate's Cargo.toml, and the
# matrix of extended.yml's fuzz job. Nothing reconciled them, and the failure mode of the
# third is silent -- a target that is written and committed but never run, with no check
# anywhere reporting it. Pure text, no cargo, sub-second, which is why it is here and not
# in lint.
run: mise run check-fuzz-matrix
- name: Check PR commit messages
# Only PRs have a base..head range; validate just the PR's own commits so the pre-existing
# non-conventional history on master doesn't fail the check.
Expand Down Expand Up @@ -147,6 +155,15 @@ jobs:
# corpus, so this job does not grow the ~178 MiB fetch that `test-dng-real` requires.
- name: Real-DNG conformance tier compiles
run: mise run check-dng-real
# Same hole, same shape: `tooling/gamut-fuzz` is workspace-excluded and nothing depends on
# it, so no gate on the pull-request path compiles its targets at all -- an API change in
# gamut-ifd, gamut-tiff, gamut-dng, gamut-isobmff or gamut-heic breaks them unnoticed until
# the next Extended run on master. This is build-only: no nightly, no sanitizer, no engine,
# so it stays bounded and reproducible and does not put a coverage-guided run on the PR path
# (docs/testing.md, "Why fuzzing is not in the per-PR gate"). The driven crates are already
# built by the Clippy step above, so the marginal cost is the targets themselves.
- name: Fuzz tier compiles
run: mise run check-fuzz
- name: gamut-ffi feature sync
run: mise run check-ffi-features
- name: gamut-ffi header sync
Expand Down
31 changes: 27 additions & 4 deletions .github/workflows/extended.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,22 @@ jobs:
# One target's crash says nothing about another's, so a failure must not cancel the rest.
fail-fast: false
matrix:
# One entry per file in tooling/gamut-fuzz/fuzz_targets. A matrix rather than three steps
# in one job, so each target gets the full time budget below in parallel instead of the
# job's wall time growing with every crate that gains laws.
target: [ifd_read_ledger, core_convert, tonemap_curves]
# One entry per file in tooling/gamut-fuzz/fuzz_targets. A matrix rather than one job with
# a step per target, so each target gets the full time budget below in parallel instead of
# the job's wall time growing with every crate that gains laws or a parser entry point.
target:
- ifd_read_ledger
- core_convert
- tonemap_curves
# The parser entry points (#264): the untrusted-input surface `docs/testing.md`'s
# per-crate table names, one target per entry point rather than per crate, because
# gamut-heic's container walk and its `hvcC`/NAL layer are two independent surfaces.
- ifd_read
- tiff_decode
- dng_decode
- isobmff_boxes
- heic_container
- heic_hvcc
env:
MISE_TASK_RUN_AUTO_INSTALL: false
steps:
Expand Down Expand Up @@ -158,6 +170,17 @@ jobs:
# A crash found here is minimised and promoted into a NAMED DETERMINISTIC TEST in the
# crate's own suite -- the corpus is a search aid, not the regression record -- so a
# failure of this job is a signal to go and write that test, not to re-run until green.
#
# EXPECTED RED: `tiff_decode` and `dng_decode` fail today on filed, accepted defects
# (#563, #564). They are deliberately not narrowed to make these rows green; read the
# per-row status rather than this workflow's aggregate until both close. Restoring the
# aggregate's meaning is #593.
#
# CADENCE (#594), decided: this job stays on the workflow's own trigger. Nothing
# accumulates between runs -- each starts from the committed seeds and discards what the
# engine finds -- so a run is ten minutes of cold search whatever the cadence, and running
# less often just searches less. Persisting the corpus (#603) is the change that makes
# frequency and this time budget worth tuning; the README states the full argument.
run: mise run fuzz ${{ matrix.target }} -- -max_total_time=600
- name: Upload any crash artifacts
if: failure()
Expand Down
26 changes: 21 additions & 5 deletions crates/gamut-ifd/tests/robustness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
//! panic, a hang, or unbounded allocation (STATUS P6).
//!
//! Every input is also driven through the streaming [`IfdReader`] and the two entry points must
//! *agree* — both parse to equal files, or both fail. The slice functions are thin wrappers over
//! the streaming engine (one parser), so this differential layer is now a regression gate on the
//! wrappers themselves staying faithful.
//! *agree* — both parse to equal files, or both fail. This is a **structure pin, not a
//! differential**: `read` is *defined* as `IfdReader::open(data)?.read_file()` (and `read_tree`
//! likewise), so the two sides are one parser reached twice and the comparison cannot fail while
//! those bodies stand. It is kept because a `read` that stopped delegating — growing a second
//! directory walk, and with it a second set of hostile-input guards to drift — is exactly the
//! regression the crate's one-parser design exists to prevent. Being unfalsifiable by input, it
//! belongs here, over this bounded corpus, and not in the unbounded fuzz tier, where it would cost
//! half of every execution and search for a counterexample that does not exist.

use gamut_ifd::{
ByteOrder, Ifd, IfdReader, TiffFile, Value, Variant, read, read_audited, read_tree, write,
Expand Down Expand Up @@ -73,7 +78,7 @@ fn survives(data: &[u8]) {
}

#[test]
fn specific_malformed_inputs_error_without_panic() {
fn specific_malformed_inputs_yield_typed_errors_not_panics() {
let cases: &[&[u8]] = &[
b"",
b"II",
Expand All @@ -83,7 +88,11 @@ fn specific_malformed_inputs_error_without_panic() {
b"MM\x00\x2a\xff\xff\xff\x7f", // first-IFD offset past EOF (big-endian)
b"II\x2a\x00\x08\x00\x00\x00", // first IFD at EOF
b"II\x2a\x00\x08\x00\x00\x00\xff", // truncated IFD count
b"II\x2a\x00\x00\x00\x00\x00", // first-IFD offset 0 (no IFD)
// A whole entry count of 65 535 with no entry bytes at all behind it — the count is
// present and well-formed, so the reader reaches the point of sizing the directory from
// it. Nothing may be reserved for the 786 KiB it claims in a ten-byte file.
b"II\x2a\x00\x08\x00\x00\x00\xff\xff",
b"II\x2a\x00\x00\x00\x00\x00", // first-IFD offset 0 (no IFD)
// A 1-entry IFD whose value count is huge (byte-length overflow path), then truncated.
b"II\x2a\x00\x08\x00\x00\x00\x01\x00\x00\x01\x03\x00\xff\xff\xff\xff\x08\x00\x00\x00\x00\x00\x00\x00",
// An IFD whose next-IFD pointer loops back to itself.
Expand All @@ -94,6 +103,13 @@ fn specific_malformed_inputs_error_without_panic() {
}
// The loop case must be a typed error, not a hang.
assert!(read(b"II\x2a\x00\x08\x00\x00\x00\x00\x00\x08\x00\x00\x00").is_err());
// The hostile entry count must be refused against the source length, not merely survived.
assert!(
read(b"II\x2a\x00\x08\x00\x00\x00\xff\xff")
.expect_err("65 535 entries in a ten-byte file")
.to_string()
.contains("IFD extends past end of file")
);
}

#[test]
Expand Down
65 changes: 56 additions & 9 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,52 @@ lives where the DNG sample corpus lives: an excluded `tooling/` crate, a corpus
task, and an `extended.yml` job. **A crash it finds is minimised and promoted into a named
deterministic case in that crate's `tests/robustness.rs`**, which is where the regression value is.

What the per-PR path *does* carry is the **compile** half, for the reason the excluded real-DNG
tier already carries it: nothing else builds an excluded crate, so an API change in a driven crate
breaks its targets unnoticed until the next run on master. CI's lint job runs `mise run
check-fuzz` — no nightly, no sanitizer, no engine, nothing unbounded — and its `Format & Metadata`
job runs `mise run check-fuzz-matrix`, which reconciles the three hand-maintained lists that
describe the target set (the files, the `[[bin]]` entries, the `extended.yml` matrix), because a
target missing from the third is one that never runs and nothing reports it. Both are mise tasks
rather than commands written into the workflow, so a contributor runs exactly what CI runs.

A **robustness** target is not a law and does not route through an `invariants` module: its
primary oracle is the engine's own — a panic, a hang, or an allocation past `-malloc_limit_mb` —
which no function can express. Any check it adds beyond that oracle must be able to *fail*, and
**its module doc records the injected defect that made it fail** — the patch, the message the
target printed, and the command that reproduces it.

**"Can fail" means some defect in the code the check names makes some *input* fail it**, and both
halves bite. A check whose two sides are computed from one another cannot be separated by any
input, however hostile: it can only report a defect in that shared computation, never one in the
subject it advertises. Three shapes recur, and every one of them was found here by injecting the
defect the check named and getting nothing back:

- **a wrapper against the expression its own body is.** `gamut_ifd::read` against
`IfdReader::open(..)?.read_file()` is one function call written twice.
- **two sides that come from one reader.** A decoded-versus-described geometry check sees nothing
when the decoder and the probe share a tag reader.
- **a value silently derived from the value it is compared with.** A decode's sample count looks
like the pixel pipeline's own output, but `gamut_core::convert::convert_from_raw` allocates its
result as `ImageBuf::<Q>::zeroed(src.dims)` — so the count *is* the dimensions' product, and
comparing it against the declared geometry's product is the geometry comparison times a
constant. A transposition passes it. In the same shape, "the box cursor strictly advances"
cannot fail for any declared box size, because `BoxReader::next_box` consumes its 8-byte header
before any success return.

Anchor a check on a value the compared side does not produce, and keep the tautology — if it is
worth keeping at all — as a **structure pin**: named as one at the site, and kept out of the
target's list of checks. A pin earns its one comparison where a future change could genuinely
split the two bodies apart; it is worth nothing as a search.

**Inject once per listed check, not once per target.** A target that lists two checks and records
one injection has evidence for one of them, and the other can sit dead for rounds — two of this
workspace's did. Where a check is an equality between an accessor and a count, inject in both
directions, because one direction is silent on a file that holds no instance of the thing. And
where an injection reports nothing because no *committed seed* reaches the check, add the seed: a
check whose only witness has to be synthesised by the engine is a check the tier is asking luck
for.

`#[ignore]` is not used in this workspace and must not be introduced: `coverage` is the only test
gate, so an ignored test is not deferred, it is unrun.

Expand All @@ -177,15 +223,16 @@ gate, so an ignored test is not deferred, it is unrun.
The authority and primary technique for each crate are decided **here, once**. A row changes only
in a pull request that says why. "Authority" is the **in-crate** authority — several crates are
additionally covered by a consuming codec's oracle, which their own `STATUS.md` records. "Fuzz
entry point" names the untrusted-input surface a fuzz target takes; ☐ marks one not yet wired
(#264). The binary, binding and stub crates (`gamut`, `gamut-cli`, `gamut-wasm`, `gamut-ffi`,
`gamut-jxl-sys`, `gamut-av2`, `gamut-vvc`) have no row: they are excluded from the coverage and
mutation gates, and the stubs carry no function bodies.
entry point" names the untrusted-input surface a fuzz target takes; ☑ marks one a target in
`tooling/gamut-fuzz` drives today, ☐ one not yet wired (#264). The binary, binding and stub crates
(`gamut`, `gamut-cli`, `gamut-wasm`, `gamut-ffi`, `gamut-jxl-sys`, `gamut-av2`, `gamut-vvc`) have
no row: they are excluded from the coverage and mutation gates, and the stubs carry no function
bodies.

| Crate | Authority | Primary technique | Fuzz entry point |
| --- | --- | --- | --- |
| gamut-core | *none* — no oracle exists | **property** (`convert`, `image` stride math) | — |
| gamut-ifd | *none in-crate* — libtiff/exiv2 reach it via the consuming codecs (STATUS.md P7) | **property** + exact-byte | `IfdReader`, `read` ☑ laws; driver |
| gamut-ifd | *none in-crate* — libtiff/exiv2 reach it via the consuming codecs (STATUS.md P7) | **property** + exact-byte | `IfdReader`, `read` ☑ laws; driver |
| gamut-tonemap | *none* | **property** (monotonicity, endpoints, no NaN) | — |
| gamut-bitstream | *none* — self-inverse | property + exact-byte | — |
| gamut-dsp | AV1 §7.13 / T.81 §A.3 transform definitions | example + in-test reference transform | — |
Expand All @@ -196,13 +243,13 @@ mutation gates, and the stubs carry no function bodies.
| gamut-deflate | zlib | differential | — |
| gamut-png | libpng (both directions) | differential + conformance | `PngDecoder` ☐ |
| gamut-jpeg | libjpeg-turbo | differential + exact-byte | `JpegDecoder` ☐ |
| gamut-tiff | libtiff | differential | `TiffDecoder` |
| gamut-dng | Adobe DNG SDK; libtiff (container) | conformance + differential | `DngDecoder` |
| gamut-isobmff | ISO/IEC 14496-12 + 23008-12; libavif/dav1d via gamut-avif | exact-byte + law | `read` |
| gamut-tiff | libtiff | differential | `TiffDecoder` |
| gamut-dng | Adobe DNG SDK; libtiff (container) | conformance + differential | `DngDecoder` |
| gamut-isobmff | ISO/IEC 14496-12 + 23008-12; libavif/dav1d via gamut-avif | exact-byte + law | `read` |
| gamut-riff | libwebp demux | differential + law | `RiffReader` ☐ |
| gamut-webp | libwebp (both directions) | differential + size/effort contract | `WebpDecoder` ☐ |
| gamut-avif | libavif; dav1d | differential + law | `decode` ☐ |
| gamut-heic | libheif + libde265 | differential + law | container `parse`, NAL `parse` |
| gamut-heic | libheif + libde265 | differential + law | container `parse`, NAL `parse` |
| gamut-av1 | libaom (definitive); dav1d | differential | — |
| gamut-jxl | libjxl (the `jxl` crate is the decoder under test, not an authority) | differential | `decode` ☐ |
| gamut-exif | exiv2 | differential + golden | `parse` ☐ |
Expand Down
19 changes: 19 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,25 @@ run = "git submodule update --init --checkout third_party/gamut-dng-samples"
description = "Coverage-guided fuzzing of the invariants laws; `mise run fuzz <target>`"
run = "./tooling/gamut-fuzz/run.sh"

# The compile half of the tier above -- the only part of it the per-PR lane can afford, and the
# same argument `check-dng-real` below makes for the other excluded `tooling/` crate: `gamut-fuzz`
# is workspace-excluded *and* nothing depends on it, so `clippy --workspace --all-targets` and
# `test --workspace` never build it, and an API change in gamut-ifd, gamut-tiff, gamut-dng,
# gamut-isobmff or gamut-heic can break every target while all four required checks stay green.
# Build-only: no nightly, no sanitizer, no engine, so it stays bounded and reproducible and does
# not put a coverage-guided run on the pull-request path (docs/testing.md).
[tasks.check-fuzz]
description = "Compile the fuzz tier's targets (no nightly, no engine)"
run = "cargo check --manifest-path tooling/gamut-fuzz/Cargo.toml --all-targets"

# The drift guard for the three hand-maintained lists that describe the same target set: the files
# under `fuzz_targets/`, the `[[bin]]` entries, and `extended.yml`'s fuzz matrix. Missing from the
# third means the target is written, reviewed, committed -- and never run, silently. Pure text: no
# cargo, no toolchain, no network, which is why CI runs it in the cheap `Format & Metadata` job.
[tasks.check-fuzz-matrix]
description = "Reconcile the fuzz targets across fuzz_targets/, Cargo.toml and extended.yml"
run = "./tooling/gamut-fuzz/check-targets.sh"

[tasks.test-dng-real]
description = "Validate gamut-dng against real camera DNGs (issue #174; needs fetch-dng-samples)"
run = "cargo test --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml"
Expand Down
57 changes: 54 additions & 3 deletions tooling/gamut-fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,21 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"

# Each target's crate is pulled in with `test-support`, the `doc(hidden)` feature that exposes its
# `invariants` module. That feature is never enabled by the `gamut` umbrella, so the shipped
# A *law* target's crate is pulled in with `test-support`, the `doc(hidden)` feature that exposes
# its `invariants` module. That feature is never enabled by the `gamut` umbrella, so the shipped
# surface and `mise run check-ffi-features` are unaffected — verified in #434.
gamut-ifd = { path = "../../crates/gamut-ifd", features = ["test-support"] }
#
# A *robustness* target's crate needs no such feature: it drives the crate's ordinary public
# parser entry point on the engine's raw bytes, which is exactly the surface a caller has.
# `bigtiff` is on for gamut-ifd so the 64-bit variant is reachable from the same byte string
# rather than being silently rejected at the magic number.
gamut-ifd = { path = "../../crates/gamut-ifd", features = ["bigtiff", "test-support"] }
gamut-core = { path = "../../crates/gamut-core", features = ["test-support"] }
gamut-tonemap = { path = "../../crates/gamut-tonemap", features = ["test-support"] }
gamut-tiff = { path = "../../crates/gamut-tiff" }
gamut-dng = { path = "../../crates/gamut-dng" }
gamut-isobmff = { path = "../../crates/gamut-isobmff" }
gamut-heic = { path = "../../crates/gamut-heic" }

[[bin]]
name = "ifd_read_ledger"
Expand All @@ -58,5 +67,47 @@ test = false
doc = false
bench = false

[[bin]]
name = "ifd_read"
path = "fuzz_targets/ifd_read.rs"
test = false
doc = false
bench = false

[[bin]]
name = "tiff_decode"
path = "fuzz_targets/tiff_decode.rs"
test = false
doc = false
bench = false

[[bin]]
name = "dng_decode"
path = "fuzz_targets/dng_decode.rs"
test = false
doc = false
bench = false

[[bin]]
name = "isobmff_boxes"
path = "fuzz_targets/isobmff_boxes.rs"
test = false
doc = false
bench = false

[[bin]]
name = "heic_container"
path = "fuzz_targets/heic_container.rs"
test = false
doc = false
bench = false

[[bin]]
name = "heic_hvcc"
path = "fuzz_targets/heic_hvcc.rs"
test = false
doc = false
bench = false

# Prevent this from being picked up as a workspace member of anything above it.
[workspace]
Loading
Loading