test(fuzz): fuzz targets for the parser entry points - #568
Open
justin13888 wants to merge 24 commits into
Open
justin13888 wants to merge 24 commits into
justin13888 wants to merge 24 commits into
Conversation
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
This was referenced Sep 10, 2026
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the parser-entry-point half of the fuzz tier (#264). The tier so far drove
invariantslaws over normalised inputs;
docs/testing.md's per-crate table also names, per crate, theuntrusted-input surface a fuzz driver should take, and every one of those was marked
"not yet wired". Six are wired here.
ifd_readgamut-ifdread,read_tree,read_auditedtiff_decodegamut-tiffTiffDecoder::{page_count,info_page,decode_page}width × height × Rgb8::CHANNELSsamples for the geometry the tags declaredng_decodegamut-dngDngDecoder::{decode,verify_new_raw_image_digest}width × height × planessamples, after every rewriting stageisobmff_boxesgamut-isobmffwalk_segments,walk_meta_children,read,BoxReader0..lenexactlyheic_containergamut-heicHeifContainer::parse0..lenexactly and every accessor agrees with that tilingheic_hvccgamut-heicHevcConfig::parse,annex_b*,validate_still_payload,iter_nal_unitsOne target per entry point rather than per crate: gamut-heic's container walk and its
hvcC/NALlayer are independent surfaces. These are robustness targets, so unlike the three law targets
they do not route through an
invariantsmodule — the primary oracle is the engine's own. Eachof these crates is
#![forbid(unsafe_code)]and promises a typed error on hostile input, so apanic, 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 existingextended.ymlfuzz matrix, and one case added togamut-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.
gamut-tiffpanics onSamplesPerPixel = 0. A 62-byte file reachesinfo.rs:122with an emptybitsvector: the guard above it comparesbits.len()againstsamples_per_pixel(0 == 0) andanyover an empty iterator isfalse, sobits[0]isreached unguarded.
index out of bounds: the len is 0 but the index is 0.gamut-dngsizes its raw buffer from declared geometry. An 872-byte file declaring60000 × 60000requestsmalloc(7200000000); the fuzzer-found 780-byte case requests 34 GB.gamut-tiffhas the guard this path lacks (MAX_IMAGE_BYTES). Worth noting how this isvisible: an oversized
Vec::with_capacityis virtual memory nothing touches, so the processpeaks at 3 MB resident and returns a clean
Err— measuring RSS finds nothing, and libFuzzer's-malloc_limit_mbis the only oracle that sees the request.Consequence to accept knowingly: the
Fuzz tiff_decodeandFuzz dng_decoderows of theExtended 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 rowwith 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/IfdReaderrather than assumed:01-truncated-header.tifTIFF: header too short— the two cases the issue lists separately are the same four bytes ("II" LE16(42))03-invalid-byte-order.tifTIFF: bad byte-order mark04-invalid-magic.tifTIFF: bad magic number05-ifd0-offset-past-eof.tifTIFF: read out of bounds [byte offset: 1000]06-truncated-entry-count.tifTIFF: read out of bounds [byte offset: 8]07-truncated-entries.tifTIFF: IFD extends past end of file08-value-offset-past-eof.tifTIFF: value offset out of bounds09a-circular-ifd-self.tif,09b-circular-ifd-two-node.tifTIFF: IFD chain loops— terminates, both the 1-node and the 2-node cycle10a-hostile-entry-count.tif,10b-…-bigtiff.tifTIFF: IFD extends past end of file— classic0xFFFFand the BigTIFFu64twin11-hostile-value-count.tifTIFF: field value out of bounds12-unknown-tag-preserved.tif13-unknown-field-type.tifThe 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 andsingle-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,testandmutants-diffran inside aMemoryMax=16Gsystemd scope withCARGO_BUILD_JOBS=2.__CARGO_TEST_ROOTis the documented workaround for cargo walking past anested worktree root when loading the
tooling/*manifests; it changes no manifest.cargo +nightly fuzz build --fuzz-dir tooling/gamut-fuzz --target x86_64-unknown-linux-gnu./tooling/gamut-fuzz/run.sh ifd_read <corpus> -- -max_total_time=180./tooling/gamut-fuzz/run.sh isobmff_boxes <corpus> -- -max_total_time=180./tooling/gamut-fuzz/run.sh heic_container <corpus> -- -max_total_time=180./tooling/gamut-fuzz/run.sh heic_hvcc <corpus> -- -max_total_time=180./tooling/gamut-fuzz/run.sh tiff_decode <corpus> -- -max_total_time=180./tooling/gamut-fuzz/run.sh dng_decode <corpus> -- -max_total_time=180out-of-memory (malloc(34225522680)), filed as #564cargo test -p gamut-ifd --all-features --test robustnessMISE_TASK_RUN_AUTO_INSTALL=false mise run fuzz heic_hvcc -- -max_total_time=5__CARGO_TEST_ROOT=… mise run fmt-check__CARGO_TEST_ROOT=… mise run fmt-tooling-checkmise run check-testsmise run check-commitsmise run lint--all-targets --all-features -D warnings, 15 m 55 smise run testmise run check-release-depsmise run check-ffi-featuresmise run mutants-diffNo mutants to filter: the onlycrates/change is a test file, which produces no mutants)Two harness defects were found and fixed before committing, both by the targets themselves:
ifd_readcompared two parses withassert_eq!.TiffFilederivesPartialEq, notEq,because a
FLOAT/DOUBLEfield holdsf32/f64; the engine found aNaNwithin a minuteand the target reported two identical parses as a disagreement. It compares the
Debugrendering now, which is total.
tests/robustness.rshas the same latent defect — its fixturessimply 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 bothtiers one copy of the differential.
input, where an empty list already tiles
0..0. Both now walk a cursor, which states thewhole 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
tooling/gamut-fuzzis workspace-excluded, socargo test --workspacenever builds it; the onlycrates/change is one byte string and oneassertion in an existing test.
gamut-tiff,gamut-dng,gamut-isobmff,gamut-heic) andbigtiffongamut-ifd. All are dev-tier and workspace-excluded; no shippedmanifest changes, so
check-release-depsandcheck-ffi-featurestopology is untouched.tooling/gamut-fuzz/corpus/ignore byforce-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 inround 1 as the most likely false positive, is gone — see round 2 below. An
Errfrom thedigest route is now a classified outcome.
cargo checkstep 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-cameraseed corpus is not, and is filed as #567. Filed from this work:
fix(tiff):page_infopanics onSamplesPerPixel = 0(found bytiff_decode)fix(dng): raw decode sizes its buffer from declared geometry (found bydng_decode)chore(fuzz): record the wired entry points indocs/testing.md; express theseed-corpus exception in the ignore rules (both paths outside this change's manifest)
test(ifd): hoist the two-reader differential intoinvariants; stop comparing parseswith
PartialEqchore(fuzz): decide whether a real-camera seed corpus earns a fetch tasktest(ifd): do not hoist the two-reader comparison intoinvariants— it is atautology (supersedes test(ifd): hoist the two-reader differential into invariants, and stop comparing parses with PartialEq #566; this run cannot edit an existing issue)
ci(fuzz): keep Extended's aggregate status meaningful while two fuzz targets areexpected to fail
ci(fuzz): choose and record a cadence for the fuzz job as the target matrix grows(answered in round 3 below; the answer is "unchanged", with its argument written into the README
and the job comment. The issue itself is left untouched — this run does not edit existing
issues.)
test(fuzz): decide whether "a robustness check can fail" can be mechanically guardedci(fuzz): persist the fuzz corpus between Extended runs, so each run is not a cold startci(fuzz): the fuzz tier is compiled by CI but never linted (found in round 4; the fivefindings are all in the pre-existing law targets, none in the six robustness targets added here)
#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 tautologycrates/gamut-ifd/src/reader.rsdefines the slice door as the streaming one:The "other door" the target compared against was character-for-character that body.
src/stream.rssays so itself: "This module is the parser … there is exactly onedirectory-body walk." Falsifier executed:
read_filewas made to drop the last directory of amulti-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, whosesurviveshelper alreadydrives 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 shapeHevcConfig::annex_b's body isannex_b_parameter_sets(out); annex_b_payload(payload, out), soasserting 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 theheadline —
annex_b,annex_b_parameter_setsandannex_b_payloadall document that bytesalready 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 reusingcaller 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 F2Applying the rule consistently turned up a third:
dng_decodecompared the digest verdictagainst
decoded.new_raw_image_digest, but both sides readNewRawImageDigestout of IFD 0 withthe identical expression, so
verdict == Absent⟺ the field isNoneby construction. Repricedas 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 theone stated in the table: the raw image that arrives, after linearisation and crop handling, holds
exactly
width × height × planessamples.F3 (medium) —
dng_decodecould report a false crashverify_new_raw_image_digestis not a second call todecode: it re-reads the container and then,on the file's own
Compressioncode, either re-decodes the raw samples or walks the compressedchunk 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-crashgenerator, on a tier that runs unattended where a false crash is indistinguishable from a real one
until a human minimises it.
Resolved: the
Errarm returns — a case with nothing to compare. Stated honestly: on today's codethe containment happens to hold (the lossy branch does strictly less work than
decode_image_datadoes 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. Thisis 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 crashesis the weakening this change exists to refuse. Instead the expectation is written where a reader
meets it —
tooling/gamut-fuzz/README.mdunder its own heading, and a comment on the job thatproduces 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-fuzzis workspace-excluded and nothing depends on it, so no gate built its 442lines 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.shreconciles the three hand-maintained lists — the files underfuzz_targets/, the[[bin]]entries, and theextended.ymlmatrix — and fails on a[[bin]]whose
namedisagrees with its ownpath. 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 (
bigtiffwent in forifd_read; Cargoresolves features once per crate, so the pre-existing
ifd_read_ledgeris built with it too); theREADME'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, anuncompressed strip and an LZW strip, which enter the decoder through different code); and
gamut-ifd's malformed-input test is renamed tospecific_malformed_inputs_yield_typed_errors_not_panics, covering the error-text assertion itgained.
Round-2 validation
Every command below completed in this run, from the worktree.
lint,testandmutants-diffraninside a
MemoryMax=16Gsystemd scope withCARGO_BUILD_JOBS=2.cargo +nightly fuzz build --fuzz-dir tooling/gamut-fuzz --target x86_64-unknown-linux-gnu./tooling/gamut-fuzz/run.sh ifd_read <scratch> <seeds> -- -max_total_time=120./tooling/gamut-fuzz/run.sh heic_hvcc <scratch> <seeds> -- -max_total_time=120./tooling/gamut-fuzz/run.sh dng_decode <scratch> <seeds> -- -max_total_time=90out-of-memory (malloc(4326684768)), i.e. #564 still fires after the F3 change./tooling/gamut-fuzz/check-targets.shcargo check --manifest-path tooling/gamut-fuzz/Cargo.toml --all-targetscargo test -p gamut-ifd --all-features__CARGO_TEST_ROOT=… mise run fmt-check__CARGO_TEST_ROOT=… mise run fmt-tooling-checkmise run check-testsmise run check-commitsmise run lint--all-targets --all-features -D warningsmise run testtest result: oklines, 0 failuresmise run check-release-depsmise run check-ffi-featuresmise run mutants-diffNo mutants to filter(the onlycrates/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:
HevcConfig::annex_b_parameter_setsbegins without.clear()heic_hvcc:69— "an annex_b emitter overwrote what was already in the buffer", in under 30 sread_auditedclaims the file's last byte asClaim::Parsedwithout reading itifd_read:56— "parser claimed bytes it never read", with the offendingSegmentin the reportRawImage::new_cfapushes one extra sample pastcheck_sample_countdng_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 thenew
Fuzz target lists in stepstep (step 10,success); Clippy & Doctests ran the newFuzz tier compilesstep (step 13,success, 19 s, immediately afterReal-DNG conformance tier compiles). The 442 lines this change adds are now compiled by apull-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_decodeadvertised two checks, neither of which could failThe rule round 2 introduced was applied to two targets and not to the third — the one this body and
the README lead with.
page_countisread(data)?.ifds.len();info_pageisread(data)?.ifds.get(page). The assertion compared a count against the same expression thatproduces 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.
decode_page_samplessays outright that "everything the pagedeclares comes from one shared reader", so transposing inside
info::page_info— a real defecthanding 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_decodecheck the reviewerproved live.
Injection that proves the new check fires, recorded in the module doc so anyone can re-run it:
at the point
decode_page_samplesbuilds itsDecodedImage, trim the last row from the samplesand report
height - 1— a crop stage that describes what it cropped. It is internallyconsistent, so
RawImage::newandImageBuf::newboth accept it and nothing crashes; only thedeclared geometry contradicts it. The committed seeds alone report it under
-runs=0: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 rulein
docs/testing.md, and the table at the top of this body, while a row for which it was falseshipped 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_hvccasserted no NAL unit is empty.NalUnitIter::nextreturnsErr("zero-length NAL unit")forlen == 0before it can yield an empty slice, so no input reaches anOkthat failsit. 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_emptyon aslice 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 mutantsnever builds; and a syntactic lint forassert_eq!(f(x), f(x))catches none of the five real cases, every one of which compared twodifferent 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:
ifd_readheader_size() - 1unclaimed_reads: [Range { start: 7, len: 1 }]tiff_decodedng_decodenew_cfapushes a sample pastcheck_sample_countisobmff_boxesb.offset + 8..endheic_containerboxes()skips theftypboxheic_hvccannex_b_parameter_setsbegins without.clear()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 -23report the second copy as present on theleft and absent on the right, so the guard printed "a
[[bin]]points at a file that does notexist" 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 repeatedmatrix row: both new messages fire, and the restored tree passes.
F7 — the count
Five ☐ marks flip in the register, not six (the
gamut-ifdrow 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.tomlwas outside the manifest. The hazardthe 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-fuzzandmise run check-fuzz-matrixnow exist and the workflowcalls them by name.
Manifest revision, stated plainly: this widens the manifest by one file,
mise.toml, for thatreason 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:
matrix grows the number of parallel runners, not the job's wall time. The lane is post-merge
with
fail-fast: falseand blocks no pull request.the committed seeds and discards what the engine finds (the job uploads
artifacts/only onfailure, and
rust-cachecaches build artefacts, not the corpus). A run is therefore ten minutesof 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_timebudget are worth re-openingafter that, not before.
is a decision about the workflow's shape, not about this tier.
What deliberately did not change
Fuzz tiff_decodeandFuzz dng_decodefail on two genuine defectsthis tier found (fix(tiff): page_info panics on SamplesPerPixel = 0 #563, fix(dng): raw decode sizes its buffer from declared geometry, not from the file #564). The
tiff_decodepanic reproduced again this round, atinfo.rs:122, during the re-anchored target's own 120-second run.heic_containeraccessor check is not reshaped. Its reach is one three-line function peraccessor; that is a limitation, and it is now recorded as one in the module doc rather than
papered over. It is kept because the accessors are what callers use, and it costs one pass over a
list the target already walks.
Round-3 validation
Every command below completed in this run, from the worktree, on
test/264-parser-fuzz-targets.lintandtestran inside aMemoryMax=16Gsystemd scope withCARGO_BUILD_JOBS=2.__CARGO_TEST_ROOTis the documented workaround for cargo walking past a nested worktree root whenloading the
tooling/*manifests; it changes no manifest.cargo +nightly fuzz build --fuzz-dir tooling/gamut-fuzz --target x86_64-unknown-linux-gnurun.sh tiff_decode <seeds> -- -runs=0run.sh ifd_read <seeds> -- -runs=0run.sh isobmff_boxes <seeds> -- -runs=0run.sh heic_container <seeds> -- -runs=0boxes()run.sh heic_hvcc <seeds> -- -runs=0out.clear()run.sh dng_decode <seeds> -- -runs=0run.sh tiff_decode <corpus> <seeds> -- -max_total_time=120index out of boundsatinfo.rs:122, i.e. #563, at ~55 000 exec/s./tooling/gamut-fuzz/check-targets.sh[[bin]]and on a duplicated matrix rowmise run check-fuzzmise run check-fuzz-matrix__CARGO_TEST_ROOT=… mise run fmt-check__CARGO_TEST_ROOT=… mise run fmt-tooling-checkmise run check-testsmise run check-commitsmise run lint--all-targets --all-features -D warningsmise run testtest result: oklines, 0 failuresEvery injection above was reverted immediately and the tree re-verified clean before the next one;
git statusafter each shows only this change's own files.No
crates/file changed in round 3, somutants-diff,check-release-depsandcheck-ffi-featureshave the same inputs they had at the round-2 head; the round-2 results standand 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_decodecheck was strictly weaker than the one it replacedVerified, and the review is right.
convert_from_rawallocates its output asImageBuf::<Q>::zeroed(src.dims), soas_samples().len()iswidth × height × CHANNELSof thedimensions, 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:
DecodedImagedimensions with the sample count alone —exit 0, no report;"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::newslip beside it is corrected:ImageBuf::newis never called on that path,RawImage::newis the only gate.The more important half was the normative document.
docs/testing.mdhad generalised themistaken 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 checkReproduced.
BoxReader::next_boxreads its 4-byte size and 4-byte type throughtakebefore anysuccess return, so no declared box size can stall the cursor. With the
size < header_sizeguardremoved and the body taken as
size.saturating_sub(header_size)— the exact defect the assertionnames — the committed seed reports nothing, and 3 162 778 executions over 121 s report nothing.
The control fires on the first seed: rewinding
self.posgives "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 ownReproduced:
HeifContainer::parsestoresgamut_isobmff::walk_segments(data)?verbatim, and theb.offset + 8..endinjection produced the identical message in both targets. Dropped fromheic_container, which keeps what is genuinely its own (the accessor agreement and thedata()containment). Re-measured after the removal: under that same injection
heic_containernow exits0 while
isobmff_boxesreports, which is the intended split.F4, F5, F6, F7, F8 — the lower findings
ifd_read's "no claim unread" half was live but unreachable from every committed seed.corpus/ifd_read/padding-unread-claim.tifis a 22-byte TIFF pointing IFD0 at offset 16, leaving8..16as padding nothing reads; it turns the over-claim into a report.pointer-range comparison it appeared to be, so the check was written rather than the doc
weakened: every slice
boxes(),appended_stream(),trailer()andunknown_meta_boxes()handout must lie inside
data().tiff_decodeearly-return branch called two entry points that both re-enter thereadthat already failed. Removed; the comment says why.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 — ratherthan read by hand. Six robustness rows yield ten listed checks. The audit is published in
tooling/gamut-fuzz/README.mdso a check with no injection is visible without reading six moduledocs.
Deriving it that way immediately found one hole the rule already covered:
boxes()is anaccessor-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
invariantsfunction 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 oneapplied to a clean tree and reverted immediately after —
git statuswas verified empty betweenevery 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.
header_size() - 1ifd_readunclaimed_reads: [Range { start: 7, len: 1 }]header_size() + 1ifd_readunread_claims: [Segment { range: Range { start: 0, len: 9 }, kind: Header }]b.offset + 8..endisobmff_boxessegments.pop()before the returnisobmff_boxesDecodedImagedimensionstiff_decodecheck_sample_countdng_decodeboxes()skips theftypboxheic_containerboxes()yields everyBoxsegment twiceheic_containerappended_stream()returnsNoneheic_containerappended_stream()returnsSome(self.data)heic_containertrailer()returnsNoneheic_containertrailer()returnsSome(self.data)heic_containerdata()returns a leaked copyheic_containerboxes()yields leaked bodiesheic_containerannex_b_parameter_setsbeginsout.clear()heic_hvccmain-still-vps-sps-pps.binannex_b_payloadclears before returningErrheic_hvcctruncated-payload-nal.binNothing 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:
page_countover-reports the chain, with the droppedinfo_pageassertion restoredinfo.height + 1rowsself.posrewound to the box offsetb.offset + 8..endagainstheic_containerafter the duplicate was droppedpadding-unread-claim.tifappended-stream.heic/trailer.heictruncated-payload-nal.binThe 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
lintran inside aMemoryMax=16Gsystemd scope withCARGO_BUILD_JOBS=2;lintwaited for the machine's load average to fall below 32 first (it started at 28.97).
__CARGO_TEST_ROOTis the documented workaround for cargo walking past a nested worktree root whenloading the
tooling/*manifests; it changes no manifest.run.sh <target> <committed seeds> -- -runs=0, all six robustness targets, clean treerun.sh isobmff_boxes <corpus> -- -max_total_time=120with the box-size guard removedmise run check-fuzzmise run check-fuzz-matrixfuzz_targets/,Cargo.tomlandextended.yml__CARGO_TEST_ROOT=… mise run fmt-checkfmt-tooling-check)mise run check-testsconvco check origin/master..HEADmise run lint--all-targets --all-features -D warningscargo clippy --manifest-path tooling/gamut-fuzz/Cargo.toml --all-targets --keep-going -- -D warningsorigin/master, filed as #615Every injection was applied to a clean tree, run, and reverted immediately;
git statuswasverified empty between every pair, and the two temporary edits to the target files themselves (the
sample-count-only control and the restored
page_countassertion) were reverted the same way.mise run test,mise run mutants-diff,check-release-depsandcheck-ffi-featuresare notre-claimed for this round: round 4 changed no file under
crates/and no manifest, so their inputsare 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
Appended during delivery:
Appended in round 2:
Appended in round 3:
Unresolved review notes
None outstanding. Things a reader should know rather than discover:
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 containment between
decodeandverify_new_raw_image_digestholds by accident of the twobodies, not by contract. Recorded so nobody reads the change as a fixed reproduction.
— resolved in round 4, andtiff_decode's live class is narrower than the table row soundsthe 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::newalone —ImageBuf::newis nevercalled on that path. Both statements are now measured rather than reasoned; see round 4.
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.
over the crate's manifest under
-D warnings: five findings, all five in the three pre-existinglaw targets, none in the six robustness targets this change adds. Nothing gates on it today —
check-fuzziscargo check— so it is filed rather than fixed here, outside this round'sdecisions.