Skip to content

feat(cli): report a HEIC's C2PA manifest store in gamut inspect, without validating it - #531

Open
justin13888 wants to merge 18 commits into
feat/224-png-encoder-efficiencyfrom
feat/448-cli-inspect-c2pa
Open

justin13888 wants to merge 18 commits into
feat/224-png-encoder-efficiencyfrom
feat/448-cli-inspect-c2pa

Conversation

@justin13888

@justin13888 justin13888 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #485 (feat/224-png-encoder-efficiency) and opened against that branch, because #485
changed crates/gamut-cli/src/commands/inspect.rs substantially — the exit-code contract, the PNG
arm and docs/inspect-exit-codes.md are all its work, and this section fits inside that contract
rather than fighting it. Review the nine commits this branch adds on top of that base only.

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

Summary

gamut inspect gains a HEIC arm that answers one question — does this file carry a C2PA manifest
store, and where? — and answers only it:

Real output from the binary built at e54622c5, verbatim (long lines are one terminal line each):

$ gamut inspect mid-update.heic
mid-update.heic: HEIF/HEIC
  C2PA: 2 manifest stores located, NOT VALIDATED — gamut locates a C2PA manifest store and never validates it: it checks no signature, no hash binding and no trust list. Validate with c2pa-rs.
    box_purpose "original": 22 bytes at [61, 83) — located, not validated
    box_purpose "update": 27 bytes at [126, 153) — located, not validated
(exit 0)

$ gamut inspect merkle-only.heic
merkle-only.heic: HEIF/HEIC
  C2PA: no manifest store could be read, but 1 C2PA box is present — that is NOT absence of provenance — gamut locates a C2PA manifest store and never validates it: it checks no signature, no hash binding and no trust list. Validate with c2pa-rs.
    unread C2PA box at [16, 65): its box_purpose is not one C2PA 2.4 §A.5.3 gives a manifest store
(exit 0)

$ gamut inspect no-c2pa.heic
no-c2pa.heic: HEIF/HEIC
  C2PA: no manifest store found in the top-level boxes of the primary stream — gamut locates a C2PA manifest store and never validates it: it checks no signature, no hash binding and no trust list. Validate with c2pa-rs.
(exit 0)

$ gamut inspect photo.avif
error: photo.avif: unsupported container brand 'avif' — gamut inspect reads HEVC still images (HEIF/HEIC) here
(exit 1)

An earlier revision of this body showed a sample no run produced. It is replaced above.

  • The logic is in gamut-heic, not in the CLI — because that is where the facts and the words
    about them belong, so every host renders the same wording and none can reword the disclaimer
    away. HeifContainer::c2pa_summary() returns a C2paSummary of C2paStoreSummary entries
    (half-open range, size, box_purpose) and report_lines() renders them; inspect.rs prefixes
    its own indent and holds nothing else. The premise an earlier revision of this body gave for
    that placement — that gamut-cli is outside the coverage gate, so logic there ships untested —
    is false and is retracted.
    Coverage exclusion is not test exclusion: crates/gamut-cli/tests/
    drives the built binary, and inspect.rs carries an inline #[cfg(test)] mod tests. See
    decision 15.
  • A present-but-unreadable C2PA box is reported as such, not as absence. A genuine
    ContentProvenanceBox whose FullBox version is non-zero, whose box_purpose is the auxiliary
    merkle, that is truncated, or whose JUMBF LBox is hostile now gets its own line naming the
    reason (C2paSummary::unread). Printing "no manifest store found" for it would let a reader infer
    no provenance from bytes gamut merely could not read — the mirror of the defect this PR exists
    to prevent. It stays exit 0: the exit code says whether the inspection succeeded, not what it
    found (decisions 11 and 12).
  • A summary cannot print a store. C2paStoreSummary does not hold the bytes, by construction —
    they are opaque to gamut, routinely hundreds of kilobytes once a manifest embeds a thumbnail, and
    a byte range is what a caller hands to c2pa-rs.
  • The disclaimer is inline and covered by a test. C2PA_NOT_VALIDATED lives beside the helper,
    so the wording is under test rather than living only in a CLI format string. C2PA 2.4 §15.12 puts
    validation on a validator; "C2PA: present" printed beside EXIF and ICC otherwise reads as
    verified to anyone who has seen a Content Credentials badge.
  • Every store is listed with its own purpose. A file mid-update legitimately carries an
    original and an update box (§A.5.3), and which is active is a validator's judgement.
  • The exit code never moves on a finding. The HEIC arm classifies no bytes and looks for no
    unknowns, so it has nothing to hold against the file: a located store, an unreadable box and no
    box at all all exit 0. Only a container that cannot be parsed, or one that is not this arm's
    container, exits non-zero. docs/inspect-exit-codes.md records that, and states plainly that
    absence of a store line is not absence of provenance.
  • The container is confirmed, not guessed. The ftyp test is a route into the arm; the arm then
    confirms with gamut-heic's own HeifImage::is_hevc_still (references/heif §7) after the parse
    it performs anyway, and reports an unsupported container brand otherwise. An earlier revision
    matched the major brand alone against a hand-rolled list on the claim that this excludes AVIF;
    that claim is falsemif1 is the generic MIAF structural brand and an AVIF may carry it as
    its major brand. A real AVIF is now declined by name rather than blamed on TIFF for having no
    byte-order mark. --format heic skips both the sniff and the confirmation, and is documented as
    doing so.

Validation

Every command below completed in the run that produced this body, in the worktree at e54622c5.
GAMUT_MUTANTS_BASE is set to this PR's base branch so --diff selects this PR's changes rather
than also the stacked base's.

command outcome
cargo test -p gamut-heic -p gamut-cli --all-features pass — 0 failed; 37 in gamut-heic/tests/c2pa.rs, 4 in the new gamut-cli/tests/inspect_c2pa.rs
cargo clippy -p gamut-cli -p gamut-heic --all-targets --all-features -- -D warnings pass — no warnings
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt applied
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass (exit 0)
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
mise run check-commits pass — "no errors in 74 commits"
convco check origin/feat/224-png-encoder-efficiency..HEAD pass — "no errors in 9 commits"
mise run lint pass (exit 0), whole workspace
mise run test pass (exit 0), whole workspace — 208 test binaries, 0 failed
GAMUT_MUTANTS_BASE=origin/feat/224-png-encoder-efficiency mise run mutants-diff pass — 40 mutants: 30 caught, 10 unviable, 0 missed

mise run lint, mise run test and mise run mutants-diff ran inside a MemoryMax=16G,
MemorySwapMax=0 systemd scope with CARGO_BUILD_JOBS=2 and ulimit -v.

mise run check-release-deps / check-ffi-features / check-ffi-header were not run: no
Cargo.toml changed and no C-surface type changed — gamut-ffi and gamut-wasm mention C2PA
nowhere (grep -rni c2pa crates/gamut-ffi crates/gamut-wasm is empty).

What the mutation gate does not cover. crates/gamut-cli is in .cargo/mutants.toml's
exclude_globs, so none of the 40 mutants above came from inspect.rs or error.rs, and the new
tests/inspect_c2pa.rs killed none of them. That test's value is demonstrated differently: reverting
the is_hevc_still confirmation, or adding .skip(1) to the report loop, fails it. The gate is
stated here rather than implied.

End-to-end behaviour, against fifteen hand-built fixtures plus a gamut-encoded AVIF, run through
the built binary. Before/after on the two defects this revision repairs:

