Skip to content

feat(dng)!: type the C2PA manifest store and report both exclusion ranges - #508

Open
justin13888 wants to merge 9 commits into
masterfrom
feat/442-dng-c2pa-manifest-store
Open

justin13888 wants to merge 9 commits into
masterfrom
feat/442-dng-c2pa-manifest-store

Conversation

@justin13888

@justin13888 justin13888 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Types the C2PA manifest store (C2PA 2.4 §A.3.6: tag 52545 / 0xCD41, type UNDEFINED) on both sides of gamut-dng, applies the specification's placement rule, and reports the two disjoint exclusion ranges an external signer needs (§18.5.5). The placement and exclusion rules live in a new shared gamut_ifd::c2pa module so #446 (plain TIFF) reuses them rather than re-deriving §A.3.6.

gamut-ifd (feat(ifd), minor):

  • c2pa::C2PA_MANIFEST_STORE, C2paExclusions { store, count_field } — two ranges in the crate's own Range, never one.
  • reserve_entry / append_store: the entry is reserved inline in the last main IFD while the tree is laid out, and the store is appended at the word-aligned end of the finished file with only the entry's count/offset words patched. A post-write relocation rather than a writer mode, because a codec's pixel data is appended after the writer's stream and "end of stream" is not "end of file".
  • locate: walks any ReadAt source to the chain's last directory and reports both ranges (out-of-line or inline store). A mistyped or misplaced entry is absence; a store past EOF is the same InvalidInput read gives it.
  • Bytes cross verbatim — §A.3.6: the header's ByteOrder does not govern the store — pinned on MM fixtures with an asymmetric store. Stores shorter than a JUMBF box header (8 bytes) are refused.

