Skip to content

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

Open
justin13888 wants to merge 1 commit into
feat/224-png-encoder-efficiencyfrom
feat/440-png-cabx
Open

feat(png): carry the C2PA manifest store in the caBX chunk#511
justin13888 wants to merge 1 commit into
feat/224-png-encoder-efficiencyfrom
feat/440-png-cabx

Conversation

@justin13888

Copy link
Copy Markdown
Collaborator

Stacked on #485 (feat/224-png-encoder-efficiency, base fac39dc): 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.

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.

  • Decode. DecodedPng::c2pa / PngMetadata::c2pa carry the first caBX verbatim and uncompressed. Exactly one store per file: a later caBX is counted in c2pa_duplicates (saturating u8), never concatenated. The store is charged to the cumulative with_max_metadata_bytes budget like every other attacker-sized payload — past the remainder it is skipped, not an error, and stays "the first" so a smaller one after it is a duplicate, not a substitute.
  • Encode. PngEncoder::with_c2pa(store) embeds a caller-computed store; with_c2pa_reserved(len) writes len zero bytes in its place. Emitted 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 — pinned as an exact-byte diff. EncodeImage is untouched.
  • Exclusion span. PngEncoder::encode_with_report and PngReport::c2pa() name the chunk's whole span — length, type, payload and CRC — as C2paSpan (with the payload bracketed inside), the c2pa.hash.data exclusion of §18.5.4; derived from the same chunk walk the byte accounting uses, so it is always a claimed segment. The first CRC-valid caBX, so it names the chunk decode surfaces.
  • 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; the issue's prose had it backwards, its bytes did not). The bit is documented as the container's own enforcement of the facade's no-copy-forward law.
  • 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, three encoder methods, one report method).

Validation

Run in the lane worktree (.claude/worktrees/agent-a3ce3726ec4e209c9, branch feat/440-png-cabx). mise run fmt/fmt-check need the nested-worktree prefix __CARGO_TEST_ROOT=$(git rev-parse --show-toplevel).

command outcome
CARGO_BUILD_JOBS=2 cargo test -p gamut-png --all-features pass — lib 161, accounting 27, ancillary_colour_type 9, backends 23, c2pa 10, colour_key 9, conformance 9, malformed 12, metadata 10, oracle 20, roundtrip 7, size_contract 5, transparent_cleanup 9, doctests 6
__CARGO_TEST_ROOT=… mise run fmt then mise run fmt-check pass
mise run check-tests pass
CARGO_BUILD_JOBS=2 cargo clippy -p gamut-png --all-targets --all-features -- -D warnings pass
mise run lint (workspace, in the capped scope) running — this table is updated when it completes
mise run test (workspace, in the capped scope) running — this table is updated when it completes
convco check origin/feat/224-png-encoder-efficiency..HEAD pending
mise run mutants-crate gamut-png --shard i/n over the diff pending

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

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.
  • c2pa_duplicates is a new field on two #[non_exhaustive] structs: additive.
  • A reservation is filled by a second encode (documented); no in-place fill helper — see Unresolved review notes.
  • Rollback: revert the single commit.

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 (fork the record's item 4 states 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: `c2pa_duplicates: u8` (saturating at 255) 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 a duplicate, never promoted into its place
   Rejected: `c2pa_duplicates` 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: nothing

Unresolved review notes

  • Filling a reservation costs a second full encode (at Level::Best + BruteForce that is the whole brute-force set again). An in-place fill_c2pa(&mut [u8], &C2paSpan, &[u8]) that overwrites the payload and recomputes the chunk CRC would make the flow O(store) instead of O(encode); not in the record, so not added here — a reviewer may want it as a follow-up under Integrate C2PA #239.
  • c2pa_duplicates is reported but no other malformed-file signal is; if a decoder-wide anomalies channel is ever added, this field is the first candidate to fold into it.

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
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