Skip to content

feat(exif): streaming ReadAt entry point + a report of what a lenient parse dropped - #522

Open
justin13888 wants to merge 15 commits into
masterfrom
feat/419-exif-streaming-reader
Open

justin13888 wants to merge 15 commits into
masterfrom
feat/419-exif-streaming-reader

Conversation

@justin13888

@justin13888 justin13888 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Issue #419 named two gaps in gamut-exif's reader, and this delivers both. Branch
feat/419-exif-streaming-reader, based on origin/master (6a75ec4).

A ReadAt streaming entry point. gamut-ifd shipped a lazy positioned reader in P9 (#252) —
ReadAt, IfdReader, Rebased — and gamut-exif exposed none of it: its only entry point took a
&[u8], so pulling the few kilobytes of EXIF out of a 300 MB raw file meant loading the raw file.
ExifReader::parse_from<S: ReadAt> now sits alongside parse, and parse is the &[u8] case of it
(a slice is a ReadAt source), so there is exactly one parse engine and the two entry points
cannot drift. The marker is detected through the source and the TIFF stream reached with Rebased,
so every offset the crate reads or hands back stays in EXIF's own frame of reference.

A drop report. The lenient reader discards a malformed Exif/GPS/Interop sub-IFD or an
out-of-bounds thumbnail range so the rest of a real-world blob still parses — but it was silent
about it, and follow removed the pointer tag before attempting the parse, so the evidence of
what had been there was gone by the time it failed. parse_with_report / parse_from_with_report
return a ReadReport alongside the Exif naming each discarded region: a DroppedRegion, the tag
that addressed it, the offset that tag carried, and a DropReason separating an address outside the
blob from bytes inside it that were not a directory. Both enums are fieldless with an explicit repr
and append-only discriminants; Dropped is Copy plain data behind accessors.

Nothing existing changes. parse keeps its exact signature and behaviour and stays silent; the
pointer removal moves after the read attempt but the removal itself is unchanged. The crate's
pre-existing tests for lenient/strict sub-IFD and thumbnail handling are untouched and pass as they
stood. Nothing in gamut-ifd was modified, and no dependency was added.

Two incidental improvements ride along in the thumbnail path: the range check moves from usize
slicing to a 64-bit bound against the source length, which drops an overflow branch only 32-bit
targets could reach, and it now bounds the range before allocating, so a hostile
JPEGInterchangeFormatLength cannot make the reader reserve 4 GiB.

Repaired after review (5a766f7). A read-only review of c49f83b proved parse's equivalence
unusually strongly — a truncation sweep of a marked and a bare fixture across {strict} ×
{require_marker}, ~2 600 cases, found 0 mismatches in error string or re-serialised bytes —
and raised two Mediums that the third commit fixes:

  • M1 — the report's completeness claim was false. A top-level directory past the 1st IFD was
    discarded silently (ifds.next() twice, then the iterator dropped), so a three-directory chain
    lost the third with an empty report. DroppedRegion::TrailingIfd + DropReason::Unrepresentable
    now name it at its own offset. Separately, gamut_ifd::decode_ifd does silently drop a
    shadowed duplicate-tag entry, which falsifies the premise of decision 7 — that is one layer below
    this crate, so it is filed as gamut-ifd: decode_ifd silently discards a shadowed duplicate-tag entry #528 and the documented promise is narrowed everywhere it
    appears rather than overclaimed.
  • M2 — a failing ReadAt source was blamed on the file. follow matched Err(_) without
    inspecting the error, so a source whose transport failed mid-parse returned Ok with sub-IFDs
    missing and a report calling structurally perfect directories malformed — worst for the
    network-backed sources this entry point exists to enable, and asymmetric with the thumbnail path,
    which already propagated. Both follow and the marker probe now key on Error::kind.

Repaired again after a second review (3242ea1, 44594fa, ba0a139). An independent review
verified every round-2 item closed by construction — it re-ran the mutation gate on the two new
files, re-ran a full corruption-and-truncation sweep against master, and twice failed to break the
new failure-propagation contract — and raised six items, all documentation or test reach, plus one
real silent loss:

  • A thumbnail offset with no length vanished. read_thumbnail's catch-all _ => None arm
    swallowed a 1st IFD carrying JPEGInterchangeFormat without JPEGInterchangeFormatLength: no
    bytes, no error, no report entry, in either mode. That is a silent loss inside the exact region
    this PR's report claims completeness over, so it is in scope. Exif 3.0 §4.6.9.2 Table 21 marks
    both tags mandatory for a compressed thumbnail, so half the pair is a malformed range rather than
    an absent thumbnail: lenient mode now names it with a new DropReason::Incomplete, strict mode
    rejects it as it already did an out-of-bounds range, and a length with no offset — which addresses
    nothing — stays silent.
  • parse_with_report claimed "a strict report is always empty", which ReadReport's own docs
    contradict in this same branch and which is false: a strict parse of a well-formed three-directory
    chain returns exactly one TrailingIfd entry. The sentence predates TrailingIfd and re-hid the
    very loss that variant was added to surface. Corrected, stated identically in both places, and
    pinned.
  • parse's error strings did change for marked blobs, which the previous body denied. Routing
    through IfdReader::open(source.rebased(base)) attaches the physical offset, so a diagnostic that
    was TIFF-stream-relative is now blob-relative. Both frames are kept deliberately and are now
    documented and pinned against each other; see Validation for the measurement.
  • The failing-source sweep did not reach every read site it claimed. Neither fixture had a
    thumbnail or a trailing directory, so read_range's fetch and record_trailing_ifds' chain
    re-walk were never swept. The fixture was extended rather than the claim narrowed.
  • Two stale 0-sentinel comments sitting above assertions of None, a README sample showing the
    pre-rewrite Display grammar, and two rustdoc links broken by the round-2 import narrowing.

Closing repairs after a third review (d650f62, d3e2207, 68a57fb). The third review
rebuilt the equivalence differential on its own fixture at 4 504 cases and reproduced this
branch's result independently — the same two families, the same +6 offset delta with zero residual,
the same 12 strict-only verdict changes, the same quadrant split, the same explanation for why 12
and not 16, and every doubly-successful case byte-identical. It re-ran the mutation gate to the
same numbers and closed all seven round-3 items. Four Lows remained, every one of them about what
the code claims rather than what it does:

  • The spec grounding was conditioned on a field the code ignores. Four sites cited Exif 3.0
    §4.6.9.2 Table 21 as making the thumbnail pointer pair mandatory. That table states each 1st IFD
    tag's support level per Compression column: JPEGInterchangeFormat (513) and
    JPEGInterchangeFormatLength (514) are M only under Compressed, and Nnot allowed to
    record
    — under all three uncompressed columns. So an uncompressed thumbnail carrying only an
    offset was rejected with a message naming a sibling the cited table forbids recording there
    (over-reach), while a compressed thumbnail carrying only a length is accepted (under-reach). The
    rejection does not actually rest on that mandate — an offset with nothing to size it addresses
    bytes that cannot be read, whatever Compression says — so every site is narrowed to say exactly
    that, and the message becomes JPEGInterchangeFormat offset with no length to size it. Whether
    the rule should instead be conditioned on Compression, and whether the length-only case should
    be rejected for symmetry, is a behavioural change with its own sweep to run: filed as gamut-exif: should the thumbnail JPEG pair rule be conditioned on Compression? #574,
    not decided here, because neither direction regresses the base.
  • DropReason::Incomplete is renamed DropReason::ThumbnailLengthMissing, before a release
    freezes its discriminant. It has exactly one call site and a name that described a shape, which
    invites unrelated reuse; a merely similar future defect gets its own variant on the
    #[non_exhaustive] enum instead. The variant is new on this branch and has never been published,
    so no released API changes. The rendered clause moves with it, from is addressed but never fully described to has no JPEGInterchangeFormatLength to size the read.
  • The #548 interaction has a second instance, and it is sharper. #548 names only the
    out-of-bounds case, but jpeg.is_some() gates the pointer removal for both lenient arms, so the
    new one leaks the pointer identically — and there the re-emitted blob still has an offset and
    still has no length, so a strict parse rejects a blob this crate itself just wrote. Executed
    and recorded beside the code and below; the writer fix stays in #548.
  • parse_from and parse_from_with_report carried no offset-frame note, though a streaming
    caller is the one most likely to correlate an error offset against bytes on disk. Both frames are
    now documented where they are produced, as they already were on the slice entry points.

One item is filed rather than fixed (#548, a dangling thumbnail pointer the writer re-emits —
pre-existing on master and a writer change outside this manifest), and the structural gap that let
two of these rot undetected is filed as #549.

No human approved this plan or any of the repairs. This is an unattended automated run; the
decision record below is what a reviewer reads in place of an approval.

Validation

Round-3 gates, at ba0a139f

Every command in this table was run in this worktree
at head ba0a139f, and each one completed in that run. It is kept as the record of that head; the
round-4 table below is separate and states plainly which gates were re-run at 68a57fb2 and which
were not.

Command Outcome Classification
CARGO_BUILD_JOBS=2 cargo test -p gamut-exif --all-features pass — 68 lib, 10 report, 1 streaming, 3 golden, 2 oracle, 3 doctests pass
CARGO_BUILD_JOBS=2 cargo clippy -p gamut-exif --all-targets --all-features -- -D warnings pass pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt applied pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass, exit 0 pass
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform" pass
mise run check-commits pass — "no errors in 8 commits" pass
convco check origin/master..HEAD pass — "no errors in 8 commits" pass
mise run lint (whole workspace, memory-capped scope) pass, exit 0 pass
mise run test (whole workspace, memory-capped scope) exit 0 — 204 suites ok, 0 failures pass
mise run mutants-diff (memory-capped scope) 65 mutants: 55 caught, 10 unviable, 0 missed, exit 0 pass
RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-exif --no-deps one error, unresolved link to exif_tags at src/tag.rs:4 pre-existing

__CARGO_TEST_ROOT is required for fmt-check in a nested worktree:
cargo otherwise walks past the worktree root to the primary checkout's Cargo.toml when loading the
tooling/* manifests and exits 101 on an untouched tree. It is an environment artefact, not a
manifest problem, and no manifest was changed to work around it.

The cargo doc row is classified pre-existing, demonstrated rather than asserted:
diff <(git show origin/master:crates/gamut-exif/src/tag.rs) crates/gamut-exif/src/tag.rs is empty,
so the file carrying the broken link is byte-identical to master. The two links this branch broke
(crate::stream, which stopped resolving when the module stopped being published, and
ExifError::MissingMarker, which stopped resolving when 5a766f7 narrowed the import) are fixed;
exif_tags is not this branch's to fix, and is filed with the missing gate as #549.

Cargo.toml was not touched, so check-release-deps / check-ffi-features / check-ffi-header do
not apply. mise run coverage was not run: nothing here adds a low-reach module — the diff adds one
enum variant, one match arm and six tests.

Round-4 gates, at 68a57fb2

The round-4 diff is confined to crates/gamut-exif/: one enum variant renamed, one error string,
and documentation. These gates completed in this run, at head 68a57fb2.

Command Outcome Classification
CARGO_BUILD_JOBS=2 cargo test -p gamut-exif --all-features pass — 68 lib, 3 golden, 2 oracle, 10 report, 1 streaming, 3 doctests pass
CARGO_BUILD_JOBS=2 cargo clippy -p gamut-exif --all-targets --all-features -- -D warnings pass pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt applied pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass, exit 0 pass
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform" pass
mise run check-commits pass — "no errors in 11 commits" pass
convco check origin/master..HEAD pass — "no errors in 11 commits" pass
mise run mutants-diff (memory-capped scope) 65 mutants: 55 caught, 10 unviable, 0 missed, exit 0 pass
RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-exif --no-deps one error, unresolved link to exif_tags at src/tag.rs:4 pre-existing

The cargo doc row is the same pre-existing failure as at ba0a139f, demonstrated the same way:
diff <(git show origin/master:crates/gamut-exif/src/tag.rs) crates/gamut-exif/src/tag.rs is empty,
so the file carrying the broken link is byte-identical to master. It is filed with the missing
gate as #549.

Workspace-wide mise run lint and mise run test were NOT re-run at 68a57fb2. The rename
touches a pub enum, so that needed deciding rather than assuming: DropReason is reachable from
outside the crate only through whole-module re-exports (pub use gamut_exif as exif in both
gamut and gamut-metadata), which cannot break on a variant rename, and a workspace grep for
DropReason across crates/, tooling/ and docs/ returns zero hits outside
crates/gamut-exif/. The variant itself is new on this branch — src/report.rs does not exist on
master — so nothing outside the branch has ever been able to name it. Both gates passed at
ba0a139f, and no Cargo.toml, feature, or cross-crate signature moved since. mise run mutants-diff did run at this head and rebuilds gamut-exif against its dependency graph, so a
compile break inside the crate would have surfaced there. check-release-deps / check-ffi-features / check-ffi-header do not apply:
no manifest and no C-surface type changed. mise run coverage was not run: the round-4 diff adds no
module and no new branch.

Executed, not asserted: the two interactions recorded below

Both were measured in this worktree rather than reasoned about, with throwaway harnesses that were
removed before committing.

  • The #548 second instance. A 1st IFD with Compression = 6 and JPEGInterchangeFormat = 4
    and no length, parsed leniently, re-serialised, re-parsed:

    report:                       [Dropped { region: ThumbnailJpeg, tag: Some(513), offset: 4,
                                             reason: ThumbnailLengthMissing }]
    pointer after to_bytes:       Some(Long([4]))
    strict parse of that output:  Err("invalid thumbnail: JPEGInterchangeFormat offset with no
                                       length to size it")
    

    So strict mode rejects the blob the crate itself emitted, which is the sharper form of gamut-exif: to_bytes re-emits a thumbnail pointer whose bytes the report says were dropped #548.

  • The blast-radius reason change. A blob carrying both a dangling ExifIFD pointer and an
    incomplete thumbnail pair, parsed with strict(true), run against both heads:

    origin/master's crates/gamut-exif/src:  InvalidIfd("Exif")  => "malformed Exif sub-IFD"
    this branch:                            BadThumbnail(...)   => "invalid thumbnail: ..."
    

    read_thumbnail runs before follow in parse_source, so the thumbnail's verdict now arrives
    first. Both heads reject; only the error identity moved.

The equivalence sweep, measured at ba0a139f

The previous body claimed 0 mismatches in error string or re-serialised bytes for parse. That
claim was wrong, and the correction is measured rather than argued. A harness dumps parse's
observable answer — the re-serialised bytes for an Ok, the rendered error for an Err — for a
structurally rich fixture in marked and bare form, across {strict} × {require_marker}, over every
truncation prefix and every single-byte flip: 3 144 cases. The same binary was run against this
head and against origin/master's crates/gamut-exif/src.

64 of 3 144 cases differ, in exactly two families and no others:

  • 52 differ only in an error message's byte offset, every one of them a marked blob, every one
    larger by exactly 6 — the length of the Exif\0\0 marker. Normalising that one number back
    makes 0 of them differ. All 52 marked cases that carry a byte offset are in this set; all 26
    bare cases that carry one are unchanged, which is the mechanism: the base is 0 for a bare stream.
  • 12 change verdict, all of them strict(true), and all of them a byte flip inside the
    JPEGInterchangeFormatLength entry header — the corruption that orphans the thumbnail offset, and
    therefore the new strict rejection above. There are 12 rather than 16 because the four
    bare + require_marker cases already failed with MissingMarker.

Nothing else moved: 0 other verdict changes, and every one of the 732 Ok cases is byte-identical
in re-serialised output. Lenient mode is unchanged in verdict and in bytes.

What the round-3 tests pin

  • report_offsets_ignore_the_marker_but_error_offsets_include_it (tests/report.rs) — the two
    frames, together, so neither can drift onto the other: the same dangling pointer reports the same
    offset from a marked and a bare blob, while the same corruption produces error offsets differing by
    exactly MARKER.len().
  • a_strict_parse_still_reports_a_trailing_directory (tests/report.rs) — the corrected strict
    contract, on the one loss strictness has no grounds to reject.
  • a_thumbnail_offset_without_a_length_is_named and a_thumbnail_with_no_jpeg_range_reports_nothing
    (tests/report.rs) — both directions of the incomplete pair, so the fix cannot degenerate into
    reporting every thumbnail that has no JPEG.
  • a_thumbnail_offset_without_a_length_is_rejected_strictly (inline in src/reader.rs, beside its
    out-of-bounds sibling) — the strict half, pinned on the message so the reader is told which half of
    the pair is missing.
  • the_deep_fixture_has_a_pin_a_thumbnail_and_a_trailing_directory_to_lose (inline in
    src/stream.rs) — the fixture-integrity guard, without which the sweep below could pass against a
    blob with nothing to lose and a clean report that already blamed the file.
  • a_failing_source_is_propagated_not_reported_as_a_malformed_file — now over healthy_blob and the
    extended deep_blob, under the stronger law that an Ok from a failing source equals the clean
    parse in report, maker-note pin and thumbnail bytes. Over 40 budgets the deep fixture splits
    17 Ok / 23 Err, every error keeping ErrorKind::Io, zero violations.

Round-5 gates, at c5157fa0

Round 5 is documentation plus one doc-comment line added to parse's # Errors list. No
executable statement changed, so the workspace-wide lint/test/coverage gates were not
re-run: they were run at 68a57fb2 and nothing they observe moved. The capped set below was run
in full, and the crate's own build, test, doctest and rustdoc passes were re-run because doc
comments are compiled (intra-doc links, doctests).

__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt        -> ok (3.93s)
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check  -> ok (3.70s)
cargo test -p gamut-exif --all-features                                -> ok, all suites pass,
                                                                          3 doctests pass
cargo clippy -p gamut-exif --all-targets --all-features -- -D warnings -> ok
cargo doc -p gamut-exif --all-features --no-deps                       -> 1 warning, pre-existing
mise run check-tests                                                   -> ok
mise run check-commits (convco, merge-base..HEAD)                      -> no errors in 12 commits
mise run mutants-diff (16 GiB capped scope)                            -> 65 mutants: 55 caught,
                                                                          10 unviable, 0 MISSED

The single cargo doc warning is unresolved link to exif_tags at src/tag.rs:4. It is
pre-existing, demonstrated by git show origin/master:crates/gamut-exif/src/tag.rs | sed -n 4p returning the identical line, and src/tag.rs is not in this branch's diff. It is already
filed and is not repaired here. Re-running cargo doc without -D warnings shows it is the
only diagnostic, so none of round 5's new intra-doc links is broken.

Round-6 gates, at 54c550a2

Round 6 is documentation only: the diff touches README.md plus rustdoc and // comments in
three source files, and git diff -U0 -- crates/gamut-exif/src filtered to non-comment lines is
empty. So the gates that observe executable behaviour were justified as not needed rather than
skipped for cost: mise run lint, mise run test, mise run coverage and mise run mutants-diff all observe compiled statements and mutable expressions, and this round produces no
new mutant and moves no statement — they were run at 68a57fb2/c5157fa0 and nothing they
observe changed. check-release-deps / check-ffi-features / check-ffi-header need no run
either: no Cargo.toml and no public C-surface type is touched.

What is needed is everything that compiles doc comments (intra-doc links, doctests) or reads the
commit series. All of it was run in full:

__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt        -> ok (1.82s)
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check  -> ok (2.12s), exit 0
cargo test -p gamut-exif --all-features                                -> ok, all suites pass,
                                                                          3 doctests pass
cargo clippy -p gamut-exif --all-targets --all-features -- -D warnings -> ok
cargo doc -p gamut-exif --all-features --no-deps                       -> 1 warning, pre-existing
mise run check-tests                                                   -> ok, exit 0
mise run check-commits (convco, merge-base..HEAD)                      -> no errors in 13 commits

The single cargo doc warning remains unresolved link to exif_tags at src/tag.rs:4, still
pre-existing (src/tag.rs is not in this branch's diff) and still the only diagnostic, so
none of round 6's edited intra-doc links is broken.

The union search, and every site it finds

Decision 18 and the c5157fa0 commit body both claim that grepping for the citation 4.6.9.2
finds every site stating the Table 21 rule. That claim is false, and correcting it is round
6's own lesson (decision 25). The correct method is the union of two greps — for the citation
and for the table name — because a site can carry either half alone:

grep -rn '4\.6\.9\.2'  crates/gamut-exif/   ->  8 hits
grep -rni 'table 21'      crates/gamut-exif/   ->  9 hits

Nine distinct sites, listed at 54c550a2:

site citation table name
README.md:95 (Compatibility) yes yes
src/lib.rs:34 yes yes
src/reader.rs:286 yes yes
src/report.rs:65 yes yes
src/report.rs:144 yes yes
src/stream.rs:221 yes yes
src/stream.rs:251 no yes
tests/report.rs:302-303 yes (l. 302) yes (l. 303)
tests/report.rs:342 yes yes

src/stream.rs:251 is the site the citation grep does not reach — the exact mirror of round 5's
miss, which named the clause without the table. tests/report.rs:302-303 shows the second way a
line-scoped grep can half-see a site: the citation and the table name are split across a wrap.
No defect follows: src/stream.rs:251 was written correct and has never stated the false rule,
so this round changes no text there.

Round-7 gates, at a6529623 (the eighth review pass)

This round is documentation by construction, and the claim is proved by a stronger method than the
comment-line filter round 6 used. The --all-features lib target is macro-expanded at both ends
of the round and compared with doc lines stripped
(///, //!, #[doc = ):

cargo +nightly rustc -p gamut-exif --all-features --lib -- -Zunpretty=expanded \
  | grep -vE '^[[:space:]]*(///|//!|#\[doc = )'

54c550a2  -> 3 098 lines  sha256 f72897dd8d4100b022173ba1ffc0b2f3a0a86491cdaf077b3fad162a0305f292
a6529623  -> 3 098 lines  sha256 f72897dd8d4100b022173ba1ffc0b2f3a0a86491cdaf077b3fad162a0305f292
diff -q   -> identical

Nothing executable moved, so the gates that observe executable behaviour are not needed rather
than skipped for cost: mise run lint, mise run test, mise run coverage and mise run mutants-diff all observe compiled statements and mutable expressions, and this round produces no
new mutant. check-release-deps / check-ffi-features / check-ffi-header need no run either: no
Cargo.toml and no public C-surface type is touched. Everything that compiles a doc comment or
reads the series was run in full:

cargo +nightly rustc -p gamut-exif --all-features --lib -- -Zunpretty=expanded  -> exit 0, twice
cargo doc -p gamut-exif --all-features --no-deps                        -> 1 warning, pre-existing
cargo test -p gamut-exif --all-features --doc                           -> 3 passed; 0 failed
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt         -> ok (2.81s)
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check   -> ok, exit 0
mise run check-tests                                                    -> ok, exit 0
mise run check-commits (convco, merge-base..HEAD)                       -> no errors in 14 commits

The single cargo doc warning is still unresolved link to exif_tags at src/tag.rs:4,
pre-existing (src/tag.rs is not in this branch's diff) and still the only diagnostic, so none
of this round's edited intra-doc links is broken.

Table 21 re-read from the vendored PDF, and what the nine sites now say

references/exif/exif-3.0-dc-008-translation-2023.pdf, §4.6.9.2, read with pdftotext -layout.
The table header spans Uncompressed → Chunky | Planar | YCC, plus Compressed — four
columns. JPEGInterchangeFormat (513 / 0x201) reads N N N M; JPEGInterchangeFormatLength
(514 / 0x202) reads N N N M — identical in every column, which is the fact the "no conformant 1st
IFD changes verdict" claim rests on. §4.6.5.1.4 defines Compression as 1 = uncompressed,
6 = JPEG compression (thumbnails only), Other = reserved: two values, so the four columns
cannot be its values.

Re-running the union search at a6529623 still returns exactly nine sites in gamut-exif (plus
two gamut-icc hits against a different specification, §7.2.11 of the ICC spec, correctly not
sites). Their state after this round:

site before after
README.md (Compatibility) axis named, appositive misattached rescoped (decision 34)
src/lib.rs "per Compression column" "per thumbnail-format column" (decision 31)
src/report.rs DroppedRegion::ThumbnailJpeg "per Compression column" corrected (decision 31)
src/report.rs DropReason::ThumbnailLengthMissing "per column" unchanged, already axis-neutral
src/stream.rs read_thumbnail doc "per Compression column" corrected (decision 31)
src/stream.rs read_thumbnail inline comment Compression = Compressed left alone — exact (decision 33)
src/reader.rs strict-rejection test doc Compression = Compressed left alone — exact (decision 33)
tests/report.rs:303 "per Compression column" corrected in 0bfdb5e2 (decision 38)
tests/report.rs:342 (now 343) "per Compression column" corrected in 0bfdb5e2 (decision 38)

The two exact sites are statements about one column's value (Compression = Compressed) and
about the uncompressed group — not claims that the axis is the Compression tag — and both
verify against the table as read above. The two tests/report.rs sites carried the same false
phrase and sat outside the round's original manifest; the revision was requested rather than taken
silently, granted, and applied in 0bfdb5e2. Re-running the union search at 0bfdb5e2 returns the
same nine sites, and a grep for the phrase per `Compression` column across crates/ now returns
none.

Gates at 0bfdb5e2, the granted manifest revision

crates/gamut-exif/tests/report.rs was added to the manifest so the last two sites could be
corrected. The change is doc comments only — every changed line is a /// line, and a filter of
the diff for non-/// lines returns nothing — and the claim is proved the same way the rest of the
round proved it, on the target that actually contains the file:

cargo +nightly rustc -p gamut-exif --all-features --test report -- -Zunpretty=expanded \
  | grep -vE '^[[:space:]]*(///|//!|#\[doc = )'

a6529623  -> 1 039 lines  sha256 a456591d91616cb4b2c7694ba5d3eafead2ed145446e7ca8227b00cb26882a9f
0bfdb5e2  -> 1 039 lines  sha256 09c86f40bcf8c7b4b04ccebe81967af2563747e1b8421a9effbe5d81ef3e14d9
diff      -> 16 lines differ

The digest moved, and the reason is published rather than normalised away silently. All sixteen
differing lines are start_line: / end_line: inside the test::TestDesc values the #[test]
macro generates; four tests moved down the file because two doc comments each gained a line. That
is source-position metadata for a failure message, not behaviour: no assertion, no expression, no
test name, no should_panic and no ignore flag differs. Filtering the diff for lines that are
not start_line/end_line returns 0. Normalising those positions and re-comparing:

sed -E 's/(start_line|end_line): [0-9]+usize,/\1: N,/'

a6529623  -> 1 039 lines  sha256 764490d5971c45e8c967c086aecd7ee4f708566527157cfb090c5589a4a1eed2
0bfdb5e2  -> 1 039 lines  sha256 764490d5971c45e8c967c086aecd7ee4f708566527157cfb090c5589a4a1eed2
diff -q   -> identical

The lib target needs no normalisation and is byte-identical at a6529623 and 0bfdb5e2
(3 098 lines, sha256 f72897dd8d4100b022173ba1ffc0b2f3a0a86491cdaf077b3fad162a0305f292), which is
the independent check that no src/ file moved with the test file. Gates run at 0bfdb5e2:

cargo +nightly rustc -p gamut-exif --all-features --test report -- -Zunpretty=expanded -> exit 0, twice
cargo +nightly rustc -p gamut-exif --all-features --lib -- -Zunpretty=expanded          -> exit 0, twice
cargo test -p gamut-exif --all-features             -> 87 passed across 6 targets; 0 failed
cargo doc -p gamut-exif --all-features --no-deps                        -> 1 warning, pre-existing
cargo test -p gamut-exif --all-features --doc                           -> 3 passed; 0 failed
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt         -> ok, no further change
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check   -> ok, exit 0
mise run check-tests                                                    -> ok, exit 0
mise run check-commits (convco, origin/master..HEAD)                    -> no errors in 15 commits

mise run lint, mise run test, mise run coverage, mise run mutants-diff,
check-release-deps, check-ffi-features and check-ffi-header are not needed here for the
reasons given for a6529623 above, which the normalised expansion re-establishes at this commit:
no executable statement moved, no mutable expression was introduced, no Cargo.toml and no public
C-surface type is touched.

Risks and rollout

  • One behavioural break, in strict mode only — an accept becomes a reject. Stated here
    explicitly rather than left to a changelog reader's inference: the interface is unchanged, but a
    1st IFD carrying JPEGInterchangeFormat with no JPEGInterchangeFormatLength is now rejected
    by ExifReader::strict(true) where it previously parsed as a thumbnail without bytes. A caller
    that passes such blobs through strict mode today will start seeing
    ExifError::BadThumbnail("JPEGInterchangeFormat offset with no length to size it"). The
    justification is structural, not a support level: an offset with nothing to size it addresses
    bytes that cannot be read, which is what strictness is for, and it is the same answer strict
    already gave an out-of-bounds range. Blast radius measured above: 12 of 3 144 sweep cases, all
    strict(true). Lenient callers see a new report entry and no change in verdict or bytes.
  • Within that break, one error identity also moves for a blob with two defects. A blob
    carrying both a dangling sub-IFD pointer and an incomplete thumbnail pair failed on master as
    InvalidIfd("Exif") ("malformed Exif sub-IFD") and now fails as BadThumbnail ("invalid
    thumbnail: …"), because read_thumbnail runs before follow in parse_source. Both heads
    reject the blob, so no accept-to-reject is added; what changes is which defect is named first.
    Neither the 3 144-case sweep nor the reviewer's 4 504-case rebuild produced that pair — both
    corpora corrupt one fixture at a time — so it was found by construction and is executed above,
    not inferred. A caller matching on the error variant to route a repair would route this blob
    differently.
  • Error message offsets moved for marked blobs, by exactly the six-byte marker, because a
    diagnostic now points into the buffer the caller handed in. A caller that parses offsets out of a
    Display string would need to adjust; a caller that reads Dropped::offset sees no change. Both
    frames are now documented on the items that produce them and pinned against each other.
  • Otherwise purely additive at the API boundary. Two methods on ExifReader, one new public
    module (report), one new DropReason variant (ThumbnailLengthMissing) on a
    #[non_exhaustive] enum with append-only discriminants. No existing signature or return type
    changed, so this stays a minor version bump. The round-4 rename of that variant is not a break
    either: src/report.rs does not exist on master, so the old spelling was never published, and
    a workspace grep finds no use of DropReason outside crates/gamut-exif/.
  • The largest remaining risk is a behavioural drift in parse that the sweep does not see, since
    the parse moved from gamut_ifd::read / read_ifd_at onto IfdReader directly. Mitigated by
    gamut-ifd's own guarantee that the slice functions are thin wrappers over the same streaming
    engine (asserted in its tests/robustness.rs), by the 3 144-case sweep above, and by leaving every
    pre-existing gamut-exif reader test unmodified.
  • Rollback is per-commit and the order matters: 3242ea1 reverts the thumbnail fix, c49f83b
    reverts the report, b3e8a5d reverts the streaming entry point. Reverting 5a766f7 alone is
    not safe — it would restore the silent trailing-directory drop and the swallowed transport
    failure; roll back to 6a75ec4 instead. The three round-4 commits (d650f62, d3e2207,
    68a57fb) are independently revertible: the first is a rename, the second an error string, the
    third documentation only. c5157fa0 and 54c550a2 are documentation only as well and revert
    independently of everything above them.

Issue

Refs #419. Not Closes — the issue's title names per-entry error recovery, and the report's
granularity is the directory: one unparseable entry fails its whole IFD inside gamut-ifd, which is
the layer that would have to recover per entry. That, a byte-completeness verdict over the blob, and
the real-camera-DNG half of the laziness assertion are filed as #521 and recorded in
crates/gamut-exif/STATUS.md. The shadowed duplicate-tag entry found in the round-2 review is filed
against gamut-ifd as #528.

Filed by this round and linked here rather than fixed:

  • gamut-exif: to_bytes re-emits a thumbnail pointer whose bytes the report says were dropped #548gamut-exif: to_bytes re-emits a thumbnail pointer whose bytes the report says were
    dropped. Pre-existing on master byte for byte, and the repair changes writer behaviour well
    outside this manifest. The interaction is noted where the report documents its completeness: the
    report does name the drop; it is the emitted blob that is silent about it.
  • No gate compiles a README code block or fails on a broken rustdoc link #549 — no gate compiles a README code block or fails on a broken rustdoc link. This is the
    structural reason two of this round's findings could rot undetected. It needs .github/workflows/
    edits, which this run is not permitted to make.
  • gamut-exif: should the thumbnail JPEG pair rule be conditioned on Compression? #574gamut-exif: should the thumbnail JPEG pair rule be conditioned on Compression?
    Filed by round 4. The documentation over-reach is fixed here; the behavioural half — whether
    strict should condition the rule on the Compression column Table 21 keys it on, and whether
    compressed-with-only-a-length should be rejected for symmetry — is a change in both directions
    that needs its own equivalence sweep, and neither direction regresses master.

Decisions taken

Read to the end: the record is never edited in place. Three entries below are superseded by later ones — decision 28's exemption of the five "per Compression column" sites by decision 31, decision 30's "from the next release" tense by decision 32, and decision 37's reduction of the deliverable to three of the five sites by decision 38, after the manifest revision it requested was granted. Decisions 31-38 were appended in round 8.

Issue 419 - gamut-exif: streaming entry point + per-entry error recovery
Plan:     v1
Branch:   feat/419-exif-streaming-reader
Base:     origin/master (6a75ec4)
Cause:    -
Touches:  gamut-exif reader.rs, lib.rs, new stream.rs and report.rs, tests, STATUS/README
Will not: add async or any runtime dependency; change what `parse` returns or accepts; touch gamut-ifd; add tag breadth or value descriptions (that is #417, which stacks on this head)
Lane:     parallel (root; #417 branches from this head)
Settled:  S1 no new runtime dependency; S3 docs/testing.md

Decisions taken.
1. Deliverable boundary
   Taken:    both halves of the issue - a ReadAt-based entry point over gamut_ifd::IfdReader, and a per-entry/per-sub-IFD drop report - because the report is what makes the lenient reader honest and neither half is useful alone
   Rejected: the streaming entry alone (leaves the silent drop, the defect the issue actually names); the report alone (leaves the "open a 300 MB NEF and read KBs" capability unexposed)
   Reverses: drop the stream.rs commit
   Filed:    -
2. Async
   Taken:    DECLINED. A sync `ReadAt` surface an async caller drives itself, as the issue's own scope note invites; recorded rather than absorbed
   Rejected: a tokio feature - it would put a runtime dependency in a crate that has none and constrain the public shape against CLAUDE.md's C-portability rule, for a capability the caller can supply
   Reverses: add a feature-gated async wrapper over the same surface
3. The streaming entry point
   Taken:    additive alongside `parse`, never replacing it: `ExifReader::parse_from<S: ReadAt>(&self, source: S) -> Result<Exif>`, so a caller can pull EXIF out of a large RAW without loading it; `parse` keeps its exact signature and behaviour
   Rejected: changing `parse` to a generic - a breaking change for every caller, for no gain
   Reverses: remove parse_from
4. The drop report
   Taken:    `ExifReader::parse_with_report(...) -> Result<(Exif, ReadReport)>` (and the ReadAt twin), where `ReadReport` lists every dropped sub-IFD, entry and thumbnail range with its tag, its offset and a typed reason; `parse` and `parse_from` stay silent as today, so no existing caller changes behaviour; the pointer tag's removal at reader.rs:145 must no longer happen before the parse attempt, or the report cannot name what it dropped
   Rejected: making the report always-on by changing the return type - breaking; a `strict(true)` -only signal - strict already fails the whole parse, which is the thing lenient callers are avoiding
   Reverses: delete report.rs and revert the follow() change
5. Acceptance
   Taken:    a bounded-read assertion in the shape gamut-ifd's tests/streaming.rs already uses, proving EXIF extraction from a large synthetic TIFF reads only a bounded number of bytes; and, over the malformed corpus, that EVERY dropped entry is NAMED in the report rather than silently missing - the issue's own acceptance. No oracle is needed for the report half; use exiv2 only where it already gates this crate

Appended during delivery, in the same shape:

6. Where the engine lives
   Taken:    `parse` delegates to `parse_from`, and the shared engine lives in a private `stream` module as an `impl ExifReader` block. `reader.rs` keeps the options and the `&[u8]` entry points; `follow`, `read_thumbnail`, `maker_note_offset` move there and become generic over `ReadAt`
   Rejected: keeping two parse bodies (a slice one and a streaming one) - the exact drift the issue's own "one parser" note in gamut-ifd warns against, and the silent-drop defect would then have to be fixed twice; also rejected making `stream` a `pub mod`, since it exports no items of its own
   Reverses: inline the stream module back into reader.rs
7. Report granularity and the four drop sites
   Taken:    the report names the four regions the lenient reader actually discards - the Exif, GPS and Interop sub-IFDs, and the thumbnail JPEG byte range. `gamut_ifd::decode_ifd` does not silently drop entries (an unknown field type becomes `Value::Unknown`; a bad value is a hard error), so these four are the complete set of silent losses at this layer
   Rejected: per-tag recovery inside one directory - it needs a recovering decode path in gamut-ifd, which the manifest forbids touching; filed as #521 rather than half-built here
   Reverses: -
8. What is NOT in the report
   Taken:    the byte-completeness verdict the issue floated (via gamut_ifd's Tracked/SegmentMap/read_audited) is out of scope for this head. The record scopes ReadReport to "every dropped sub-IFD, entry and thumbnail range"; completeness is a second, larger surface
   Rejected: building both in one PR - it would double the review surface and the audit engine changes what the reader must keep alive during a parse
   Reverses: -
   Filed:    #521
9. The DNG-corpus half of the acceptance
   Taken:    only the synthetic-TIFF bounded-read assertion. The issue's acceptance also asked for one over third_party/gamut-dng-samples, a ~178 MiB fetch-gated submodule gamut-exif does not depend on; the decision record's own acceptance clause names only the synthetic case
   Rejected: adding a dev-dependency or a fetch-gated tier to gamut-exif for it - it belongs in the extended `test-dng-real` tier where the corpus is already wired
   Reverses: -
   Filed:    #521
10. Two reasons, not one
   Taken:    `DropReason` separates `OutOfBounds` (the address lay at or past the end of the blob, or the byte range was not wholly inside it) from `Malformed` (the address was inside the blob but the structure there did not parse). A dangling pointer and a corrupt directory send a caller to different places
   Rejected: a single opaque "dropped" signal - it would make the report no more actionable than the absence it replaces, and would leave the distinction untestable
   Reverses: collapse DropReason to one variant

Appended after review, decided by the orchestrator on review:

11. The review's findings (M1, M2, L1-L4, questions 1-4)
   Taken:    all six findings repaired in one commit, 5a766f7.
             M1 (completeness) - split in two. The half this crate owns is fixed: a top-level
               directory past the 1st IFD is now NAMED via DroppedRegion::TrailingIfd +
               DropReason::Unrepresentable, at its own offset, with tag 0 because no tag addresses
               it. The half it does not own - gamut_ifd::decode_ifd dropping a shadowed
               duplicate-tag entry via last-wins Ifd::set - is filed as #528, because fixing it
               needs a reporting decode path in gamut-ifd and the manifest forbids touching it.
               The documented promise is narrowed everywhere it appears (report.rs module docs,
               ReadReport, is_empty, parse_with_report, README, STATUS P9): the report is complete
               over the regions DroppedRegion enumerates and is NOT a byte-completeness verdict.
             M2 (2a) - follow and tiff_base key on Error::kind. InvalidInput is what leniency is
               for; Error::Io and anything else propagate unchanged in both strict and lenient
               mode. This also fixes L4: the marker probe's three-way question (marked, unmarked,
               unknown) is no longer collapsed onto .is_ok(), so require_marker(true) cannot answer
               MissingMarker for a blob whose marker is merely unreadable. Keyed on the kind and
               never on a length, so a slice's behaviour is bit-for-bit what it was.
             L1 (3a) - `mod stream;`. The module exports no items, so publishing it put an empty
               page in rustdoc and pinned a path in the surface for nothing. This is what decision
               6 already said; the code contradicted it.
             L2 (4a) - report-only. The comment claiming the reorder was load-bearing is corrected:
               `offset` is bound before the removal, so the old ordering produced an identical
               report. The pointer is still removed unconditionally, and the docs now say plainly
               that pointer PRESERVATION was never in scope - #419's "the pointer was lost too" is
               answered by naming the tag and offset in the report.
             L3 - DroppedRegion::name's doc no longer claims every variant matches
               ExifError::InvalidIfd; the thumbnail's strict error is BadThumbnail, and a trailing
               directory has no strict error at all.
   Rejected: 4b, preserving the dropped pointer entry - it changes what to_bytes emits for a
               malformed blob, a behaviour change beyond the issue.
             Reporting the duplicate-tag loss from gamut-exif - the entry is gone before this crate
               sees the Ifd; any signal here would be invented, not observed.
             Leaving is_empty documented as "this parse lost nothing" - the review produced two
               executed counterexamples, so the claim was simply false.
   Reverses: revert 5a766f7. That restores the silent trailing-directory drop and the swallowed
               transport failure, so it is not a safe rollback of the feature - roll back to
               6a75ec4 instead.
   Filed:    #528 (gamut-ifd: decode_ifd silently discards a shadowed duplicate-tag entry)
   Note:     this SUPERSEDES decision 7, whose premise - "gamut_ifd::decode_ifd does not silently
               drop entries" - the review disproved. The four regions it named were not the
               complete set of silent losses: it missed trailing directories (now fixed) and
               duplicate tags (now filed and documented).

Appended during the round-3 repairs, in the same shape:

12. An incomplete thumbnail pair
   Taken:    a `JPEGInterchangeFormat` with no `JPEGInterchangeFormatLength` is a MALFORMED range,
             not an absent thumbnail: lenient mode names it with a new `DropReason::Incomplete` at
             the offset the tag carried, and strict mode rejects it with `BadThumbnail`, exactly as
             it already rejected an out-of-bounds range. A `JPEGInterchangeFormatLength` with no
             offset addresses nothing at all, so nothing was dropped and nothing is reported.
             Grounded in Exif 3.0 §4.6.9.2 Table 21, which marks both tags mandatory for a
             compressed thumbnail
   Rejected: reusing `DropReason::Malformed` - it is about bytes that WERE read and did not parse,
             and conflating it with an address that could never be sized breaks decision 10's own
             rule that two defects sending a caller to different places get different reasons
             Reporting it in strict mode without rejecting it - that contradicts the strict contract
             this same round settled: strictness rejects malformed regions and reports
             well-formed-but-unrepresentable ones, and half a mandatory pair is malformed
             Leaving it silent and filing it - the region is one this PR's own report claims
             completeness over, so a silent loss inside it falsifies the deliverable
   Reverses: drop the two `(Some(_), None)` arms and the `Incomplete` variant
   Measured: 12 of 3144 sweep cases change verdict, every one of them `strict(true)`
13. The failing-source law, under a fixture that legitimately drops
   Taken:    the sweep's law becomes "an `Ok` from a failing source equals the CLEAN PARSE in
             report, maker-note pin and thumbnail bytes" rather than "the report is empty". The
             extended fixture has a trailing directory, which a clean parse reports, so emptiness is
             no longer available - and equality is the stronger claim anyway, since it also catches
             a quietly diminished answer
   Rejected: keeping emptiness by dropping the trailing directory from the fixture - that is the
             narrowing this round explicitly declined; `record_trailing_ifds`' chain re-walk would
             stay unswept
             Equality alone - it would be vacuous if the clean report itself blamed the file, so the
             fixture-integrity test now asserts the clean report's only entry is `Unrepresentable`
   Reverses: restore `report.is_empty()` and shrink `deep_blob` back to `maker_note_blob`
14. The unreachable arm: corrected in the comment, not by amending the commit
   Taken:    `refactor(exif): drop an unreachable arm from the maker-note pin` is pushed history and
             is not rewritten. Its body overstates the claim - the reviewer built a `ReadAt` that
             answers differently on a second read, which is exactly the rewritten-file case
             `parse_from` exists to enable, and the arm was reachable over it. The code comment,
             which was already correctly hedged to a DETERMINISTIC source, now also says the arm is
             reachable in general and why a hard error is still right there; the correction is
             restated in this body
   Rejected: amending or force-pushing the commit - LANE-COMMON forbids rewriting pushed history
             Restoring the arm - the reviewer's own verdict is that removal was the right repair: it
             is unfalsifiable for any deterministic source, and a hard error beats a silent unpinned
             re-emit of a vendor MakerNote
   Reverses: -

Appended during the round-4 closing repairs, in the same shape:

15. The Table 21 grounding, split into a documentation fix and a filed behavioural question
   Taken:    the four sites citing Exif 3.0 §4.6.9.2 Table 21 as making the thumbnail pointer pair
             mandatory are NARROWED, and the strict message is changed from
             "JPEGInterchangeFormat without JPEGInterchangeFormatLength" to "JPEGInterchangeFormat
             offset with no length to size it". The table keys each 1st IFD tag's support level on
             the Compression column: M only under Compressed, N (not allowed to record) under all
             three uncompressed ones. The reader does not read Compression, so its rule cannot rest
             on that mandate - it rests on the structural fact that an offset with nothing to size
             it addresses bytes that cannot be read, which is true in every column. Behaviour is
             unchanged by this decision; only what the code claims changes
   Rejected: conditioning the strict rule on Compression in this PR - a behavioural change in both
             directions (an uncompressed offset-only thumbnail would stop being rejected; a
             compressed length-only one would start), each needing its own equivalence sweep
             against the base, in a round that had otherwise converged
             Rejecting compressed-with-only-a-length for symmetry with decision 12's own
             "half a mandatory pair is malformed" - same reason, and it additionally needs a
             DroppedRegion/DropReason pairing for a region with no address, since a length alone
             addresses nothing and so cannot be NAMED at an offset
             Leaving the citation as it stood - it asserts of the spec something the spec does not
             say, which is the one thing a spec-grounded crate cannot do
   Reverses: revert 68a57fb (documentation only) and d3e2207 (the message); neither changes a
             verdict
   Filed:    #574 (gamut-exif: should the thumbnail JPEG pair rule be conditioned on Compression?)
16. DropReason::Incomplete becomes DropReason::ThumbnailLengthMissing
   Taken:    renamed before a release freezes the discriminant. The variant has exactly one call
             site and its name described the SHAPE of the defect ("addressed but never fully
             described"), which invites an unrelated future drop to reuse it and quietly widen what
             the reason means. The rendered clause moves with it, to "has no
             JPEGInterchangeFormatLength to size the read". src/report.rs does not exist on master,
             so the old spelling was never published and no released API changes; a workspace grep
             finds no use of DropReason outside crates/gamut-exif/
   Rejected: keeping the generic name and documenting the restriction - a doc comment does not stop
             a later contributor reaching for a variant that already exists, and the discriminant
             is append-only once released
             A middle name such as IncompleteRange - still a shape, so it has the same failure mode
   Reverses: rename back; the discriminant (3) is unchanged either way
17. #548's second instance is recorded, not fixed
   Taken:    the pointer removal stays gated on jpeg.is_some(), so the new ThumbnailLengthMissing
             arm leaks the JPEGInterchangeFormat pointer into the model exactly as the OutOfBounds
             arm does, and to_bytes re-emits it. Recorded in the PR body and in a comment beside the
             code, including the sharper consequence measured this round: the re-emitted blob still
             has an offset and still has no length, so a STRICT parse rejects a blob this crate
             itself wrote
   Rejected: removing the pointer unconditionally here - that is #548's option 1, a writer
             behaviour change outside this manifest and wanting its own equivalence sweep
             Editing #548 to name the second instance - this run does not edit filed issues; the
             record lives in the PR body and beside the code, both of which #548 links to
   Reverses: -
   Filed:    already #548

Appended in round 5 (each names the round-4 entry it corrects; no entry above is edited):

18. The crate front page is corrected, and the citation is what gets grepped
   Corrects: decision 15 and the round-4 commit message, both of which said "four sites" as if
             that were the whole set
   Taken:    src/lib.rs:30-33 still said the pair is "which Exif 3.0 4.6.9.2 requires together"
             (false - Table 21 FORBIDS recording either tag under all three uncompressed columns)
             and still called it "the malformed pair it is", the framing the round-4 commit
             message itself abandoned. It now carries the structural grounding the other four
             sites carry. The miss mechanism is the reusable half: that site cites the clause
             without naming the table, so a grep for the phrase "Table 21" finds four sites while
             a grep for the citation "4.6.9.2" finds all of them. Grep for the citation, not the
             prose around it
   Rejected: leaving the front page and relying on the four narrowed sites - the front page is the
             crate's most-read document and it ships to docs.rs and crates.io
   Reverses: revert the src/lib.rs hunk
19. The strict rule stays unconditional, on the table rather than on assumption
   Corrects: -  (settles the option issue #574 leaves open; the issue is NOT edited)
   Taken:    Table 21 gives JPEGInterchangeFormat and JPEGInterchangeFormatLength an IDENTICAL
             support level in all four Compression columns - N under Uncompressed Chunky, N under
             Uncompressed Planar, N under YCC, M under Compressed - and Compression itself is M in
             all four. So an offset with no length is non-conformant under EVERY column, and there
             is no conformant 1st IFD the strict arm wrongly rejects. Verified against the
             vendored references/exif/exif-3.0-dc-008-translation-2023.pdf, not from memory.
             Recorded in DropReason::ThumbnailLengthMissing's docs in #574's own terms so a human
             can close that option
   Rejected: conditioning the refusal on Compression now - it changes nothing about which inputs
             are conformant, and it is #574's to decide
   Reverses: the rule is doc-only here; nothing to revert but the wording
   Filed:    already #574
20. #574's conditioning option is recorded as cheap, not costly
   Corrects: decision 15's supporting text and three doc sites that said Compression is "a tag
             this crate does not read"
   Taken:    that claim is false - Thumbnail::compression is a public accessor (src/thumbnail.rs)
             with six references in the crate. Only the src/stream.rs site got it right by saying
             this READER does not consult it. Every site now says that, and #574's option is
             recorded as needing no plumbing, since the accessor already exists
   Rejected: deleting the clause instead of correcting it - the distinction between "the crate
             cannot see this tag" and "the parse does not branch on it" is exactly what #574 turns
             on
   Reverses: revert the report.rs / lib.rs / tests/report.rs wording hunks
21. #548's second instance is distinguished by novelty, not by self-rejection
   Corrects: decision 17, which argued the missing-length instance is "sharper" because a strict
             parse of the re-emitted blob fails
   Taken:    that is a property BOTH instances share - an out-of-bounds offset survives the round
             trip and is refused strictly just as an unsized one is. The real distinction is that
             the OutOfBounds instance is already rejected strictly on the default branch, so it is
             pre-existing, while the missing-length instance is CREATED by this pull request. The
             comment beside the code now says that
   Rejected: dropping the comparison - #548 is linked from here and a reader needs to know which
             half of the second instance this PR is responsible for
   Reverses: revert the src/stream.rs comment hunk
   Filed:    already #548
22. The changed strict verdict goes into a shipped document, without forcing a major version
   Corrects: -  (the round-3/4 record stated the break only in the PR body)
   Taken:    the README now names the changed verdict: the pair is a loss, reported as
             DropReason::ThumbnailLengthMissing and rejected by strict with BadThumbnail, with
             Table 21's four columns given as the reason it is a fix. A caller running strict can
             now learn of it from something that ships, not only from a commit body
   Rejected: a type(scope)! subject with a BREAKING CHANGE footer - the old behaviour accepted
             input non-conformant under every column of the table, so this is a fix rather than a
             redefinition and a forced major on a v1.0.0 crate would misdescribe it
             Hand-editing CHANGELOG.md - release-plz owns it
   Reverses: revert the README hunk
23. The mirror case stays filed, with its asymmetry grounded
   Corrects: the tests/report.rs note, which called the length-only case "as malformed as the
             offset-only case" and left the asymmetry as an assertion
   Taken:    the two are equally NON-CONFORMANT but not equally a LOSS, and loss is what the
             report names: an offset with no length addresses bytes, a length with no offset
             addresses nothing. Recorded at the reporting-contract test and in
             DropReason::ThumbnailLengthMissing. The strict-mode question stays #574's
   Rejected: rejecting the length-only case here for symmetry - it would make the report name a
             region from which nothing was lost
   Reverses: revert the tests/report.rs wording hunk
   Filed:    already #574
24. parse_with_report gets the offset-frame note and parse's error list gets BadThumbnail
   Corrects: decision 15's coverage claim, which left the slice entry point that returns BOTH
             frames in one call without the note
   Taken:    parse, parse_from and parse_from_with_report carried it; parse_with_report - the one
             handing a caller an error frame and a report frame in a single call - did not. It
             does now. Separately, parse's # Errors list omitted ExifError::BadThumbnail, the
             variant this change gives a new way to reach; parse_from's list already named it
   Rejected: pointing parse_with_report at parse's note instead of stating it - the frames MEET
             here, which is the fact worth stating at the site
   Reverses: revert the src/reader.rs doc hunks
25. The citation grep is not the method; the union of two greps is
   Corrects: decision 18 and the c5157fa0 commit body, both of which said a grep for the citation
             "4.6.9.2" finds every site and told a later reader to grep for the citation rather
             than the prose around it
   Taken:    that is false. A ninth site, src/stream.rs:251, names "Table 21" with no clause
             number and the citation grep does not reach it - the exact mirror of round 5's miss,
             which named the clause with no table. A line-scoped grep can also half-see a site
             whose citation and table name straddle a wrap (tests/report.rs:302-303). The method
             recorded from here is the UNION of both greps, and the reason is that either half can
             appear without the other, which this round proved in both directions. The nine sites
             are tabulated in the Validation section above. The commit body cannot be amended
             because it is pushed, so this entry is the correction
   Rejected: normalising every site to carry the full citation so one grep suffices - it does not
             remove the need for the union (a later author can still write half a citation) and it
             would edit a site that has never been wrong
   Reverses: nothing to revert - no shipped text stated the false rule; only this record did
26. parse_with_report's offset-frame note is corrected to its streaming twin's wording
   Corrects: decision 24, which shipped "both reach the caller here, in one call"
   Taken:    that sentence is false in shipped rustdoc. The return is Result<(Exif, ReadReport)>,
             a sum type: record_trailing_ifds runs before follow in parse_source, so a blob with a
             reportable trailing directory AND a strict-fatal dangling sub-IFD pointer returns,
             under strict, an Err and NO report - discarding a drop already recorded. The
             streaming twin at src/stream.rs:68 already said "the two frames MEET here", which is
             the true, weaker claim. The slice entry point now uses it and states the sum-type
             reason they only meet
   Rejected: leaving it and relying on the # Errors section - the sentence is an affirmative claim
             about what one call hands back, and it was the stronger reading
   Reverses: revert the src/reader.rs hunk
27. #574's "cheap" verdict is given the reason that carries it
   Corrects: decision 20, which grounded "cheap" in Thumbnail::compression already reading the tag
   Taken:    that fact does not reach the site. Thumbnail::compression reads a FINISHED thumbnail,
             and the arm #574 would condition (src/stream.rs, (Some(_), None) under strict)
             returns before a Thumbnail is constructed. The conclusion is nonetheless true, for an
             adjacent reason: the 1st IFD is already in scope in read_thumbnail and Compression is
             the same ifd.get_u32(...) one-liner used two lines above for the offset and the
             length. The doc now gives that reason and names why the accessor is not it
   Rejected: dropping the cheap/costly verdict - #574 asks whether to condition the rule, and how
             much it costs is exactly what a human closing it needs
   Reverses: revert the src/report.rs hunk
   Filed:    already #574
28. Table 21's axis is named for what it is, not for the Compression tag
   Corrects: decision 22's README wording, "each of its four Compression columns"
   Taken:    Compression (tag 259) has two values in Exif; Table 21's four columns are three
             uncompressed ones - Chunky, Planar, YCC, an axis of photometric interpretation and
             planar configuration - plus Compressed. Verified against the vendored
             references/exif/exif-3.0-dc-008-translation-2023.pdf. The README names the axis. The
             five in-crate sites that say "per Compression column" are shorthand for the column
             the compressed case selects, are not false, and no conclusion rests on them, so they
             are left alone rather than churned
   Rejected: rewriting all nine sites for uniformity - it would enlarge a documentation-only diff
             to fix nothing
   Reverses: revert the README hunk
29. The two axes are separated: readability is the rule, conformance is why it is a fix
   Corrects: decision 22's supporting text, which argued one through the other
   Taken:    both claims are true and they answer different questions. The rule is READABILITY
             driven - an offset with nothing to size it addresses bytes that cannot be read, which
             is what strict is for, and the reader does not consult Compression at all. CONFORMANCE
             answers only whether the changed verdict is a fix or a redefinition: because the two
             tags carry an identical level in every column of Table 21, an offset with no length is
             non-conformant under all four, so no conformant 1st IFD changes verdict. The README
             and DropReason::ThumbnailLengthMissing now state each under its own heading
   Rejected: keeping the single chained sentence - using the conformance fact to justify the rule
             is the same failure mode decision 27 repairs
   Reverses: revert the README and src/report.rs hunks
30. The changed verdict is dated, headed and downgraded to what the repository can show
   Corrects: decision 22, which put the note inside ## Usage, undated
   Taken:    a reader arriving from crates.io or docs.rs cannot date "one read verdict changed"
             without a version, so the note moves under a ## Compatibility heading and names
             1.0.0 as the version it changed from. It stays a FIX and not a breaking release, on
             the verified ground that no conformant input changes verdict. The shipped claim is
             the grounding the vendored table gives; the measured before/after comparison behind
             it is pointed at this pull request rather than restated in the README
   Rejected: committing a harness that measures the change against the PUBLISHED 1.0.0 - that is a
             real dependency change (a crates.io dev-dependency on the crate's own prior version)
             and is not worth it for a claim already disclosed here and grounded in the spec
             A type(scope)! subject with a BREAKING CHANGE footer - unchanged from decision 22
   Reverses: revert the README hunk
31. The five "per `Compression` column" sites are corrected, not exempted
   Corrects: decision 28, which exempted them as shorthand and left them alone
   Taken:    at all five the phrase names the AXIS the levels vary over, and two of the three
             in-crate ones then enumerate all four columns - so they assert Table 21 is keyed on a
             tag that decision 28 itself established has two values (Exif 3.0 S4.6.5.1.4: 1 =
             uncompressed, 6 = JPEG compression, other reserved). That is not shorthand for the
             column the compressed case selects; it is a false statement about the table, and
             after 54c550a2 two sites 79 lines apart disagreed about it. The three sites inside
             this round's manifest - src/lib.rs, src/report.rs (DroppedRegion::ThumbnailJpeg) and
             src/stream.rs (read_thumbnail) - now name the thumbnail-format axis and say it is not
             that tag
   Rejected: leaving them per decision 28 - the churn is a documentation-only diff, proved by
             byte-identical stripped macro expansion at both ends of the round, and the
             alternative is a crate whose own text asserts what this branch disproved
   Reverses: revert a6529623's src hunks
32. The changed-verdict note loses its tense
   Corrects: decision 30, whose wording round 7 left as "from the next release it is a loss"
   Taken:    a README is packaged per version and rendered on the registry page for that version,
             so the copy shipped WITH the release that carries the change tells its reader the
             change is still upcoming. The heading above it is already release-anchored ("changed
             after 1.0.0") and correct in perpetuity, which is what made the body's tense the odd
             one out. The sentence now states the verdict with no tense at all
   Rejected: restoring the tense on the reading that, with 1.0.0 as the topic sentence, "the next
             release" means the one being read - an honest counter-reading, but it makes the
             sentence ambiguous rather than correct
   Reverses: restore the tense in the README
33. src/reader.rs and read_thumbnail's inline comment are deliberately left alone
   Taken:    both say Table 21 "makes the pair mandatory only under `Compression = Compressed`,
             and forbids recording either tag under the uncompressed columns". That is a statement
             about one column's value and about the uncompressed group, not a claim that the axis
             IS the Compression tag, and it is exact against the vendored table, where 0x201 and
             0x202 both read N N N M. Editing them would be churn with nothing to fix
   Rejected: rewriting all nine union sites for uniformity - decision 28's rejection clause on
             that point stands and is not reversed
   Reverses: nothing; a later round that finds them inexact should say why
34. The README's axis description is rescoped to the columns it describes
   Corrects: decision 28's README wording, introduced by round 7
   Taken:    "three uncompressed ones (Chunky, Planar, YCC) plus **Compressed**, an axis of
             photometric and planar layout" attaches a photometric/planar description to a list
             whose fourth member is a compression state. 54c550a2's commit message scoped it
             correctly and the README did not. The description now scopes to the three
             uncompressed columns it actually distinguishes, and "that axis is not the two-valued
             `Compression` tag" becomes its own sentence about the whole axis
   Reverses: revert a6529623's README hunk
35. The streaming twin gets the sum-type caveat, closing a known asymmetry
   Taken:    round 6's finding on this branch was precisely one twin worded correctly and the
             other not. Round 7 fixed parse_with_report and left parse_from_with_report terse, so
             the same finding was one review away from returning. Both now say the two offset
             frames MEET but do not always arrive together. The claim holds identically at both:
             parse_with_report delegates to parse_from_with_report, and `?` on parse_source
             discards an already-recorded report along with the Ok
   Rejected: restoring the terse wording on the ground that the slice twin is the documented one
   Reverses: restore the terse wording
36. The ## Compatibility section stays; the convention question is recorded, not filed
   Taken:    a reader arriving on a registry page cannot date "one read verdict changed" without a
             version, which is exactly what the section supplies, so it earns its place here.
             Whether every gamut crate README should carry such a section is a repo-wide
             convention question, and proposing one on the strength of a single instance at the
             close of a converged loop is noise
   Rejected: filing an issue for a repo-wide README convention. It is stated in Unresolved review
             notes as an open question for a maintainer instead
   Reverses: delete the section
37. Two of the five shorthand sites are outside this round's manifest and are left standing
   Taken:    tests/report.rs:303 and tests/report.rs:342 carry the same false "per `Compression`
             column" phrase. This round's manifest is crates/gamut-exif/src/** doc comments plus
             crates/gamut-exif/README.md, so editing a test file would widen it. The deliverable
             is reduced to the three in-manifest sites; the remaining two are published as a
             manifest revision request rather than taken silently or left unrecorded
   Rejected: widening the manifest to finish decision 31 in one commit; freezing the whole round
             over two doc-comment lines, which would have delivered none of decisions 32-36 either
   Reverses: a manifest that includes crates/gamut-exif/tests/report.rs
38. The requested manifest revision is granted, and decision 31 is finished at all five sites
   Corrects: decision 37, which reduced the deliverable to the three in-manifest sites
   Taken:    the revision was requested rather than taken silently, and it was granted on the
             SHAPE of the defect rather than its size: a correction applied at some sites and not
             others - documentation asserting a rule fixed in N places while N+1 and N+2 still
             state the old contract - is the failure mode this whole sub-thread exists to repair,
             and after a6529623 the two test-file sites actively disagreed with the three
             corrected ones, in the file a reader of the test suite meets first.
             crates/gamut-exif/tests/report.rs joins the manifest; 0bfdb5e2 corrects lines 303 and
             342, doc comments only, no assertion and no executable line
   Measured: 16 lines of the expanded report test target differ across 0bfdb5e2, and all 16 are
             start_line/end_line in the #[test] harness's TestDesc, because two doc comments each
             gained a line. Normalised, the target is byte-identical; the lib target is
             byte-identical with no normalisation at all. A grep across crates/ for the phrase
             "per `Compression` column" now returns none, and the union search still returns the
             same nine sites
   Rejected: normalising the moved source positions away without publishing that they moved - the
             point of the digest is that it moves when something unintended moves, so a digest
             that had to be massaged is reported with the reason, not quietly replaced
   Reverses: revert 0bfdb5e2

Unresolved review notes

  • The Malformed fixture in tests/report.rs points the ExifIFD pointer at offset 1, which
    straddles the byte-order mark and the magic so the entry count read there is nonsense. It is
    in-bounds and unparseable, which is exactly what the case needs, but it is a construction chosen
    for that property rather than one seen in the wild. A reviewer who prefers a corpus-derived
    fixture should say so.
  • tiff_base treats a source too short to hold the six-byte marker as unmarked rather than
    surfacing the short read.
    Resolved in 5a766f7 (review finding L4): the probe now keys on
    the error kind, so a transport failure propagates and only a genuinely short source reads as
    unmarked.
  • record_trailing_ifds re-walks the next-IFD chain to recover the offsets of the directories it
    reports, because read_file returns decoded Ifds without them. It runs only when there is
    something to report, so no well-formed blob pays for it — but it is a second read of the
    directory bodies, and a reviewer who would rather change the walk to carry offsets through (at
    the cost of no longer sharing read_file with gamut_ifd::read, which is what the ~2 600-case
    equivalence sweep rests on) should say so.

Appended in round 3 — corrections to the record above, which is never edited in place:

  • Decision 11's "tag 0" wording is superseded. It says TrailingIfd is reported "with tag 0
    because no tag addresses it". Dropped::tag is Option<u16> and returns None, deliberately:
    0 is a real tag number (GPSVersionID), so a 0 sentinel would read as a fact about the
    source. Two comments still carrying the old wording — src/report.rs and tests/report.rs,
    both sitting directly above assertions of None — are corrected in ba0a139.
  • Decision 11's rejection of a duplicate-tag signal was argued from a false premise. It gives as
    the reason that "the entry is gone before this crate sees the Ifd; any signal here would be
    invented, not observed". That is wrong as stated, and this branch's own report module docs say
    so: RawIfd::entries is public and in on-disk order, so comparing its length against the decoded
    Ifd::fields() observes that a directory lost an entry, in three lines. What this crate cannot
    do is say what was lost without re-decoding the shadowed entry. The conclusion stands — the
    signal belongs at the layer that discards, which three crates share, and is filed as gamut-ifd: decode_ifd silently discards a shadowed duplicate-tag entry #528 — but it
    stands on detectability-without-description, not on undetectability.
  • The round-2 body's "0 mismatches in error string or re-serialised bytes" is withdrawn, and so
    is src/lib.rs's "neither is a change for existing callers". Both are replaced by the measured
    result in Validation above: 64 of 3 144 cases differ, 52 by exactly the marker length in an error
    offset and 12 by the new strict rejection.

Round-3 notes that remain open:

  • The thumbnail-pointer interaction with gamut-exif: to_bytes re-emits a thumbnail pointer whose bytes the report says were dropped #548. This report claims completeness over the
    thumbnail's JPEG bytes and delivers it — a dropped range is always named. The blob to_bytes
    emits is a separate matter: it still carries the dangling JPEGInterchangeFormat value, so a
    downstream reader of the re-serialised bytes sees a thumbnail pointer with no report beside it.
    Filed as gamut-exif: to_bytes re-emits a thumbnail pointer whose bytes the report says were dropped #548 with the executed reproduction; a reviewer who thinks the writer change belongs in
    this PR rather than its own should say so.
  • DropReason::Incomplete is currently reachable from exactly one site. It is named for the
    defect rather than the site, so an out-of-line value whose count is present but whose type is
    unreadable could reuse it later; a reviewer who would rather it were named
    ThumbnailLengthMissing and kept single-purpose should say so before 1.0 freezes the discriminant.
  • The deep_blob fixture patches a sentinel JPEGInterchangeFormat value after write lays the
    directories out, because the payload's position is not knowable beforehand. It asserts the sentinel
    names exactly one value field, so a collision fails loudly rather than silently patching the wrong
    bytes — but a reviewer who would rather the fixture were a checked-in golden blob should say so.

Appended in round 4 — corrections to the record above, which is never edited in place:

  • Decision 12's grounding clause is superseded. It reads "Grounded in Exif 3.0 §4.6.9.2 Table
    21, which marks both tags mandatory for a compressed thumbnail". That is true of the Compressed
    column only, and the reader never reads Compression, so the mandate is not what the rule rests
    on. Decision 15 replaces the grounding with the structural one; the behaviour decision 12 took is
    unchanged, and the substantive question the citation raised is filed as gamut-exif: should the thumbnail JPEG pair rule be conditioned on Compression? #574. Decision 12's
    rejection clause "half a mandatory pair is malformed" inherits the same correction — it is
    half an unreadable range, which is why the length-only direction (where nothing is addressed)
    is not symmetric with it by that argument alone.
  • Decision 12's Measured: line understates the blast radius. It reads "12 of 3144 sweep cases
    change verdict, every one of them strict(true)", which remains exact for that corpus. It is not
    the whole radius: a blob carrying both a dangling sub-IFD pointer and an incomplete thumbnail
    pair changes the error it fails with — InvalidIfd("Exif") on master, BadThumbnail here —
    because read_thumbnail runs before follow. Neither this branch's 3 144-case sweep nor the
    reviewer's independent 4 504-case rebuild contains that pair, since both corrupt one fixture at a
    time, so no sweep count would ever have surfaced it. Executed against both heads and recorded in
    Validation and in Risks.
  • DropReason::Incomplete is renamed, adopting the name the round-3 note itself proposed:
    ThumbnailLengthMissing. See decision 16 for why a shape-named variant with one call site is the
    wrong thing to freeze a discriminant on.

Round-3 notes resolved in round 4:

  • DropReason::Incomplete is currently reachable from exactly one site. Resolved in
    d650f62
    : renamed to DropReason::ThumbnailLengthMissing, single-purpose and named for that
    site, before a release freezes the discriminant.

Round-3 notes that remain open, unchanged:

  • The thumbnail-pointer interaction with gamut-exif: to_bytes re-emits a thumbnail pointer whose bytes the report says were dropped #548, now with a second and sharper instance recorded
    beside the code (see decision 17). The writer fix stays in gamut-exif: to_bytes re-emits a thumbnail pointer whose bytes the report says were dropped #548.
  • The deep_blob fixture patches a sentinel JPEGInterchangeFormat value after write lays
    the directories out. Deliberately kept: the fixture is built from named parts and asserts the
    sentinel names exactly one value field, so a collision fails loudly; that is more legible than a
    checked-in golden blob, whose contents a reader would have to decode to review. A reviewer who
    disagrees should say so.
  • The Malformed fixture in tests/report.rs points the ExifIFD pointer at offset 1.
    Deliberately kept, for the same reason: it is constructed for the property the case needs
    (in-bounds and unparseable) and says so in its own comment.

New in round 4:

  • Nothing in this round changes a verdict. The three round-4 commits are a rename of a variant
    that has never been published, one error string, and documentation. The only behavioural change
    in the PR remains the one decision 12 took and round 3 measured.

New in round 5:

  • Nothing in this round changes a verdict either. The one round-5 commit is documentation plus
    a single line added to parse's # Errors list. mise run mutants-diff over the whole branch
    reports 0 missed, confirming no executable behaviour moved. The only behavioural change in the
    PR remains the one decision 12 took, round 3 measured, decision 19 grounded in Table 21 and
    decision 22 has now put in the README.
  • src/tag.rs:4 has a broken intra-doc link (exif_tags!). Pre-existing on master, in a
    file this branch does not touch, and already filed. Not repaired here.
  • Whether #574 should now be closed as answered for the offset-only half. Decision 19 gives
    the table reading that makes the unconditional rule defensible, so the "should the refusal be
    conditioned on Compression?" half of that issue has an answer a human can act on. This run
    does not edit or close filed issues, so it is left as a note here.

New in round 8:

  • Nothing in this round changes a verdict. The one round-8 commit is documentation. Proved by
    macro-expanding the --all-features lib target at 54c550a2 and at a6529623, stripping doc
    lines, and comparing: 3 098 lines and sha256
    f72897dd8d4100b022173ba1ffc0b2f3a0a86491cdaf077b3fad162a0305f292 at both ends, diff -q
    identical. The only behavioural change in the PR remains the one decision 12 took, round 3
    measured, decision 19 grounded in Table 21 and decision 22 put in the README.
  • Manifest revision request: crates/gamut-exif/tests/report.rs. Two of the five sites
    decision 31 corrects live there (lines 303 and 342).
    Granted, and resolved in 0bfdb5e2
    (decision 38): both sites now name the thumbnail-format axis, matching the three src/ sites.
    No site in the crate still says the table is keyed on Compression.
  • The stripped-expansion proof is not byte-exact for a test target when a doc comment changes
    line count.
    The #[test] macro embeds each test's source position in its TestDesc, so a doc
    comment that gains a line moves start_line/end_line for every test below it. Recorded here
    because it is a property of the method, not of this change: a later round using the same proof on
    a test file should expect the digest to move, check that only those fields moved, and publish
    both digests as this one does. On a lib target the method stays byte-exact.
  • Open question for a maintainer: is ## Compatibility a convention or a one-off? This crate
    now carries a dated changed-verdict section because a registry reader cannot otherwise date the
    change. Decision 36 keeps it and declines to propose a repo-wide README convention on one
    instance. If it should be one, that is a repository decision, not this branch's.
  • src/tag.rs:4 still has a broken intra-doc link (exif_tags!). Pre-existing on master, in
    a file this branch does not touch, and already filed. Not repaired here.
  • No human approved this round's plan. This is an unattended run; the record above is what a
    human reads afterwards.

`gamut-ifd` shipped a streaming reader in P9 (#252) — `ReadAt`, `IfdReader`,
`Rebased` — that fetches only the directory bodies and the values they
reference. `gamut-exif` exposed none of it: its only entry point took a
`&[u8]`, so pulling the few kilobytes of EXIF out of a 300 MB raw file meant
loading the raw file.

Move the parse onto `IfdReader` and add `ExifReader::parse_from<S: ReadAt>`
alongside `parse`, which is now the `&[u8]` case of it — a slice is a `ReadAt`
source, so there is exactly one parse engine and the two entry points cannot
drift. The marker is detected through the source and the TIFF stream is reached
with `Rebased`, so every offset the crate reads or hands back stays in EXIF's
own frame of reference.

`parse` keeps its signature and its behaviour; the crate's existing tests for
lenient/strict sub-IFD and thumbnail handling are unchanged and pass as they
stood. The thumbnail range check moves from `usize` slicing to a 64-bit bound
against the source length, which drops an overflow branch that only 32-bit
targets could reach and stops a hostile `JPEGInterchangeFormatLength` being
allocated before it is bounded.

Deliberately synchronous: an async caller drives a `ReadAt` source itself,
which keeps a runtime dependency out of a crate that has none.

Refs #419
The default reader drops a malformed Exif/GPS/Interop sub-IFD or an
out-of-bounds thumbnail range so the rest of a real-world blob still parses,
but it was silent about it: a blob that never carried GPS and one whose GPS
pointer was dangling produced the same `Exif`. Worse, `follow` removed the
pointer tag *before* attempting the parse, so the evidence of what had been
there was gone by the time it failed.

Add `ExifReader::parse_with_report` and `parse_from_with_report`, returning a
`ReadReport` alongside the `Exif`. Each discarded region is named by a
`DroppedRegion`, the tag that addressed it, the offset that tag carried, and a
`DropReason` separating an address outside the blob from bytes inside it that
were not a directory. Both enums are fieldless with an explicit `repr` and
append-only discriminants, and `Dropped` is `Copy` plain data behind accessors,
so the report crosses an FFI boundary unchanged.

The pointer is now removed after the read is attempted rather than before. The
removal itself is unchanged, so `parse` and `parse_from` return exactly what
they did; they simply discard the report. In strict mode the first malformed
region still fails the parse, so a strict report is always empty.

Closes the reader-validation item `STATUS.md` had deferred. Two finer
capabilities stay deferred and are recorded there: per-tag recovery inside one
directory (a `gamut-ifd` concern — one bad entry fails its whole IFD) and a
byte-completeness verdict over the blob.

Refs #419
Review of #522 found the report's completeness claim false and its leniency
too broad. Both are fixed here; neither existed before that PR.

The report silently lost a top-level directory past the 1st IFD. EXIF defines
exactly two, so `parse_source` took `ifds.next()` twice and dropped the
iterator: a three-directory chain lost the third with an empty report, and
`to_bytes` round-tripped 86 source bytes to 56. Add `DroppedRegion::TrailingIfd`
and `DropReason::Unrepresentable` so such a directory is named at its own
offset. It is the first region no tag addresses — the chain is followed through
the structural next-IFD pointer — so `Dropped::tag` is 0 there and `Display`
omits the tag clause rather than claiming tag 0x0000, which is a real tag
number. The offsets come from a second walk of the chain that runs only when
there is something to report, so the lazy read bound is untouched.

A failing `ReadAt` source was swallowed and blamed on the file. `follow` matched
`Err(_)` without inspecting the error and `address_reason` decided purely from
`offset < len`, so a source whose transport failed mid-parse returned `Ok` with
the sub-IFDs missing and a report calling structurally perfect directories
malformed — worst for exactly the network-backed sources this entry point
exists to enable, and asymmetric, since the same failure during the thumbnail
read already propagated. Key both `follow` and the marker probe on
`Error::kind`: `InvalidInput` is what leniency is for, everything else is
propagated unchanged. Keying the probe on the kind rather than on a length also
stops `require_marker(true)` reporting `MissingMarker` for a blob whose marker
is unknown rather than absent, while a genuinely short slice still reads as
unmarked, so `parse`'s behaviour on slices is unchanged.

Two known losses remain outside the report, both below this crate: a shadowed
duplicate tag (#528) and a single unparseable entry failing its whole directory
(#521). The module docs, README and STATUS now say so, and `is_empty` is
documented as a verdict over the regions the report covers rather than as
"this parse lost nothing".

Also: `stream` is a private module, as the record said it should be — it
exports no items; `follow`'s comment no longer claims a reorder was
load-bearing when the offset was already bound before the removal; and
`DroppedRegion::name` no longer claims every variant matches `InvalidIfd`,
which was never true of the thumbnail.

Refs #419, #521, #528
Round-2 review of #522 found the one read site the transport/malformed split
missed, plus a sweep whose fixture could not reach it.

`maker_note_offset` did `read_ifd(..).ok()?`, discarding every error including
`Error::Io`. The falsifier "a later read resurfaces it" is false: `follow`
returns before reading at all when the GPS and Interop pointers are absent,
which is the common case. So a source that failed there returned `Ok` with
`maker_note_offset() == None` and an empty report — and since the writer uses
that offset to pin the note, a vendor MakerNote with absolute internal offsets
was re-emitted unpinned: wrong bytes, no error, nothing reported. It now keys on
`Error::kind` like the other three sites.

The sweep asserted it covered "every read site" while using a fixture with no
MakerNote and an Interop pointer, so every budget that failed inside the pin was
rescued by the later Interop read. It now runs over two fixtures — the second
has an out-of-line MakerNote and neither optional pointer — and additionally
asserts that an `Ok` from a failing source is the *whole* answer, not a quietly
diminished one. Reverting the fix makes it fail at budget 9, the reviewer's own
reproduction.

`Dropped::tag` becomes `Option<u16>`. The `0` sentinel rested on "0 is never a
pointer tag", which held only while every region was pointer-addressed —
`TrailingIfd` broke that, and `0` is a real tag number (`GPSVersionID`). `Dropped`
is unreleased, so this costs nothing now and could not be done later. `Display`
gains one grammar with an explicit `(tag none)` rather than two shapes a caller
would have to parse.

`record_trailing_ifds` takes a `bool` instead of a count it only zero-tested, so
`saturating_sub(2)` can no longer be mutated to `saturating_sub(1)` with
identical behaviour. The walk is the single source of truth for which
directories are trailing. The laziness bound drops from 512 to 300 bytes to make
that guard falsifiable: an unnecessary re-walk costs 347 bytes against 251
clean, so it is now a failure rather than an invisible inefficiency.

Also corrected: the deferral in #528 was justified by "no signal this crate can
observe", which is false — `RawIfd::entries` is public and in on-disk order, and
`follow` already holds the `RawIfd`, so a shadowed tag is detectable here in
three lines. The deferral stands, but on the real reason: this crate cannot say
*what* was lost without re-decoding it, and three crates need the same signal.
STATUS.md and the README now say that; #528's body still carries the weaker
reason. The retracted "this parse lost nothing" phrasing is gone from the last
place it survived, and `TrailingIfd` being reported in strict mode too is
documented as intended rather than left to prose.

Refs #419, #521, #528
`mise run mutants-diff` left one survivor: replacing
`e.kind() != ErrorKind::InvalidInput` with `true` in `maker_note_offset`
changed nothing, because the lenient arm behind it is unreachable.

That guard was copied from the three sites where it is load-bearing, but this
one is different: it runs only after `follow` has already read *and* decoded
this exact directory at this exact offset, so a deterministic source cannot fail
here for a reason the bytes explain. The malformed-input arm could never be
taken, which is why no test could kill the mutant.

Propagate every error with `?` instead. The transport-failure behaviour NEW-1
asked for is unchanged — that is what the sweep pins — and an unfalsifiable
branch is removed rather than papered over with an exclusion or a test for a
source that contradicts itself.

Refs #419
A 1st IFD carrying `JPEGInterchangeFormat` without `JPEGInterchangeFormatLength`
fell into `read_thumbnail`'s catch-all `None` arm: no bytes, no error and no
report entry, in either mode. That is a silent loss inside the exact region
`ReadReport` claims completeness over — an address with nothing to size the read
by, so the JPEG behind it is gone with no trace that it was ever addressed.

Exif 3.0 §4.6.9.2 Table 21 marks both tags mandatory for a compressed thumbnail,
so half the pair is a malformed range rather than an absent thumbnail. Lenient
mode now names it with the new `DropReason::Incomplete` at the offset the tag
carried; strict mode rejects it, as it already did for an out-of-bounds range. A
length with no offset addresses nothing at all, so it stays silent.

`DropReason` is `#[non_exhaustive]` with append-only discriminants, so the added
reason is not a breaking change. Over a 3144-case truncation-and-corruption
sweep against the previous release the strict rejection changes 12 cases, every
one of them `strict(true)` with a byte flip inside the
`JPEGInterchangeFormatLength` entry header; lenient mode is unchanged.
…g source

The failing-source sweep claimed to reach "every read site", but neither fixture
had a thumbnail or a trailing directory, so `read_range`'s fetch and
`record_trailing_ifds`' chain re-walk were never swept — the two read sites added
most recently, and the ones a transport failure would reach last.

`maker_note_blob` becomes `deep_blob`: it keeps the out-of-line `MakerNote` and
the absent GPS/Interop pointers that make a lost pin observable, and adds an
in-bounds thumbnail range and a third top-level directory. The payload's position
is not knowable before `write` lays the directories out, so the fixture patches a
sentinel `JPEGInterchangeFormat` value and asserts the sentinel names exactly one
value field.

A trailing directory is a legitimate drop that a clean parse reports too, so the
law can no longer be "the report is empty". It is now the stronger claim that an
`Ok` from a failing source equals the clean parse in report, maker-note pin and
thumbnail bytes — which still catches a transport failure blamed on the file, and
also catches a quietly diminished answer. Over 40 budgets the deep fixture splits
17 `Ok` / 23 `Err`, every error keeping `ErrorKind::Io`.

The one `ReadAt` method left unswept is `len`, which answers a length rather than
reading bytes; the doc comment now says so instead of claiming every site.
…links

Five documented claims did not match the code, and each is now stated once and
pinned where it can be falsified.

`ExifReader::parse_with_report` said "a strict report is always empty". It is
not, and `ReadReport`'s own docs in the same crate say the opposite and are
right: strictness rejects malformed regions, and a trailing directory is
well-formed and merely unrepresentable, so it is reported in both modes. The
stale sentence predates `TrailingIfd` and re-hid the very loss that variant
exists to surface. A strict three-directory chain now pins one `TrailingIfd`
entry.

`parse`'s error offsets and `Dropped::offset` are in different frames, and the
divergence was undocumented and unmeasured. Routing through
`IfdReader::open(source.rebased(base))` attaches the physical offset, so a
diagnostic that was TIFF-stream-relative became blob-relative. Both frames are
kept and named: a diagnostic points into the buffer the caller handed in, while a
report offset addresses the TIFF structure the report describes, so for a marked
blob they differ by the six-byte marker. A 3144-case truncation-and-corruption
sweep against the previous release finds 52 differing error strings, all marked
blobs, all differing by exactly six, and none differing once normalised; every
bare-blob offset and every re-serialised byte is identical. The claim of zero
mismatches was therefore wrong, and so was "neither is a change for existing
callers".

Two comments still described `Dropped::tag` as a `0` sentinel, sitting directly
above assertions of `None`; the README still showed the pre-`Display`-rewrite
grammar in a block nothing compiles; and two rustdoc links broke when the
`ExifError` import was narrowed and the private `stream` module stopped being
published. `RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-exif --no-deps` now
reports only the link that predates this branch.

Finally, the claim that a malformed-input arm in `maker_note_offset` is
unreachable holds only for a deterministic source. A `ReadAt` may answer
differently on a second read — a file rewritten underneath the reader, which is
what `parse_from` exists to enable — and the comment now says so, along with why
a hard error is still the right answer there.
`DropReason::Incomplete` was reachable from exactly one place — a
`JPEGInterchangeFormat` offset with no `JPEGInterchangeFormatLength` beside
it — but its name described a shape ("addressed but never fully described")
rather than that site. A generic name on a single-site variant attracts
unrelated reuse, and the discriminant is append-only once released, so rename
it to `ThumbnailLengthMissing` before it freezes. A future defect that is
merely *similar* gets its own variant on the `#[non_exhaustive]` enum instead.

The rendered clause moves with it: `has no JPEGInterchangeFormatLength to size
the read` states what is missing, which the old wording left to the reader.

The variant is new on this branch and has never been published, so no released
API changes.
Strict mode rejected a thumbnail offset with no length as
`JPEGInterchangeFormat without JPEGInterchangeFormatLength`, which reads as
"you failed to record a required tag". Exif 3.0 §4.6.9.2 Table 21 gives that
pair's support level per `Compression` column: mandatory under **Compressed**,
and `N` — not allowed to record — under all three uncompressed columns. So for
an uncompressed thumbnail the message named a sibling the cited table forbids
recording there.

The rejection itself is unchanged and is not derived from the support level: an
offset with nothing to size it addresses bytes that cannot be fetched, whatever
`Compression` says. The message now states exactly that, so it asserts nothing
the spec does not.

Whether the rule should instead be conditioned on `Compression`, and whether a
length with no offset should be rejected for symmetry, is a behavioural change
with its own equivalence sweep to run.

Refs #574
Four sites cited Exif 3.0 §4.6.9.2 Table 21 as making the thumbnail pointer
pair mandatory full stop. It does not: the table gives each 1st IFD tag's
support level per `Compression` column, and both `JPEGInterchangeFormat` and
`JPEGInterchangeFormatLength` are `M` only under **Compressed** — under all
three uncompressed columns they are `N`, not allowed to record. The reader does
not read `Compression`, so its rule cannot rest on that mandate. Each site now
grounds the rule in the structure (an offset with nothing to size it addresses
bytes that cannot be read) and states the table's conditioning as the open
question it is.

Two further notes the code did not carry:

- `parse_from` and `parse_from_with_report` had no offset-frame note, though a
  streaming caller is the one most likely to correlate an error offset against
  a file. Both frames — error offsets counted in the caller's source, report
  offsets from the start of the TIFF stream — are now stated where they are
  produced, as they already were on the slice entry points.
- The thumbnail pointer's removal is conditioned on bytes having been read,
  which is #548. That issue names only the out-of-bounds case; the
  missing-length arm is a second instance, and a sharper one, because the
  re-emitted blob still has an offset and still has no length, so a strict
  parse rejects a blob this crate itself wrote. Recorded beside the code.

Refs #548, #574
…dict

The previous commit narrowed four sites that cited Exif 3.0 §4.6.9.2 Table 21 as
making the thumbnail pointer pair mandatory outright. It missed a fifth — the
crate's own front page — because it was found by grepping for the phrase "Table
21", and that site cites the clause without naming the table. Grepping for the
citation (§4.6.9.2) finds all of them; grep for the citation, not the prose
around it.

`lib.rs` therefore still said the pair is "which Exif 3.0 §4.6.9.2 requires
together" and called it "the malformed pair it is". Both are wrong: the table
gives each 1st IFD tag a level *per `Compression` column*, and the pair is `N`
(not allowed to record) under all three uncompressed columns. It now grounds the
rule structurally, as the other sites do.

Reading the table settles the open question of whether the unconditional strict
refusal is defensible. `JPEGInterchangeFormat` and `JPEGInterchangeFormatLength`
carry an identical level in all four columns (N N N M) and `Compression` itself
is mandatory in all four, so an offset with no length is non-conformant under
every column: there is no conformant 1st IFD the strict arm wrongly rejects. The
rule stays unconditional, and issue #574's conditioning option is recorded as
cheap rather than costly, since `Thumbnail::compression` already reads the tag.

Three further corrections:

- "a tag this crate does not read" was false — `Thumbnail::compression` is a
  public accessor. Every site now says this *reader* does not consult it.
- The note beside the #548 comment argued that the missing-length instance is
  "sharper" because a strict parse of the re-emitted blob fails. Both instances
  self-reject, so that is a shared property. What actually separates them is
  novelty: the out-of-bounds instance is already rejected strictly before this
  change, while the missing-length one is created by it.
- `parse_with_report` hands a caller both offset frames in one call and had no
  note saying so; `parse`'s error list omitted `BadThumbnail`, the variant this
  change gives a new way to reach.

The changed strict verdict now appears in a shipped document. It is a fix, not a
redefinition — the input that now fails was non-conformant under every column —
so no major version is forced, but a caller running strict can learn of it from
the README rather than only from a commit body.

Refs #548, #574
Four documentation corrections, none of which changes an executable line.

`parse_with_report` said the two offset frames "both reach the caller here, in
one call". The return is a sum type, so that is false: a blob carrying a
reportable trailing directory *and* a strict-fatal dangling sub-IFD pointer
returns, under `strict`, the error and no report at all — `record_trailing_ifds`
runs before `follow`, so a drop it already recorded is discarded with the `Ok`.
The streaming twin's wording ("the two frames meet here") was the correct one;
the slice entry point now uses it and says why they only meet.

`DropReason::ThumbnailLengthMissing` justified #574's conditioning option as
cheap because `Thumbnail::compression` already reads the tag. That accessor
reads a *finished* thumbnail, and the arm #574 would condition returns before
one is built, so the fact does not reach the site. The conclusion holds for the
adjacent reason the text now gives: the 1st IFD is in scope there and
`Compression` is the same one-line lookup that reads the offset and the length
two lines above.

The #548 comment said the out-of-bounds instance is "pre-existing (the default
branch already rejects it strictly)". Sitting on a `match`, "the default branch"
reads as the default arm, under which the sentence is false. It names `master`
now.

Table 21's four columns are not four values of `Compression` — that tag has two.
They are three uncompressed columns (Chunky, Planar, YCC), an axis of photometric
and planar layout, plus Compressed. The README said "each of its four
`Compression` columns" and now names the axis. The remaining "per column" sites
are shorthand and are left alone.

The README's changed-verdict note also moves under a `## Compatibility` heading
and names 1.0.0 as the version the verdict changed from, since a reader arriving
from a registry cannot otherwise date it. It separates the two axes that were
argued through one another: the rule is readability-driven — an offset with no
length cannot be read — while conformance is only what makes the move a fix
rather than a redefinition. The claim it ships is the grounding the vendored
table gives; the before/after comparison behind it stays in the pull request
rather than becoming a harness that would have to depend on a published version.

Refs #548, #574
Four documentation corrections. The macro-expanded `--all-features` lib
target is byte-identical across this commit once doc lines are stripped
(3 098 lines, sha256 f72897dd8d4100b022173ba1ffc0b2f3a0a86491cdaf077b3fad162a0305f292
at both ends), so nothing executable moved.

The `## Compatibility` note said the changed verdict applies "from the next
release". A README is packaged per version and rendered on the registry page
for that version, so the copy shipped *with* the release that carries the
change would tell its reader the change is still upcoming. The sentence now
states the verdict without a tense; the heading above it is already anchored
to 1.0.0 and stays correct in every published copy.

Three in-crate sites said Table 21 states the pair's level "per `Compression`
column". At each the phrase names the axis the levels vary over, and two go
on to enumerate all four columns — so they assert the table is keyed on a tag
that has two values (§4.6.5.1.4: 1 = uncompressed, 6 = JPEG). The table's
header spans three uncompressed columns (Chunky, Planar, YCC) plus
Compressed. They now name the thumbnail-format axis and say it is not that
tag. `src/reader.rs` and the `read_thumbnail` comment already spoke exactly —
"mandatory only under `Compression = Compressed`, and forbidden under the
uncompressed columns" is a statement about one column, not about the axis —
and are unchanged. The two remaining sites are in `tests/report.rs`.

The README's own axis description was an appositive attached to a list whose
fourth member is a compression state, so it called Compressed a photometric
and planar layout. The description now scopes to the three uncompressed
columns it actually distinguishes.

`parse_from_with_report` lacked the sum-type caveat its slice twin carries:
the two offset frames meet there but do not always arrive together, because
a strict-fatal defect reached after a reportable one returns the error and
discards the report. Both twins now say so.

Refs #574
Finishes the correction `a6529623` applied to the three sites inside the
previous round's manifest. `tests/report.rs:303` and `:342` carried the same
false phrase — Table 21 states the pair's level "per `Compression` column" —
and after `a6529623` they contradicted the three corrected sites in `src/`,
which is the shape of defect this crate's own documentation was being repaired
for. Both now name the thumbnail-format axis and say it is not that tag.

`Compression` (tag 259) has two values in Exif 3.0 §4.6.5.1.4: 1 = uncompressed
and 6 = JPEG. Table 21's four columns are three uncompressed ones (Chunky,
Planar, YCC) plus Compressed, so the table is not keyed on that tag.
`src/reader.rs` and `read_thumbnail`'s inline comment stay unchanged: both say
the pair is mandatory only under `Compression = Compressed` and forbidden under
the uncompressed columns, which is a statement about one column's value and is
exact.

Doc comments only. Every changed line is a `///` line, and the macro-expanded
`--all-features` `report` test target is unchanged across this commit once doc
lines are stripped and the `#[test]` harness's `TestDesc` source positions are
normalised: 1 039 lines, sha256
764490d5971c45e8c967c086aecd7ee4f708566527157cfb090c5589a4a1eed2 at both ends.
Un-normalised, sixteen lines differ and all sixteen are `start_line`/`end_line`
in `TestDesc`, because two doc comments each gained a line and four tests moved
down the file. The lib target is byte-identical without any normalisation
(3 098 lines, sha256
f72897dd8d4100b022173ba1ffc0b2f3a0a86491cdaf077b3fad162a0305f292).

Refs #574
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