gamut-dng (feat(dng)!, majorDngMetadata is deliberately exhaustive):

  • DngMetadata::c2pa: Option<Vec<u8>>, the fifth carrier; blocks() yields MetadataBlock::C2pa.
  • DngEncoder::with_c2pa_reserved(len) writes a zero-filled reservation; encode_with_report returns DngEncodeReport { len, c2pa: Option<C2paExclusions> }. encode delegates and is unchanged; EncodeImage untouched.
  • DecodedDng::c2pa_exclusions carries the located ranges beside metadata.c2pa; a mistyped tag, or one in IFD 0 when the chain continues, stays in ifd0_extra.
  • A reservation and a same-sized store are byte-identical outside the store; a store of a different size changes the count field and nothing else (the epic's "nothing after placement moves a byte" criterion, tested exactly).
  • The Adobe DNG SDK accepts store-carrying files (LE, BE, BigTIFF); deconstruct claims the store as IFD 0's Value { tag: 52545 } span with the file fully classified — never a Trailer.

Not done: parsing the store; touching gamut-tiff; changing the facade's C2paPolicy; adding a dependency.

Review repairs (commits 3–4)

A read-only review of the first two commits found two Medium defects and three Lows, all fixed here:

  • The 8-byte minimum did not keep a store out of line. BigTIFF's inline threshold is also 8 and value_offset compares <=, so with_big_tiff(true).with_c2pa_reserved(8) — valid per the docs — appended the bytes at EOF while writing an offset into the value word: the entry read back as the offset, the appended run was referenced by nothing, and the reported ranges covered bytes no reader returns. Both layers now gate on the variant's own inline_threshold(); nine bytes is the smallest BigTIFF store, classic TIFF is unchanged. The two docs asserting the false claim are corrected.
  • is_fully_accounted() flipped false the moment a store was embedded, because 52545 was missing from gamut-dng's KNOWN_TAGSdeconstruct reported the file's own manifest store as a private tag. Fixed with the tag-table entry (aliasing gamut_ifd::c2pa::C2PA_MANIFEST_STORE), and the accounting test now uses the file's assert_clean helper like its neighbours rather than the weaker subset that had hidden the gap.
  • Two tag-52545 entries made bytes and ranges describe different runs (the eager Ifd keeps the last duplicate, store_entry took the first). §A.3.6 admits one store per asset, so more than one entry is now absence.
  • A too-short value broke decode → encode. A foreign file with a 1–7-byte value decoded to Some(short) which the encoder then refused. Applying the rule references/c2pa/README.md already states, a value below the JUMBF header is not a store: absent on read, still a hard error on write.
  • Re-export closure: C2PA_MANIFEST_STORE and MIN_STORE_LEN join C2paExclusions on the gamut-dng root; C2paExclusions is now #[non_exhaustive], matching DngEncodeReport.

Second review round (commits 5–7)

  • The duplicate rule was closed in gamut-ifd but not at the DNG surface. metadata.c2pa read bytes through the eager Ifd (last duplicate wins) while c2pa_exclusions came from locate (absence on duplicates), so a file with two tag-52545 entries returned bytes with no ranges — contradicting c2pa_exclusions' own doc — and re-encoding silently emitted a one-entry file carrying the last duplicate. The ranges are now located first and the bytes taken only if that succeeded, so one rule decides both surfaces and they cannot drift apart again. append_store's message for a duplicated entry no longer claims the entry is missing.
  • A trailing-IFD field was being dropped. Applying that rule exposed it: a declined tag-52545 field is put back for the extras, but extras came only from IFD 0 and the raw IFD, and §A.3.6's other lawful placement (the store as "the only entity within a new IFD following the existing one") makes a directory with no image — neither of those, and no SubImage either. Gap closed, not just documented: DecodedDng::trailing_extra carries that directory's fields (empty for every file this crate writes). The narrower residue that predates this work — an interior main-chain page with no image data — is filed as gamut-dng: an interior main-chain page with no image data reaches no decode surface #525, and the promise on ifd0_extra now says exactly what the four verbatim channels reach.
  • A mutation survivor, fixed structurally. The new guard was three ||ed comparisons that no test could tell from &&: the disjuncts never disagree on any file the suite builds. Restated as one membership test over the directories already surfaced — removing the operators rather than adding a test to compensate — and pinned along the missing axis (a single-main-IFD file whose IFD 0 carries a declined field: ifd0_extra holds it, trailing_extra stays empty). Verified by hand in both directions before re-running the gate.

Third review round (commits 8–9)

Four Lows, all closed. C2paExclusions::new now validates — it returned an unchecked set while the type doc promised "they never overlap", and that set feeds a signer's hard binding; empty and overlapping ranges are refused, abutting ones accepted. The preservation claim is narrowed to what is true: a duplicated tag keeps only the last entry (the eager Ifd is last-wins), so STATUS.md says that instead of listing "duplicated" among fields that survive verbatim — and the test that pinned it, which asserted only that some tag-52545 field existed and so passed on either duplicate, now asserts the count and the exact bytes. The absolute promise is qualified where a reader meets it first — the crate front page and the RawTag doc, not only the field docs — naming both residues (last-wins duplicates, and the interior-page gap #525). A duplicated test helper is deleted in favour of the identical pre-existing one.

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

Validation

Run in the lane worktree (a nested worktree, hence the __CARGO_TEST_ROOT prefix on the fmt tasks — an environment artefact, not a manifest change). CARGO_BUILD_JOBS=2 on crate-scoped runs; workspace gates inside a systemd-run --scope -p MemoryMax=16G with ulimit -v 12000000.

All rows below are the run at head 677d38e unless noted.

command outcome
cargo test -p gamut-ifd -p gamut-dng --all-features pass — 19 test binaries green; gamut-ifd lib 161 (18 c2pa) plus fidelity 4 / hardening_audit 16 / robustness 8 / streaming 5; gamut-dng lib 188, tests/c2pa.rs 15, deconstruct 21, roundtrip 27, oracle_adobe 18, oracle_libtiff 1, adobe_samples 6, corpus 2, color_profile 6, real_world_shapes 10, rewrite 4, subimages 2, doctests
__CARGO_TEST_ROOT=<worktree> mise run fmt then … mise run fmt-check pass
mise run check-tests pass
mise run check-commits pass — "no errors in 9 commits"
cargo clippy -p gamut-ifd -p gamut-dng --all-targets --all-features -- -D warnings pass (first run flagged two doc_lazy_continuation errors in a new doc comment; reworded, then clean)
mise run lint (workspace, capped scope) pass — exit 0, whole workspace, 6m26s (at a645321; later commits are covered by the crate-scoped clippy above and by CI's green Clippy job at 6ea9565)
mise run test (workspace, capped scope) pass — exit 0, 203 test binaries, no failures (at 6ea9565; not re-run for round 3: C2paExclusions is named nowhere outside gamut-ifd/gamut-dng and its only ::new call site is one gamut-ifd test, so the signature change cannot reach another crate)
mise run mutants-diff (the selection CI blocks on, capped scope) pass — 78 mutants in 12m: 69 caught, 9 unviable, 0 survivors, including the comparison operators round 3 added to C2paExclusions::new. (Earlier rounds: 66/57 at a645321, 75/66 at 0b5b735, 78/69 at 6ea9565, all 0 survivors; the round-2 run at 6e9fb63 reported 2 survivors in a new guard, fixed structurally in 6ea9565.)
RUSTDOCFLAGS=-D warnings cargo doc -p gamut-ifd -p gamut-dng --no-deps --all-features fails on 12 broken intra-doc links, all in lines this change does not touch (gamut-ifd audit.rs/segment.rs/stream.rs, gamut-dng decoder.rs:152, lib.rs:56) — pre-existing; the repo's lint task is clippy-only and does not gate rustdoc. Every link added here resolves.

CI on 677d38e: observed to terminal after the final push; all seven required checks green at the preceding head 6ea9565 as well.

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

Risks and rollout

  • Semver-major for gamut-dng: every DngMetadata { … } struct literal must add c2pa: None. The one in-tree literal (tests/roundtrip.rs) is updated; release-plz cuts the major from the ! commit.
  • DngDecoder::decode now also runs gamut_ifd::c2pa::locate (a directory-body walk, no value fetch); it cannot newly fail on a file read accepts.
  • A DngRewrite of a file carrying a store relocates it into the value pool like any other value; a rewrite invalidates the binding regardless. Unchanged behaviour, now documented.
  • Carrying the tag does not raise DNGVersion/DNGBackwardVersion (metadata a reader may ignore, like XMP/ICC; the SDK validates the file).

Issue

Closes #442
Closes #513

#513 was filed by this lane when tags.rs sat outside its manifest; the manifest was then extended on review and the one-line tag-table entry is included here, so the issue closes with this PR rather than outliving it.

Decisions taken

Issue 442 - gamut-dng: type the C2PA manifest store tag and report both exclusion ranges
Plan:     v1
Branch:   feat/442-dng-c2pa-manifest-store
Base:     origin/master (6a75ec4)
Cause:    -
Touches:  gamut-ifd new c2pa.rs + lib.rs + tests + STATUS (the shared §A.3.6 placement and §18.5.5 exclusion helper); gamut-dng metadata.rs (DngMetadata.c2pa), decoder.rs, encoder.rs, lib.rs, tests, STATUS/README
Will not: parse the store; byte-swap it; touch gamut-tiff; change the facade's C2paPolicy; add a dependency
Lane:     parallel (root of the TIFF stack: #446 branches from this head; #163 serialised behind it on gamut-dng/STATUS.md)
Settled:  S2 - `DngMetadata` is exhaustive, so the new field is `feat(dng)!:` with a BREAKING CHANGE footer; gamut-ifd additions are minor; S3 docs/testing.md

Decisions taken.
1. Deliverable boundary
   Taken:    the whole issue - typed tag on both sides, normative placement, opaque bytes, reserved placeholder, two disjoint exclusion ranges, claimed span in the byte accounting; the placement/exclusion helper lives in gamut-ifd so #446 reuses it rather than duplicating
   Rejected: helper in gamut-dng - #446 would re-derive §A.3.6; a gamut-dng-only side channel - the epic forbids it
   Reverses: move c2pa.rs into gamut-dng
   Filed:    -
2. Field and type
   Taken:    `DngMetadata.c2pa: Option<Vec<u8>>` (opaque store bytes) and `to_blocks` yields `MetadataBlock::C2pa` for it; one `feat(dng)!:` commit with `BREAKING CHANGE: DngMetadata gains c2pa`; the tag constant `C2PA_MANIFEST_STORE = 52545` (type UNDEFINED) lives in gamut-ifd's new module with the clause cited
   Reverses: a separate side struct on DngEncoder
3. Placement on write
   Taken:    the entry is written into the last IFD of the main chain (for gamut-dng's single-main-IFD files: IFD 0), and the store's value bytes are placed LAST in the file so a resize moves no other offset; implemented in gamut-ifd as a `WriteOptions`-level "place this tag's value at end of file" (or a post-write relocation with offset patch-up) - whichever gamut-ifd's writer supports without a second writer; documented in STATUS
   Rejected: a new trailing IFD holding only the entry - lawful too, but gamut-dng writes one main IFD and the simpler form is the one Adobe's SDK reads without surprise
   Reverses: emit the trailing-IFD form
4. Reserve
   Taken:    `DngEncoder::with_c2pa_reserved(len)` writes `len` zero bytes as the store and the encoder reports where it landed via `encode_with_report(...) -> (bytes, DngEncodeReport { c2pa: Option<C2paExclusions> })`, leaving `EncodeImage` untouched (C-portability rule); `with_metadata(DngMetadata { c2pa: Some(bytes), .. })` writes a caller-computed store
   Reverses: getter after encode instead of a report
5. Exclusion set
   Taken:    `gamut_ifd::c2pa::C2paExclusions { store: Range<usize>, count_field: Range<usize> }` computed from the written bytes and the IFD walk (the 4-byte count field of the entry, or 8 in BigTIFF, at its own file offset); never a single Range; a reader helper `locate(&[u8]) -> Result<Option<C2paExclusions>>` over the shared IFD walk for the read side; the decoder exposes the located store's bytes and ranges
   Rejected: one Range - §18.5.5 needs two disjoint ranges and a single-range API would have to break later
6. Endianness
   Taken:    the store is UNDEFINED bytes, copied verbatim in both directions; pinned by a test on a big-endian ("MM") fixture whose store bytes are asymmetric
7. Byte accounting
   Taken:    `gamut-dng`'s deconstruct claims the store span as a `Value{ifd, tag: 52545}` (it already claims tag values) - verify with a test that a file with a store is fully classified; if the store lives at end-of-file after all strips the existing walker must not label it Trailer
8. Oracle
   Taken:    the Adobe DNG SDK oracle validates a DNG carrying the store (unknown private tag, still valid DNG); behavioural c2pa-rs oracle is #447's

Appended by the lane (forks the record did not cover):

9. Placement mechanism (the branch of decision 3 actually taken)
   Taken:    post-write relocation: `gamut_ifd::c2pa::reserve_entry` puts a one-byte inline placeholder in IFD 0 so the directory layout is final with the value pool untouched, gamut-dng writes its whole file as before, then `gamut_ifd::c2pa::append_store` lands the store at `align_word(len)` and patches only the entry's count/offset words
   Rejected: a `WriteOptions` "place this tag's value at end of stream" mode - gamut-dng appends preview/raw strips after the writer's stream, so end-of-stream is mid-file; a pinned span - its offset is the data's end, which depends on the layout the pin is part of
   Reverses: add the WriteOptions mode and restructure gamut-dng's writer.rs (outside this manifest) around it
10. Exclusion range type
   Taken:    `C2paExclusions { store: gamut_ifd::Range, count_field: gamut_ifd::Range }` (u64 start/len) - the crate's own byte-range type, the one `SegmentReport` measures in, C-portable plain `Copy` data; the shape (two disjoint ranges) is decision 5's
   Rejected: `core::ops::Range<usize>` - not `Copy`, and a second range vocabulary in a crate that already has one
   Reverses: convert at the boundary
11. `locate` source
   Taken:    `locate<S: ReadAt>(src: S)` - a `&[u8]` or a `StreamSource` over a file handle, since finding a 12-byte entry in a multi-hundred-MB RAW should not need the file in memory; strictly wider than decision 5's `locate(&[u8])`
   Reverses: narrow to `&[u8]`
12. Minimum store length and conflicting inputs
   Taken:    a store or reservation shorter than 8 bytes (a JUMBF box header, `MIN_STORE_LEN`) is a typed `InvalidInput` in both crates, and configuring a store and a reservation together is a typed error in gamut-dng, both raised before any pixel work; the bound also guarantees the value is out of line in both variants so the two ranges are disjoint from the directory
   Rejected: accept any length - a ≤ 8-byte value can never be a manifest store, and an inline "store" would put both exclusion ranges inside the entry
   Reverses: drop the bound and let an inline store through
13. DNGVersion
   Taken:    carrying tag 52545 raises neither `DNGVersion` nor `DNGBackwardVersion` - like XMP/ICC it is metadata a reader may ignore, the tag is C2PA's not a DNG feature, and the SDK (which names it `tcC2PAManifest`) validates the file
   Reverses: raise to 1.7.0.0 in `required_dng_version`
14. `tags::is_known_tag` not widened
   Taken:    `crates/gamut-dng/src/tags.rs` is outside the manifest, so 52545 is not added to the known-tag list; `deconstruct` reports it as an `UnknownTag` while the bytes are fully classified (the record's decision 7 criterion); documented in STATUS and reported as a manifest revision request
   Rejected: editing tags.rs - the lane contract forbids widening the manifest
   Reverses: add `C2PA_MANIFEST_STORE` to `KNOWN_TAGS` in tags.rs (one line)
   SUPERSEDED by 15.6: the manifest was extended on review and the entry is in this PR.

Decided by the orchestrator on review of a645321, and applied in commits 3–4:

15. Review decisions (orchestrator, on the read-only review of a645321)
15.1 Out-of-line bound (F1, Medium)
   Taken:    `append_store` and `DngEncoder::c2pa_store` gate on the container variant's own `inline_threshold()`, not on the `MIN_STORE_LEN` constant; the two documents asserting the 8-byte bound guaranteed out-of-line-ness "in both variants" are corrected; the exactly-at-threshold case is pinned for BOTH variants
   Rejected: raising `MIN_STORE_LEN` to 9 - it is a JUMBF-header bound cited from the spec, not a container bound, and classic TIFF would then refuse a lawful 8-byte store
   Reverses: gate on the constant again (restores the BigTIFF corruption)
15.2 Duplicate entries (F2, Low)
   Taken:    `store_entry` reports absence when the last IFD holds more than one tag-52545 entry, so bytes and ranges can never name different runs
   Rejected: keeping the first match - it disagrees with the eager `Ifd`, which keeps the last
   Reverses: drop the duplicate check
15.3 Known-tag list (F3, Medium; manifest extended to `crates/gamut-dng/src/tags.rs`)
   Taken:    52545 joins `KNOWN_TAGS` aliasing `gamut_ifd::c2pa::C2PA_MANIFEST_STORE`, so `is_fully_accounted()` stays true for a file this encoder writes; the accounting test switches to the file's `assert_clean` helper; `Closes #513` added to this body
   Rejected: leaving it to #513 - the predicate regresses for gamut's own output in the meantime
   Reverses: remove the entry and reopen #513
15.4 Too-short values (F4, Low)
   Taken:    the read side reports absence for a value below `MIN_STORE_LEN` (`references/c2pa/README.md`'s reader rule), the write side keeps its hard error; both halves documented, decode -> encode pinned
   Rejected: symmetric erroring - a foreign file would stop round-tripping; symmetric acceptance - an encoder would silently drop a store it cannot write
   Reverses: apply one rule to both directions
15.5 Re-export closure (F5, Low)
   Taken:    `C2PA_MANIFEST_STORE` and `MIN_STORE_LEN` are re-exported from `gamut-dng` beside `C2paExclusions`; the crate's own tests now import them from `gamut_dng`
   Reverses: drop the re-exports
15.6 Q2 - `C2paExclusions` is `#[non_exhaustive]`
   Taken:    consistent with `DngEncodeReport` in this same PR; additive now versus a `gamut-ifd` major later
   Reverses: remove the attribute (a breaking change once released)
15.7 Q3 - store XOR reservation stays an error
   Taken:    the signer flow is reserve -> sign -> re-encode with the store, which sets one at a time, so setting both is a caller mistake and letting one silently win would hide it; documented in STATUS
   Reverses: pick a winner and document the precedence
15.8 Q4 - the trailing-IFD write form stays out of scope
   Taken:    gamut-dng writes a single main IFD and the in-IFD form is lawful and is what the Adobe SDK reads without surprise; the decoder already reads both; documented as deliberate, nothing filed
   Reverses: emit the trailing-IFD form
15.9 Q5 - the duplicated 8-byte JUMBF bound is accepted
   Taken:    `gamut_heic::c2pa::JUMBF_HEADER_LEN` and `gamut_ifd::c2pa::MIN_STORE_LEN` may both state it - the dependency graph gives them no shared home and both cite the same clause; recorded in `gamut-ifd/STATUS.md` and under `## Unresolved review notes`
   Rejected: a new crate for one integer
   Reverses: introduce a shared C2PA-constants crate

Decided by the orchestrator on re-review of 0b5b735, and applied in commits 5–7:

16. Re-review decisions (orchestrator, on the review of 0b5b735)
16.1 N1 - one rule for both decode surfaces (Medium)
   Taken:    `DngDecoder::decode` locates the ranges FIRST and passes "was a store located" into `decode_metadata`, which takes the bytes only then; so `metadata.c2pa` and `c2pa_exclusions` are `Some` together or neither is, for a duplicated entry as for every other rejection. Pinned at the DNG layer (`a_duplicated_store_entry_is_absent_from_both_decode_surfaces`), not only in gamut-ifd. `append_store` distinguishes a duplicated entry from a missing one so its message says what is actually wrong (`append_store_names_a_duplicated_entry_as_the_problem`)
   Rejected: re-deriving the duplicate test inside the decoder - two copies of one admission rule is what let the surfaces disagree in the first place
   Reverses: read the bytes independently of the ranges again
16.2 N2 - close the preservation gap rather than document it (Low, a contract claim)
   Taken:    `DecodedDng::trailing_extra` (a new field on a `#[non_exhaustive]` struct, so additive) carries the last main-chain directory's fields when it is not IFD 0, the raw IFD or a sub-image - the shape §A.3.6's "only entity within a new IFD" placement creates; the promise on `ifd0_extra` is reworded to name the four verbatim channels and the one residue they do not reach; that residue (an INTERIOR main-chain page with no image data, which predates this work) is filed as #525
   Rejected: correcting the doc claims only - the input the store's own placement rule invites would still vanish; generalising to every uncovered page here - a caller cannot tell which page a flat list came from, so the honest shape is per-page and renaming `trailing_extra` later would be semver-major (that is #525's design note)
   Reverses: drop the field and restore the narrower doc claim
16.3 The mutation survivor the N2 fix introduced
   Taken:    the guard is one membership test over the directories already surfaced, not three `||`ed comparisons; a fixture varying the axis those comparisons never varied (single main IFD, IFD 0 holding a declined field) pins it, and both forced-constant directions were verified to fail before re-running the gate
   Rejected: adding a test that merely re-asserts the same shape - the operators decided nothing on any real file, so the defect was the expression, not the coverage
   Reverses: restore the `||` chain
16.4 Q1 - keep the read/write inline asymmetry
   Taken:    `locate` reads a lawful inline BigTIFF store that `append_store` will not write; liberal in, conservative out, documented at BOTH functions and in `gamut-ifd/STATUS.md` so it reads as a decision
   Rejected: writing the inline form - it would give a store two placements to reason about for no gain
   Reverses: emit the inline form for an 8-byte BigTIFF store
16.5 Q3 - `is_known_tag` means "a tag this crate recognises"
   Taken:    as implemented, and now documented where it could be misread: the question is about this crate's vocabulary and is answered from the tag number alone. "A tag this decode consumed" would make the answer depend on the file's contents, so the same tag would be known in one file and unknown in another
   Reverses: redefine it against consumption and re-derive every caller
16.6 Q4 - `C2paExclusions::new`
   Taken:    a public constructor beside `#[non_exhaustive]`, so a host placing a store by its own route can still name the ranges §18.5.5 asks it to exclude; pinned by `exclusions_can_be_constructed_publicly`
   Rejected: dropping `#[non_exhaustive]` - that trades extensibility for constructibility when both are available
   Reverses: remove the constructor

Decided by the orchestrator on the third review of 6ea9565, and applied in commits 8–9:

17. Third-review decisions (orchestrator, on the review of 6ea9565)
17.1 F1 - narrow the preservation claim, and stop the test passing vacuously
   Taken:    `STATUS.md` drops "duplicated" from the list of declined fields that reach the caller verbatim and says instead that the LAST of several entries survives, because every typed channel is built on the eager `Ifd`, which is last-wins; the pinning test now asserts how many tag-52545 fields arrive (one) and which bytes they carry (the second duplicate's), where it previously asserted only that some such field existed - vacuous on exactly the ambiguity it existed to pin
   Rejected: widening the channel to carry both duplicates - that means changing `gamut-ifd`'s last-wins `Ifd` model, which is outside this manifest and shared by every consumer of the IFD core; a file with two stores is malformed under §A.3.6 in any case, and `deconstruct` still accounts for both
   Reverses: restore the broader claim and the weaker assertion
17.2 F2 - `C2paExclusions::new` validates
   Taken:    `new` returns `Result`, rejecting an empty range on either side and overlapping ranges, so the "they never overlap" invariant the type advertises holds by construction; the type doc states it as an invariant; abutting ranges stay legal and the test drives both abutment orders, one-byte overlap each way, and containment
   Rejected: keeping it infallible and weakening the doc to "the caller's to get right" - the set flows to a signer's hard binding, so a consumer must be able to rely on what the type advertises
   Reverses: make it infallible again and hedge the doc
17.3 F3 - carry the qualification to where the promise is first met
   Taken:    the crate front page (`lib.rs`) and the `RawTag` doc carry the same qualification the field docs already had, naming both residues - the last-wins duplicate and the interior-page gap (#525) - and `trailing_extra` documents both in one place so the cross-references resolve
   Reverses: restore the absolute wording
17.4 F4 - delete the duplicated test helper
   Taken:    `append_raw_trailing_ifd` is removed and its two callers use the pre-existing `append_trailing_ifd`; the helpers were character-identical but for one assertion, and the doc justifying the copy claimed a byte-level/eager distinction that does not exist - the existing helper is equally byte-level and its assertion holds on both new fixtures
   Rejected: keeping both - the repository's no-duplication rule, and a second helper would drift from the first
   Reverses: reintroduce the copy
17.5 Scope of the re-run gates
   Taken:    the workspace `mise run test` is not re-run for this round: `C2paExclusions` is named nowhere outside `gamut-ifd` and `gamut-dng` (checked by grep across `crates/` and `tooling/`), and the only `::new` call site is one gamut-ifd test, so F2's signature change cannot reach another crate; the crate suites, clippy, mutants-diff, fmt-check, check-tests and convco all re-ran
   Reverses: run the workspace gate too

Unresolved review notes

Accepted residual — the 8-byte JUMBF bound is stated twice. gamut_heic::c2pa::JUMBF_HEADER_LEN and gamut_ifd::c2pa::MIN_STORE_LEN both encode the same 8-byte JUMBF box-header bound from the same clause (C2PA 2.4 §8.4.2.3, recorded in references/c2pa/README.md). The dependency graph gives them no shared home — gamut-ifd sits below gamut-heic and neither may depend on the other — and factoring it out would mean a new crate for one integer. Accepted on review, recorded in gamut-ifd/STATUS.md; nothing filed.

Filed, not fixed here. #525 — an interior main-chain page carrying no image data reaches no typed decode surface. It predates this PR (the last page is what §A.3.6's placement creates, and trailing_extra covers that); deconstruct still accounts for its bytes. The issue records why the honest fix is per-page and therefore more than a one-liner.

Not re-reviewed. Commits 5–7 (the second round of repairs) have not themselves been through a review pass.

C2PA 2.4 §A.3.6 embeds the manifest store in a TIFF-based file as tag 52545
(0xCD41), type UNDEFINED, with an unusual placement rule: one store per asset,
its entry in the last IFD of the main chain, and its bytes at the end of the
file so a resize moves no other offset. §18.5.5 then asks a signer to exclude
two disjoint ranges from its hard binding — the store and the entry's count
field. Both gamut-dng (#442) and gamut-tiff (#446) need exactly this, so the
new `c2pa` module states it once:

- `C2PA_MANIFEST_STORE` and `C2paExclusions { store, count_field }`, two
  ranges in the crate's own `Range`, never one.
- `reserve_entry` puts a one-byte inline placeholder in the directory a codec
  writes as its last main IFD, so the layout is final while the value pool
  stays untouched; `append_store` then lands the store at the word-aligned end
  of the finished file and patches only the entry's count and offset words.
  A post-write relocation rather than a writer mode, because a codec's pixel
  data is appended after the writer's stream and "end of the stream" is not
  "end of the file".
- `locate` walks any `ReadAt` source to the chain's last directory and
  reports both ranges for an out-of-line or inline store; a mistyped or
  misplaced entry is absence, a store past the end of the file is the same
  `InvalidInput` `read` gives it.

The store is opaque bytes copied verbatim — §A.3.6 says the header's
ByteOrder does not govern it — pinned on big-endian fixtures with an
asymmetric store. A store shorter than a JUMBF box header (8 bytes) is
refused. An audited read of the result is fully classified: the store is the
entry's value span and the alignment filler is padding, never a trailer.

Refs #442
…nges

The C2PA manifest store (C2PA 2.4 §A.3.6, tag 52545, type UNDEFINED) was
already visible as an untyped `RawTag`; it now has a name, a placement rule
and the exclusion ranges an external signer needs.

- `DngMetadata::c2pa: Option<Vec<u8>>` is the fifth carrier, verbatim bytes
  on the same terms as XMP/IPTC/ICC, handed over by `blocks()` as
  `MetadataBlock::C2pa`. Its entry goes in IFD 0 — the last and only IFD of
  the main chain — and its value is appended after the image data, last in
  the file, through `gamut_ifd::c2pa`'s reserve-then-append placement.
- `DngEncoder::with_c2pa_reserved(len)` writes a zero-filled reservation;
  `encode_with_report` returns `DngEncodeReport { len, c2pa }` with the two
  disjoint ranges §18.5.5 asks for — the store and the entry's count field
  (4 bytes classic, 8 BigTIFF). A reservation and a same-sized store are
  byte-identical outside the store, and a store of a different size changes
  the count field and nothing else. `encode` delegates and is unchanged.
- `DecodedDng::c2pa_exclusions` carries the located ranges beside the bytes
  in `metadata.c2pa`, read from the last main-chain IFD; a mistyped entry, or
  one in IFD 0 when the chain continues, stays an `ifd0_extra`.
- The bytes cross verbatim in either byte order; the Adobe DNG SDK accepts
  the result in little-endian, big-endian and BigTIFF; `deconstruct` claims
  the store as IFD 0's value span with the file fully classified.

Carrying the tag raises neither DNGVersion nor DNGBackwardVersion: like XMP
and ICC it is metadata a reader may ignore.

BREAKING CHANGE: `DngMetadata` gains the `c2pa` field. The struct is
deliberately exhaustive, so every struct literal must add `c2pa: None`.

Closes #442
Three defects in the manifest-store module, all found reviewing the
placement against the container's own inline rule.

`MIN_STORE_LEN` (8) is a JUMBF box header, and the module claimed that
bound also kept an appended store out of line "in both variants". It does
not: BigTIFF's inline threshold is 8 too, and `value_offset` compares `<=`,
so a `count: 8` UNDEFINED value is inline by the container's own rule.
`append_store` appended the bytes anyway and wrote an offset into the value
word, so the entry read back as the offset (`Undefined([72,0,0,...])`), the
appended run was referenced by nothing, and `locate` reported the value word
while the encoder reported the appended range -- exclusion ranges over bytes
no reader returns. It now gates on the variant's own `inline_threshold()`,
so the shortest writable BigTIFF store is nine bytes and the classic-TIFF
case is unchanged.

A directory carrying two tag-52545 entries named no single store, yet
`store_entry` took the first while the eager `Ifd` keeps the last, so bytes
and ranges could describe different runs under one name. §A.3.6 admits one
store per asset, so more than one entry is now reported as absence.

`locate` reported a value too short to hold a JUMBF box header as a store.
`references/c2pa/README.md` already prescribes the split this needs: a
reader treats such a value as not a manifest store, while a writer refuses
it -- an encoder handed a store it cannot write must say so rather than drop
it silently. Absence on the read side is also what makes decode -> encode of
a foreign file carrying a stub value work at all.

`C2paExclusions` becomes `#[non_exhaustive]`, matching `DngEncodeReport`:
§18.5.5 names two ranges today and a third must be additive.

Refs #442
`is_fully_accounted()` was true for every file this encoder writes until a
manifest store was embedded, because 52545 was missing from `KNOWN_TAGS`:
`deconstruct` then reported the file's own store as a private tag. The tag
joins the list (aliasing `gamut_ifd::c2pa::C2PA_MANIFEST_STORE`, where the
clause is stated), and the accounting test now uses the file's `assert_clean`
helper like its neighbours instead of asserting a weaker subset -- which is
what had hidden the gap.

Two smaller corrections to the store's edges:

- A BigTIFF store of exactly 8 bytes packs inline, so it cannot be the run at
  the end of the file §A.3.6 wants. It was reachable through the documented
  `with_big_tiff(true).with_c2pa_reserved(8)` and produced a file whose
  reported ranges covered bytes no reader reads back. The encoder now refuses
  it before any pixel work; nine bytes are the smallest BigTIFF store.
- A foreign file whose tag-52545 value is shorter than a JUMBF box header
  decoded to `Some(short)`, which the encoder then refused -- decode -> encode
  of a real file was not round-trippable. Such a value is not a manifest store
  (`references/c2pa/README.md`), so it decodes as absent while the encoder
  keeps its hard error for a store a caller supplies.

`C2PA_MANIFEST_STORE` and `MIN_STORE_LEN` join `C2paExclusions` on the crate
root, completing the re-export closure the freeze decisions state: this
crate's own docs name them, so a signer should not need a direct `gamut-ifd`
dependency to use them.

Refs #442
…ion set

`append_store` told a caller whose last IFD carries two tag-52545 entries
that it "carries no reserved C2PA manifest store entry", because
`store_entry` reports absence for a duplicate exactly as it does for a
missing entry. The two cases are now distinguished, so the message says what
is actually wrong.

`C2paExclusions` gains a public `new`. `#[non_exhaustive]` alone left
downstream code no way to build one at all, so a host placing a store by its
own route -- its own writer, a format this crate does not serialise -- could
never name the ranges 18.5.5 asks it to exclude. Keeping the attribute and
adding the constructor gives the type both extensibility and
constructibility.

The read/write asymmetry around an inline BigTIFF store is now stated at
both `locate` and `append_store`: `locate` reads that lawful shape,
`append_store` refuses to write it, because an inline value is not the run at
the end of the file the placement rule is built on and admitting it would
give a store two placements to reason about. Liberal in, conservative out --
recorded so it reads as a decision rather than an oversight.

Refs #442
…a page

Two surfaces described the same file differently. `metadata.c2pa` read the
store's bytes through the eager `Ifd`, where the LAST duplicate wins, while
`c2pa_exclusions` came from `c2pa::locate`, which reports absence when a
directory carries more than one tag-52545 entry. A file with two 40-byte
entries therefore returned bytes with no ranges -- contradicting
`c2pa_exclusions`' own documentation ("`Some` exactly when the store is") --
and re-encoding that metadata silently produced a one-entry file carrying
only the last duplicate's bytes.

The ranges are now located first and the bytes are taken only if that
succeeded, so a single rule decides both surfaces and they cannot drift
apart again.

Applying that rule exposed a second defect. A declined tag-52545 field is put
back for the extras, but extras were collected only from IFD 0 and the raw
IFD, and C2PA 2.4 A.3.6's other lawful placement -- the store as "the only
entity within a new IFD following the existing one" -- makes a directory with
no image, which is neither of those and becomes no `SubImage` either. Such a
field reached no surface at all, against this decoder's standing promise that
nothing in the file is silently dropped. `DecodedDng::trailing_extra` now
carries the last main-chain directory's fields when nothing else does; it is
empty for every file this crate writes. The remaining case -- an interior
page with no image data -- predates this work and is filed as #525, with the
promise on `ifd0_extra` reworded to say exactly what the four verbatim
channels reach.

`is_known_tag`'s meaning is documented where it could be misread: a tag this
crate recognises, answered from the tag number alone, not a tag some decode
path happened to consume.

Refs #442
The guard deciding whether the last main-chain directory needs its own
verbatim channel was three `||`ed comparisons, and the mutation survey found
two survivors in it: no test could tell `||` from `&&` there. The disjuncts
never disagree on any file the suite builds -- a single-main-IFD DNG makes
the first true, a trailing store directory makes all three false -- so the
operators between them decided nothing.

Stated instead as one membership test over the directories already surfaced,
which removes the operators rather than adding a test to compensate for them,
and pinned along the axis that was missing: a single-main-IFD file whose
IFD 0 carries a field the decoder declined, where `ifd0_extra` must hold it
and `trailing_extra` must stay empty. With the condition forced to `false`
that test fails; with it forced to `true` the two trailing-directory tests
fail. Both directions now die.

Refs #442
`C2paExclusions` documents "they never overlap", but `new` was infallible
and explicitly declined to check, so a caller could build a set of two empty
or overlapping ranges and the type's stated invariant would simply be false.
What that set feeds is a signer's hard binding, where a nonsensical exclusion
must not pass silently.

`new` now returns a `Result`, rejecting an empty range on either side and
overlapping ranges, and the type doc states the invariant as an invariant.
Abutting ranges stay legal -- touching is not overlapping -- which is the
boundary the new comparisons turn on, so the test drives both orders of
abutment as well as one-byte overlap each way and full containment.

`locate` and `append_store` keep constructing the struct directly: they
derive both ranges from a directory they just walked, so there is nothing for
a validator to tell them.

Refs #442
… made

The claim that a declined tag-52545 field reaches the caller listed
"duplicated" among the cases, but only the LAST of several entries does: the
typed channels are built on the eager `Ifd`, which is last-wins, as the
module those channels come from says itself. The claim is narrowed rather
than the channel widened -- carrying both would mean changing a model every
consumer of the IFD core shares, and a file with two stores is malformed
under A.3.6 anyway.

The test that pinned it built its two duplicates to be distinguishable and
then asserted only that *a* tag-52545 field was present, so it passed on the
first entry, the second, or both -- vacuous on exactly the ambiguity it
existed to pin. It now asserts how many fields arrive and which bytes they
carry.

The crate front page and the `RawTag` doc still stated the absolute promise
that the field docs had already qualified, and those two are where a reader
meets it first. Both now carry the same qualification, naming the last-wins
residue and the interior-page one (#525), and `trailing_extra` documents both
in one place so the cross-references resolve.

Also drops `append_raw_trailing_ifd`, which was character-identical to the
existing `append_trailing_ifd` but for one assertion, and justified on a
distinction that does not exist -- the existing helper is equally byte-level
and its assertion holds on both new fixtures.

Refs #442
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-dng: add the C2PA manifest-store tag (52545) to is_known_tag gamut-dng: type the C2PA manifest store tag and report both exclusion ranges

1 participant