file before after
a gamut-encoded AVIF error: invalid input: TIFF: bad byte-order mark [origin: gamut-ifd], exit 1 error: …: unsupported container brand 'avif' — …, exit 1
the same AVIF with its major brand patched to mif1 …: HEIF/HEIC + a full C2PA section, exit 0 error: …: unsupported container brand 'mif1' — …, exit 1
C2PA box, FullBox version 1 C2PA: no manifest store found … C2PA: no manifest store could be read, but 1 C2PA box is present — that is NOT absence of provenance … + unread C2PA box at [16, 90): its FullBox version or flags are not zero …
C2PA box, box_purpose = "merkle" C2PA: no manifest store found … … 1 C2PA box is present … + unread C2PA box at [16, 80): its box_purpose is not one C2PA 2.4 §A.5.3 gives a manifest store
C2PA box, LBox = 0 / 4 / 0xFFFFFFFF C2PA: no manifest store found … … 1 C2PA box is present … + unread C2PA box at [16, 90): no valid JUMBF store length sits where its box_purpose puts the store
C2PA box, truncated to the user type / unterminated box_purpose C2PA: no manifest store found … … 1 C2PA box is present … + unread C2PA box at […): the box ends before the framing C2PA 2.4 §A.5.1.2 requires
uuid box one byte off the C2PA extended type C2PA: no manifest store found … unchanged — deliberately; see decision 13
no C2PA box C2PA: no manifest store found … unchanged
a located store store line + disclaimer unchanged
gamut inspect --format heic <a real AVIF> …: HEIF/HEIC + C2PA section, exit 0 unchanged — deliberately; see decision 14

