Skip to content

feat(riff)!: carry the C2PA manifest store in the C2PA chunk - #515

Open
justin13888 wants to merge 7 commits into
masterfrom
feat/445-riff-webp-c2pa-chunk
Open

justin13888 wants to merge 7 commits into
masterfrom
feat/445-riff-webp-c2pa-chunk

Conversation

@justin13888

@justin13888 justin13888 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Carries the C2PA manifest store in a RIFF C2PA chunk (C2PA 2.4 §A.3.7), through gamut-riff and
out of gamut-webp, on exactly the verbatim-passthrough terms ICCP / EXIF / XMP already get.
The store is opaque here: nothing parses, signs or validates it.

gamut-riff (breaking)

  • MetadataChunks gains a fourth borrowed carrier, c2pa: Option<&'a [u8]>. The struct is
    exhaustive by design, so this is the crate's first breaking change since v1; the migration is
    c2pa: None or ..Default::default().
  • write_extended_preserving emits the chunk as the last sub-chunk of the RIFF/WEBP form
    after EXIF, after XMP , and after the unknown chunks RFC 9649 §2.7.1.6 asks writers to
    preserve. §A.3.7 is stricter than "at the end of the file", and nothing else in the crate ordered
    a chunk behind the preserved ones.
  • C2PA_FOURCC and WebpChunkId::C2pa name the chunk. Making it recognised rather than unknown
    is what stops a read/modify/write cycle emitting it twice — once out of unknown, once out of
    metadata.
  • c2pa_span(data) -> Result<Option<Range<usize>>> reports the chunk's whole on-disk span:
    identifier, size field and payload. That is what a c2pa.hash.data exclusion covers (§18.5),
    because an update manifest may resize the store and so change the size field's value as well as
    the bytes after it. The RIFF pad byte after an odd-length store is framing the container adds, so
    it is outside the span — the same boundary Chunk::payload already draws.
  • No VP8X feature flag: RFC 9649 §2.5's flag byte defines no C2PA bit and its reserved bits "MUST
    be 0", so presence is decided by the chunk alone.
  • Readers accept a store wherever they find it. §A.3.7 binds a writer; §2.7 does not list C2PA
    among the chunks a reader may fail a file over, so reconstruction_rank leaves it unconstrained
    alongside metadata and unknown chunks.

