Skip to content

feat(png): carry the C2PA manifest store in the caBX chunk - #511

Open
justin13888 wants to merge 12 commits into
feat/224-png-encoder-efficiencyfrom
feat/440-png-cabx
Open

justin13888 wants to merge 12 commits into
feat/224-png-encoder-efficiencyfrom
feat/440-png-cabx

Conversation

@justin13888

@justin13888 justin13888 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #485 (feat/224-png-encoder-efficiency): this PR is opened against that branch and must merge after it. Nothing #485 did is modified; this adds the caBX slot to the ancillary pass #485 already plumbs. The base has moved twice since this branch opened (fac39dcc59df6fcce9939) and is merged in here by merge commit both times (never rebase); every contact file auto-merged with no conflicts.

This is an unattended run: no human approved the plan. The decision record below is what a human reads afterwards.

Summary

gamut-png now reads and writes the C2PA manifest store in the caBX chunk (C2PA 2.4 §A.3.2) as one more raw, MetadataBlock-ready ancillary payload — the same mechanism eXIf/iCCP/XMP already use, one chunk type added to both sides. Issue #440, part of epic #239.

  • What counts as the store. One rule, in every reader (decode, both metadata entry points, PngReport::c2pa, and the encoder's own read-back): the first CRC-valid caBX before the first IDAT. First, because a file carries exactly one store; CRC-valid, because §13.1 makes a mismatch skippable and the decode skips it; before IDAT, because §A.3.2 puts it there and calls data after it bad-form — so a caBX appended to a finished file is never read as that file's provenance.
  • Decode. DecodedPng::c2pa / PngMetadata::c2pa carry that chunk verbatim and uncompressed. c2pa_ignored (usize) counts every CRC-valid caBX in the datastream that was not surfaced as the store — a later one, one after IDAT, or the store-position chunk itself when it busted the metadata budget — and deliberately does not say which. A caBX after IEND is a trailer, outside the datastream and outside the count; deconstruct's trailer segment is where that shape is visible. The store is charged to the cumulative with_max_metadata_bytes budget like every other attacker-sized payload.
  • Encode. with_c2pa(store) embeds a caller-computed store; with_c2pa_reserved(len) writes len zero bytes in its place, as the last chunk before the first IDAT, so the store's offset depends only on what precedes it.
  • Reserve, hash, fill. encode_with_report and PngReport::c2pa() name the chunk's whole span — length, type, payload and CRC — as C2paSpan, which is the c2pa.hash.data exclusion §18.5.4 asks for; fill_c2pa(&mut png, &span, store) then writes the finished store into that span in place, rewriting only the payload and the chunk CRC. That is O(store) instead of the O(encode) of a second with_c2pa pass, and it does not tie the signature to the encoder reproducing its output byte for byte. Every argument is validated before the first byte is written — bounds, framing, chunk type, the chunk's own declared length, and the store's length — so a span that does not describe the bytes it points at is rejected rather than written through, and a rejected call leaves the file untouched. png need not be the buffer the span came from, which is why the declared length is checked rather than assumed.
  • Chunk-name bits. chunk::CABX is the one place the type is spelled; a test asserts its property bits per PNG §5.4 Table 6 — ancillary and private set, reserved clear, and unsafe-to-copy clear on byte 3 (Table 6's polarity; issue gamut-png: carry the C2PA manifest store in the caBX chunk #440's prose had it backwards, its bytes did not, and the reviewer independently confirmed the reading).
  • Oracle. libpng carries caBX as an unknown chunk: for the same payload it frames the same length/type/CRC bytes as gamut, decodes gamut's file pixel-exact with the chunk in place, and gamut reads the store back from libpng's file. c2pa-rs (behavioural) is tooling/c2pa-oracle: differential oracle against c2pa-rs, both directions #447.

Semver: minor (#[non_exhaustive] structs gain fields; new pub items C2paSpan, PngEncodeReport, fill_c2pa, three encoder methods, one report method).

Validation

Run in the lane worktree (branch feat/440-png-cabx), all at the current head e1dddb7 — after both base merges and both rounds of review repairs. mise run fmt/fmt-check need the nested-worktree prefix __CARGO_TEST_ROOT=<worktree root> (a known environment artefact: cargo otherwise walks past the worktree root to the primary checkout's Cargo.toml when loading the tooling/* manifests).

command outcome
CARGO_BUILD_JOBS=2 cargo test -p gamut-png --all-features pass — 14 suites, lib 171, c2pa 16, accounting 27, backends 23, metadata 10, oracle 20, size_contract 5, doctests 7
__CARGO_TEST_ROOT=… mise run fmt then mise run fmt-check pass
mise run check-tests pass
convco check origin/feat/224-png-encoder-efficiency..HEAD pass — "no errors in 10 commits"; merge commits are skipped as merges
CARGO_BUILD_JOBS=2 cargo clippy -p gamut-png --all-targets --all-features -- -D warnings pass
mise run lint (whole workspace, capped scope) pass (exit 0)
mise run test (whole workspace, capped scope) pass (exit 0; 208 test result: ok lines, tests/c2pa.rs among them)
GAMUT_MUTANTS_BASE=origin/feat/224-png-encoder-efficiency mise run mutants --diff --crate gamut-png --budget 16 pass — 73 mutants in the diff: 64 caught, 9 unviable, 0 missed, no exclusions added

Two survivors were found and closed during the run, each verified dead before the clean re-run above. 60aec64 closed replace > with >= in fill_c2pa's bounds check, which no fixture reached because every one of them put an IDAT and an IEND after the store, so the chunk never ended at the buffer's end. e1dddb7 closed replace + with * in the declared-length read: the length field was read through four indexed offsets, and for any payload under 64 KiB the top two bytes are both zero, so reading one in place of the other is unobservable. Rather than add a 64 KiB fixture, the offsets were removed — the eight header bytes are taken as one borrow, split where §5.3 splits them, and the length compared as bytes — and the test gained a 300-byte store, where a span agreeing only in the low byte is rejected.

No Cargo.toml touched, so check-release-deps / check-ffi-features are not triggered; no C-surface type changed, so check-ffi-header is not either.

CI check state at 60aec64: Format & Metadata, Clippy & Doctests and Incremental (PR diff) 0–3 all pass; Coverage (test gate) was still running when this body was last updated — it legitimately takes hours on this workspace — and Full workspace is skipped by design for a PR.

For the record, since it is visible in this PR's history: the runs on the two earlier heads (7f60deb and its re-runs) were red for a repository-wide infrastructure outage tracked as #517, not for anything in this diff. Every job died in its first step, Install system build depssudo apt-get update exit 100, E: Failed to fetch https://dl.google.com/linux/chrome-stable/…/Packages.gz Hash Sum mismatch from the runner image's preinstalled Google Chrome apt repo — so pkg-config/mise/sccache never installed and later steps failed with sccache: command not found (127). No gamut code was compiled before the failure, and the only required job that installs no system deps (Format & Metadata) passed throughout. That outage cleared upstream on its own, and the checks above are the first ones on this branch to run gamut's code since. Nothing was weakened, skipped or bypassed at any point; the local gate set above is the evidence for this change.

Risks and rollout

  • Files without caBX are byte-identical to before on both sides (the size-contract and libpng oracle suites pass unchanged); a file with caBX previously had it ignored and now has it copied into memory under the metadata budget.
  • The post-IDAT rule makes the reader stricter: a caBX after IDAT that an earlier build of this branch would have surfaced is now counted as ignored instead. That is the intended fix, and no file gamut writes is affected (the encoder never writes there).
  • fill_c2pa mutates a caller's buffer in place; it validates span, framing, chunk type, the chunk's declared length and the store's length first, and writes nothing on rejection. The declared-length check is new in round 2 and makes previously-accepted mismatched spans an error — that is the fix, and no span this crate hands out is affected.
  • c2pa_ignored now also counts a store-position caBX skipped for the metadata budget, so a file that reported 1 may now report 2. No file gamut writes is affected; the field is new on this branch and unreleased.
  • Rollback: revert the eight feature commits (21602ed, cb30ea8, 0342473, 704fa25, a4f9e7f, 35957e0, 60aec64, eca33fa, c49be4c, e1dddb7); the two merge commits carry no changes of their own.

Issue

Closes #440.

Decisions taken

Issue 440 - gamut-png: carry the C2PA manifest store in the caBX chunk
Plan:     v1
Branch:   feat/440-png-cabx
Base:     head of PR #485 (origin/feat/224-png-encoder-efficiency, fac39dc) - stacked; PR opened against that branch
Cause:    -
Touches:  gamut-png decoded.rs (c2pa: Option<Vec<u8>>), ancillary.rs (caBX before IDAT), encoder.rs (with_c2pa_reserved / with_c2pa + encode_with_report), decoder.rs (first wins, duplicate recorded, budget), chunk.rs (property-bit assertion), deconstruct.rs (chunk span), lib.rs, tests, STATUS/README
Will not: parse the store; compress it; touch EncodeImage or codec-abi; touch gamut-cli; copy a store forward through any re-encode helper (the facade's C2paPolicy law)
Lane:     serialised behind R-485 (stacked); #483 branches from this head
Settled:  S2 semver (gamut-png's metadata structs are #[non_exhaustive] -> minor); S3 docs/testing.md; #485's decisions (WrittenHeader plumbing; race semantics) are not revisited

Decisions taken.
1. Deliverable boundary
   Taken:    the whole issue - decode surface, encode slot before IDAT, property-bit assertion, exactly-one-store rule, decode budget, whole-chunk span reporting; no split
   Filed:    -
2. Decode surface
   Taken:    `DecodedPng.c2pa: Option<Vec<u8>>` and the same field on PngMetadata (pixel-free read), raw and uncompressed; the FIRST caBX wins and any further caBX is recorded on a new `DecodedPng.duplicate_c2pa: usize`? No - keep the surface minimal: the first wins and duplicates are counted in the existing warnings/anomalies channel if the decoder has one, else a `c2pa_duplicates: u8` on PngMetadata; the decode limit `with_max_metadata_bytes` covers caBX like every other ancillary payload
   Rejected: concatenating multiple caBX - a store is not multi-chunk in PNG (unlike APP11)
   Reverses: drop the duplicate count
3. Encode slot and reserve
   Taken:    `Ancillary.c2pa: Option<Vec<u8>>` written in write_pre_plte's pass? No - §A.3.2 only requires "before IDAT"; write it as the LAST chunk before IDAT (after PLTE/tRNS and the post-PLTE metadata) so nothing the store's size could shift precedes it... but tIME/text ordering is free; taken: caBX is emitted at the END of write_post_plte, i.e. immediately before the first IDAT, so the reserved region's offset depends only on chunks that precede it and every later byte is IDAT/IEND; `PngEncoder::with_c2pa_reserved(len)` writes `len` zero bytes; `with_c2pa(bytes)` writes a caller-computed store (doc: the facade never hands one over); `encode_with_report(...) -> (bytes, PngEncodeReport { c2pa: Option<C2paSpan> })` reports the reserved chunk's whole span and its payload span, EncodeImage untouched
   Rejected: writing caBX among the pre-PLTE chunks - moves PLTE/tRNS offsets on resize; a bytes-only setter - cannot satisfy the epic's reserve criterion
   Reverses: emit caBX first after IHDR
4. Chunk-name bits
   Taken:    `chunk.rs` gains a const `CABX = *b"caBX"` and a test asserting bit 5 of bytes 0, 1 and 3 (ancillary, private, unsafe-to-copy) on the constant, not only the literal; the unsafe-to-copy bit is documented as the container's own enforcement of the no-copy-forward law
5. Exclusion span
   Taken:    `deconstruct`'s report exposes the caBX chunk's whole span (length + type + payload + CRC) via a typed accessor (e.g. `PngReport::c2pa_chunk() -> Option<Range<usize>>`), documented as the §18.5 carriage exclusion; pinned against a hand-built fixture with known offsets; nothing after placement moves a byte: an exact-byte test fills the reserved region with two different equal-length payloads and diffs exactly that span
6. Oracle
   Taken:    libpng round-trip of a file carrying caBX (unknown chunk preserved, CRC valid); c2pa-rs is #447's

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

7. Property-bit polarity (the record's item 4 states it wrongly)
   Taken:    assert the bits per PNG §5.4 Table 6 on `chunk::CABX`: bit 5 SET on bytes 0 and 1 (ancillary, private), bit 5 CLEAR on byte 2 (reserved, "shall be uppercase") and CLEAR on byte 3 ("0 (uppercase) = unsafe to copy"); `X` = 0x58 has bit 5 clear
   Rejected: the record's/issue's wording "bit 5 of byte 3 set (unsafe-to-copy)" - contradicts Table 6 and the issue's own bytes (0x58 & 0x20 == 0); the spec is the source of truth and is never downgraded to match prose
   Reverses: the wording of item 4, not its intent (ancillary, private, not safe to copy is what is asserted)
8. Duplicate channel and a store past the budget
   Taken:    the decoder has no warnings/anomalies channel, so the record's fallback: a count on PngMetadata AND on DecodedPng, so the two entry points stay field-for-field (the anti-drift test in tests/metadata.rs asserts it); a first caBX skipped for its size is still the file's first store - every later caBX is ignored, never promoted into its place
   Rejected: the count on PngMetadata only - breaks the documented field-for-field agreement; promoting a later, smaller caBX when the first busts the budget - an attacker-chosen substitution behind a budget knob
   Reverses: nothing
9. Exclusion-span accessor shape
   Taken:    `PngReport::c2pa() -> Option<C2paSpan>` where `C2paSpan { chunk: Range<usize>, payload: Range<usize> }` (non_exhaustive) is shared with `PngEncodeReport.c2pa`; the first CRC-valid caBX, matching what decode surfaces (§13.1 skips a CRC mismatch on both sides); a bad-CRC caBX stays visible as a segment and in `chunk(b"caBX").count`
   Rejected: `c2pa_chunk() -> Option<Range<usize>>` alone - the signer needs the payload range too, and two span shapes for one chunk would drift; the physically first caBX regardless of CRC - would name a chunk the decoder does not read
   Reverses: nothing (the record said "e.g.")
10. How encode_with_report learns the span
   Taken:    read it back from the bytes written by the same chunk walk `PngReport::c2pa` uses (`chunk::find_c2pa`), rather than threading offsets through the palette/cleanup races; `encode_indexed8` has no report twin - `deconstruct(&png)?.c2pa()` is the documented one-call route and is pinned in tests/c2pa.rs
   Rejected: an `encode_indexed8_with_report` twin - not in the record, and the deconstruct route is equivalent by construction
   Reverses: nothing
11. Filling a reservation
   Taken:    a reservation is filled by a second encode of equal length with `with_c2pa(store)` - output is byte-reproducible and caBX is the last chunk before IDAT, so the diff is exactly the payload and the chunk CRC (pinned exact-byte); documented on `with_c2pa_reserved`
   Rejected: an in-place fill helper that recomputes the chunk CRC - not in the record; recorded under Unresolved review notes as a candidate follow-up rather than filed, since it is not declined issue scope
   Reverses:  superseded by 13-Q2 below, which ships the helper
12. Taking the moved base (2026-09-09)
   Taken:    merge origin/feat/224-png-encoder-efficiency into this branch by merge commit whenever it moves (fac39dc -> c59df6f -> cce9939), per the contract's "update from the base by merge commit only"; every contact file auto-merged, and the whole gate set was re-run afterwards rather than relying on the green checks from an earlier head
   Rejected: rebasing onto the new base - forbidden for a pushed branch; leaving the branch on a stale base - its checks would prove nothing about the code that will actually merge
   Reverses: nothing
13. Review outcome, decided by the orchestrator on review (2026-09-09); two Lows repaired, five questions answered
   L1 - the span docs claimed the report names the chunk decode surfaces as its payload
   Taken:    say plainly that `PngReport::c2pa` reports CARRIAGE (every CRC-valid caBX in store position, budget-independent) and the decode field reports the ADMITTED payload, and that the ignored count and `chunk(b"caBX").count` answer different questions
   Rejected: keeping the "they coincide" wording - false under `with_max_metadata_bytes`: a 5 000-byte store read with a 10-byte budget gives `decode().c2pa == None` while the report still spans it, so a caller gating on the report gets a None it was told could not happen
   Reverses: the agreement sentence in items 5 and 9's docs, not the accessors themselves
   L2 - find_c2pa walked past IEND
   Taken:    stop the walk at the first IDAT or at IEND, whichever comes first, which makes the "cannot disagree" claim true rather than requiring it to be dropped
   Rejected: leaving it and weakening the claim - the walk would report a chunk in the trailer (§13.2), which is not in the datastream at all
   Reverses: nothing
   Q1 - placement
   Taken:    1a, keep caBX last before IDAT; the reserve-then-fill no-move property depends on the store's offset being a function only of what precedes it, and that property is load-bearing for epic #239. Documented with the reviewer's finding: PNG §14.3.2 says ordering relative to other ANCILLARY chunks is never assumable and an editor may insert one after ours, so "last" is this writer's guarantee about files it wrote, not a property preserved across third-party editors; readers assume only "before IDAT"
   Rejected: 1b first-after-IHDR - moves every later ancillary chunk when the store is resized; 1c - drops the guarantee the epic needs
   Reverses: nothing
   Q2 - filling a reservation
   Taken:    2b, ship `fill_c2pa(&mut [u8], &C2paSpan, &[u8])`, overwriting the payload and recomputing the chunk CRC, with span/framing/type/length validated first and typed errors; documented as the supported way to put a store into a file this encoder is not re-encoding, and the only way for a file gamut did not write
   Rejected: 2a re-encode only - O(encode) where O(store) is available, and it couples the signature to the encoder reproducing its output byte for byte
   Reverses: item 11
   Q3 - the two setters and encode_with_report stay on the EncodeImage path
   Taken:    3a; with the helper landing, an indexed caller takes the span from `deconstruct(&png)?.c2pa()` and fills in place, so a twin buys little - and that route is now documented on encode_with_report/with_c2pa_reserved and pinned by a test
   Rejected: 3b and 3c
   Reverses: nothing (confirms item 10)
   Q4 - a caBX after IDAT
   Taken:    4b with visibility; §A.3.2 calls data after IDAT bad-form and the encoder never writes there, so such a chunk is never surfaced as the store - which is what stops an appender injecting a store into a file that carries none - and it is counted rather than dropped silently, in all three readers, with the addition done in one place so they cannot drift
   Rejected: 4a - an untrusted appended chunk becomes the reported store; plain 4c - accepting it as the store at all
   Reverses: nothing
   Q4 follow-on, decided by the lane under the decision's own condition ("the duplicate count if that reads honestly")
   Taken:    no anomaly channel exists on the decoder, so the count is the channel - renamed `c2pa_ignored` and documented as "CRC-valid caBX chunks not surfaced as the store: any after the first, and any after IDAT". The rename is what makes it read honestly: a file whose only caBX sits after IDAT has an ignored chunk but no original, so "duplicates" would name something the file does not contain. The field is new on this unmerged branch, so nothing released is renamed
   Rejected: keeping the name `c2pa_duplicates` - false in the post-IDAT-only case, and the decision conditioned on honest reading; a second field for the post-IDAT case - more public surface than the decision asked for, when one honestly-named count carries both
   Reverses: the field name in item 8
   Q5 - counter width
   Taken:    5b, `usize`; a saturating u8 reported 300 ignored chunks as 255, a number the file does not contain. Counting rule unchanged (CRC-valid only, which is what decode admits) - L1's doc fix is what removes the apparent mismatch with `chunk(b"caBX")`
   Rejected: keeping the saturating u8 - no reason for a ceiling in a metadata struct
   Reverses: the field type in item 8
14. Re-review outcome, decided by the orchestrator on re-review (2026-09-09); two Lows repaired
   Finding 1 - fill_c2pa never validated the chunk's DECLARED length (Q-A: validate fully, keep cross-buffer spans supported)
   Taken:    compare the span's payload length against the chunk's own length field before writing, and keep supporting a span applied to a buffer it did not come from (the exact-fit test establishes that as intended). Reachable because `C2paSpan`'s fields are pub and `#[non_exhaustive]` blocks literal construction but not field assignment on an owned value, and both hand-out points return owned values: a span of 33..85/41..81 taken from file A and applied to file B, whose caBX declares 4 bytes, passed every guard and wrote 40 bytes plus a CRC over B's IDAT, returning Ok(()); the mirror planted the CRC inside the real payload so `find_c2pa` then skipped the chunk. No panic and no memory-safety issue - every write stayed in the slice - but the documented contract promised to reject bytes that are not a caBX chunk, and these are not one. Both directions pinned, and the fix verified by disabling the check (the new test fails) before re-enabling it
   Rejected: documenting the span-to-buffer pairing as an unchecked precondition and dropping the partial checks - that would make the four existing guards decorative
   Reverses: nothing; it completes the validation item 13-Q2 shipped
   Finding 1 follow-on, decided by the lane after the mutation gate
   Taken:    read the eight header bytes as one borrow and split them where §5.3 splits them, comparing the length field as bytes, so the four indexed offsets that carried the check are gone. The gate found `+` -> `*` on the first offset unkillable: for any payload under 64 KiB the top two length bytes are both zero, so reading one in place of the other is unobservable
   Rejected: adding a 64 KiB fixture to observe the offset - it would pin the arithmetic rather than remove it, and cost a large allocation in a unit test; excluding the mutant - an equivalence claim that would stop being true the moment a store exceeded 64 KiB
   Reverses: nothing
   Finding 2 - c2pa_ignored's docs asserted an implication the crate's own test falsifies (Q-B: widen what is counted, say exactly what is not)
   Taken:    count every CRC-valid caBX in the datastream that is not surfaced as the store, which adds the budget-skipped store-position chunk `collect` already saw; state in all four places (both struct fields, STATUS.md, README) that the count does not distinguish its three cases, and that a caBX after IEND is NOT counted because bytes after IEND are not part of the datastream and L2 deliberately stopped both walks there; point a caller wanting append detection at `deconstruct`'s trailer segment, which does see it; delete the appended-store claim. Both halves pinned by tests. First-wins is unchanged, so an oversized store still cannot be substituted by a smaller one after it
   Rejected: keeping the narrow positional definition and fixing only the prose - the field's name would promise more than it counts; keeping the appended-store sentence - `decoded.rs`'s own `busts` test is the counter-example (two caBX both before IDAT, the first over budget, gives c2pa == None with a non-zero count and nothing appended), and the converse fails too, so a caller gating injection detection on it got a false positive and a false negative on the one case the sentence named
   Reverses: the second paragraph of the field docs written under item 13-Q4, and the counting rule in item 13-Q4's follow-on (which counted the budget-skipped chunk as nothing)

Unresolved review notes

  • c2pa_ignored is the only malformed-file signal the decoder reports; if a decoder-wide anomalies channel is ever added, this field is the first candidate to fold into it, and it would let the two ignore-reasons (a duplicate, versus a chunk appended after IDAT) be told apart, which one count cannot.
  • fill_c2pa validates that the span frames a caBX chunk of the declared length but does not re-verify the existing CRC before overwriting, so filling a span in a file whose store was already corrupted silently repairs the CRC. That is the right behaviour for the reserve-then-fill flow and wrong for nothing gamut does today, but it is worth a reviewer's eye.
  • c2pa_ignored counts three different malformations without distinguishing them, which the docs now say plainly. Telling them apart needs a decoder-wide anomalies channel (the note above), not a wider integer.

Read and write the C2PA manifest store (C2PA 2.4 §A.3.2) as one more raw,
MetadataBlock-ready ancillary payload, the way eXIf/iCCP/XMP already travel.

Decode: `DecodedPng::c2pa` / `PngMetadata::c2pa` carry the first `caBX`
verbatim and uncompressed; a later `caBX` is counted in `c2pa_duplicates`
(saturating), never concatenated, since PNG has no multi-chunk store. The
store is charged to the cumulative `with_max_metadata_bytes` budget like
every other attacker-sized payload — skipped past the remainder, not an error.

Encode: `with_c2pa(store)` embeds a caller-computed store and
`with_c2pa_reserved(len)` writes `len` zero bytes in its place, as the last
chunk before the first IDAT (after PLTE/tRNS and every other ancillary chunk)
so a reservation is filled by a second equal-length encode that changes no
byte outside the chunk. `encode_with_report` and `PngReport::c2pa` name the
chunk's whole span — length, type, payload and CRC — as `C2paSpan`, the
`c2pa.hash.data` exclusion §18.5.4 asks for. `EncodeImage` is untouched.

The chunk type is spelled once, `chunk::CABX`, with its property bits
asserted per PNG §5.4 Table 6: ancillary and private set, reserved clear, and
unsafe-to-copy *clear* on the fourth byte — the polarity the issue's prose
had backwards. That bit is the container's own enforcement of the facade's
no-copy-forward law (`C2paPolicy`).

libpng carries `caBX` as an unknown chunk, which is the framing proof: for
the same payload it frames the same length/type/CRC bytes as gamut, decodes
gamut's file pixel-exact with the chunk in place, and gamut reads the store
back from libpng's file. The behavioural oracle (c2pa-rs) is #447.

Refs #440
`find_c2pa` walked to end of input, unlike every other chunk walk in the
crate (`parse_stream`, `walk_metadata_chunks` and `deconstruct` all stop at
IEND). Latent today, since its only caller is the encoder reading back its
own fresh output, but it made the "the encoder's report and the file's report
cannot disagree" claim false for any later caller: a `caBX` appended after
IEND — bytes §13.2 calls a trailer, outside the datastream — would have been
reported as the file's manifest store.

The walk now stops at the first IDAT or at IEND, whichever comes first, which
states the store rule in one place: the first CRC-valid `caBX` before the
first IDAT. Stopping at IDAT is C2PA 2.4 §A.3.2's placement — the store
precedes IDAT and data after it is bad-form — and it is what keeps a chunk
appended to a finished file from being taken as a store the file does not
carry.
`fill_c2pa(&mut png, &span, store)` writes a finished manifest store into the
`caBX` chunk a span names, rewriting the payload and the chunk CRC and
nothing else. It is the second half of the reserve-then-fill flow C2PA 2.4
§18.5 describes, and the shape that flow actually needs: a signer hashes the
reserved file with the chunk's span excluded, then fills it.

Until now the only way to fill a reservation was to encode again with
`with_c2pa`. That reaches the same bytes but costs a second full encode — at
`Level::Best` with `FilterStrategy::BruteForce`, the whole brute-force set
again — and it makes the signature depend on the encoder reproducing its
output byte for byte. Filling in place is O(store) and depends on nothing but
the chunk's own bytes, and it is the only route at all for a file gamut did
not write.

Every argument is validated before the first byte is written, with a distinct
typed error each: the span must lie inside the image, frame a chunk (payload
exactly `chunk.start + 8 .. chunk.end - 4`), name a `caBX`, and receive a
store of exactly the reserved length. A store of the wrong length is rejected
rather than resized: resizing would move every byte after the chunk and
invalidate the hash the signer signed.
C2PA 2.4 §A.3.2 places the manifest store before IDAT and calls data after it
bad-form, but the decode surfaced the first CRC-valid `caBX` wherever it sat.
That let anyone append a `caBX` to a finished PNG and have it read back as
that file's provenance — including into a file that carries no store at all,
where the appended chunk became the only answer. The encoder never writes
there, so nothing gamut produces was affected; the exposure was on read.

The store is now the first CRC-valid `caBX` before the first IDAT, in all
three readers that answer the question — `decode`, both `metadata` entry
points, and `PngReport::c2pa` — with the addition done in one place so they
cannot drift apart.

An ignored chunk stays visible rather than being silently dropped: the
counter now covers both reasons a `caBX` is not the store, a later one and
one after IDAT, so `c2pa == None` with a non-zero count is exactly the shape
of an appended store. It is renamed `c2pa_ignored` to say that, since
"duplicates" is false when the file carries no original, and it becomes a
`usize`: as a saturating `u8` a file with 300 ignored chunks reported 255,
a number the file does not contain.
`walk_metadata_chunks` grew a second result — the count of `caBX` chunks
found after IDAT — and the bare tuple tripped `clippy::type_complexity`. The
alias carries the explanation the tuple could not: which chunks come back,
and why the post-IDAT ones are a number rather than chunks.
The span docs claimed the report names the chunk the decode surfaces as its
payload. That is false under a budget: `PngReport::c2pa` has no byte budget,
so a store past `with_max_metadata_bytes` is still spanned while
`decode().c2pa` is `None` — a caller gating on the report could get a `None`
it had been told could not happen. The same block claimed the ignored count
agrees with `chunk(b"caBX")`, which it deliberately does not, since that
counts CRC-invalid and post-IDAT chunks too.

Both now say what is true: the report answers *where the bytes are* and has
no budget, the decode answers *what was admitted*, and each count answers its
own question. Exclude the span from a hash; read the payload from the decode.

Also states two things the code already did but the docs did not. The
reserve-then-fill flow's step 3 is `fill_c2pa`, with re-encoding named as the
costlier alternative rather than the route. And "last chunk before IDAT" is
this writer's guarantee about files it produces, not a property of the
format: PNG §14.3.2 says ordering relative to other *ancillary* chunks is
never assumable and an editor may insert one after ours, so readers assume
only "before IDAT" — which is exactly what they do.
The mutation gate caught the gap: `span.chunk.end > png.len()` could be
weakened to `>=` and every test still passed, because each fixture put an
IDAT and an IEND after the store, so the chunk never ended at the buffer's
end. `fill_c2pa` takes a `&mut [u8]`, so the exact fit is a legitimate call —
the prefix of a file up to the end of its store, or a file whose store is its
last chunk — and under `>=` every such call is refused.
`fill_c2pa` validated the span against itself and against the buffer's
bounds, then read exactly four bytes of `png` — the chunk type — and wrote.
It never compared the span with the length the chunk itself declares, so a
span naming a longer chunk than the one actually there passed every guard:
the payload and the recomputed CRC went over whatever followed, in practice
the IDAT, and the call returned `Ok(())`. The mirror case, a span shorter
than the chunk declares, planted the CRC inside the real payload, leaving a
chunk `find_c2pa` then skips — the store silently not carried.

Reaching it needs only a mismatched span, which the API allows by
construction: `C2paSpan`'s fields are `pub`, `#[non_exhaustive]` blocks
literal construction but not field assignment, and both hand-out points
return owned values. Taking a span from one file and filling another is
supported on purpose — the exact-fit case does it — which is precisely why
the declared length has to be checked rather than assumed.

No panic and no memory-safety issue: every write stayed inside the slice.
But the documented contract says a span whose bytes are not a `caBX` chunk
is rejected, and these bytes are not one.
`c2pa_ignored`'s docs claimed that `c2pa == None` with a non-zero count meant
someone had appended a store to a file carrying none. The unit test forty
lines below asserted the counter-example: two `caBX` chunks both *before*
IDAT, the first over the metadata budget, gives exactly that pair with
nothing appended. The converse failed too — the canonical append is a `caBX`
after IEND, which is a trailer neither walk reaches, so it counted zero. A
caller gating injection detection on the field got both a false positive and
a false negative on the one case the sentence named.

The count now covers every CRC-valid `caBX` in the datastream that was not
surfaced as the store, which adds the budget-skipped store-position chunk
`collect` already saw and previously passed over. First-wins is unchanged:
the first chunk still claims the store position whether or not it is
admitted, so an oversized store cannot be substituted by a smaller one after
it.

The docs — the two struct fields, STATUS.md and the README — now state the
three cases it counts, that it does not distinguish them, and that a chunk
after IEND is outside the datastream and therefore outside the count, with
`deconstruct`'s trailer segment named as where that shape is visible instead.
Both claims are pinned by tests.
The declared-length check read the length field through four indexed reads,
`start`, `start + 1`, `start + 2`, `start + 3`. The mutation gate found the
first of those offsets unkillable: for any payload under 64 KiB the top two
length bytes are both zero, so reading one in place of the other changes
nothing a fixture of that size can observe.

The offsets are gone rather than papered over with a 64 KiB fixture. The
eight header bytes are taken as one borrow and split where §5.3 splits them,
and the length field is compared as bytes against the span's own payload
length in network order — so there is no offset arithmetic left to get wrong,
and a wrong split fails the type check that follows.

The test gains the case the byte comparison earns: a 300-byte store, where a
span agreeing only in the low byte (44) is rejected, which no single-byte
length could have shown.
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