__CARGO_TEST_ROOT is a known artefact of running in a nested .claude/worktrees/ worktree: cargo
otherwise walks past the worktree root to the primary checkout's Cargo.toml when loading the
tooling/* manifests and fmt-check exits 101 on an untouched tree. No manifest was changed for
it.

Round 3 (head 35450f51)

Every command below completed in this round's run, in the same worktree, after the four commits
this round adds. GAMUT_MUTANTS_BASE is again the stacked base, so --diff selects only this
PR's changes.

command outcome
cargo test -p gamut-heic -p gamut-cli --all-features pass — 0 failed; 18 in gamut-heic's lib tests, 43 in tests/c2pa.rs, 7 in gamut-cli/tests/inspect_c2pa.rs
cargo clippy -p gamut-heic -p gamut-cli --all-targets --all-features pass — no warnings
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then mise run fmt-check pass (exit 0)
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/master..HEAD pass — "no errors in 78 commits"
mise run lint pass (exit 0), whole workspace
mise run test pass (exit 0), whole workspace — 208 test binaries, 0 failed
GAMUT_MUTANTS_BASE=origin/feat/224-png-encoder-efficiency mise run mutants-diff pass — 75 mutants: 65 caught, 10 unviable, 0 missed

mise run lint, mise run test and mise run mutants-diff ran inside a MemoryMax=16G,
MemorySwapMax=0 systemd scope with CARGO_BUILD_JOBS=2 and ulimit -v. An earlier invocation of
mutants-diff without GAMUT_MUTANTS_BASE selected 450 mutants — the whole stacked diff against
master, i.e. #485's PNG work as well — and was stopped and re-run against the correct base; its
result is not claimed here.

mise run check-release-deps / check-ffi-features / check-ffi-header were not run for the
same reason as before: no Cargo.toml changed and no C-surface type changed.

Which gates can see crates/gamut-cli, stated again plainly. mise run test, mise run lint,
mise run fmt-check and mise run check-tests all see it: its tests run, its clippy runs, its
formatting is checked. The mutation gate cannotcrates/gamut-cli/** is in
.cargo/mutants.toml's exclude_globs, so none of the 75 mutants above came from inspect.rs,
and no test in tests/inspect_c2pa.rs killed any of them. The coverage gate cannot either — the
same paths are excluded by --ignore-filename-regex. So the cap, the … and N more tail and the
asserted-format label are held only by tests/inspect_c2pa.rs driving the built binary, and by
mise run test running it.

Rendered output, before and after. Fixtures were written by the test helpers in
crates/gamut-cli/tests/inspect_c2pa.rs; "before" is the binary built from this branch's previous
head e54622c5 with only the three source files reverted.

The near miss — a top-level uuid box one byte off the C2PA extended type:

BEFORE                                                            (exit 0)
near-miss.heic: HEIF/HEIC
  C2PA: no manifest store found in the top-level boxes of the primary stream — gamut locates a C2PA manifest store and never validates it: it checks no signature, no hash binding and no trust list. Validate with c2pa-rs.

AFTER                                                             (exit 0)
near-miss.heic: HEIF/HEIC
  C2PA: no manifest store found in the top-level boxes of the primary stream — gamut locates a C2PA manifest store and never validates it: it checks no signature, no hash binding and no trust list. Validate with c2pa-rs.
    other top-level uuid boxes: 1 (extended type is not the C2PA one; a uuid box is not provenance framing)

Before, diff between that report and the report for a file carrying no uuid box at all was
empty below the path line — the two were byte-identical. After, they differ by exactly the one
line above.

The uncapped list — a legal 2.95 MB file carrying fifty thousand top-level C2PA boxes:

BEFORE   50 002 stdout lines, 6 212 673 bytes, 0.08 s, exit 0
AFTER        23 stdout lines,     2 701 bytes, 0.02 s, exit 0
         … the twentieth line is followed by "    … and 49980 more"

The headline, disclaimer included, is line 2 of 23 rather than line 2 of 50 002.

The asserted format — gamut inspect --format heic over a genuine AVIF:

BEFORE   forced.avif: HEIF/HEIC
AFTER    forced.avif: HEIF/HEIC (asserted by --format, not detected)

A detected container keeps the plain <path>: HEIF/HEIC label.

Round 5 (head a451684e)

Round 4 was a review round and added no commits. This round adds five: four repairing what that
review established, and a fifth repairing what the mutation gate then found in the first of them.
Every command below completed at this head, in the same worktree. GAMUT_MUTANTS_BASE is again
the stacked base — origin/feat/224-png-encoder-efficiency (cce9939c), this PR's own base — so
--diff selects this PR's changes and not #485's underneath.

command outcome
cargo test -p gamut-heic -p gamut-cli --all-features pass — 0 failed; 19 in gamut-heic's lib tests, 44 in tests/c2pa.rs, 9 in gamut-cli/tests/inspect_c2pa.rs
cargo clippy -p gamut-heic -p gamut-cli --all-targets --all-features -- -D warnings pass — no warnings
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then mise run fmt-check pass (exit 0)
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
convco check origin/feat/224-png-encoder-efficiency..HEAD pass — "no errors in 18 commits"
mise run lint pass (exit 0), whole workspace
mise run test pass (exit 0), whole workspace — 208 test binaries, 0 failed
GAMUT_MUTANTS_BASE=origin/feat/224-png-encoder-efficiency mise run mutants-diff pass — 82 mutants: 71 caught, 11 unviable, 0 missed, 0 timeouts

mise run lint and mise run test ran inside a MemoryMax=16G, MemorySwapMax=0 systemd scope
with CARGO_BUILD_JOBS=2 and ulimit -v; the mutation task builds its own capped scope.
check-release-deps / check-ffi-features / check-ffi-header were again not run: no Cargo.toml
changed and no C-surface type changed.

The gate this round failed, and why the same gate was green here. The first repair below merged
the two lists in file order through a hand-written Iterator::next. The mutation gate replaces such
a body with Some(…), which makes the stream unbounded, so every consumer of report_lines fills
memory until something stops it — and what stops it is a property of the machine, not of the code.
CI's runner sat in the loop until the 60 s test timeout and reported exit 3 on two shards
(Incremental (PR diff) (0) and (1), one mutant each: replace <impl Iterator for DetailLines<'_>>::next -> Option<String> with Some(String::new()) and with Some("xyzzy".into())).
Under this checkout's ulimit -v 8 GiB the identical mutant aborted on the failed allocation within
the timeout and counted as caught, which is why the local run of the same gate had been green
(80 mutants, 70 caught, 10 unviable). A local mutation run therefore cannot be trusted about a
hang here; only the count above, taken after the merge was made bounded, is claimed. The repair
removes the shape rather than the mutant: see decision 31.

Where the required checks stand on this head. Format & Metadata, Clippy & Doctests and all
four Incremental (PR diff) mutation shards pass; shards 0 and 1, which failed with exit 3 on
the previous head, are green. Coverage (test gate) failed on its first attempt at
1h23m with no logs uploaded at all (the job's log blob 404s and its "Generate coverage report"
step never completed), which is a lost runner rather than a gate verdict — a threshold failure
completes the step and uploads its log. It was re-run. Coverage on this stacked branch is
long-running by nature: the two most recent successful coverage jobs on it took 4 h and 5 h,
so the failed attempt was shorter than either. The re-run was still in progress when this round
ended.

The blind spot this round's first defect lived in, stated plainly. crates/gamut-cli/** is
excluded from the mutation gate (.cargo/mutants.toml's exclude_globs) and from the coverage
gate (the --ignore-filename-regex). The defect — a cap that could hide a whole class of box — sat
in the interaction between that command's cap and gamut-heic's grouped rendering, so a green
workspace gate was never evidence about it. What holds each repair is named in
docs/inspect-exit-codes.md and reproduced here:

repair test
the headline names a class whose lines the cap dropped gamut-cli/tests/inspect_c2pa.rs::the_headline_names_a_class_of_box_the_cap_hides_entirely (built binary)
the cut takes the file's last boxes, not one kind of box gamut-cli/tests/inspect_c2pa.rs::the_capped_list_is_the_files_first_boxes_and_not_its_first_stores (built binary)
the four headline shapes gamut-heic/src/c2pa.rs::tests::the_headline_states_both_categories_when_the_file_carries_both and the three single-class tests beside it (inside the mutation gate)
detail lines in true file order gamut-heic/src/c2pa.rs::tests::the_detail_lines_interleave_the_two_kinds_in_file_order, plus …::a_store_and_an_unread_box_starting_together_render_the_store_first for the tie
the list is no longer than the summary gamut-heic/src/c2pa.rs::tests::the_capped_and_uncapped_renderings_are_the_same_lines, which asserts detail_line_count() == detail_lines().count()
the count line's wording and indent gamut-heic/src/c2pa.rs::tests::uuid_boxes_of_another_extended_type_are_reported_as_a_count_of_bytes_present, and gamut-cli/tests/inspect_c2pa.rs::a_uuid_box_of_another_extended_type_reaches_stdout_as_a_count
no report can borrow the file gamut-heic/tests/c2pa.rs::a_summary_holds_no_borrow_of_the_file_it_describes

The hidden-class shape, rendered before and after. Twenty legal stores followed by one C2PA box
whose FullBox version is 1, spliced after ftyp. Twenty store-shaped boxes need no malformity —
§A.5.3 permits any number — so this is a file anyone can build. Both runs are the built binary; the
"before" is this branch's head before this round, 35450f51, with nothing else changed. Both were
executed at the heads named, and the store lines in the middle are elided here for length only.

BEFORE (binary built at 35450f51)                                            exit 0
hidden-class.heic: HEIF/HEIC
  C2PA: 20 manifest stores located, NOT VALIDATED — gamut locates a C2PA manifest store and never validates it: it checks no signature, no hash binding and no trust list. Validate with c2pa-rs.
    box_purpose "manifest": 14 bytes at [69, 83) — located, not validated
    … 19 further store lines, [128, 142) through [1190, 1204) …
    … and 1 more

  lines matching "unread C2PA box at": 0

Nothing on that stdout says the file carries a C2PA box gamut could not read through. A caller
following the normative document's own instruction — treat the unread C2PA box line as unknown,
never as absence — read a clean bill of health.

AFTER (binary built at a451684e)                                             exit 0
hidden-class.heic: HEIF/HEIC
  C2PA: 20 manifest stores located, NOT VALIDATED, and 1 C2PA box is present from which no store could be read — a box gamut could not read through is NOT absence of provenance — gamut locates a C2PA manifest store and never validates it: it checks no signature, no hash binding and no trust list. Validate with c2pa-rs.
    box_purpose "manifest": 14 bytes at [69, 83) — located, not validated
    … 19 further store lines, [128, 142) through [1190, 1204) …
    … and 1 more

  lines matching "unread C2PA box at": 0

The unreadable box's own line falls past the cap either way — that is what a cap does — but the
class is now stated in the line no cap can reach. With that box first in the file instead, the
cut is category-blind: its line is the report's first entry and one store falls past the cut.
Executed at the same head:

file-order.heic: HEIF/HEIC                                                   exit 0
  C2PA: 20 manifest stores located, NOT VALIDATED, and 1 C2PA box is present from which no store could be read — …
    unread C2PA box at [24, 83): its FullBox version or flags are not zero, which C2PA 2.4 §A.5.1.2 fixes them at
    box_purpose "manifest": 14 bytes at [128, 142) — located, not validated
    … 18 further store lines …
    … and 1 more

The count line, after decision 28's two wording fixes:

BEFORE   near-miss.heic: HEIF/HEIC
           C2PA: no manifest store found in the top-level boxes of the primary stream — …
             other top-level uuid boxes: 1 (extended type is not the C2PA one; a uuid box is not provenance framing)

AFTER    near-miss.heic: HEIF/HEIC
           C2PA: no manifest store found in the top-level boxes of the primary stream — …
           top-level uuid boxes of another extended type: 1 (not the C2PA one; a uuid box is not provenance framing)

It now sits at the head's indent rather than the detail list's, and "other" no longer needs an
antecedent this file does not supply.

What reproduced, and what did not. All four findings this round was given reproduced. The hidden
class was executed exactly as described and is shown above. The drift guard was falsified in both
directions: a type carrying a borrowing field compiled clean under the old form, and — with that
drift applied to the real C2paSummary — the old form still compiled while the repaired form failed
with error[E0597] naming the local buffer. The near-miss report and an unrelated vendor UUID's
report were byte-identical below the path line. Finding 4's reading of decision 25 is correct, and is
verified against the code: classify_uuid_box still carries its short-body arm, correctly, and the
correction at the head of the decision record says what was actually removed.

Residual risk. The rendering repaired here is held by tests in two places, and only one of them
is inside the mutation gate. The wording, the four headline shapes, the file ordering and the length
bound are pinned in gamut-heic and are covered by the count above; the command's cap and its tail
are pinned only by gamut-cli/tests/inspect_c2pa.rs driving the built binary, which no mutation or
coverage figure can speak for. That is the same blind spot this round's defect came out of, and it
is a property of where the gates are drawn rather than of this change.

Risks and rollout

  • Additive for TIFF/DNG/PNG: those arms, their gates and their exit codes are untouched.
  • The sniff now routes every ISOBMFF file (first box ftyp) into the HEIC arm, where it used to
    fall through to TIFF unless its major brand was on a five-entry list. Nothing gamut inspects
    starts with ftyp at offset 4 other than ISOBMFF, so no TIFF/DNG/PNG verdict moves; what changes
    is the message an ISOBMFF file that is not a HEVC still image gets, from TIFF: bad byte-order mark to unsupported container brand '…'. Both are exit 1.
  • New public API in gamut-heic (C2paSummary, C2paStoreSummary, C2paUnreadBox,
    C2paUnreadReason, C2PA_NOT_VALIDATED, C2paBoxPurpose::as_str,
    HeifContainer::c2pa_summary) — all additive; the structs are #[non_exhaustive], so
    C2paSummary gaining its unread field is a minor change, and C2paUnreadReason is a fieldless
    #[repr(u8)] enum with permanent discriminants.
  • C2paSummary::is_present() keeps its meaning (a store was located) and so is now narrower than
    "the file carries C2PA framing". Its docs say so and point at unread; a caller that treated it
    as "has provenance" was already wrong, and now has a way to be right.
  • gamut inspect prints no byte accounting for a HEIC: the arm is a provenance report, not a
    deconstruct. gamut-heic does account every byte (HeifContainer::segments), so a HEIC
    deconstruct arm remains possible later; it was not in this issue's scope and is not implied by
    it.
  • Rollback is deleting the HEIC arm and the Format::Heic variant; the gamut-heic helper is
    independently useful and can stay.

Issue

Closes #448.

Filed for the remainder, which is other issues' work and not declined scope:
#530gamut-cli: extend gamut inspect's C2PA section to each container as its
locator slice lands
(PNG #511, DNG #508, AVIF #506, RIFF/WebP #515, TIFF #520).

Filed by this lane's round-2 repair: #551gamut-cli: issue #530 rests on a
false premise — coverage exclusion is not test exclusion
. The premise is retracted in-repo by this
PR; #530's own body still carries it, and editing an existing issue is outside what this run may do
(decision 17).

Filed by this lane's round-3 repair: #596gamut-cli: gamut inspect has no
machine-readable output, so a consumer must parse prose
(decision 22) — and
#597gamut-heic: C2paSummary::is_present is a two-valued answer to a
three-valued question
(decision 23). Neither is declined scope of #448; both are follow-on work
this round deliberately did not take.

Decisions taken

Correction to decision 25, appended in round 5. Its "both removed" overstates the code. The
short-body arm of classify_uuid_box was kept, and had to be: the function must be total for a
body its own signature does not constrain, and the arm is still there with a comment that now
describes it accurately. What was actually removed is the false rationale — the old comment
claimed a uuid body too short to hold the extended type "is a foreign box rather than a truncated
one", which nothing can reach, since gamut_isobmff::BoxReader::next_box rejects such a box and the
file never parses — together with four unreachable fallible operations in
read_content_provenance_box. The code that entry describes is right; the entry's summary of it is
not. The frozen entry below is left exactly as it was written.

Issue 448 - gamut-cli: report a C2PA manifest store in gamut inspect, and state that it is not validated
Plan:     v1
Branch:   feat/448-cli-inspect-c2pa
Base:     head of PR #485 (origin/feat/224-png-encoder-efficiency, cce9939) - stacked; PR opened against that branch
Cause:    -
Touches:  gamut-cli inspect.rs (+main.rs if a format variant is needed); gamut-heic c2pa.rs/lib.rs + tests + STATUS; docs/inspect-exit-codes.md only if an exit code's meaning changes
Will not: print the store's contents, decoded or hex-dumped; report a dcterms:provenance URL as a store (that is #449, delivered in PR #503); wire any container whose C2PA slice is still an open pull request; imply validation anywhere in the output
Lane:     serialised behind R-485 (stacked)
Settled:  S3 docs/testing.md; the reporting logic lives in a testable crate because gamut-cli is outside the coverage gate

Decisions taken.
1. Deliverable boundary
   Taken:    HEIC only - the one container whose locator is already on master - reporting presence, size, half-open byte range and box_purpose, with an explicit non-validation disclaimer; every other container follows its own slice
   Rejected: waiting until the five in-flight container slices merge, which would strand an acceptance criterion behind five other pull requests; wiring them speculatively from branches that may still change
   Reverses: delete the HEIC arm
   Filed:    a follow-up naming the containers to add as their slices land (search first; file only if absent)
2. Where the logic lives
   Taken:    a `C2paSummary`-shaped helper in gamut-heic, unit-tested there, carrying presence, byte range, size and purpose; gamut-cli formats it and owns no logic
   Rejected: computing the summary in the CLI - gamut-cli is excluded from the coverage gate, so the logic would ship untested, which is exactly what the issue forbids
   Reverses: inline the helper in inspect.rs
3. The disclaimer
   Taken:    the line that reports the store also says gamut does not check the signature, the hash binding or the trust list, and names c2pa-rs as what does; the text lives beside the helper so it is covered by a test, not only in the CLI's format string
   Rejected: a footnote or a one-off note at the end of the output - a reader who has seen a Content Credentials badge reads "C2PA: present" as verified, and the issue makes phrasing that permits that inference a defect rather than a wording preference
   Reverses: move the text into the CLI
4. Multiple stores
   Taken:    report each store with its own purpose rather than collapsing to a count - a file mid-update legitimately carries two, and hiding that behind "1 store found" is misleading, which the issue states outright
   Reverses: report only the first
5. Exit code
   Taken:    reporting a store NEVER changes the exit code; presence and absence are both normal outcomes, and the exit-code contract your base establishes stays exactly as it is
   Rejected: treating a located store as an accounting anomaly
   Reverses: fold it into the verdict

Appended by this lane, in the record's shape:

6. What `gamut inspect <file>.heic` prints besides the C2PA section
   Taken:    nothing. The HEIC arm parses the container, prints the provenance section and returns Ok; it is a provenance report, not a deconstruct, and docs/inspect-exit-codes.md says so
   Rejected: a HEIC byte-accounting arm over HeifContainer::segments() - real, since gamut-heic accounts every byte, but it needs its own gate (what does an unknown meta box mean for the exit code?), which is a contract decision issue 448 does not authorise and which would change what exit 1 means for a new format
   Reverses: delete inspect_heic and the Format::Heic variant
7. Adding HEIC to docs/inspect-exit-codes.md
   Taken:    the doc gains a HEIC row and a "HEIC has no gate" section. The manifest permits the edit "only if an exit code's meaning changes", and admitting a format that exits 0 on every parseable file does change what exit 0 covers - the document's own table said "the file passed its format's gate", which is now not the only way to exit 0
   Rejected: leaving the document silent on a format the command now accepts, which would make the one normative record of the contract incomplete
   Reverses: revert the two edits to that file
8. Sniffing HEIC by major brand alone
   Taken:    the ftyp major brand must be one of heic/heix/heim/heis/mif1. The sequence brands are absent (gamut is image-first) and compatible brands are not consulted
   Rejected: matching mif1 anywhere in the compatible-brand list, which is how an AVIF is written - that would route an AVIF through the HEIF container reader and report a store under a slice (#506) this pull request does not own
   Reverses: widen HEIF_MAJOR_BRANDS
9. Where the sniff lives
   Taken:    in gamut-cli's existing `sniff`, beside the PNG-signature and DNGVersion branches it joins. Format detection is not the reporting logic decision 2 is about, and splitting one three-branch sniff across two crates costs more than the coverage it buys
   Rejected: a brand predicate in gamut-heic, which would be testable but would leave the PNG and DNG branches of the same function untested anyway
   Reverses: move HEIF_MAJOR_BRANDS and the check into gamut-heic

Appended by this lane in round 2, after review. The record above is left exactly as it was written,
including the decisions round 2 reverses.

10. Which ISOBMFF files the HEIC arm accepts (REVERSES decisions 8 and 9)
    Taken:    the ftyp box is a cheap route into the arm and nothing more; inspect_heic confirms the container with gamut-heic's own HeifImage::is_hevc_still (references/heif §7) after the parse it performs anyway, and reports an unsupported container brand instead of a HEIC banner when the confirmation fails. HEIF_MAJOR_BRANDS is deleted
    Rejected: keeping a brand list in the CLI and widening it - decision 8's premise, that matching the major brand alone excludes AVIF, is false: mif1 is the generic MIAF structural brand and an AVIF may carry it as its MAJOR brand. Executed: gamut's own encoder produced an AVIF, four bytes were patched from avif to mif1, and gamut inspect reported HEIF/HEIC at exit 0. A corrected list would also have to carry mif2, avci and avcs, and would still be wrong about mif1, because the rule is not a brand test - crates/gamut-heic/src/image.rs:104 additionally requires the primary item to carry an hvcC, and crates/gamut-heic/tests/roles.rs already tests that case
    Rejected: decision 9's reasoning that a brand predicate in gamut-heic would leave the PNG/DNG branches untested anyway - the predicate is already in gamut-heic and already tested; duplicating it in the CLI was the defect, not the coverage of the sniff
    Reverses: restore HEIF_MAJOR_BRANDS and drop the is_hevc_still call
11. A C2PA box that is present and could not be read
    Taken:    it is a third outcome, distinct from a located store and from no C2PA box at all. C2paSummary::unread lists each such box with a C2paUnreadReason (truncated / non-zero FullBox version or flags / not a manifest-store box_purpose / no valid JUMBF LBox), and report_lines gives each its own line. Reporting it as "no manifest store found" is the mirror of the verdict C2PA_NOT_VALIDATED prevents: it stops a reader inferring VERIFIED, then lets one infer NO PROVENANCE from bytes gamut merely could not read
    Rejected: leaving it silent because the box is malformed - malformedness is a fact about the reader's reach, not about whether the file carries provenance, and docs/inspect-exit-codes.md actively tells callers to gate on this stdout
    Reverses: drop the unread field and its rendering
12. The exit code for an unreadable C2PA box
    Taken:    exit 0. inspect reports; its exit code says whether the inspection succeeded, not what it found. docs/inspect-exit-codes.md states that asymmetry against TIFF/DNG/PNG explicitly, and states plainly that absence of a store line is not absence of provenance
    Rejected: exit 1 - that would make a property of this reader's reach into a verdict about the file, which is the same class of error as reporting the box as absence
    Reverses: return an error from inspect_heic for a non-empty unread list
13. A uuid box whose extended type is one byte off the C2PA UUID
    Taken:    reported as absence, unchanged. §A.5.1.1 makes the extended type the whole test, and an ordinary file carries vendor uuid boxes that are no evidence of provenance; calling a near miss damaged C2PA framing would claim provenance where there is none, which is the same defect as claiming absence where there is some, pointed the other way. Pinned by a test and recorded in the doc and in STATUS
    Rejected: treating a near-miss extended type as an unread C2PA box, which would fire on every vendor uuid box in circulation
    Reverses: relax the extended-type test to a distance
14. --format heic on a container the sniff would decline
    Taken:    it stays "you asked for it": --format skips the sniff AND the confirmation, since a forced format is the caller's own assertion about the file and overriding detection is what the flag is for. The behaviour is unchanged and is now documented in docs/inspect-exit-codes.md and in the module docs, and pinned by a test
    Rejected: applying the confirmation to the forced path too, which would make --format unable to do the one thing it exists for
    Reverses: drop HeicRoute and confirm unconditionally
15. Where report_lines() and C2PA_NOT_VALIDATED live (RETRACTS decision 2's premise, keeps its outcome)
    Taken:    they stay in gamut-heic. The reason recorded is now the true one - the facts and the words about them belong together, so every host renders the same wording and none can reword the disclaimer away - not the false one decision 2 gave. Vec<String> is not a C-ABI surface and gamut-ffi exposes none of it, so nothing about C portability is at stake either
    Rejected: the premise that gamut-cli being outside the coverage gate means logic placed there ships untested. It is falsified twice in-repo: crates/gamut-cli/tests/icc.rs is a working binary-driving integration test, and inspect.rs already carried an inline #[cfg(test)] mod tests. Coverage exclusion is not test exclusion. The premise is retracted in inspect.rs's module docs, in docs/inspect-exit-codes.md and in gamut-heic's STATUS.md
    Rejected: moving the rendering into the CLI now that the premise is gone - the outcome was right for the other reason
    Reverses: nothing to reverse; the code is unchanged
16. Testing the command itself
    Taken:    crates/gamut-cli/tests/inspect_c2pa.rs drives the built binary on the tests/icc.rs model, pinning that the disclaimer and the store's own line reach stdout unabridged, that a present-but-unreadable box is not printed with the absence wording, that an AVIF whose major brand is mif1 is declined by name, and that --format heic overrides the confirmation. brand_label's escaping is pinned inline beside the code, being private
    Rejected: relying on gamut-heic's tests alone - executed: adding .skip(1) to the report loop deleted the named checks and the pointer to c2pa-rs from the output while cargo test -p gamut-cli, clippy and every gate stayed green
    Note:     gamut-cli is in .cargo/mutants.toml's exclude_globs, so the mutation gate does not see this file. That is stated in the Validation section rather than implied
    Reverses: delete the file
17. Issue #530 carries the same false premise
    Taken:    it is left untouched and named here. This lane's contract forbids editing, commenting on, labelling or closing an existing issue, so the retraction lands in-repo (module docs, docs/inspect-exit-codes.md, STATUS.md) and in this body; #530's body still states that logic in gamut-cli ships untested, and a human should correct it when acting on that issue
    Rejected: editing #530, which the contract forbids
    Reverses: nothing

Appended by this lane in round 3, after review. The record above is left exactly as it was written,
including the decisions round 3 revises.

18. A uuid box whose extended type is one byte off the C2PA UUID (REVISES decision 13, keeps its core)
    Taken:    the classification is unchanged - it is not C2PA framing, earns no unread line and no reason, because §A.5.1.1 makes the sixteen bytes the box's whole identity and the specification has no notion of an approximate one. What changes is that it is no longer reported by saying nothing: C2paSummary::other_uuid_boxes counts these boxes and the report states the count, disclaiming provenance in the same line. Executed on the built binary: before, the near-miss report and the report for a file with no uuid box at all were byte-identical below the path line; a signed file corrupted in transit is exactly the first, and decision 13 left it indistinguishable from the second. Telling the reader nothing is the same silent-absence defect round 2 fixed, one step out; stating a byte count claims nothing
    Rejected: giving each such box a range or a C2paUnreadReason, which would be decision 13's error - claiming provenance where there is none
    Rejected: leaving it unreported on decision 13's reasoning alone. That reasoning is about what may be *claimed*, and a count claims nothing
    Reverses: drop the other_uuid_boxes field and its line
19. Where a C2PA box sits relative to the file's media data
    Taken:    reported as a positional fact on that box's line - "; its box begins after the first mdat box" - and only when true. §A.5.3 places a manifest-store box before the first mdat, and an appended box is precisely the adversarial shape a byte range does not reveal
    Rejected: wording it as a violation or a finding. §A.5.3 equally requires a mid-update file's update box to be the *last* box of the file, which on a file with media data puts it past this boundary by the specification's own instruction; calling that a fault would be a verdict, which this crate does not reach
    Rejected: testing §A.5.3's other boundary, any moov box. HeifContainer::parse refuses a file carrying a top-level movie box outright (image sequences are out of the workspace's scope), so the branch is unreachable - executed, and pinned by a test that asserts the parse error rather than a classification
    Reverses: drop C2paBoxPosition and the clause
20. Where the C2PA list is capped
    Taken:    in gamut-cli, at the same MAX_LIST = 20 and with the same "… and N more" tail as every other list the command prints. gamut-heic keeps returning every entry; its rendering splits into summary_lines (headline plus the uuid-box count - at most two lines whatever the file holds) and lazy detail_lines with detail_line_count beside them, so the command builds only the lines it prints and every word stays pinned in the crate
    Rejected: capping inside gamut-heic. The cap is a terminal-presentation decision and the limit already lives in the command; a library that truncated would make the full list unreachable to a host that wants it
    Rejected: leaving it uncapped. Executed: a legal 2.95 MB file carrying fifty thousand of these boxes printed 50 002 lines and 6 212 673 bytes at exit 0 in 0.08 s, putting the non-validation disclaimer at line 2 of fifty thousand; §A.5.3 permits any number, so this needs no malformity
    Reverses: print summary.report_lines() unconditionally again
21. The label on the forced-format path
    Taken:    "<path>: HEIF/HEIC (asserted by --format, not detected)". On that path --format skipped both the sniff and the confirmation, so the command tested nothing about the container; the plain label is its own claim about the file and printing it would echo the caller's assertion back as a finding
    Rejected: applying the confirmation on the forced path, which decision 14 settled and this does not disturb - the behaviour is unchanged, only the label
    Reverses: print the plain label unconditionally
22. A machine consumer of the report
    Taken:    no third exit code, and no change to the two the command contracts for. Structured output is the real answer and is filed as visualcommons/gamut#596; docs/inspect-exit-codes.md now says so, and says that a caller matching on wording is matching on wording that document is free to improve. This also covers the finding that the unread case and the absent case are indistinguishable to a machine
    Rejected: a third exit code for "reported, but the reader could not read through". Two exit codes are the contract, an outcome stream does not fit in an integer, and it would make this reader's reach into a verdict about the file - the error decision 12 rejected
    Reverses: nothing in-repo; close #596
23. C2paSummary::is_present
    Taken:    left in place with its documentation strengthened to name the failure it invites - false for a file that plainly carries C2PA framing, which is what a corrupted signed file looks like - and the shape question filed as visualcommons/gamut#597. gamut inspect correctly does not use it
    Rejected: changing or removing it here. Every candidate shape (a three-valued accessor, a rename, removal) is a breaking change to gamut-heic's public surface and a maintainer call, not a repair this round is scoped to make
    Reverses: nothing in-repo; close #597
24. The no-byte-leak guarantee
    Taken:    pinned by a compile-time drift guard - the summary types must be 'static, which is exactly "borrows nothing". The guarantee previously rested on structure (no lifetime parameter, no byte field, fieldless payload enums, no serialisation path) that an additive change to a #[non_exhaustive] struct could weaken with nothing failing
    Rejected: relying on the existing test that greps a rendered report for the store's contents. It can only see bytes a rendering actually emits, and the risk is a field added years from now that a future rendering emits
    Reverses: delete the guard test
25. Two unreachable branches and the rationale for one of them
    Taken:    both removed. The comment claiming a uuid body too short to hold the extended type "is a foreign box rather than a truncated one" described a branch nothing can reach: gamut_isobmff::BoxReader::next_box rejects such a box with a fatal "truncated uuid user type", so the file fails to parse and no report is produced - executed for a 15-byte and a 0-byte body, and now pinned by a test that asserts the parse error. The split is still written to be total, since the function must not depend on a guarantee its signature does not carry, and the comment now says which. Reading the rest of the box took four fallible operations of which three could not fail once the one before had succeeded; it now takes one per field that can genuinely be absent
    Rejected: leaving the rationale to be corrected later. A comment that describes an unreachable branch wrongly is worse than no comment: it is the reason a reader believes the branch matters
    Reverses: restore the get(..)/ok_or chain and the old comment

Appended by this lane in round 5, after review. The record above is left exactly as it was written,
including the decisions round 5 reverses and the entry the correction at the head of this section
names.

26. A cap that could hide an entire class of box (REVERSES the grouped rendering; keeps decision 20's cap)
    Taken:    repaired at the root rather than at the cap, in two parts. (a) The headline states EVERY non-empty class with its count, not the first one it finds, so a file with stores and unreadable boxes says both on the line a reader cannot lose. (b) C2paSummary::detail_lines yields the boxes in TRUE FILE ORDER, the two kinds interleaved, rather than every store then every unread box - which is what that method's own documentation already promised ("in file order"), so the grouped rendering contradicted the crate's stated contract independently of any cap. Executed before: a file with 20 legal stores plus one C2PA box whose FullBox version is 1 rendered zero "unread C2PA box" lines at exit 0 under the headline "20 manifest stores located", so a caller doing exactly what docs/inspect-exit-codes.md instructs - treat the unread line as unknown, never as absence - misclassified. Twenty store-shaped boxes need no malformity: A.5.3 permits any number, which is decision 20's own reasoning. The asymmetry was entirely grouped order plus a headline stopping at the first non-empty class; hiding STORES was harmless only because the headline counted them
    Rejected: fixing it at the cap - raising MAX_LIST, or exempting unread boxes from it. The number of boxes is chosen by the input, so any finite cap has the same hole; and the defect is not that the cap is too small but that what it drops was decided by the rendering rather than by the file
    Reverses: restore the grouped chain in detail_lines and the first-class headline
27. One list budget, not one per class
    Taken:    the single MAX_LIST budget over one file-ordered list stays. With the headline naming every class and the details in file order, one budget can no longer silence a class: it hides the file's last boxes, whatever kind they are, and the tail states how many
    Rejected: a per-class cap (twenty stores AND twenty unread boxes). It buys nothing decision 26 has not already bought, and it is a second number that has to be kept in step with the first - two budgets, two tails, two ways for the arithmetic to drift
    Reverses: split MAX_LIST per class in inspect_heic
28. The near-miss count line (REVISES decision 18's presentation, keeps its outcome)
    Taken:    the line stays, ungated by any flag: a uuid box being present is genuine information and a count claims nothing false. What changes is what is said about it. The documentation now states what the count cannot do - a corrupted C2PA extended type and an ordinary vendor uuid box are the SAME observation to it, executed and byte-identical, so the count separates a file with such a box from a file with none and nothing finer, and it fires on ordinary camera output. Two wording defects are fixed with it: the line said "other top-level uuid boxes", whose "other" has no antecedent in a file carrying no C2PA box, and it rendered at the detail indent so it read as the list's first entry when it belongs to the head
    Rejected: putting the line behind a flag or removing it. Decision 18's evidence stands - without it a signed file corrupted in transit prints byte-for-byte what a file with no such box prints - and a count that overstates nothing does not need suppressing
    Rejected: narrowing the count to "near misses" by Hamming distance. A.5.1.1 has no notion of an approximate extended type; that was decision 13's error pointed the other way
    Reverses: restore the old wording and indent, and drop the "what it cannot tell you" paragraphs
29. The no-byte-leak drift guard (REVERSES decision 24's implementation, keeps its intent)
    Taken:    the guard is rebuilt so the 'static bound binds, and moved beside the fixtures it now needs (crates/gamut-heic/tests/c2pa.rs). It builds a file locally, parses it, and takes the bound on the summary and on one element of each list REACHED THROUGH that summary. The old form asserted a tautology: given C2paSummary::default() or a struct literal, inference is free to choose 'static for a lifetime parameter the type does not carry today, so the bound held whatever the type became. Executed: a summary type grown a borrowing field compiled clean under the old form; under the same drift applied to the real C2paSummary, the old form still compiled and the new form failed with error[E0597] naming the local buffer
    Rejected: keeping it as it was. docs/testing.md's one-thing-one-reason rule does not admit a test that cannot fail for its stated reason
    Rejected: deleting it, the alternative the brief allows if the bind could not be made. It could be made
    Reverses: restore the default-and-literal form inline in src/c2pa.rs
30. What holds this round's repairs, given where they live
    Taken:    stated in docs/inspect-exit-codes.md and in the Validation section above, per repair, by test name. gamut-cli is excluded from the mutation gate AND from the coverage gate, so this round's defect lived in a double blind spot and a green workspace gate is no evidence about it; the two cases that matter are driven through the built binary, and the wording, the headline's four shapes and the file ordering are pinned in gamut-heic, inside the mutation gate
    Rejected: leaving the reader to infer the coverage from the gate table. The gate table's rows are green either way, which is exactly the problem being recorded
    Reverses: delete the test-authority table from the document
31. The shape of the file-order merge, after the mutation gate timed out on it (REVISES decision 26(b)'s implementation, keeps its outcome)
    Taken:    the merge is a bounded list of borrowed entries ordered by the byte each box starts at, mapped to lines on demand - not a hand-written Iterator::next over two peekable slices. A `next` returning Option<String> is one function deciding both what the stream holds and how long it is, so the gate's own replacement of its body with Some(...) makes it unbounded and every consumer of report_lines fills memory until something stops it. What stops it is a property of the machine: CI sat in the loop to the 60s test timeout and failed two shards with exit 3, while under this checkout's ulimit -v the same mutant aborted on the failed allocation and counted as caught - so the local run of that same gate had been green. The length is now fixed by the summary, one entry per store plus one per unread box, which is exactly detail_line_count, and a test asserts those two agree. Rendering is byte-identical either side, pinned by the exact-line tests; lines are still built only as they are taken, and what is now built eagerly is one borrowed entry per box, which the summary already holds
    Rejected: excluding the mutant in .cargo/mutants.toml. The timeout is the gate reporting what the code permits, not a flaw in the gate; and that file is outside this lane's manifest besides
    Rejected: raising the gate's test timeout, which hides the class instead of removing it
    Rejected: trusting the local mutation run over CI's. It is the local run that is weaker here - its per-process address-space limit converts a hang into a caught mutant - and that is now recorded beside the count
    Reverses: restore the DetailLines iterator and its Peekable merge

Unresolved review notes

None.

A reporting tool needs presence, a byte range, a size and a box_purpose, and must
not print the store itself: it is opaque to this crate, routinely tens or hundreds
of kilobytes, and rendering it invites the reading that gamut has understood — and
so checked — the manifest.

C2paSummary carries exactly those facts and renders them, so a host that only
formats output holds no logic of its own and cannot drop the disclaimer on the way
to the terminal. C2PA 2.4 §15.12 puts validation on a validator, so the line that
reports a store says gamut checks no signature, no hash binding and no trust list,
and names c2pa-rs as what does — inline, never as a footnote a reader can skip.

Refs #448
gamut inspect gains a HEIC arm that answers one question - does this file carry a
C2PA manifest store, and where? - and answers only it. Every store is listed with
its box_purpose, its size and its half-open byte range, because a file mid-update
legitimately carries two and collapsing them to a count hides the one fact that
tells them apart.

The arm gates nothing. It classifies no bytes and looks for no unknowns, so it has
nothing to hold against the file: presence and absence are both ordinary outcomes
and both exit 0, and only a container that cannot be parsed exits non-zero. Every
reportable fact and every word of the report come from gamut-heic, since gamut-cli
is outside the coverage gate and logic placed here would ship untested; this file
prefixes its own indent and nothing else.

The sniff matches the ftyp *major* brand alone. An AVIF lists mif1 among its
compatible brands, and reading one through the HEIF container reader would report
a store this command has no slice for.

Refs #448
A top-level box whose extended type is the C2PA one and that still yields
nothing — a non-zero FullBox version or flags, an auxiliary `merkle` or
unknown `box_purpose`, a truncated body, or no valid JUMBF `LBox` where the
purpose puts the store — was dropped silently, so `c2pa_summary` reported it
byte-identically to a file carrying no C2PA box at all.

That is the mirror of the verdict `C2PA_NOT_VALIDATED` exists to prevent: it
stops a reader inferring *verified* from a located store, then lets one infer
*no provenance* from bytes the crate merely could not read through.

`C2paSummary::unread` now lists those boxes with a `C2paUnreadReason`, and
`report_lines` gives each its own line naming the reason. A `uuid` box whose
extended type is not `C2PA_UUID` — even one byte off — deliberately stays
absence: §A.5.1.1 makes the extended type the whole test, and an ordinary file
carries vendor `uuid` boxes that are no evidence of provenance.

Both public views are now built from one scan, so "a store" and "a box that
yielded no store" cannot disagree about which boxes were looked at.

Refs #448
`gamut inspect` sniffed the HEIC arm from a hand-rolled list of `ftyp` major
brands whose doc claimed that matching the major brand alone excludes AVIF.
The premise is false: `mif1` is the generic MIAF structural brand and an AVIF
may carry it as its *major* brand, so an AVIF was reported as `HEIF/HEIC` at
exit 0 — and had it carried a `ContentProvenanceBox`, that box would have been
reported as a HEIC's.

The repository already ships the correct predicate. The `ftyp` test is now a
cheap route into the arm, and `inspect_heic` confirms the container with
`gamut-heic`'s own `HeifImage::is_hevc_still` (`references/heif` §7, which for
`mif1` additionally requires the primary item to carry an `hvcC`) after the
parse it performs anyway, reporting an unsupported container brand instead of
a HEIC banner when the confirmation fails.

That also fixes the diagnostic for a real AVIF, which previously fell through
to TIFF and was rejected as `TIFF: bad byte-order mark` by a sniffer this
command had already taught to read `ftyp`.

`--format heic` skips the sniff and the confirmation: a forced format is the
caller's own assertion about the file, and overriding detection is what the
flag is for.

Refs #448
Nothing in the workspace executed `gamut inspect`: adding `.skip(1)` to the
report lines deleted the disclaimer and the pointer to `c2pa-rs` from the
output while every gate stayed green.

The premise offered for that — that `gamut-cli` is excluded from the coverage
gate, so logic placed in it would ship untested — is false in-repo twice over:
`tests/icc.rs` is a working binary-driving integration test, and `inspect.rs`
already carries an inline `#[cfg(test)] mod tests`. Coverage exclusion is not
test exclusion, and this file retracts the premise by contradicting it.

Four tests on the `icc.rs` model pin what the command itself decides: that the
non-validation disclaimer and the store's own line reach stdout unabridged,
that a present-but-unreadable C2PA box is not printed with the absence
wording, that an AVIF whose major brand is `mif1` is declined by name rather
than reported as a HEIC, and that `--format heic` overrides that confirmation.
`brand_label`'s escaping is pinned inline beside the code, being private.

Refs #448
The reporting shape has a third outcome beside a located store and no C2PA box
at all, and the ledger row that claimed the shape did not mention it.

Refs #448
The normative contract copied the false claim that matching an `ftyp` major
brand alone excludes AVIF, and told callers to gate on stdout without saying
that a C2PA box gamut could not read through prints there as well.

It now records the route/confirm split and the brands a list in the command
would have had to carry, the unreadable-box row and the near-miss extended
type that deliberately stays absence, that `--format heic` skips both the
sniff and the confirmation, and that the exit code says whether the inspection
succeeded rather than what it found.

Refs #448
A scan that reported only the manifest stores it read, and the C2PA boxes
it could not read through, left two facts about the bytes unstated.

A `uuid` box whose extended type is one byte off the C2PA one is not
C2PA framing — §A.5.1.1 makes the sixteen bytes the box's whole identity
and the specification has no notion of an approximate one — but it was
reported by saying nothing at all, so a file carrying such a box rendered
byte-for-byte identically to a file carrying no `uuid` box whatsoever.
The first is what a signed file corrupted in transit looks like, and it
was the file the report said the least about. `other_uuid_boxes` counts
them and the report states the count, disclaiming provenance in the same
line: no range, no reason, no claim.

`C2paBoxPosition` records whether the walk had already passed an `mdat`
when it reached the box. §A.5.3 places a manifest-store box before the
first one, and an appended box is the adversarial shape — but the clause
the report adds is positional and not a verdict, since §A.5.3 equally
requires a mid-update file's `update` box to be its last box. The `moov`
half of that boundary is unreachable: the container refuses a top-level
movie box before any of this runs, and a test pins that.

Rendering splits into `summary_lines` (the headline and the count, at
most two lines whatever the file holds) and lazy `detail_lines` with
`detail_line_count` beside them, so a host can truncate a list whose
length the input chooses without rewording anything; `report_lines` still
renders the whole report.

Reading a box now takes one fallible split per field that can genuinely
be absent. The old code took four, three of which could not fail once the
one before it had succeeded, and justified the fourth with a rationale
about a short body that the container rejects outright.
The near-miss count, the positional fact, and the split rendering a host
caps a terminal report with.
The provenance report was the only list in `gamut inspect` with no cap.
C2PA 2.4 §A.5.3 permits any number of these boxes, so their count is
chosen by the input and needs no malformity to grow: a legal 2.7 MB file
carrying fifty thousand of them printed 50 002 lines at exit 0 and put
the headline — non-validation disclaimer and all — at line 2 of fifty
thousand, where the unread lines below it each repeat their own hedge and
the unprinted ones carry none. It now truncates at the same twenty
entries, with the same "… and N more" tail, as every other list here;
`gamut-heic` keeps returning all of them, and the headline and the
`uuid`-box count are never truncated.

The forced path printed `<path>: HEIF/HEIC`, which is this command's own
claim about the file — over a file for which `--format` skipped both the
sniff and the confirmation, so nothing about the container was tested.
It now says the format was asserted rather than detected.
Plus the positional clause, the asserted-format label, and why a machine
consumer gets a filed issue rather than a third exit code.
…order

A host caps the detail lines, so a class of box that falls wholly past the
cut left no trace at all: a file with twenty legal stores and one C2PA box
no store could be read from rendered zero unread lines under a headline
reading "20 manifest stores located". That defeats the contract the report
is for, which is that an unreadable box is unknown and never absence.

Repaired where it is caused rather than at the cap. The headline now names
every non-empty class with its count, so no cap can reach it; and the
detail lines are merged back into true file order rather than grouped by
kind, which is what `c2pa_summary`'s own documentation already promised and
what makes the cut category-blind instead of systematically favouring
stores.

The `uuid`-box count line loses its detail indent, being part of the head
rather than the first list entry, and names what it counts without an
"other" the file may give no antecedent for. Its documentation now states
what it cannot do: a corrupted C2PA extended type and an ordinary vendor
box are the same observation to it.
The guard asserted a tautology. Given a hand-built value -
`C2paSummary::default()`, or a struct literal - inference is free to pick
`'static` for a lifetime parameter the type does not carry today, so the
bound was satisfied whatever the type became: executed, a summary type
grown a borrowing field still compiled under it, and for the literal forms
it was only a missing-field detector.

Taking the bound on values produced from a buffer local to the test forces
the lifetime instead. Executed under the same drift, this form fails to
compile with E0597 while the old form still passes.
Two cases the command's own tests could not see before, both on the built
binary, since this crate is outside the mutation gate and outside the
coverage gate alike: twenty legal stores ahead of one unreadable box, where
the headline must still name the class whose line the cap dropped; and an
unreadable box ahead of the stores, where the surviving lines must be the
file's first boxes rather than its first stores.

The near-miss assertion follows the count line's new wording, and the
command's comment records why one budget over one file-ordered list is
enough.
The cap paragraph promised only that the headline and the count line
survive, which left the caller-guidance sentence above it false: gating on
the presence of an `unread C2PA box` line was exactly what the cap could
defeat. It now states the three things separately - the cap can hide any
individual box's line of either kind and does not say which kind, it cannot
hide that a kind exists, and it cannot silence one kind systematically -
and the caller guidance points at the headline, which is not capped.

Records that there is deliberately one budget rather than one per kind, what
the near-miss count cannot separate, and which binary-driving test holds
each claim, this command being outside both the mutation and the coverage
gate.
… merge

The file-order merge was a hand-written `Iterator::next`, which is a
function free to decide both what the stream contains and how long it is.
Replacing its body with `Some(...)` - the mutation the gate generates for
any `-> Option<T>` - therefore yields an unbounded stream, and every
consumer of `report_lines` fills memory until something stops it. What
stops it differs by machine, which is why this was invisible here and red
in CI: under this checkout's `ulimit -v` the mutant aborts on the failed
allocation and counts as caught, while an unconstrained runner sits in the
loop until the 60s test timeout.

The merge is now a bounded list of borrowed entries, ordered by the byte
each box starts at, mapped to lines on demand. The length is fixed by the
summary - one entry per store plus one per unread box, which is exactly
`detail_line_count` - so no defect in the ordering can lengthen it, and
the tie a hand-assembled summary can present resolves to the store by the
sort being stable rather than by a comparison written to prefer it.

Lines are still built only as they are taken, which is what a host capping
the list at twenty pays for; what is built eagerly is one borrowed entry
per box, which the summary already holds.
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