gamut-webp (one breaking signature; otherwise additive)

  • WebpEncoder::with_c2pa(store) embeds a caller-computed store; with_c2pa_reserved(len) writes
    len zero bytes to be filled in later. Last call wins, whichever of the two it was.
  • encode_with_report(image) -> Result<(Vec<u8>, WebpEncodeReport)> returns the file plus where the
    store landed. The object-safe EncodeImage seam is untouched, per the C-portability rule.
  • WebpMetadata.c2pa surfaces the store on read (the struct is #[non_exhaustive], so additive),
    and gamut_webp::c2pa_span reports the exclusion span of any WebP file without decoding pixels.
  • with_unknown_chunks now returns Result<Self> and refuses a FourCC the encoder writes itself.

Exactly one store per file, enforced rather than assumed (review round 2)

A read-only review found that a C2PA chunk arriving through the preserved unknown chunks was
emitted before the configured store, and every reader here takes the first chunk of a kind — so
c2pa_span and MetadataChunks::read both returned the stale copy and encode_with_report
returned Ok with a range over bytes the caller never wrote. A signer would have excluded the wrong
span and hashed its real store as content. Reproduced on the branch before fixing: unknown = [C2PA "STALE-STORE"] with c2pa: Some(b"FRESH-STORE") gave ["VP8X","VP8L","C2PA","C2PA"] and both
readers returned STALE-STORE. Three independent defences now close it:

  1. gamut_riff::write_extended_preserving drops a C2PA chunk from the unknown list it forwards —
    MetadataChunks::c2pa owns that chunk. write_extended stays unfiltered as the hand-assembly
    escape hatch.
  2. WebpEncoder::with_unknown_chunks rejects VP8X/VP8 /VP8L/ALPH/ICCP/EXIF/XMP /C2PA
    with a typed InvalidInput, so the mistake is caught at the call that made it. This is the only
    setter that takes a FourCC rather than only a payload, hence the only one with an invalid input.
  3. encode_with_report reads the store back out of its own output and refuses to report a range
    whose bytes are not the configured store. The old guard fired only on a case that cannot occur (a
    configured store that could not be found); the case that could occur returned Ok.

Also from that review: with_c2pa_reserved now rejects a length past the uint32 RIFF size field
instead of reaching vec![0; len], which panics with "capacity overflow" for len > isize::MAX
CLAUDE.md forbids a panic on a library path. The "no cap" policy is unchanged (decision 12 below);
this is only the representability check.

Round 3 (review of 46019a5; Medium and both Lows verified closed, five Lows raised):

  • The reserved-chunk gate had already drifted. It restated eight of WebpChunkId::from's ten
    arms by hand and omitted ANIM/ANMF — so with_unknown_chunks(&[(FourCc::ANIM, ..)]) was
    accepted, encoded, and produced a file WebpLayout::parse refuses as "reconstruction chunks are
    out of order": an encoder steered into writing a file it cannot read back. Probed and confirmed on
    the branch before fixing. It now asks gamut-riffmatches!(WebpChunkId::from(fc), Unknown(_)) — which covers all ten arms and cannot drift again. ANIM/ANMF are pinned.
  • The error names the offender. Error::with_detail(format!("{fourcc}")), the workspace's
    owned-context channel, which this crate already uses at backend.rs:417. My earlier claim that
    the message had to be static was wrong: it read only the constructor's &'static str, not the
    diagnostic channel. FourCc's Display escapes non-printable bytes, so a hostile code is safe.
  • The reservation boundary is genuinely pinned. The limit moved into reservation_len, tested
    at u32::MAX and one past it; the previous assertion tested core and could not fail.
  • A test at the wrong scope was removed — the webp-side c2pa_span # Errors pin reached a
    one-line delegation whose only mutants another test already kills.
  • The BREAKING CHANGE: footer of 46019a5 named only one of two breaks; a follow-up commit's
    footer names with_c2pa_reserved too, since release-plz publishes it as the changelog note.

Under decision D1, the C2PA filter is now conditional: a carried C2PA chunk is dropped
only when a store is configured to displace. With none, it is kept and written in the store's place —
in that file it is the store, and losing it silently would be worse than carrying it.

Validation

All commands were run in this PR's worktree. Rows marked r3 were re-run at 9a7fba9 after the
re-review fixes. Workspace-wide gates ran inside a memory-capped systemd scope (--slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0) with CARGO_BUILD_JOBS=2 CMAKE_BUILD_PARALLEL_LEVEL=2.

Command Outcome
cargo test -p gamut-riff --all-features r3 pass — 64 unit + 7 + 7 + 4 + 7 + 3 doc, 0 failed
cargo test -p gamut-webp --all-features r3 pass — 266 unit + 26/8/13/20/24/4/6 integration + 4 doc, 0 failed
cargo clippy -p gamut-riff -p gamut-webp --all-targets --all-features -- -D warnings r3 pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then mise run fmt-check r3 pass
mise run check-tests r3 pass
mise run check-commits r3 pass — no errors in 7 commits
mise run fetch-av1-oracles pass — aom + dav1d checked out (this worktree started with them empty)
mise run lint (whole workspace) pass — exit 0 in 9m09s (at d7d1c41; crate-scoped clippy re-run each round, and CI's Clippy & Doctests covers every head)
mise run test (whole workspace, in scope) r3 pass — exit 0; 202 test result: ok lines, 0 failures
mise run mutants-diff (in scope) r3 pass — 58 mutants: 43 caught, 15 unviable, 0 missed
mise run mutants-crate gamut-riff --shard 0/2 pass — 93 mutants: 85 caught, 8 unviable, 0 missed (at d7d1c41)
mise run mutants-crate gamut-riff --shard 1/2 pass — 92 mutants: 85 caught, 7 unviable, 0 missed (at d7d1c41)

Both defects found by review were reproduced against the branch before being fixed, with
throwaway tests removed before committing: the round-2 Medium (["VP8X","VP8L","C2PA","C2PA"], both
readers returning STALE-STORE) and the round-3 ANIM drift (accepted, encoded, then
WebpLayout::parse → "reconstruction chunks are out of order"). The permanent pins are
a_c2pa_chunk_in_unknown_never_displaces_the_configured_store and
with_unknown_chunks_refuses_every_chunk_the_container_defines.

Whole-crate mutation testing of gamut-webp was not run: the crate has 3409 mutants, and this
change touches only encoder.rs / metadata.rs, which mutants-diff covers in full. Nothing here
is evidenced by a run that did not complete.

mise run check-release-deps, check-ffi-features and check-ffi-header were not run and are
not applicable: no Cargo.toml changed and no C-surface type changed. mise run coverage was not
run — no new module was added, and every new item is exercised by the suites above.

Note on the environment: mise run fmt / fmt-check were run with the
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) prefix, which is required in a nested
nested worktree; without it cargo walks past the worktree root to the primary
checkout's manifests and the task exits 101 on an untouched tree.

Risks and rollout

  • Breaking for gamut-riff consumers. Only struct literals of MetadataChunks break, and the
    only in-tree consumer, gamut-webp, is updated in this PR. release-plz will take gamut-riff to
    2.0 from the BREAKING CHANGE: footer.
  • Breaking for gamut-webp consumers, added in review round 2: with_unknown_chunks returns
    Result<Self>, so a call site needs ? or .expect(..). gamut-webp is 0.3.1, so release-plz
    bumps it to 0.4.0 — still a minor version step, as the record's semver line said, and the only
    callers in the tree are this crate's own tests. The alternative was to accept a reserved FourCC and
    resolve the duplicate silently, which is the defect this PR is fixing.
  • No behaviour change for anyone who does not ask for a store. An encoder with no with_c2pa*
    call produces byte-identical output to before: MetadataChunks::is_empty still decides simple vs
    extended, and the new field is None.
  • A C2PA chunk in an existing file changes classification. A file that already carried a
    C2PA chunk used to arrive in WebpLayout::unknown; it now arrives in metadata.c2pa. Code that
    hand-scanned unknown for it must read the new field. This is the correct behaviour — it is what
    prevents the chunk being written out twice — and is covered by a test.
  • with_unknown_chunks now rejects input it used to accept. A caller passing any chunk the
    container defines through it was already getting a wrong file — a doubled chunk whose first copy
    won, or, for ANIM/ANMF, one gamut's own strict reader refuses. That is now a typed error naming
    the FourCC. No in-tree caller did this.
  • Rollback is the revert of this branch's seven commits; nothing persists outside encoded files.

Issue

Closes #445.

Decisions taken

No human approved this plan. This is an unattended automation run: the decision record below is the
authority the work was built under, and it is what a human reviews after the fact.

Issue 445 - gamut-riff/gamut-webp: carry the C2PA manifest store in the C2PA chunk
Plan:     v1
Branch:   feat/445-riff-webp-c2pa-chunk
Base:     origin/master (6a75ec4)
Cause:    -
Touches:  gamut-riff webp.rs (MetadataChunks.c2pa, placement, span reporting) + chunk.rs + tests + docs; gamut-webp encoder.rs/metadata.rs (with_c2pa_reserved / with_c2pa, WebpMetadata.c2pa, span accessor) + tests + docs
Will not: parse the store; add a VP8X feature bit; include the RIFF pad byte in the store or its reported range; touch gamut-avif, gamut-png or the metadata facade
Lane:     parallel (root; no other open PR touches gamut-riff or gamut-webp)
Settled:  S2 semver - `MetadataChunks` is exhaustive so gamut-riff takes `feat(riff)!:` with a BREAKING CHANGE footer, while gamut-webp's metadata structs are `#[non_exhaustive]` and stay minor; S3 docs/testing.md

Decisions taken.
1. Deliverable boundary
   Taken:    the whole issue - the chunk on both sides, normative placement, VP8X flags untouched, pad-byte exactness, and the whole-chunk exclusion span; no split
   Filed:    -
2. Field and semver
   Taken:    `MetadataChunks.c2pa: Option<&'a [u8]>` as a fourth borrowed field in one `feat(riff)!:` commit with `BREAKING CHANGE: MetadataChunks gains c2pa`; `WebpMetadata.c2pa: Option<Vec<u8>>` is additive (minor)
   Rejected: a side struct beside MetadataChunks - the three existing carriers are fields and a fourth carrier on different terms would be gratuitous asymmetry
   Reverses: move the field to a separate struct
3. Placement
   Taken:    the `C2PA` chunk is written as the LAST sub-chunk of the RIFF/WEBP form - after `EXIF`, after `XMP `, and after the preserved unknown chunks - and `reconstruction_rank` is extended so a file read back in that order validates; pinned by an exact-byte test over a file carrying ICCP+EXIF+XMP+unknown+C2PA
   Rejected: writing it beside EXIF/XMP in the metadata group - §A.3.7 says last sub-chunk of the first RIFF chunk, which is stricter than "somewhere at the end"
   Reverses: move the emission ahead of the unknown-chunk pass
4. Reserve and range
   Taken:    `WebpEncoder::with_c2pa_reserved(len)` writes `len` zero bytes and `with_c2pa(bytes)` writes a caller-computed store; the encoder reports where the store landed through `encode_with_report(...) -> (bytes, WebpEncodeReport { c2pa: Option<Range<usize>> })`, leaving the object-safe `EncodeImage` untouched (the C-portability rule); the reported range covers the chunk's WHOLE span - identifier, length field and payload - and excludes any pad byte, per §18.5; an exact-byte test fills the reserved region with two different equal-length payloads and gets two files differing in exactly that span
   Rejected: reporting only the payload span - §18.5 excludes the carriage, and a resize moves the length field's value; returning the range from EncodeImage - changes the object-safe seam
   Reverses: drop encode_with_report and expose a getter after encode
5. Odd-length stores
   Taken:    an odd-length store is padded to an even boundary by the writer, the pad byte is not part of the store, not returned by the reader and not inside the reported range; pinned by a fixture whose store length is odd
6. Oracle
   Taken:    libwebp demux must read a file carrying the C2PA chunk unchanged (pixels and the other metadata chunks identical to the same encode without it); the behavioural c2pa-rs oracle is #447's

Appended during delivery, in the record's shape:

7. Where the `C2PA` FourCC constant lives
   Taken:    `gamut_riff::C2PA_FOURCC`, a `pub const` in `webp.rs` beside the code that places the
             chunk, not a twelfth `FourCc::` associated constant
   Rejected: `FourCc::C2PA` in `fourcc.rs` - that module documents itself as RFC 9649 §2.2's FourCC
             vocabulary and its eleven constants are exactly the RFC's chunks; `C2PA` is C2PA 2.4
             §A.3.7's identifier, which RFC 9649 classifies only as an unknown chunk (§2.7.1.6).
             It also keeps the change inside the predicted manifest.
   Reverses: move the constant to `FourCc::C2PA` and re-export it from there
8. How a reader learns where the store is
   Taken:    one locator, the free function `c2pa_span(data)`, walking the finished bytes; the
             encoder derives its report by calling it on its own output rather than threading
             offsets out of `RiffWriter`
   Rejected: returning a range from `RiffWriter::write_chunk` or a parallel reporting writer - the
             writer's byte positions would then be a second source of truth for the exclusion range,
             and PR #506 / #511 both settled on re-walking the written bytes with the read-side
             locator so the two can never disagree
   Reverses: add a reporting writer and drop `c2pa_span`
9. A configured store the locator cannot find
   Taken:    `encode_with_report` returns `Error::InvalidInput` naming it, never a silent `None` -
             handing a signer a file with no exclusion range it can bind is worse than an error
   Rejected: reporting `None` (gamut-png's choice in #511); gamut-avif #506 takes the error, and a
             WebP store is written unconditionally when configured, so `None` could only ever mean
             the writer is broken
   Reverses: return `Ok` with `c2pa: None`
   REVISED by decision 10: the condition that guard tested cannot occur. It now tests the condition
             that can - that the located chunk is not the configured store.

Decided by the orchestrator on review of d7d1c41, and implemented in 1c3824c / 46019a5:

10. A C2PA chunk that displaces the configured store (MEDIUM)
   Taken:    three defences - (a) `write_extended_preserving` filters `C2PA_FOURCC` out of the
             `unknown` chunks it forwards, so `MetadataChunks::c2pa` owns that chunk and the
             "exactly one store" claim is true by construction; (b) `encode_with_report` verifies
             the located payload equals the configured store, repointing decision 9's dead error
             arm at the failure that can actually happen; (c) `WebpEncoder::with_unknown_chunks`
             rejects a reserved FourCC with a typed error, catching the mistake at the call that
             made it. All three pinned.
   Rejected: filtering alone - it would silently discard a chunk the caller asked for; and the
             report-side check alone - it would turn a recoverable input mistake into an encode
             failure the caller cannot diagnose
   Reverses: drop the filter and the reserved-FourCC check, and let "first of each kind wins"
             resolve a duplicate
11. `vec![0; len]` panics on a library path (LOW)
   Taken:    `with_c2pa_reserved` returns `Result<Self>` and refuses a `len` past the `uint32` RIFF
             chunk size field, which `RiffWriter::write_chunk` would refuse anyway
   Rejected: capping the reservation - that is a policy change, and decision 12 keeps "no cap"
   Reverses: return `Self` and allocate unconditionally
12. Q3 - a cap on the reserved length
   Taken:    no cap; the representability check of decision 11 is the whole of it. A signer's
             `reserve_size` is its own business, and neither #506 nor #511 caps one.
   Rejected: an arbitrary ceiling, which would reject stores the container can carry
   Reverses: add a maximum and document it
13. Q4 - where `C2PA_FOURCC` lives
   Taken:    it stays in `webp.rs` (decision 7 stands); revisit only when a second format-external
             FourCC appears and the two can be grouped on evidence
   Rejected: pre-emptively moving it to `fourcc.rs` for one constant
   Reverses: move it to `FourCc::C2PA`
14. Q5 - `encode_with_report`'s shape
   Taken:    keep it; it matches #506 and #511 exactly. Whether the object-safe `EncodeImage` seam
             should carry a report channel at all is a larger question than this issue and is
             recorded as a residual below rather than decided here.
   Rejected: designing a `Report` hook into `EncodeImage` inside this PR
   Reverses: replace the inherent method with a trait-level channel
15. Q7 - a stale claim in decision 3
   Taken:    correct the record. `reconstruction_rank` was ALREADY permissive for `C2PA` before this
             PR, through its `Unknown(_)` arm - so no file that previously failed to parse now
             passes, and the new `WebpChunkId::C2pa` arm exists for match exhaustiveness only.
             Decision 3's "so a file read back in that order validates" overstated it: what the
             change does is keep the chunk unconstrained after giving it its own variant.
   Rejected: leaving the claim as written - it would have a reader looking for a behaviour change
             that is not there
   Reverses: nothing to reverse; this is a wording correction to the record

Decided by the orchestrator on re-review of 46019a5, and implemented in a3fe617 / 9a7fba9:

16. D1 - filter the carried C2PA chunk conditionally, not unconditionally
   Taken:    drop a `C2PA` chunk from `unknown` only when `metadata.c2pa.is_some()`; with no store
             configured, keep it - and write it in the store's place, because in the file it came
             from it IS the store and §A.3.7 puts a store last. That still closes the Medium, which
             required a configured store to displace, and stops a caller who collected chunks from
             a third-party reader losing a foreign manifest store with no signal. The layer
             asymmetry is kept and documented as deliberate: the high-level builder rejects the
             mistake and can name the offending call, the low-level writer stays total.
   Rejected: dropping unconditionally - heavier than the defect needed, and silent; and leaving a
             carried store among the unknown chunks in list order, which can put it where §A.3.7
             does not allow
   Reverses: filter unconditionally again, or emit the carried chunk in its original position
17. D2 - defence (b) stays a live runtime guard
   Taken:    keep the cross-check in release builds - this is a signing path, and the protection is
             worth one walk of the chunk list - but mark its `# Errors` clause as defence in depth,
             so an error no caller can observe through this API is not published as though it were
             reachable
   Rejected: demoting it to `debug_assert!`, which drops the protection exactly where it matters
   Reverses: remove the check, or gate it behind `debug_assertions`
18. D3 - the fallible builder stays
   Taken:    `with_unknown_chunks` keeps `Result<Self>`. Rejecting at the call site names the
             offending call rather than surfacing at encode time, the break is already taken, and
             it matches repo precedent (`aae9afa` was itself a "now returns Result" break).
   Rejected: deferring validation to `wrap`/`encode_image`, which would have kept the builder
             infallible and the signature unbroken, but reports "somewhere you added a reserved
             chunk" at a point far from the call that added it
   Reverses: revert to `-> Self` and validate at encode time
19. NEW-1 - the reserved list is asked for, not restated
   Taken:    `!matches!(WebpChunkId::from(fc), WebpChunkId::Unknown(_))` replaces the hand-written
             eight-name table, which had already drifted (no `ANIM`/`ANMF`) and let the encoder
             write a file its own strict reader rejects. Pinned over all ten arms.
   Rejected: adding `ANIM`/`ANMF` to the list - it would fix this drift and invite the next
   Reverses: restore an explicit table
20. NEW-2 - the constraint recorded for the static message did not bind
   Taken:    name the offending FourCC with `Error::with_detail(format!("{fourcc}"))`. My earlier
             claim that the message had to be static was WRONG: it read the constructor's
             `&'static str` and not `with_detail`, the workspace's owned-context channel, which
             this very crate uses with `format!` at `backend.rs:417`. `FourCc`'s `Display` escapes
             non-printable bytes, so naming a hostile code is safe.
   Rejected: leaving the caller to diff an eight-item list against their input
   Reverses: fold the detail back into a static message

Unresolved review notes

  • A report channel on the object-safe seam (Q5, deferred by decision 14). encode_with_report
    is an inherent method because EncodeImage has nowhere to put a report, and gamut-avif and
    gamut-png made the same choice. Whether the trait should grow one — and what that means for the
    C ABI, where each monomorphization has to be reachable — is a workspace-wide question that three
    format crates now have a stake in. Nothing here blocks on it.
  • encode_with_report's error arm is defensive, and now says so in its own # Errors clause
    (decision 17). No public API can make the encoder write a C2PA chunk that is not the configured
    store, so the arm is unreachable by construction rather than tested from the outside. What is
    pinned is the claim it enforces — for every store the encoder accepts, the reported span contains
    exactly that store — and the != in the check is mutation-visible, so removing it fails the
    suite. Exercising the arm directly would need a seam letting a test write a rogue chunk, a wider
    API change than this issue.
  • A pre-existing hazard this PR only partly closes. with_unknown_chunks now refuses ANIM and
    ANMF, so the encoder can no longer be steered into writing a file WebpLayout::parse rejects
    by that route. Whether any other public path can still produce a file gamut cannot read back was
    not surveyed here — it is outside this issue, and the animation chunks are out of scope for the
    crate under the image-first charter.
  • §18.7.3.5 defines a general box hash over a RIFF L1 chunk as running "from the beginning of the
    chunk identifier (byte 0) to the padding byte, if any, inclusive" — i.e. it includes the pad
    byte, where the range reported here excludes it. The two are different assertions
    (c2pa.hash.boxes versus the c2pa.hash.data this PR serves, per decision 4), and no code here
    computes either hash, so nothing is inconsistent today. A reviewer who intends gamut to serve
    c2pa.hash.boxes later should decide then whether that wants a second reported range rather than
    a change to this one. Range::len() % 2 recovers the pad byte's presence from the reported span.

C2PA 2.4 §A.3.7 embeds a manifest store in a RIFF `C2PA` chunk that
"shall appear as the last sub-chunk of the first RIFF header chunk".
`MetadataChunks` gains a fourth borrowed carrier for it, on the same
verbatim terms as `ICCP`/`EXIF`/`XMP `, and the writer emits it after the
metadata *and* after the unknown chunks §2.7.1.6 asks it to preserve.

`c2pa_span` reports the chunk's whole on-disk span — identifier, size
field and payload — which is what a `c2pa.hash.data` exclusion covers
(§18.5): an update manifest may resize the store, changing the size
field's value as well as the bytes after it. The RIFF pad byte that
follows an odd-length store is framing the container adds, so it stays
outside the span, exactly as `Chunk::payload` excludes it.

RFC 9649 §2.5's `VP8X` flag byte defines no C2PA bit, so presence is
decided by the chunk and never by a flag. `WebpChunkId::C2pa` makes the
store recognised rather than unknown, so a read/modify/write cycle
re-emits it once, in its mandated place, instead of twice.

BREAKING CHANGE: `MetadataChunks` gains a `c2pa` field. The struct is
exhaustive by design, so a struct literal needs `c2pa: None` or
`..Default::default()`.
`WebpEncoder::with_c2pa` embeds a finished C2PA manifest store as a
`C2PA` chunk (C2PA 2.4 §A.3.7); `with_c2pa_reserved` leaves room for one
that cannot exist yet, because its hard binding digests the finished file
(§15.12.1.1). Either way the chunk goes last, as §A.3.7 requires, and no
`VP8X` feature flag advertises it — RFC 9649 §2.5 defines no C2PA bit.

`encode_with_report` returns the file together with a `WebpEncodeReport`
naming the chunk's whole byte span: identifier, size field and payload,
which is what a `c2pa.hash.data` assertion excludes (§18.5). The range is
read back out of the finished bytes with the same locator the read side
uses, so a writer and a reader can never disagree about it, and the
object-safe `EncodeImage` seam is left untouched. `c2pa_span` recovers
the range from any WebP file and `WebpMetadata::c2pa` its bytes.

gamut carries the store: it never builds, hashes, signs or validates one.
The span arithmetic ran before the match test, so the pad-byte term was
computed and thrown away on the chunk being looked for. The doc's parity
aside was also backwards: the chunk header is eight bytes, so the span's
parity is the store's, not its opposite.
`write_extended_preserving` emitted the preserved unknown chunks before
the configured store, so a `C2PA` chunk arriving through `unknown` was
written first — and every reader here takes the *first* chunk of a kind.
A file built with `unknown = [C2PA "STALE"]` and `c2pa: Some("FRESH")`
came out as `[VP8X, VP8L, C2PA, C2PA]`, with both `MetadataChunks::read`
and `c2pa_span` reporting the stale copy. A signer would then exclude a
range over bytes it never wrote and hash its own store as content.

`MetadataChunks::c2pa` now owns the chunk: a `C2PA` chunk among the
preserved chunks is dropped rather than emitted, so a file this function
writes carries at most one store. Nothing is lost — `WebpLayout::parse`
never puts a `C2PA` chunk in `unknown`, since the crate recognises it —
and `write_extended` stays unfiltered for a caller assembling by hand.

Also pins `c2pa_span`'s documented `# Errors` contract, which had no
assertion reaching it in either direction.
Three defences so a WebP file carries exactly one C2PA manifest store,
which the encoder's docs already claimed but nothing enforced.

`with_unknown_chunks` now rejects a FourCC the encoder writes itself —
`VP8X`, `VP8 `, `VP8L`, `ALPH`, `ICCP`, `EXIF`, `XMP `, `C2PA`. Each has
a dedicated setter, so passing one through emitted the chunk twice and
`gamut-riff`'s "first of each kind wins" resolved it in favour of the
pass-through copy. It is the only setter that takes a FourCC from the
caller rather than only a payload, so it is the only one with an invalid
input to reject.

`encode_with_report` now reads the store back out of the finished file
and refuses to report a range whose bytes are not the configured store.
The previous guard fired only when a configured store could not be found
at all, which cannot happen; the failure that could happen — a range over
somebody else's bytes — returned `Ok`.

`with_c2pa_reserved` rejects a length past the `uint32` a RIFF chunk size
field holds instead of reaching `vec![0; len]`, which panics with
"capacity overflow" on a length no allocator can serve. CLAUDE.md forbids
a panic on a library path. The no-cap policy is unchanged: the bound is
what the container can express, not a limit on a signer's reserve size.

BREAKING CHANGE: `WebpEncoder::with_unknown_chunks` returns
`Result<Self>` and rejects a reserved FourCC; add `?` at the call site.
Dropping a `C2PA` chunk from the preserved chunks unconditionally was
heavier than the defect needed. Displacement requires a configured store
to displace; with none, the chunk is the manifest store of the file it
was read out of, and discarding it lost a foreign store with no signal.

It is now kept, and written in the store's place rather than wherever the
caller's list put it — in that file it *is* the store, so §A.3.7's "last
sub-chunk" applies to it. With a store configured the copy is still
dropped, so the field keeps sole ownership of the slot and the range
`c2pa_span` reports is still always over the configured bytes.

The layer asymmetry is deliberate and now documented: this function is
total, while `gamut-webp`'s builder rejects the same input with a typed
error. The builder can name the offending call; the writer cannot, and
must stay usable for a re-wrap that has to succeed.
… them

The reserved-FourCC gate restated eight of `WebpChunkId::from`'s ten arms
by hand and had already drifted: `ANIM` and `ANMF` were missing, so
`with_unknown_chunks(&[(FourCc::ANIM, ..)])` was accepted, encoded, and
produced a file `WebpLayout::parse` then refused — "reconstruction chunks
are out of order". The encoder could be steered into writing a file it
cannot read back. Asking `WebpChunkId::from` covers all ten arms and
cannot drift from `gamut-riff` again, per CLAUDE.md's no-duplication rule.

The error now names the offending FourCC through `Error::with_detail`,
the workspace's owned-context channel — used with `format!` in this crate
already — instead of making the caller diff a list in a static message.
`FourCc`'s `Display` escapes non-printable bytes, so a hostile code is
safe to render.

The reservation limit moves into `reservation_len`, so the `uint32`
ceiling is pinned at the boundary without allocating the 4 GiB reaching
it would need; the assertion that stood in for that pinned a property of
`core` and could not fail. `encode_with_report`'s cross-check stays a
live runtime guard — this is a signing path — but its `# Errors` clause
now says plainly that it is defence in depth and unreachable through this
API. The `c2pa_span` `# Errors` pin here is dropped: it reached a
one-line delegation whose only mutants another test already kills, and
asserted a `gamut-riff` property that crate now pins inline.

BREAKING CHANGE: `WebpEncoder::with_c2pa_reserved` also returns
`Result<Self>`, which the footer of 46019a5 omitted; it named only
`with_unknown_chunks`. Both need `?` at the call site. Additionally
`with_unknown_chunks` now rejects `ANIM` and `ANMF`, which it previously
accepted and mis-encoded.
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.

gamut-riff/gamut-webp: carry the C2PA manifest store in the C2PA chunk

1 participant