Skip to content

test(fuzz): fuzz targets for the parser entry points - #568

Open
justin13888 wants to merge 24 commits into
masterfrom
test/264-parser-fuzz-targets
Open

justin13888 wants to merge 24 commits into
masterfrom
test/264-parser-fuzz-targets

Conversation

@justin13888

@justin13888 justin13888 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the parser-entry-point half of the fuzz tier (#264). The tier so far drove invariants
laws over normalised inputs; docs/testing.md's per-crate table also names, per crate, the
untrusted-input surface a fuzz driver should take, and every one of those was marked
"not yet wired". Six are wired here.

target crate entry points check beyond the crash oracle
ifd_read gamut-ifd read, read_tree, read_audited the dual-ledger audit (#263) is complete: no byte read outside a claim, no claim unread
tiff_decode gamut-tiff TiffDecoder::{page_count,info_page,decode_page} a page that decodes yields exactly width × height × Rgb8::CHANNELS samples for the geometry the tags declare
dng_decode gamut-dng DngDecoder::{decode,verify_new_raw_image_digest} the raw image that arrives holds exactly width × height × planes samples, after every rewriting stage
isobmff_boxes gamut-isobmff walk_segments, walk_meta_children, read, BoxReader the box cursor strictly advances; the segments tile 0..len exactly
heic_container gamut-heic HeifContainer::parse the segments tile 0..len exactly and every accessor agrees with that tiling
heic_hvcc gamut-heic HevcConfig::parse, annex_b*, validate_still_payload, iter_nal_units the Annex-B emitters append rather than replace, on the success path and the error path

One target per entry point rather than per crate: gamut-heic's container walk and its hvcC/NAL
layer are independent surfaces. These are robustness targets, so unlike the three law targets
they do not route through an invariants module — the primary oracle is the engine's own. Each
of these crates is #![forbid(unsafe_code)] and promises a typed error on hostile input, so a
panic, a hang, or an allocation past libFuzzer's malloc limit is the defect. The extra check in
each target exists so a defect that produces no crash is still visible.

Also here: a curated seed set under corpus/<target>/, six new rows in the existing
extended.yml fuzz matrix, and one case added to gamut-ifd's robustness corpus (a whole,
well-formed entry count of 65 535 with no entry bytes behind it — the corpus held a count
truncated mid-word, but not one that reaches the point of sizing the directory from it).

Two of the six targets fail today, against real defects

This is the answer to "a fuzz target that cannot fail is worth nothing". Both were found within
three minutes of the target first running, and both are filed, not fixed — this change does
not touch a parser.

  • fix(tiff): page_info panics on SamplesPerPixel = 0 #563gamut-tiff panics on SamplesPerPixel = 0. A 62-byte file reaches
    info.rs:122 with an empty bits vector: the guard above it compares bits.len() against
    samples_per_pixel (0 == 0) and any over an empty iterator is false, so bits[0] is
    reached unguarded. index out of bounds: the len is 0 but the index is 0.
  • fix(dng): raw decode sizes its buffer from declared geometry, not from the file #564gamut-dng sizes its raw buffer from declared geometry. An 872-byte file declaring
    60000 × 60000 requests malloc(7200000000); the fuzzer-found 780-byte case requests 34 GB.
    gamut-tiff has the guard this path lacks (MAX_IMAGE_BYTES). Worth noting how this is
    visible: an oversized Vec::with_capacity is virtual memory nothing touches, so the process
    peaks at 3 MB resident and returns a clean Err — measuring RSS finds nothing, and libFuzzer's
    -malloc_limit_mb is the only oracle that sees the request.

Consequence to accept knowingly: the Fuzz tiff_decode and Fuzz dng_decode rows of the
Extended workflow will be red until those two are fixed. The Extended workflow is post-merge and
manual-dispatch only, fail-fast: false, so this blocks nothing on any pull request; a red row
with a linked issue is the signal the tier exists to produce. Narrowing a target to make its row
green would be weakening a check to make a gate report green.

The other four ran clean: 2.2 M, 6.5 M, 6.2 M and 8.8 M executions respectively (commands below).

The enumerated malformed cases, verified

The issue enumerates thirteen byte-exact cases and states the class each must land in. Each was
transcribed and run through read / read_tree / IfdReader rather than assumed:

# seed outcome
1, 2 01-truncated-header.tif TIFF: header too short — the two cases the issue lists separately are the same four bytes ("II" LE16(42))
3 03-invalid-byte-order.tif TIFF: bad byte-order mark
4 04-invalid-magic.tif TIFF: bad magic number
5 05-ifd0-offset-past-eof.tif TIFF: read out of bounds [byte offset: 1000]
6 06-truncated-entry-count.tif TIFF: read out of bounds [byte offset: 8]
7 07-truncated-entries.tif TIFF: IFD extends past end of file
8 08-value-offset-past-eof.tif TIFF: value offset out of bounds
9 09a-circular-ifd-self.tif, 09b-circular-ifd-two-node.tif TIFF: IFD chain loops — terminates, both the 1-node and the 2-node cycle
10 10a-hostile-entry-count.tif, 10b-…-bigtiff.tif TIFF: IFD extends past end of file — classic 0xFFFF and the BigTIFF u64 twin
11 11-hostile-value-count.tif TIFF: field value out of bounds
+ 12-unknown-tag-preserved.tif parses (1 IFD) — the positive case holds
+ 13-unknown-field-type.tif parses (1 IFD) — the positive case holds

The slice and streaming readers agreed on every one. Cases 1–9 and 11 already had deterministic
coverage in crates/gamut-ifd/tests/robustness.rs (named cases plus the exhaustive truncation and
single-byte-overwrite sweeps); case 10 did not, and is the one added there.

Validation

Every command below completed in this run, from the worktree, on test/264-parser-fuzz-targets.
lint, test and mutants-diff ran inside a MemoryMax=16G systemd scope with
CARGO_BUILD_JOBS=2. __CARGO_TEST_ROOT is the documented workaround for cargo walking past a
nested worktree root when loading the tooling/* manifests; it changes no manifest.

command result
cargo +nightly fuzz build --fuzz-dir tooling/gamut-fuzz --target x86_64-unknown-linux-gnu pass — all nine targets link
./tooling/gamut-fuzz/run.sh ifd_read <corpus> -- -max_total_time=180 pass — 2 207 595 runs, no crash
./tooling/gamut-fuzz/run.sh isobmff_boxes <corpus> -- -max_total_time=180 pass — 6 475 483 runs, no crash
./tooling/gamut-fuzz/run.sh heic_container <corpus> -- -max_total_time=180 pass — 6 226 622 runs, no crash
./tooling/gamut-fuzz/run.sh heic_hvcc <corpus> -- -max_total_time=180 pass — 8 808 263 runs, no crash
./tooling/gamut-fuzz/run.sh tiff_decode <corpus> -- -max_total_time=180 caused finding — panic, minimised to a 62-byte file, filed as #563
./tooling/gamut-fuzz/run.sh dng_decode <corpus> -- -max_total_time=180 caused findingout-of-memory (malloc(34225522680)), filed as #564
cargo test -p gamut-ifd --all-features --test robustness pass — 8 tests
MISE_TASK_RUN_AUTO_INSTALL=false mise run fuzz heic_hvcc -- -max_total_time=5 pass — the exact CI invocation form reaches a new target and picks up its committed seeds with no extra wiring
__CARGO_TEST_ROOT=… mise run fmt-check pass
__CARGO_TEST_ROOT=… mise run fmt-tooling-check pass
mise run check-tests pass — module docs, pinned proptest seeds and oracle filenames all conform
mise run check-commits pass — no errors in 4 commits
mise run lint pass — whole workspace, --all-targets --all-features -D warnings, 15 m 55 s
mise run test pass — whole workspace
mise run check-release-deps pass — no dev-only workspace edges
mise run check-ffi-features pass — gamut-ffi features in sync with gamut
mise run mutants-diff pass — 0 missed (No mutants to filter: the only crates/ change is a test file, which produces no mutants)

Two harness defects were found and fixed before committing, both by the targets themselves:

  1. ifd_read compared two parses with assert_eq!. TiffFile derives PartialEq, not Eq,
    because a FLOAT/DOUBLE field holds f32/f64; the engine found a NaN within a minute
    and the target reported two identical parses as a disagreement. It compares the Debug
    rendering now, which is total. tests/robustness.rs has the same latent defect — its fixtures
    simply never produce a NaN — filed as test(ifd): hoist the two-reader differential into invariants, and stop comparing parses with PartialEq #566 together with the hoist that would give both
    tiers one copy of the differential.
  2. The two tiling checks asserted "non-empty segment list", which is wrong for a zero-length
    input, where an empty list already tiles 0..0. Both now walk a cursor, which states the
    whole claim once — start at 0, contiguous, non-overlapping, no empty segment, ending at end
    of file — and is correct at length 0.

Risks and rollout

  • The two red Extended rows, above. Post-merge and manual only; blocks no pull request.
  • Nothing on the per-PR path changes. tooling/gamut-fuzz is workspace-excluded, so
    cargo test --workspace never builds it; the only crates/ change is one byte string and one
    assertion in an existing test.
  • The fuzz crate gains four path dependencies (gamut-tiff, gamut-dng, gamut-isobmff,
    gamut-heic) and bigtiff on gamut-ifd. All are dev-tier and workspace-excluded; no shipped
    manifest changes, so check-release-deps and check-ffi-features topology is untouched.
  • The seeds total under 3 KB. They are tracked past the tooling/gamut-fuzz/corpus/ ignore by
    force-adding, which keeps the split that ignore exists for: seeds tracked, engine output not.
    A tidier expression of that is chore(fuzz): record the wired parser entry points in docs/testing.md and express the seed-corpus exception in the ignore rules #565.
  • dng_decode's "a file which decodes also reaches a digest verdict" assertion, flagged here in
    round 1 as the most likely false positive, is gone — see round 2 below. An Err from the
    digest route is now a classified outcome.
  • Nothing on the per-PR path compiled these 442 lines in round 1. It does now: a build-only
    cargo check step in the lint job, and a drift guard in Format & Metadata. See round 2.

Issue

Refs #264 — the targets, the seeds and the CI wiring are delivered; the issue's real-camera
seed corpus is not, and is filed as #567. Filed from this work:

#565's first half — recording the wired entry points in docs/testing.md — is delivered here
(see round 2, F4); its second half, expressing the seed-corpus exception in the ignore rules, is
not, so the reference stays Refs.

No human approved this plan. This is an unattended run; the record below is what a human
reads afterwards.


Round 2 — review findings, resolved

A review of round 1 established, by construction rather than by re-running the fuzzer, that three
of the advertised "checks beyond the crash oracle" were tautologies. It also confirmed what does
hold and is not revisited: every one of the thirteen enumerated seed cases is transcribed
byte-for-byte and reaches the path it names; the two crashes reproduce independently; the three
hand-maintained lists were in sync; and the workflow wiring follows the repository's precedent.

F1 (high) — ifd_read's differential was a tautology

crates/gamut-ifd/src/reader.rs defines the slice door as the streaming one:

pub fn read(data: &[u8]) -> Result<TiffFile> { crate::IfdReader::open(data)?.read_file() }
pub fn read_tree(data: &[u8], tags: &[u16]) -> Result<TiffFile> { crate::IfdReader::open(data)?.read_tree(tags) }

The "other door" the target compared against was character-for-character that body.
src/stream.rs says so itself: "This module is the parser … there is exactly one
directory-body walk." Falsifier executed: read_file was made to drop the last directory of a
multi-directory chain and a hand-built two-directory file run through the comparison — no report,
because both sides dropped it.

Resolved: the two duplicate parses are dropped and the dual-ledger byte audit — which the
reviewer proved fires, and which the engine cannot make on its own — is promoted to the headline.
The wrapper claim is real but is a structure pin, not a differential, and one bounded run
answers it; it stays in crates/gamut-ifd/tests/robustness.rs, whose survives helper already
drives both doors over the exhaustive truncation and single-byte-overwrite corpus, relabelled
there. Measured effect: 12 264 → 20 596 exec/s over 120 s.

F2 (medium) — heic_hvcc's composition check, same shape

HevcConfig::annex_b's body is annex_b_parameter_sets(out); annex_b_payload(payload, out), so
asserting the whole equals the halves pinned that body and nothing else; and the equality of two
is_ok() values from the same expression is trivially true.

Resolved: the is_ok() comparison is deleted. The append contract is promoted to the
headline — annex_b, annex_b_parameter_sets and annex_b_payload all document that bytes
already in the buffer are left in place, including on the error path, and nothing in any of the
three bodies makes that true by construction; a clear() or an indexed write breaks every reusing
caller with no crash. The composition pin survives, named as a pin, folded into the append check's
pre-filled buffer so it costs no third emitter pass. 59 936 exec/s over 120 s.

F2b — the same defect in dng_decode, found while resolving F2

Applying the rule consistently turned up a third: dng_decode compared the digest verdict
against decoded.new_raw_image_digest, but both sides read NewRawImageDigest out of IFD 0 with
the identical expression, so verdict == Absent ⟺ the field is None by construction. Repriced
as a structure pin — kept, because the call is made anyway for its own crash-oracle reach, and
because the two readers could genuinely be split apart later. dng_decode's live check is now the
one stated in the table: the raw image that arrives, after linearisation and crop handling, holds
exactly width × height × planes samples.

F3 (medium) — dng_decode could report a false crash

verify_new_raw_image_digest is not a second call to decode: it re-reads the container and then,
on the file's own Compression code, either re-decodes the raw samples or walks the compressed
chunk grid — and only the first is a subset of decode's work. Its own docs bound its errors by
decode's for lossless storage only. expect(...) on that call is therefore a false-crash
generator, on a tier that runs unattended where a false crash is indistinguishable from a real one
until a human minimises it.

Resolved: the Err arm returns — a case with nothing to compare. Stated honestly: on today's code
the containment happens to hold (the lossy branch does strictly less work than decode_image_data
does for the same file), so this is prospective false-positive surface, not a present bug. That
is the argument for removing it, not against: a panic that cannot fire today has no detection power
to trade away.

F4 (medium, process) — the register is updated here

docs/testing.md's per-crate table says a row changes only in a pull request that says why. This
is that pull request, so the five ☐ marks flip and the legend gains the meaning of ☑ it never
defined. Two rules the tier needed are added beside them: the per-PR path carries an excluded fuzz
tier's compile half plus its drift guard (neither runs an engine, so the "bounded and
reproducible" rule stands), and a robustness target's extra check must be able to fail.

F5 (medium, operational) — the red is accepted and made legible

Not marked, not skipped, not continue-on-error: a row that reports green while its target crashes
is the weakening this change exists to refuse. Instead the expectation is written where a reader
meets it — tooling/gamut-fuzz/README.md under its own heading, and a comment on the job that
produces the status — saying plainly that Extended runs on every push to the default branch and
its aggregate stays red until #563 and #564 close
, and to read the per-row status meanwhile. The
structural alternative is filed as #593.

F6 (medium) — the pull-request path now compiles these targets

tooling/gamut-fuzz is workspace-excluded and nothing depends on it, so no gate built its 442
lines and the four green checks said nothing about them. The lint job now runs
cargo check --manifest-path tooling/gamut-fuzz/Cargo.toml --all-targets — build-only, no nightly,
no sanitizer, no engine — exactly as it already does for the excluded real-DNG tier, in the same
job, two steps apart. Marginal cost: the driven crates are already built by the Clippy step above.

Drift guard, and the lower findings

tooling/gamut-fuzz/check-targets.sh reconciles the three hand-maintained lists — the files under
fuzz_targets/, the [[bin]] entries, and the extended.yml matrix — and fails on a [[bin]]
whose name disagrees with its own path. Pure text, sub-second, so it runs in Format & Metadata.
All five failure modes were fault-injected and confirmed to fire (matrix row removed, [[bin]]
removed, name/path mismatch, orphan file, and the restored tree passing). The scheduling question
it raises — nine parallel ten-minute runners per push, growing with the matrix — is filed as
#594 rather than changed here.

Also: the silently-shared feature graph is documented (bigtiff went in for ifd_read; Cargo
resolves features once per crate, so the pre-existing ifd_read_ledger is built with it too); the
README's new heading no longer orphans the two paragraphs annotating the law-target table, and its
seed description matches what is actually committed (corpus/tiff_decode/ holds two files, an
uncompressed strip and an LZW strip, which enter the decoder through different code); and
gamut-ifd's malformed-input test is renamed to
specific_malformed_inputs_yield_typed_errors_not_panics, covering the error-text assertion it
gained.

Round-2 validation

Every command below completed in this run, from the worktree. lint, test and mutants-diff ran
inside a MemoryMax=16G systemd scope with CARGO_BUILD_JOBS=2.

command result
cargo +nightly fuzz build --fuzz-dir tooling/gamut-fuzz --target x86_64-unknown-linux-gnu pass — all nine targets link, verified by listing the nine binaries
./tooling/gamut-fuzz/run.sh ifd_read <scratch> <seeds> -- -max_total_time=120 pass — 2 492 191 runs, 20 596 exec/s (was ~12 264), no crash
./tooling/gamut-fuzz/run.sh heic_hvcc <scratch> <seeds> -- -max_total_time=120 pass — 7 252 285 runs, 59 936 exec/s, no crash
./tooling/gamut-fuzz/run.sh dng_decode <scratch> <seeds> -- -max_total_time=90 caused finding, expectedout-of-memory (malloc(4326684768)), i.e. #564 still fires after the F3 change
./tooling/gamut-fuzz/check-targets.sh pass — 9 in step; and exits 1 on each of the four injected mismatches plus the name/path one
cargo check --manifest-path tooling/gamut-fuzz/Cargo.toml --all-targets pass — 47 s, the exact command the new CI step runs
cargo test -p gamut-ifd --all-features pass
__CARGO_TEST_ROOT=… mise run fmt-check pass
__CARGO_TEST_ROOT=… mise run fmt-tooling-check pass
mise run check-tests pass
mise run check-commits pass — no errors in 10 commits
mise run lint pass — whole workspace, --all-targets --all-features -D warnings
mise run test pass — whole workspace, 202 test result: ok lines, 0 failures
mise run check-release-deps pass
mise run check-ffi-features pass
mise run mutants-diff pass — No mutants to filter (the only crates/ change is a test file)

Each promoted check was fault-injected and confirmed to fire, so "the live check" is a measured
claim and not a reading of the code. In each case the injection was reverted immediately and the
tree re-verified clean:

injected defect target's report
HevcConfig::annex_b_parameter_sets begins with out.clear() heic_hvcc:69"an annex_b emitter overwrote what was already in the buffer", in under 30 s
read_audited claims the file's last byte as Claim::Parsed without reading it ifd_read:56"parser claimed bytes it never read", with the offending Segment in the report
RawImage::new_cfa pushes one extra sample past check_sample_count dng_decode:60"decoded raw holds 49 samples for Dimensions { width: 8, height: 6 } × 1 planes"

CI, on the pushed head (6725ee81): all four required checks green. Format & Metadata ran the
new Fuzz target lists in step step (step 10, success); Clippy & Doctests ran the new
Fuzz tier compiles step (step 13, success, 19 s, immediately after
Real-DNG conformance tier compiles). The 442 lines this change adds are now compiled by a
pull-request gate.


Round 3 — review findings, resolved

A second review re-ran the fuzzer and fault-injected every promoted check in the opposite
direction from round 2
— a claim short rather than long, an error path rather than a success
path, a post-construction stage rather than a construction — so both directions of each are now
proven live. It confirmed what holds and is not revisited here: five of the six targets fire, the
drift guard survived eleven injections (ten firing, the eleventh correct behaviour), the build-only
step compiles all nine targets and did run, and one of the two genuine crashes reproduces
independently.

What it found wrong is below. Every item was reproduced here before anything was changed.

F1 (high) — tiff_decode advertised two checks, neither of which could fail

The rule round 2 introduced was applied to two targets and not to the third — the one this body and
the README lead with.

  • The page-index bound. page_count is read(data)?.ifds.len(); info_page is
    read(data)?.ifds.get(page). The assertion compared a count against the same expression that
    produces it, so it reduced to indexing a vector one past its own length. Injecting the exact
    defect the docs named — a count that over-reports the chain — moves both sides together and
    produces no report. Dropped.
  • The geometry equality. decode_page_samples says outright that "everything the page
    declares comes from one shared reader", so transposing inside info::page_info — a real defect
    handing every caller a transposed image — reports nothing, while the identical transposition
    injected downstream does. The check saw only the few lines copying described values into decoded
    ones. Re-anchored, not repriced.

Resolved: the target now compares the samples the decode physically produced against the
declared dimensions and the channel count of the layout asked for — a number the geometry reader
does not produce, so the check reaches the strip/tile assembly, the predictor pass and the
photometric unpack rather than a copy. Same shape as the sibling dng_decode check the reviewer
proved live.

Injection that proves the new check fires, recorded in the module doc so anyone can re-run it:
at the point decode_page_samples builds its DecodedImage, trim the last row from the samples
and report height - 1 — a crop stage that describes what it cropped. It is internally
consistent, so RawImage::new and ImageBuf::new both accept it and nothing crashes; only the
declared geometry contradicts it. The committed seeds alone report it under -runs=0:

assertion `left == right` failed: page 0: decoded 54 samples for the 6 × 4 × 3 the tags declare

Recorded beside it, because it bounds what the check reaches: the first injection tried —
decoding one row more than the file declares — produced no report, because the strip assembly
runs out of bytes and the page is refused. A wrong-volume defect that leaves the dimensions alone
becomes a typed error before it reaches a caller; the live class is a stage that rewrites the
geometry it hands on.

One assertion beside it, "a page that decodes must also describe", cannot fail either — decoding
calls the tag reader before it reads a pixel. It is kept at zero cost and labelled a structure
pin at the site
, not counted as a check.

F2 (medium) — three documents claimed something the same commit made false

"A check is only listed here if it can fail" appeared in tooling/gamut-fuzz/README.md, the rule
in docs/testing.md, and the table at the top of this body, while a row for which it was false
shipped alongside. All three are corrected: the tiff row now states the sample-count check, the
README's paragraph counts five unfalsifiable entries rather than three and says which two were
dropped rather than repriced, and the register learns the shape that failed — a comparison whose
two sides come from one reader is a tautology, so anchor a decode check on what the decode
produced.

The rule also gains the half that makes it checkable: each target's module doc records the
injection that made its check fire
, with the message and the command that reproduces it.

F3 (low) — one more assertion that cannot fail by input

heic_hvcc asserted no NAL unit is empty. NalUnitIter::next returns Err("zero-length NAL unit") for len == 0 before it can yield an empty slice, so no input reaches an Ok that fails
it. Unlike the others it was not labelled. It is now a structure pin, named at the site and in
the module doc: it pins that early return staying where it is, for the cost of one is_empty on a
slice already in hand.

F4 (low) — the new rule has no guard, and gets evidence instead of ceremony

Every other rule this tier added has a script behind it. This one does not, and mechanising it is
genuinely hard: "can this assertion fail" is reachability of a panic; mutation testing does not
reach an excluded crate that cargo mutants never builds; and a syntactic lint for
assert_eq!(f(x), f(x)) catches none of the five real cases, every one of which compared two
different expressions that reduce to one. A lint that checks a paragraph is present would look
like a gate without being one, which is worse than prose.

So instead of a ceremonial guard, the rule now demands re-runnable evidence — the injection, its
message, its command, in the module doc, which is what both reviewers did by hand anyway. Injected
and confirmed firing in this round, from the committed seeds with no search:

target injected defect reported
ifd_read header claimed as header_size() - 1 "parser read bytes it never claimed", unclaimed_reads: [Range { start: 7, len: 1 }]
tiff_decode a crop stage that trims a row and describes the trim "page 0: decoded 54 samples for the 6 × 4 × 3 the tags declare"
dng_decode new_cfa pushes a sample past check_sample_count "decoded raw holds 49 samples for Dimensions { width: 8, height: 6 } × 1 planes"
isobmff_boxes a box's segment recorded as b.offset + 8..end "segment 8..24 leaves a gap or overlaps at 0"
heic_container boxes() skips the ftyp box "boxes() disagrees with the Box segments"
heic_hvcc annex_b_parameter_sets begins with out.clear() "an annex_b emitter overwrote what was already in the buffer"

Whether a guard is possible at all is filed as #602, with the three options and why each is
hard, so the next person does not re-derive it.

F5 — the drift guard misdiagnosed a duplicate, twice

A list that names the same target twice makes comm -23 report the second copy as present on the
left and absent on the right, so the guard printed "a [[bin]] points at a file that does not
exist"
for a file that exists, and "CI names a target that cannot be built" for a target that
builds. Duplicates are now diagnosed first, by name, and both lists are de-duplicated before the
set comparisons so those keep saying what they mean. Injected a repeated [[bin]] and a repeated
matrix row: both new messages fire, and the restored tree passes.

F7 — the count

Five ☐ marks flip in the register, not six (the gamut-ifd row changes from "☑ laws; ☐ driver" to
"☑ laws; ☑ driver"). Corrected above.

The two task entries, and the manifest revision they need

Round 2's decision 13 put the compile step and the drift guard on the pull-request path with their
commands written inline in the workflow, because mise.toml was outside the manifest. The hazard
the reviewer named is real: a contributor cannot run the automation's command through the task
list, so the two copies drift, and every comparable gate in this repository is a task for exactly
that reason. mise run check-fuzz and mise run check-fuzz-matrix now exist and the workflow
calls them by name.

Manifest revision, stated plainly: this widens the manifest by one file, mise.toml, for that
reason and nothing else. Two task entries, no behaviour change to either check.

The cadence question, answered

Nine parallel ten-minute runners on every push to the default branch, growing by one per target,
was inherited from the workflow's trigger rather than chosen (#594). It is kept, and the
argument is now written in the README and in the job's own comment so the next target added does
not reopen it:

  • The cost is queue time, not budget — Actions minutes are free for public repositories — and the
    matrix grows the number of parallel runners, not the job's wall time. The lane is post-merge
    with fail-fast: false and blocks no pull request.
  • Frequency is the wrong dial, because nothing accumulates between runs: each run starts from
    the committed seeds and discards what the engine finds (the job uploads artifacts/ only on
    failure, and rust-cache caches build artefacts, not the corpus). A run is therefore ten minutes
    of cold search whatever the cadence — running less often searches strictly less, running more
    often re-derives the same shallow space. Persisting the corpus is the change that would move the
    tier's yield, filed as ci(fuzz): persist the fuzz corpus between Extended runs, so each run is not a cold start #603; cadence and the -max_total_time budget are worth re-opening
    after that, not before.
  • Changing the trigger would also move ci(fuzz): keep Extended's aggregate status meaningful while two fuzz targets are expected to fail #593's premise — whether a per-push aggregate is red — which
    is a decision about the workflow's shape, not about this tier.

What deliberately did not change

Round-3 validation

Every command below completed in this run, from the worktree, on test/264-parser-fuzz-targets.
lint and test ran inside a MemoryMax=16G systemd scope with CARGO_BUILD_JOBS=2.
__CARGO_TEST_ROOT is the documented workaround for cargo walking past a nested worktree root when
loading the tooling/* manifests; it changes no manifest.

command result
cargo +nightly fuzz build --fuzz-dir tooling/gamut-fuzz --target x86_64-unknown-linux-gnu pass — all nine targets link, verified by listing the nine binaries
run.sh tiff_decode <seeds> -- -runs=0 pass on the clean tree; reports under the injected crop stage
run.sh ifd_read <seeds> -- -runs=0 pass on the clean tree; reports under the short header claim
run.sh isobmff_boxes <seeds> -- -runs=0 pass on the clean tree; reports under the header-less segment
run.sh heic_container <seeds> -- -runs=0 pass on the clean tree; reports under the filtered boxes()
run.sh heic_hvcc <seeds> -- -runs=0 pass on the clean tree; reports under out.clear()
run.sh dng_decode <seeds> -- -runs=0 pass on the clean tree; reports under the extra CFA sample
run.sh tiff_decode <corpus> <seeds> -- -max_total_time=120 caused finding, expectedindex out of bounds at info.rs:122, i.e. #563, at ~55 000 exec/s
./tooling/gamut-fuzz/check-targets.sh pass — 9 in step; and exits 1, with the new message, on a duplicated [[bin]] and on a duplicated matrix row
mise run check-fuzz pass — the exact command the CI step now runs
mise run check-fuzz-matrix pass — likewise
__CARGO_TEST_ROOT=… mise run fmt-check pass
__CARGO_TEST_ROOT=… mise run fmt-tooling-check pass
mise run check-tests pass — module docs, pinned proptest seeds and oracle filenames all conform
mise run check-commits pass — no errors in 15 commits
mise run lint pass — whole workspace, --all-targets --all-features -D warnings
mise run test pass — whole workspace, 202 test result: ok lines, 0 failures

Every injection above was reverted immediately and the tree re-verified clean before the next one;
git status after each shows only this change's own files.

No crates/ file changed in round 3, so mutants-diff, check-release-deps and
check-ffi-features have the same inputs they had at the round-2 head; the round-2 results stand
and are not re-claimed here.


Round 4 — review findings, resolved

Round 3's review refuted this branch's headline change with a control in both directions. Every
finding below was reproduced here before it was acted on, and every injection already recorded on
this branch was re-run afterwards to show it still reproduces verbatim.

F1 (high) — the re-anchored tiff_decode check was strictly weaker than the one it replaced

Verified, and the review is right. convert_from_raw allocates its output as
ImageBuf::<Q>::zeroed(src.dims), so as_samples().len() is width × height × CHANNELS of the
dimensions
, by construction, for every input. Asserting it against the declared geometry's
product is the pair comparison multiplied by a constant on both sides.

Executed, on the committed seeds with no search:

  • transposing the DecodedImage dimensions with the sample count aloneexit 0, no report;
  • the same transposition with the pair equality restored — reports
    "page 0: decoded 4 × 6 for the 6 × 4 the tags declare" immediately.

So the pair equality is back beside the sample count (it costs one comparison on values already in
hand, and it fires on exactly the class the row advertises), and the sample count is relabelled a
structure pin at the site. The module doc's central claim — that the count is not read from the
geometry reader — is deleted as false, and the ImageBuf::new slip beside it is corrected:
ImageBuf::new is never called on that path, RawImage::new is the only gate.

The more important half was the normative document. docs/testing.md had generalised the
mistaken anchor into a rule for the whole workspace. It now states the boundary instead, with the
three shapes that violate it and the tautology each one hides.

F2 (medium) — a fifth entry that cannot fail: isobmff_boxes's cursor-advance check

Reproduced. BoxReader::next_box reads its 4-byte size and 4-byte type through take before any
success return, so no declared box size can stall the cursor. With the size < header_size guard
removed and the body taken as size.saturating_sub(header_size) — the exact defect the assertion
names — the committed seed reports nothing, and 3 162 778 executions over 121 s report nothing.
The control fires on the first seed: rewinding self.pos gives "BoxReader did not advance past 0
(len 229)"
. The empty-segment sub-assertion is unreachable for the same structural reason. Both
moved to the structure-pin column, beside the three already labelled honestly.

F3 (medium) — heic_container's tiling check had zero reach of its own

Reproduced: HeifContainer::parse stores gamut_isobmff::walk_segments(data)? verbatim, and the
b.offset + 8..end injection produced the identical message in both targets. Dropped from
heic_container, which keeps what is genuinely its own (the accessor agreement and the data()
containment). Re-measured after the removal: under that same injection heic_container now exits
0 while isobmff_boxes reports, which is the intended split.

F4, F5, F6, F7, F8 — the lower findings

  • F4ifd_read's "no claim unread" half was live but unreachable from every committed seed.
    corpus/ifd_read/padding-unread-claim.tif is a 22-byte TIFF pointing IFD0 at offset 16, leaving
    8..16 as padding nothing reads; it turns the over-claim into a report.
  • F5 — the doc promised an accessor-subslice check the code did not make. It is the cheap
    pointer-range comparison it appeared to be, so the check was written rather than the doc
    weakened: every slice boxes(), appended_stream(), trailer() and unknown_meta_boxes() hand
    out must lie inside data().
  • F6 — the tiff_decode early-return branch called two entry points that both re-enter the
    read that already failed. Removed; the comment says why.
  • F7 — the README count is derived from the list rather than restated.
  • F8 — the chore: fuzz coverage for parser entry points #264 seed numbering is documented: case 2 carries no file because its bytes are
    case 1's, and cases 9 and 10 carry two files each.

The per-check audit (the round's real deliverable)

Rows with two checks had been getting one injection, which is how F2 and F4 both survived three
rounds. The check set is now derived from the README's own table mechanically — split each row's
last cell on its own bold and; every conjunct is one listed check owed one injection — rather
than read by hand. Six robustness rows yield ten listed checks. The audit is published in
tooling/gamut-fuzz/README.md so a check with no injection is visible without reading six module
docs.

Deriving it that way immediately found one hole the rule already covered: boxes() is an
accessor-versus-count equality with only its under-reporting direction injected. The
over-reporting direction is now recorded too, so all three accessors carry both.

The three law targets are outside the audit by construction: their oracle is the invariants
function the property tier already drives, not a check beyond the crash oracle.

Every recorded injection, re-run on this head

All sixteen live-check injections and all nine controls below were executed after the changes
above, on the committed seeds only (run.sh <target> <seeds> -- -runs=0, no search), each one
applied to a clean tree and reverted immediately after — git status was verified empty between
every pair. The control run first: all six robustness targets exit 0 on the committed seeds at this
head, so every report below is caused by its injection.

# injection target message
1 header claimed as header_size() - 1 ifd_read "parser read bytes it never claimed", unclaimed_reads: [Range { start: 7, len: 1 }]
2 header claimed as header_size() + 1 ifd_read "parser claimed bytes it never read", unread_claims: [Segment { range: Range { start: 0, len: 9 }, kind: Header }]
3 box recorded as b.offset + 8..end isobmff_boxes "segment 8..24 leaves a gap or overlaps at 0"
4 segments.pop() before the return isobmff_boxes "coverage does not run to end of file", left 217 right 229
5 transpose the DecodedImage dimensions tiff_decode "page 0: decoded 4 × 6 for the 6 × 4 the tags declare"
6 push a sample past check_sample_count dng_decode "decoded raw holds 49 samples for Dimensions { width: 8, height: 6 } × 1 planes"
7 boxes() skips the ftyp box heic_container "boxes() disagrees with the Box segments", left 2 right 3
8 boxes() yields every Box segment twice heic_container the same, left 6 right 3
9 appended_stream() returns None heic_container "appended_stream() disagrees with the AppendedStream segments"
10 appended_stream() returns Some(self.data) heic_container the same, other direction
11 trailer() returns None heic_container "trailer() disagrees with the Trailer segments"
12 trailer() returns Some(self.data) heic_container the same, other direction
13 data() returns a leaked copy heic_container "data() is not the input"
14 boxes() yields leaked bodies heic_container "a borrowed slice is not inside data(): 16 bytes at 0x…, data() is 272 bytes at 0x…"
15 annex_b_parameter_sets begins out.clear() heic_hvcc "an annex_b emitter overwrote what was already in the buffer", on main-still-vps-sps-pps.bin
16 annex_b_payload clears before returning Err heic_hvcc the same, on truncated-payload-nal.bin

Nothing failed to reproduce. Rows 15 and 16 were checked against the artefact libFuzzer wrote:
their SHA-256s are the two committed seeds, so the success path fires on the well-formed record and
the error path on the truncated one, as documented.

The controls, all of which are recorded claims this branch makes:

control outcome
transposition with the sample count alone no report, exit 0 — F1's refutation, reproduced
page_count over-reports the chain, with the dropped info_page assertion restored no report
decode info.height + 1 rows no report — refused by the strip assembly, as documented
size guard removed, committed seed no report
size guard removed, 3 162 778 executions over 121 s no report
self.pos rewound to the box offset reports "BoxReader did not advance past 0 (len 229)"
b.offset + 8..end against heic_container after the duplicate was dropped no report, exit 0
row 2's injection without padding-unread-claim.tif no report — the seed is load-bearing
rows 9 and 11 without appended-stream.heic / trailer.heic no report — likewise
row 16 without truncated-payload-nal.bin no report — likewise

The last four are the evidence for decision 7's rule: a check whose only witness has to be
synthesised by the engine is a check the tier is asking luck for.

Round-4 validation

Every command below completed in this run, from the worktree, on test/264-parser-fuzz-targets.
Fuzz runs and lint ran inside a MemoryMax=16G systemd scope with CARGO_BUILD_JOBS=2; lint
waited for the machine's load average to fall below 32 first (it started at 28.97).
__CARGO_TEST_ROOT is the documented workaround for cargo walking past a nested worktree root when
loading the tooling/* manifests; it changes no manifest.

command result
run.sh <target> <committed seeds> -- -runs=0, all six robustness targets, clean tree pass — exit 0 on all six; this is the control every injection below is read against
the same, under each of the sixteen injections in the table above reports, sixteen for sixteen, each message as recorded
the same, under each of the ten controls in the table above as recorded — nine silent, one reporting
run.sh isobmff_boxes <corpus> -- -max_total_time=120 with the box-size guard removed no report in 3 162 778 executions, at ~26 000 exec/s
mise run check-fuzz pass — the exact command CI's Clippy job runs
mise run check-fuzz-matrix pass — 9 targets in step across fuzz_targets/, Cargo.toml and extended.yml
__CARGO_TEST_ROOT=… mise run fmt-check pass (includes fmt-tooling-check)
mise run check-tests pass — module docs, pinned proptest seeds and oracle filenames all conform
convco check origin/master..HEAD pass — no errors in 24 commits
mise run lint pass — whole workspace, --all-targets --all-features -D warnings
cargo clippy --manifest-path tooling/gamut-fuzz/Cargo.toml --all-targets --keep-going -- -D warnings fail, pre-existing — five findings, all five in the three law targets, none in the six robustness targets here; demonstrated on origin/master, filed as #615

Every injection was applied to a clean tree, run, and reverted immediately; git status was
verified empty between every pair, and the two temporary edits to the target files themselves (the
sample-count-only control and the restored page_count assertion) were reverted the same way.

mise run test, mise run mutants-diff, check-release-deps and check-ffi-features are not
re-claimed for this round: round 4 changed no file under crates/ and no manifest, so their inputs
are byte-identical to the round-3 head where they passed. The four required checks re-run on this
head are the independent evidence for that.

The matrix size, and what did not change

Nine rows is kept. The cadence argument holds for the size too: what changes this tier's yield per
minute is persisting the corpus (#603), and until that lands a shorter matrix is simply less
search. A target is removed when its checks stop having reach — which is what this audit
decides — never to make a job finish sooner.


Decisions taken

Issue 264  Plan: v1  Branch: test/264-parser-fuzz-targets  Base: origin/master
Touches: tooling/gamut-fuzz/{Cargo.toml,README.md,fuzz_targets/*} — targets ifd_read,
         tiff_decode, dng_decode, isobmff_boxes, heic_container, heic_hvcc;
         tooling/gamut-fuzz/corpus/** seeds; .github/workflows/extended.yml (matrix);
         crates/gamut-ifd/tests/robustness.rs (the 0xFFFF entry-count case)
Will not: change any parser. If a target finds a real defect, that is an issue to file,
          not a fix to make in this lane.
Settled: S5 — this is the one entry whose issue asks for CI wiring, so a workflow edit
         is permitted here and nowhere else in the run.

Decisions taken.
1. Boundary: targets, seeds and extended-lane wiring. PR-gated short fuzz runs are
   declined: fuzzing needs a nightly toolchain and a sanitizer, and the extended lane is
   this repository's stated posture for oracle-class work.
   Reverses: add a `fast` job.
2. Seeds: the malformed-TIFF cases the issue enumerates go in as byte files under
   corpus/ifd_read/. The real-camera seed corpus is NOT vendored — it is an external
   release running to hundreds of megabytes.
   Reverses: vendor it behind a fetch task.

Appended during delivery:

3. Seeds are committed past the ignore rather than by editing it. `tooling/gamut-fuzz/corpus/`
   is already ignored, with a stated reason: the corpus is a search aid, not the regression
   record. That reason is about the engine's *search state*, not about a curated seed set, and
   force-adding the seeds preserves exactly that split — seeds tracked, everything libFuzzer
   writes beside them untracked — with no wiring, since cargo-fuzz already uses the directory.
   The ignore file itself is outside the manifest, so the tidier expression is filed (#565).
   Rejected with evidence: dropping the seeds entirely. `heic_container` needs a well-formed
   `ftyp`+`meta` before it reaches anything; from nothing it would spend its whole ten-minute
   budget on four-character box types.
   Reverses: add a negation to the ignore file and drop the force-add.

4. Robustness targets do not route through an `invariants` module. `docs/testing.md` requires a
   law to be written once in `invariants` and driven from both tiers — but its own per-crate
   table distinguishes "☑ laws" from "☐ driver" for gamut-ifd, so an entry-point driver is a
   recognised second thing, not a law needing a home. The primary oracle here is the engine's
   (panic / hang / malloc limit), which no function can express, and adding `invariants` modules
   to four more crates is four `src/` files outside the manifest.
   Rejected with evidence: stating the checks in `invariants` anyway. It would put the
   `gamut-ifd` differential in one place (good — filed as #566) but require new `test-support`
   features and `invariants` modules in gamut-tiff, gamut-dng, gamut-isobmff and gamut-heic, none
   of which the manifest reaches.
   Reverses: hoist per #566, then follow the same shape for the other four crates.

5. The two defects the targets found are filed, not fixed, and their CI rows are left red.
   Decision 1's "Will not" says so outright. Narrowing `tiff_decode`/`dng_decode` so their rows
   pass would be weakening a check to make a gate report green; the Extended lane is post-merge
   with `fail-fast: false`, so a red row costs no pull request and is the signal the tier exists
   to produce.
   Reverses: fix #563 and #564, at which point both rows go green with no change here.

6. `docs/testing.md`'s "Fuzz entry point" column is left stale rather than widening the manifest.
   Six rows there now say "not yet wired (#264)" about targets this change wires. The file is
   outside the manifest and the deliverable does not need it, so it is filed (#565) rather than
   edited. This is a manifest revision request: the one extra path wanted is `docs/testing.md`.
   Reverses: revise the manifest to include it and flip the six marks.

7. The `gamut-ifd` robustness addition is one corpus entry, not a new test. The manifest names
   "the 0xFFFF entry-count case". A first draft made it a standalone boundary test asserting the
   body bound at both ends; the `>=` half was then measured against the mutant it would kill
   (`stream.rs`'s `if body_end > len` → `>=`) and found already caught by seventeen inline tests,
   so it was redundant by the repository's own standard. What remains is the hostile count added
   to the existing named-case list plus an assertion on the error text — the smallest change that
   covers the gap, in the shape that file already uses.
   Reverses: none wanted; the boundary is covered.

Appended in round 2:

8.  A tautological check is repriced, not silently deleted. Three assertions compared a function
    against the expression its own body is. Deleting them outright would lose a claim worth
    keeping -- that `read` goes on delegating, that `annex_b` goes on being its two halves -- so
    each is kept where it is bounded and free, and NAMED a structure pin rather than a
    differential, in the target, the README, the register and this body.
    Rejected with evidence: hoisting them into `invariants` as #566 proposes. `docs/testing.md`
    gives `invariants` one job -- a law driven from both the property tier and the fuzzer -- and a
    function with the same answer for every input is not a specification. Falsified directly:
    breaking `read_file` produced no report.
    Reverses: none wanted. The superseding argument is filed as #592.

9.  The `gamut-ifd` wrapper pin lives in the deterministic suite, not in the fuzz tier. It cannot
    fail on any input, so a search for a counterexample searches an empty space at half of every
    execution. `crates/gamut-ifd/tests/robustness.rs` already drives both doors over the
    exhaustive corpus; the fuzz copy is removed and that one relabelled.
    Reverses: none wanted; the pin is unchanged in strength.

10. `dng_decode`'s digest cross-check becomes a classified outcome rather than a panic. Stated
    against my own interest: on today's code the containment holds, so no input reaches the
    `expect`. That makes it pure false-positive surface with no detection power to trade away, on
    a tier with no human at the other end.
    Rejected with evidence: keeping the panic until it fires. A false crash in an unattended lane
    is indistinguishable from a real one until someone minimises it.
    Reverses: restore the assertion if `verify`'s contract is ever widened to promise the
    containment.

11. The register is revised here rather than filed. `docs/testing.md`'s own rule three lines above
    the table -- a row changes only in a pull request that says why -- makes this the place, which
    reverses decision 6 of round 1. #565's other half (the ignore rules) is untouched and the
    reference stays `Refs`.
    Reverses: decision 6.

12. The two red rows stay red and stay unmarked; the expectation is documented instead. A marked
    row that reports green while its target crashes is exactly the weakening this change refuses,
    and `continue-on-error` on a list nobody prunes silently retires a gate. What is fixable
    without weakening anything is legibility, so the expectation is written where a reader meets
    it and the structural alternative -- a separate expected-to-fail lane -- is filed as #593.
    Rejected with evidence: `continue-on-error: true` on the two rows. It needs its own guard
    asserting the listed targets still fail, which is a second thing to keep in step; #593 records
    that requirement.
    Reverses: none.

13. The pull-request path gets a build-only step and a text drift guard, and neither is a mise
    task. Both are delivered inside the scope this entry holds: the guard is a script under
    `tooling/gamut-fuzz/`, and the compile is one `cargo check` line in the workflow, mirroring
    `check-dng-real`'s command exactly. A `[tasks.*]` entry would have needed `mise.toml`, which
    is outside the scope, and freezing the entry over an ergonomic wrapper would have left a
    442-line hole open. The guard is discoverable from the README and runnable directly.
    Reverses: add `[tasks.check-fuzz]` and `[tasks.check-fuzz-matrix]` to `mise.toml` and have CI
    call those, once a manifest revision permits it.

Appended in round 3:

14. `tiff_decode`'s two advertised checks are dropped and the target re-anchored, rather than
    repriced as pins. Neither could fail by input: the page bound compares a count against the
    expression that produces it, and the geometry equality reads both sides from the one shared
    tag reader `decode_page_samples` names. Round 2's rule -- a tautology is kept where it is free
    and named a pin -- does not save either, because the page bound costs a whole extra parse per
    execution and the geometry equality was the target's headline claim. What replaces them is a
    count the geometry reader does not produce: the samples the decode physically yielded, against
    the declared dimensions and the layout's channel count.
    Rejected with evidence: keeping the geometry equality alongside as a pin. Its reach is the few
    lines the sample count already covers, and listing it beside a live check is what made two
    documents claim more than the target proves.
    Reverses: none wanted; the injection that fires the new check is recorded in the module doc.

15. The "a check can fail" rule gets re-runnable evidence, not a guard. Each robustness target's
    module doc now names the injected defect, the message it produced and the command that
    reproduces it -- which is what both reviewers did by hand.
    Rejected with evidence: a doc-shape lint requiring the paragraph to exist. It would check that
    the claim is present, never that it is true, and a gate that cannot fail for the reason it
    exists is worse than prose, since it buys the appearance of enforcement. The three options and
    why mechanising this is hard -- panic reachability, `cargo mutants` not reaching an excluded
    crate, a syntactic lint catching none of the five real cases -- are filed as #602.
    Reverses: adopt option 2 there (a committed injection harness) if the rot cost is acceptable.

16. `mise.toml` enters the manifest, for two task entries and nothing else. Decision 13 kept both
    new pull-request steps as inline workflow commands because the manifest did not reach
    `mise.toml`; the cost is that a contributor cannot run what CI runs without reading YAML, and
    the two copies drift. Every comparable gate here is a task. `check-fuzz` and
    `check-fuzz-matrix` now exist and the workflow calls them by name.
    Reverses: decision 13.

17. The fuzz job's cadence is decided, and decided as unchanged. 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 Actions minutes are free for public
    repositories while the matrix grows parallel runners rather than wall time.
    Rejected with evidence: moving the job to a schedule or to manual dispatch. It would cut total
    search with nothing accumulated to compensate, and it would also settle #593 by side effect --
    a per-push aggregate cannot be red if the job does not run per push -- which is deciding a
    filed workflow-topology question inside a lane scoped to the targets.
    Reverses: persist the corpus (#603), then re-open cadence and the `-max_total_time` budget
    together.

18. The `heic_container` accessor check keeps its one-function reach, recorded as a limitation.
    `boxes`, `appended_stream` and `trailer` are each a three-line `filter_map`/`find_map`, so the
    check sees a defect there and nothing deeper. It is live (injected: `boxes()` skips the `ftyp`
    box), it costs one pass over a list the target already walks, and the accessors are what
    callers actually use.
    Rejected with evidence: reshaping it into something deeper. The tiling check beside it already
    has the deep reach -- it sees the whole parse -- so a second deep check here would duplicate
    reach rather than add it.
    Reverses: none wanted.

19. Both `tiff_decode` assertions are kept: the geometry pair and the sample count. The pair
    equality is not a tautology -- it fires on any downstream stage that rewrites the geometry it
    hands on, which is the class the row advertises -- and it costs one comparison on values
    already in hand. The sample count stays beside it, relabelled a structure pin at the site.
    Rejected with evidence: keeping only the sample count, which decision 14 chose. Executed both
    ways on the committed seeds: a transposition injected where the decode builds its
    `DecodedImage` gives exit 0 under the sample count alone and reports immediately under the
    pair. `convert_from_raw` allocates its output as `ImageBuf::<Q>::zeroed(src.dims)`, so the
    count is the dimensions' own product for every input and no search can reach it.
    Reverses: decision 14.

20. `docs/testing.md` is corrected first, before the target. The wrong rule had been generalised
    from one target into a document normative for the whole workspace, telling every future author
    to anchor on a count that is dims-derived by construction. It now states the boundary and the
    three shapes that violate it. A wrong rule propagates further than a wrong test.
    Reverses: revert the doc commit.

21. "Can fail" is written down with the qualifier F2 turns on: a check can fail if some defect
    makes some *input* fail it, and the defect must be one a file can express. A check reachable
    only by a defect no input can produce belongs in the structure-pin column. That is what
    separates a live check from `isobmff_boxes`'s cursor-advance assertion, and it was written
    nowhere.
    Reverses: none wanted.

22. The cursor-advance check and the empty-segment sub-assertion move to the structure-pin column.
    `next_box` consumes its 8-byte header through `take` before any success return, so no declared
    box size can stall the cursor. Executed with the `size < header_size` guard removed -- the
    exact defect the assertion names: no report on the committed seed, and none in 3 162 778
    executions over 121 s. The control (a `self.pos` rewind) reports on the first seed.
    Reverses: restore the rows to the check column.

23. The duplicated tiling check is dropped from `heic_container`. `HeifContainer::parse` stores
    `gamut_isobmff::walk_segments(data)?` verbatim, so the same injection produced the identical
    message in both targets -- two ten-minute runners searching one function at a measured-zero
    marginal yield. The target keeps the accessor agreement and the `data()` containment, which are
    genuinely its own.
    Rejected with evidence: keeping it for defence in depth. It is reached only for files
    `gamut_isobmff::read` also accepted, so its input set is strictly narrower than the sibling's;
    there is no input it can see that the sibling cannot.
    Reverses: restore the check.

24. One injection per *listed check*, not per target row, and the audit that rule produces is
    published rather than left implicit. Rows with two checks had been getting one injection, which
    is how two dead halves survived three rounds. The check set is derived from the README's own
    table mechanically -- split each row's last cell on its own bold `and` -- so the count is not a
    reading of the prose: six rows, ten checks, sixteen injections.
    Rejected with evidence: leaving the evidence distributed across the six module docs, where it
    already was. That is exactly the arrangement under which a check with no injection stayed
    invisible for three rounds; a coverage table is the artefact that makes a hole visible without
    reading six files. Deriving it mechanically found one on the first pass -- `boxes()` had only
    its under-reporting direction injected -- and the over-reporting direction is now recorded.
    Reverses: revert to per-row injections and delete the table.

25. A seed is added whenever a listed check has no witness in the corpus. Four now exist for that
    reason and each names the check it feeds. Each was first observed to report *nothing* under its
    check's injection on the seed set that preceded it, so the seed is load-bearing rather than
    decorative.
    Reverses: none wanted.

26. `heic_container` gains the containment check its doc already promised, rather than the doc
    being weakened to match the code. It is the cheap pointer-range comparison it appeared to be --
    one pass over lists the target already walks -- and it is the promise that makes the crate
    zero-copy: an accessor that re-allocated on the way out would satisfy every count and still
    break it. Injected in both shapes: a copying `data()` and copying `boxes()` bodies.
    Reverses: drop the check and correct the doc instead.

27. The matrix stays at nine rows. Shrinking a test matrix mid-run to buy back queue time trades
    coverage for a cost that #603 already owns -- persisting the corpus is what changes this tier's
    yield per minute, and until it lands a shorter matrix is simply less search. A target is
    removed when its checks stop having reach, which is what the audit decides, and never to make a
    job finish sooner.
    Reverses: none before #603 lands.

Unresolved review notes

None outstanding. Things a reader should know rather than discover:

  • The expected-red Extended rows. Documented above and in tooling/gamut-fuzz/README.md;
    restoring the aggregate's meaning is ci(fuzz): keep Extended's aggregate status meaningful while two fuzz targets are expected to fail #593, and both rows go green when fix(tiff): page_info panics on SamplesPerPixel = 0 #563 and fix(dng): raw decode sizes its buffer from declared geometry, not from the file #564 close.
  • The F3 removal is prospective. No input on today's code reaches the panic that was removed;
    the containment between decode and verify_new_raw_image_digest holds by accident of the two
    bodies, not by contract. Recorded so nobody reads the change as a fixed reproduction.
  • tiff_decode's live class is narrower than the table row sounds — resolved in round 4, and
    the note was wrong on a detail.
    The sample count was the tautology, not the narrow check; the
    live class is a stage that rewrites the geometry it hands on, seen by the restored pair equality.
    A defect that produces the wrong volume while leaving the dimensions alone is still turned into
    a typed error before a caller sees it, but by RawImage::new alone — ImageBuf::new is never
    called on that path. Both statements are now measured rather than reasoned; see round 4.
  • The "can fail" rule is enforced by review, not by a gate (test(fuzz): decide whether "a robustness check can fail" can be mechanically guarded #602). What is mechanised is the
    three-list drift guard and the compile step; that a listed check is live rests on the injection
    recorded in each module doc, which a reader can re-run but nothing runs automatically. Round 4
    narrows the manual part: the coverage of that evidence is now a published table derived from
    the README's own rows, so a check with no injection is visible without reading six module docs —
    but whether an injection was faithful still rests on a reader re-running it.
  • The fuzz tier is compiled by CI and never linted (ci(fuzz): the fuzz tier is compiled by CI but never linted #615). Found in round 4 by running Clippy
    over the crate's manifest under -D warnings: five findings, all five in the three pre-existing
    law targets, none in the six robustness targets this change adds. Nothing gates on it today —
    check-fuzz is cargo check — so it is filed rather than fixed here, outside this round's
    decisions.

The tier so far drove `invariants` laws over normalised inputs. This adds
the other half `docs/testing.md`'s per-crate table asks for: a driver on
each crate's untrusted-input surface, handed the engine's bytes unchanged.

Six targets — `ifd_read`, `tiff_decode`, `dng_decode`, `isobmff_boxes`,
`heic_container`, `heic_hvcc` — one per entry point rather than per crate,
since gamut-heic's container walk and its `hvcC`/NAL layer are independent
surfaces. Their primary oracle is the engine's own: each crate is
`forbid(unsafe_code)` and promises a typed error, so a panic, a hang or an
allocation past the malloc limit is the defect. Each target adds one check
beyond that — reader agreement and the dual-ledger audit for IFD, the
segment tiling for the two container walks, the documented `annex_b`
composition for the NAL layer — so a defect that produces no crash is still
visible.

`corpus/` carries a curated seed set, force-added past the ignore that
exists to keep the engine's search state out of the tree: the malformed
cases enumerated on the issue for `ifd_read`, and one small well-formed
file per decoder target so it starts from something that reaches its pixel
path.

Refs #264
The corpus already held a directory whose entry count is truncated
mid-word. It did not hold one whose count is whole and well-formed but has
no entry bytes at all behind it, which is the case that reaches the point
where the directory would be sized from the count: 65 535 entries claimed
in a ten-byte file.

Asserted on the error text rather than `is_err`, so a guard that stops
bounding the count against the source cannot be masked by a later failure.

Refs #264
Six new rows in the existing fuzz matrix, so each entry point gets the
whole ten-minute budget in parallel rather than the job's wall time growing
with every surface added. The lane stays off the per-PR path for the reason
docs/testing.md gives: the coverage job is the only gate that runs tests, so
anything in it must be bounded and reproducible, and a coverage-guided
engine is neither.

Refs #264
The seeds are tracked past the ignore rule; the engine writes its own
findings beside them and those stay untracked, which is the split the
ignore exists for. A second blanket force-add would collapse it — a few
minutes of one target adds a couple of hundred files.

Refs #264
`ifd_read` advertised a differential between two public doors: `read(data)` against
`IfdReader::open(data)?.read_file()`. `reader.rs` *defines* `read` as exactly that
expression, and `read_tree` likewise, so the two sides were one function call written
twice and the comparison could not fail for any input. Falsified before changing
anything: making `read_file` drop the last directory of a multi-directory chain and
running a hand-built two-directory file produced no report at all, because both sides
dropped it. `stream.rs` says as much in its own module docs -- that module is the
parser, and the slice functions are thin wrappers over it, so there is exactly one
directory-body walk to disagree about.

`heic_hvcc` had the same shape twice over: it asserted that `annex_b` equals the
concatenation of the two calls its own body makes, and that two `is_ok()` values
computed from the same expression are equal.

The claim about two bodies agreeing is worth keeping, but it is a structure pin, not a
search: one bounded run answers it, and repeating it nine thousand times a second
searches an empty space. So

- `ifd_read` drops both duplicate parses and promotes the dual-ledger byte audit -- the
  check the engine cannot make, and the one that can actually fail -- to the headline.
  The wrapper pin stays where it is bounded and exhaustive, in
  `crates/gamut-ifd/tests/robustness.rs`. Throughput measured over 120 s went from
  roughly 12 000 exec/s to 20 596 exec/s.
- `heic_hvcc` drops the trivially-true `is_ok()` comparison, promotes the append
  contract -- which nothing in any of the three emitter bodies makes true by
  construction -- to the headline, and folds the remaining composition pin into the
  append check's buffer so it costs no third emitter pass. 59 936 exec/s over 120 s.

Refs #264
`dng_decode` asserted, with `expect`, that any file which decodes also reaches a digest
verdict. `verify_new_raw_image_digest` is not a second call to `decode`: it re-reads the
container and then, on the file's own `Compression` code, either re-decodes the raw
samples or walks the compressed chunk grid -- a route `decode` never takes. Only the
first of those is a subset of decode's work, and neither function's documented contract
promises the containment; `verify`'s own docs bound its errors by `decode`'s for
*lossless storage only*.

An `Err` from that call therefore has to be a classified outcome, not a panic. On a tier
that runs unattended a false crash is indistinguishable from a real one until a human
minimises it, and this one was the target's own stated most-likely false positive. The
`Err` arm now returns: a case with nothing to compare, rather than a report.

Nothing that could fail is lost. The self-consistency check on the raw image that
arrives is untouched, and the digest comparison survives whole -- relabelled as the
structure pin it is, since both sides read `NewRawImageDigest` out of IFD 0 with the
same expression.

Refs #264
The case list gained an assertion on the error *text* -- that a 65 535-entry count in a
ten-byte file is refused with "IFD extends past end of file" -- while the test was still
named for surviving without a panic. A test's name has to cover what it fails for, so it
is now `specific_malformed_inputs_yield_typed_errors_not_panics`.

The module docs also called the two-reader comparison a differential. It is not one:
`read` is defined as `IfdReader::open(data)?.read_file()`, so both sides are the same
parser reached twice. It is a structure pin on the wrappers going on delegating rather
than growing a second directory walk with a second set of hostile-input guards to drift,
and it is named as one now. Being unfalsifiable by input, this bounded exhaustive corpus
is where it belongs, rather than in the unbounded fuzz tier that was also running it.

Refs #264
…st path

Two holes, both silent.

`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 and every check stays green until the
next Extended run on master. That is the hole `check-dng-real` already closes for the
other excluded tier, in the same job, for the same reason, so this follows it exactly:
`cargo check --manifest-path tooling/gamut-fuzz/Cargo.toml --all-targets`. Build-only --
no nightly, no sanitizer, no engine -- so nothing unbounded reaches the per-PR path and
the rule in docs/testing.md stands. The driven crates are already built by the Clippy
step above it.

A target also exists in three hand-maintained places nothing reconciled: its file under
`fuzz_targets/`, its `[[bin]]` entry, and its row in `extended.yml`'s matrix. Miss the
third and the target is written, reviewed, committed -- and never run, with nothing
anywhere reporting it. `check-targets.sh` fails on any of the four mismatches, and on a
`[[bin]]` whose `name` disagrees with its own `path`. It is pure text -- no cargo, no
toolchain -- so it runs in the cheap Format & Metadata job. Each of the five failure
modes was fault-injected and confirmed to fire.

Refs #264
The per-crate selection table's own rule is that a row changes only in a pull request
that says why. This is that pull request: six entry points it marked "not yet wired"
now have a driver in `tooling/gamut-fuzz`, so gamut-ifd's driver mark, gamut-tiff,
gamut-dng, gamut-isobmff and gamut-heic's two both flip, and the legend gains the
meaning of the tick it never defined.

Two rules the tier needed and the file did not state are added beside them. First, the
per-PR path carries the *compile* half of an excluded fuzz tier, plus the drift guard
over the three lists that describe the target set -- neither runs an engine, so the
"bounded and reproducible" rule above is untouched. Second, a robustness target is not a
law, does not route through `invariants`, and any check it adds beyond the engine's own
oracle must be able to fail: comparing a wrapper against the expression its own body is
is a tautology, not a differential, and belongs in the crate's bounded suite as a
structure pin if it is worth keeping at all.

Refs #264, #565
…rows

The targets table sold three checks that cannot fail. They are repriced rather than
merely deleted: a claim about two bodies agreeing is a structure pin, kept where it is
free and named as one, and the table now states the rule that put them there -- a check
is listed only if it can fail.

Two rows fail today, on #563 and #564, and a reader meets that first in Extended's
aggregate status, which stays red on every push to the default branch until both close.
Narrowing either target to make its row green would be weakening a check to make a
report green, so instead the expectation is written where it will be met: in the README,
and in a comment on the job that produces it. Restoring the aggregate's meaning is #593;
the job's cadence, inherited rather than chosen and now nine parallel ten-minute runners
per push, is #594.

Also: two paragraphs annotating the law-target table had been orphaned under the seed
section by a heading added between them; the seed set is described by what it actually
contains, `corpus/tiff_decode/` holding two files and not one; and the note that a
feature added for one target is on for every target, because Cargo resolves features
once per crate -- `bigtiff` went in for `ifd_read` and the pre-existing `ifd_read_ledger`
is built with it too.

Refs #264
Both checks `tiff_decode` advertised were unfalsifiable by input.

The page-index bound compared a count against the same expression that
produces it: `page_count` is `read(data)?.ifds.len()` and `info_page` is
`read(data)?.ifds.get(page)`, so the claim reduced to indexing a vector
one past its own length, and the defect it named -- a count that
over-reports the chain -- moves both sides together. It is dropped.

The geometry equality saw only the lines copying a described number into
a decoded one: `decode_page_samples` states outright that "everything the
page declares comes from one shared reader", so a transposition inside
`info::page_info` hands every caller a transposed image and the
comparison stays quiet. It is replaced by a count the geometry reader
does not produce -- the samples the strip/tile assembly, the predictor
pass and the photometric unpack physically yielded -- against the
declared dimensions and the channel count of the layout asked for.

Injected to prove it fires, and recorded in the module doc so a reader
can re-run it: trim the last row at the point the `DecodedImage` is
built and report `height - 1`, a crop stage that describes what it
cropped. Both `RawImage::new` and `ImageBuf::new` accept it, nothing
crashes, and the committed seeds alone report "page 0: decoded 54 samples
for the 6 x 4 x 3 the tags declare". Also recorded: the injection that
did *not* report (decoding one row more than declared), because the
assembly runs out of bytes and the page is refused -- which bounds what
the check reaches.

The remaining "a page that decodes must also describe" assertion cannot
fail either, since decoding calls the tag reader first; it is kept at
zero cost and labelled a structure pin at the site.

Refs #264
"This check can fail" was a reading of the code, and twice it was wrong.
Every robustness target's module doc now names the defect that was
injected to make its check fire, the message the target printed, and the
command that reproduces it -- all of them report from the committed seeds
alone under `-runs=0`, with no search:

  ifd_read       header claimed as `header_size() - 1` -> "parser read
                 bytes it never claimed", unclaimed_reads [7, len 1]
  isobmff_boxes  a box's segment recorded as `b.offset + 8..end` ->
                 "segment 8..24 leaves a gap or overlaps at 0"
  heic_container `boxes()` skips the ftyp box -> "boxes() disagrees with
                 the Box segments"
  heic_hvcc      `annex_b_parameter_sets` begins with `out.clear()` ->
                 "an annex_b emitter overwrote what was already in the
                 buffer"
  dng_decode     `new_cfa` pushes a sample past `check_sample_count` ->
                 "decoded raw holds 49 samples for 8 x 6 x 1 planes"

Two further notes a reader would otherwise have to derive. The
`heic_container` accessor check reaches exactly one three-line function
per accessor -- that is a limitation, not a flaw, since the accessors are
what callers use and the tiling check beside it is the deep one. And
`heic_hvcc`'s "no empty NAL unit" assertion cannot fail by input at all:
`NalUnitIter::next` errors on a zero length before it can yield an empty
slice, so it is labelled a structure pin at the site rather than counted
as a check.

Refs #264
A hand-maintained list can name the same target twice, and `comm -23`
reports the second copy as a line present on the left and absent on the
right. The guard then printed "a [[bin]] points at a file that does not
exist" for a file that does exist, and "CI names a target that cannot be
built" for a target that builds -- twice sending a reader after the wrong
thing.

Duplicates are now diagnosed first, by name, and both lists are
de-duplicated before the set comparisons so those keep saying what they
mean. `find` cannot produce a duplicate filename, so the files list needs
no such check.

Verified by injecting a repeated `[[bin]]` and a repeated matrix row:
both new messages fire, and the restored tree passes.

Refs #264
Both steps this change added to the pull-request path carried their
command inline in the workflow, so a contributor could not run what CI
runs without reading YAML -- and the two copies drift. Every comparable
gate in this repository is a mise task for that reason.

`check-fuzz` compiles the excluded fuzz tier (build-only: no nightly, no
sanitizer, no engine), mirroring `check-dng-real` for the other excluded
tooling crate. `check-fuzz-matrix` runs the drift guard that reconciles
the three hand-maintained lists describing the target set. The workflow
now calls both by name.

Refs #264
The README claimed "a check is only listed here if it can fail" while
listing two that could not, and the register carried the same rule. Both
now say what holds: five earlier entries were unfalsifiable, the two
`tiff_decode` ones are dropped rather than repriced, and the rule gains
the half that makes it checkable -- each target's module doc names the
injection that made its check fire, with the message and the command to
reproduce it. The two assertions kept for their pinning value are listed
as pins, not as checks.

The register also learns the shape that failed here: a comparison whose
two sides come from one reader is a tautology, so anchor a decode check
on what the decode physically produced rather than on the geometry the
probe read.

Cadence, answered rather than left open (#594): the job stays on the
workflow's trigger. Actions minutes are free for public repositories and
the matrix grows parallel runners rather than wall time, so the cost is
queue time on a post-merge lane that blocks no pull request; and
frequency is the wrong dial while nothing accumulates between runs --
each starts from the committed seeds and discards what the engine finds,
so running less often simply searches less. Persisting the corpus is what
would change the tier's yield, filed as #603, and the cadence and time
budget are worth re-opening after that lands. Changing the trigger would
also move the premise of #593, which is a question about the workflow's
shape rather than about this tier.

Whether "a check can fail" can be mechanically guarded at all is filed as
gate without being one, which is worse than prose.

Refs #264
…om it

`convert_from_raw` allocates its output as `ImageBuf::<Q>::zeroed(src.dims)`, so the
returned sample count is the dimensions' own product for every input. Asserting it
against the declared geometry is that comparison times a constant on both sides: a
stage that transposes the geometry it hands on passes it, and the target's module doc
claimed the opposite. Restore the dimension pair beside it as the live check, name the
sample count as the structure pin on `ImageBuf`'s constructor that it is, and record
the transposition injection that separates them.

Also drop the early-return branch driving `info`/`decode_page` after `page_count`
failed: all three begin with the same `read`, so the two extra calls fail at the byte
it already failed at.
`next_box` reads the 4-byte size and 4-byte type through `take` before any success
return, so `position()` has grown by 8 before a `RawBox` exists and no hostile box
size can fail "the cursor strictly advances". Every segment shape `walk_segments`
pushes is non-empty for the same reason. Both are structure pins, not checks beyond
the crash oracle, and are labelled as such. Record an injection for the tiling's
end-of-file half, which had none.
…sed containment

`HeifContainer::parse` stores `gamut_isobmff::walk_segments` verbatim and `segments()`
returns it unchanged, so the tiling check here searched the same function the sibling
`isobmff_boxes` target searches, over a narrower input set: the same injection produced
the identical message in both. Drop it, and make the check the module doc already
promised but the code never asserted -- every borrowed slice the accessors hand out
lies inside `data()`, which is what makes the crate zero-copy.
Every #264 seed puts IFD0 at offset 8, so byte 8 is read as the entry count and an
over-claimed header lands on a byte the ledger already holds: the "no claim unread"
half of the audit had no witness in the committed corpus and was left to the engine to
synthesise. A 22-byte TIFF pointing IFD0 at offset 16 leaves 8..16 as internal padding
nothing reads, and turns the over-claim into a report.
…r check

`heic-single-item.heic` carries neither, so an accessor returning `None`
unconditionally agreed with the segment list and reported nothing: one of the two
directions of each equality had no witness in the corpus. A HEIC with a second
top-level `ftyp` and one with a truncated trailing box header close both.
The append contract is claimed "on the success path and the error path", but the
well-formed seed's payload splits cleanly, so `annex_b_payload` never returns `Err`
for it and an emitter that unwinds the buffer when it gives up went unreported. The
same record with one NAL length prefix raised past the end of the payload closes that
half.

Take the prefix and tail slices with `get` rather than by indexing, so a truncating
emitter reports the assertion's own message instead of a bare out-of-range panic
raised inside the target.
…udit

The robustness table listed the sample count as `tiff_decode`'s check and the box
cursor as one of `isobmff_boxes`'s; neither can fail for any input, and
`heic_container`'s tiling row searched the same function as `isobmff_boxes`'s. Move
them to a structure-pin table, name each row's checks separately so a row with two
gets two injections, derive the count of dead entries from the list rather than
restating it, and document the #264 seed numbering, the four witness seeds and why the
matrix keeps its nine rows.
The paragraph told every future author in this workspace to anchor a fuzz target's
check on "the count of samples the decode physically yielded". That count is not
produced by the decode: `convert_from_raw` allocates its output as
`ImageBuf::<Q>::zeroed(src.dims)`, so it is the dimensions' own product and the
comparison is a tautology a transposition walks through. State the boundary the rule
actually needs -- a check can fail only if some defect makes some input fail it, which
two sides computed from one another can never do -- give the three shapes that
violate it, and require one injection per listed check rather than per target.
The rule "one injection per listed check" was stated but its result was not written
down anywhere, so a check with no injection stayed invisible until someone read six
module docs. Derive the check set from the robustness table mechanically -- split each
row's last cell on its own bold `and` -- and publish the ten checks it yields against
the injections that stand behind them. Deriving it that way exposed one hole the rule
already covers: `boxes()` is an accessor-versus-count equality with only its
under-reporting direction injected, so record the over-reporting one beside it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant