diff --git a/crates/gamut-dng/README.md b/crates/gamut-dng/README.md index c47c2f03..3af2e994 100644 --- a/crates/gamut-dng/README.md +++ b/crates/gamut-dng/README.md @@ -63,7 +63,10 @@ Implemented and conformance-checked against the Adobe DNG SDK (issue #109); see `LinearizationTable`, `MaskedAreas`), active area, default crop, and 8/10/12/14/16-bit packing; typed `OpcodeList1/2/3` containers (parse + pass-through write); an embedded RGB preview; EXIF/XMP/IPTC/ICC metadata — the `ExifIFD` carried whole as `gamut-metadata`'s `Exif`, the - XMP/IPTC-IIM/ICC payloads verbatim and handed over as its `MetadataBlock`s; classic TIFF and + XMP/IPTC-IIM/ICC payloads verbatim and handed over as its `MetadataBlock`s; a **C2PA manifest + store** (C2PA 2.4 §A.3.6, tag 52545) typed on both sides — written last in the file, or + reserved zero-filled with `with_c2pa_reserved`, with `encode_with_report` returning the two + exclusion ranges (§18.5.5) an external signer hashes around; classic TIFF and **BigTIFF**; the minimal `DNGVersion` and the spec's `DNGBackwardVersion` raises computed automatically. - **Beyond the raw image** (decode): every other image IFD as a typed `SubImage` — previews, diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index b539a25e..79d047d1 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -50,6 +50,7 @@ internal encode→decode round-trips guard every lossless path. | P23 | — | **Explicitness**: every unmodelled IFD field surfaces verbatim as a typed `RawTag` (`ifd0_extra`/`raw_extra`/per-sub-image), via a consumption-tracking reader — issue #109's "all metadata explicitly represented" clause. The EXIF sub-IFD needs no extras list of its own since #353: it arrives whole, inside `Exif` | ✅ done | | P24 | Ch2-4 | **Real camera conformance** (#174): the `gamut-dng-samples` corpus + `tooling/gamut-dng-real-conformance`; `Predictor`/`PlanarConfiguration` honoured, byte accounting and the preserving rewrite fixed for real files, optional camera profile | ✅ done | | P25 | Ch6 | **Colour projection** (#353): the camera-profile colour tags `CameraProfile` does not model — hue/sat and look tables (dims/data/encoding), `ProfileToneCurve`, `BaselineExposureOffset`, the DNG 1.6 third calibration set, `ReductionMatrix1/2/3` — as a typed read-direction `ColorProfileInfo`, plus the raw IFD's `NoiseProfile` as a typed `NoiseProfile`; a value outside the spec's domain stays in the extras | ✅ done | +| P26 | C2PA §A.3.6, §18.5.5 | **C2PA manifest store** (#442): typed on both sides (`DngMetadata::c2pa`), entry in IFD 0 with the value last in the file, a zero-filled reservation (`with_c2pa_reserved`), both exclusion ranges reported (`encode_with_report` / `DecodedDng::c2pa_exclusions`), bytes verbatim in either byte order, Adobe-validated | ✅ done | ## Apple ProRAW (DNG 1.7 + JPEG XL): fully covered for decode @@ -178,6 +179,91 @@ now — `gamut-dng-oracle` exposes the reference implementation's own derivation (`dng_color_spec::SetWhiteXY`), and the single-illuminant, interpolated and clamped cases are all required to agree with it. +## C2PA manifest store (issue #442, epic #239) — a `DngMetadata` break + +The store (C2PA 2.4 §A.3.6: tag 52545 / `0xCD41`, type `UNDEFINED`) was already *visible* — a +decoded file carried it as an untyped `RawTag` — but had no name, no placement rule and no +exclusion ranges. Now: + +- **`DngMetadata::c2pa: Option>`** is the fifth carrier, on the same terms as XMP / + IPTC-IIM / ICC: opaque bytes, verbatim in both directions, handed over by `blocks()` as + `MetadataBlock::C2pa`. `DngMetadata` is deliberately exhaustive (see the freeze decisions), + so this is **semver-major**: every struct literal gains `c2pa: None`. Unlike the other + carriers, a store is bound to one exact file — it is a signed hash over the bytes around it — + so the only valid input is one an external signer computed over *this* encoder's output; + `gamut_metadata::Metadata::encode` never hands one back (`C2paPolicy::Drop`). +- **Placement** is `gamut_ifd::c2pa`'s (the shared statement of §A.3.6, reused by #446): the + entry goes in IFD 0 — the last and only IFD of this crate's main chain, the form the Adobe + SDK reads without surprise — as an inline placeholder while the tree is laid out, and the + store's bytes are appended **after the image data, last in the file**, with only the entry's + count/offset words patched. The alternative §A.3.6 form, a trailing IFD holding only the + entry, is *read* (the decoder consults the last main-chain IFD) but not written. +- **A reservation.** `DngEncoder::with_c2pa_reserved(len)` writes `len` zero bytes where the + store goes; `encode_with_report` returns `DngEncodeReport { len, c2pa: Option }` + with the **two disjoint ranges** §18.5.5 needs — the store, and the entry's `count` field (4 + bytes classic, 8 BigTIFF) — so a signer hashes around them and overwrites the reservation in + place. A reservation and a same-sized store produce byte-identical files outside the store + range, and 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. `encode` is unchanged (it + delegates), and `EncodeImage` is untouched. A store and a reservation together, or a store + shorter than a JUMBF box header (8 bytes), are typed errors before any pixel work. +- **Decode.** `DecodedDng::c2pa_exclusions` carries the ranges `gamut_ifd::c2pa::locate` finds, + alongside the bytes in `metadata.c2pa`. A tag-52545 entry of another type is not a store and + stays in `ifd0_extra`; so does one in IFD 0 when the main chain continues past it. +- **Byte accounting.** `deconstruct` claims the store as IFD 0's `Value { tag: 52545 }` span and + the alignment filler before it as `Padding` — a store at the end of the file is never a + `Trailer` — so a store-carrying file is fully classified. 52545 is in `tags::KNOWN_TAGS` + (aliasing `gamut_ifd::c2pa::C2PA_MANIFEST_STORE`, where the clause is stated), so + `is_fully_accounted()` stays **true** for a file this encoder writes: a manifest store gamut + itself embedded is not a private tag. +- **Lengths.** A store shorter than a JUMBF box header (8 bytes) is refused by the encoder and + read as *absent* by the decoder — the split `references/c2pa/README.md` prescribes, and what + makes decode → encode of a foreign file carrying a stub value work. In **BigTIFF** the inline + threshold is those same 8 bytes, so a store must exceed them or it would pack into the entry + instead of landing at the end of the file; the encoder refuses that case with its own message + rather than writing a file whose exclusion ranges cover bytes no reader reads back. +- **Duplicates.** Two tag-52545 entries in the last main IFD name no single store (§A.3.6: one + per asset), so both `metadata.c2pa` and `c2pa_exclusions` report absence rather than + describing different byte runs. The two surfaces cannot drift apart: the ranges are located + first and the bytes are taken *only* if that succeeded, so one rule decides both. (Reading the + bytes independently is what made them disagree — the eager `Ifd` keeps the last duplicate, so + the bytes surface reported that entry while the ranges reported none, and re-encoding produced + a one-entry file carrying only the last duplicate.) On the write side, `append_store` names a + duplicated entry as the problem instead of claiming the entry is missing. +- **A declined field still reaches the caller.** A tag-52545 field the decoder declines to read + as a store — wrong type, or too short — arrives verbatim. Where it lands depends on the + directory: IFD 0's go to `ifd0_extra`, and the last main-chain directory's to the new + `DecodedDng::trailing_extra`. **A duplicated entry is the one partial case**: the typed + channels carry `gamut-ifd`'s eager `Ifd`, which keeps the *last* of several entries under one + tag, so the last duplicate's bytes arrive and the earlier ones do not. Carrying both would + mean changing that last-wins model, which every consumer of the IFD core shares; a file with + two stores is malformed under §A.3.6 in any case, and `deconstruct` still accounts for every + byte of both. That field exists because §A.3.6's other lawful placement (the + store as "the only entity within a new IFD following the existing one") produces a directory + with no image, which is therefore neither IFD 0, nor the raw IFD, nor a `SubImage` — so before + it, such a directory's fields reached no surface at all. It is empty for every file this crate + writes, which puts the entry in IFD 0. +- **Version.** Carrying the tag raises neither `DNGVersion` nor `DNGBackwardVersion`: like XMP + and ICC it is metadata a reader may ignore, and the tag is C2PA's, not a DNG feature a + reader must implement (the SDK's tag table names it as `tcC2PAManifest` and validates a file + carrying it). +- **A store and a reservation together is an error, deliberately.** The signer flow is reserve → + sign → re-encode with the store, which sets one at a time; setting both is a caller mistake, + and letting either silently win would hide it. +- **Writing §A.3.6's trailing-IFD form is out of scope, deliberately.** For a single-main-IFD + asset the clause permits the entry either in that IFD or as the only entity of a new IFD + following it. This crate writes one main IFD and uses the in-IFD form, which is lawful and is + what the Adobe SDK reads without surprise. The **decoder** reads both, since it consults the + last IFD of the chain whatever its shape. +- **The one directory still not surfaced.** An *interior* main-chain page — neither the first + nor the last — that carries no image data is no `SubImage` either, so its fields reach no + verbatim channel. That predates #442 and is not what §A.3.6 creates (the store's own placement + is the *last* directory, which `trailing_extra` now covers); `deconstruct` still accounts for + its bytes. Filed as #525. +- **Not done here.** The store is never parsed; a `DngRewrite` of a file carrying one relocates + it into the value pool like any other value (a rewrite invalidates the binding regardless); + the behavioural `c2pa-rs` oracle is #447's. + ## Bridge surface for external RAW pipelines (issue #253) Downstream raw *processors* (e.g. rawshift) consume gamut-dng's decode as their DNG front end and @@ -225,12 +311,15 @@ separate path (below). #353 did to `DngMetadata::exif`, changing one's type — is accepted as semver-major. `ExifMetadata` was on this list until #353 retired it in favour of `gamut-exif`'s `Exif`, whose own construction is `Exif::new(order)` plus `set_tag`/`exif_ifd_mut`; `DngMetadata` itself is - still a struct literal, so the four carriers stay visible at the point of use. `GainValues` + still a struct literal, so the carriers (five since #442 added `c2pa`) stay visible at the + point of use. `GainValues` stays exhaustive — its four variants are the spec's closed `DataType` set. - Re-export closure: everything on the crate root, including `RawPhotometry`, `cfa_color`, - `opcode_id`, `new_subfile_type`, `gamut_ifd::Value` (the `RawTag` payload type), and the + `opcode_id`, `new_subfile_type`, `gamut_ifd::Value` (the `RawTag` payload type), the metadata surface's own types — `Exif`, `ExifTag`, `Rational` and `MetadataBlock` — so a caller - builds and reads `DngMetadata` without a direct `gamut-metadata` dependency; + builds and reads `DngMetadata` without a direct `gamut-metadata` dependency, and (since #442) + the whole C2PA surface this crate's docs name: `C2paExclusions`, `C2PA_MANIFEST_STORE` and + `MIN_STORE_LEN`, so a signer needs no direct `gamut-ifd` dependency either; `lossless_jpeg::{encode, decode}` stay module-scoped deliberately (a codec namespace). - `Compression::is_supported` became `is_decodable` (every decodable scheme encodes, with the documented `jxl-encode`/Deflate-depth caveats). @@ -302,7 +391,8 @@ use; additions are semver-additive. - **JPEG-compressed previews as pixels** — surfaced as verbatim chunks (`SubImageData:: Undecoded`); decoding them needs the baseline DCT codec above. - **Advanced 1.7 metadata without a typed surface** (`RGBTables`, `ImageStats`, - `ImageSequenceInfo`, `ProfileDynamicRange`, C2PA) — explicitly surfaced as typed `RawTag`s. + `ImageSequenceInfo`, `ProfileDynamicRange`) — explicitly surfaced as typed `RawTag`s. (The + C2PA manifest store left this list in #442: `DngMetadata::c2pa`.) - **The GPS sub-IFD (`GPSInfo`, 34853) and an EXIF 0th IFD / thumbnail** — `DngMetadata::exif` is a whole `gamut_exif::Exif`, so those directories are *expressible* in the encoder input, but only its Exif sub-IFD is written: IFD 0 and the preview sub-IFDs are the DNG container's diff --git a/crates/gamut-dng/src/decoder.rs b/crates/gamut-dng/src/decoder.rs index e22009b4..e3aa8dd6 100644 --- a/crates/gamut-dng/src/decoder.rs +++ b/crates/gamut-dng/src/decoder.rs @@ -8,6 +8,7 @@ use std::cell::RefCell; use gamut_core::{Dimensions, Error, Result}; +use gamut_ifd::c2pa::{self, C2paExclusions}; use gamut_ifd::{ByteOrder, Ifd, TiffFile, Value, Variant, read, read_ifd_at}; use gamut_metadata::exif::Exif; @@ -28,9 +29,17 @@ use crate::{bitpack, compression, lossless_jpeg, tags}; /// One IFD entry preserved verbatim: the tag number and its fully typed [`Value`]. /// -/// This is how the decoder represents every field it does not model — private maker tags, -/// DNG features without a typed surface yet — so nothing in the file is silently dropped -/// (issue #109's decode contract). The value is `gamut-ifd`'s typed enum, not opaque bytes. +/// This is how the decoder represents a field it does not model — private maker tags, DNG +/// features without a typed surface yet — so such fields are not silently dropped (issue #109's +/// decode contract). The value is `gamut-ifd`'s typed enum, not opaque bytes. +/// +/// The contract holds over four channels — [`DecodedDng::ifd0_extra`], +/// [`DecodedDng::raw_extra`], [`SubImage::extra_tags`] and [`DecodedDng::trailing_extra`] — with +/// two residues those channels do not reach, both documented on +/// [`trailing_extra`](DecodedDng::trailing_extra): a **duplicated tag** keeps only the last +/// entry, because the eager [`Ifd`] this is built from is last-wins, and an **interior** +/// main-chain page carrying no image data reaches no channel at all (issue #525). +/// [`deconstruct`](crate::deconstruct) still accounts for the bytes in both cases. #[derive(Debug, Clone, PartialEq)] pub struct RawTag { /// The TIFF/DNG tag number. @@ -168,12 +177,51 @@ pub struct DecodedDng { /// against [`RawImage::new_raw_image_digest`] to verify raw-data integrity. pub new_raw_image_digest: Option<[u8; 16]>, /// Every IFD 0 field the pipeline does not model, verbatim — proprietary maker tags - /// included — in tag order. Nothing in the file is silently dropped; `deconstruct` remains - /// the byte-accounting *diagnostic* view of the same principle. + /// included — in tag order. + /// + /// This is one of four verbatim channels that together carry the unmodelled fields of every + /// directory the decode surfaces: this one, [`raw_extra`](Self::raw_extra), + /// [`SubImage::extra_tags`] per image directory, and + /// [`trailing_extra`](Self::trailing_extra) for the last main-chain directory when it is + /// none of those. Nothing those four reach is silently dropped; `deconstruct` remains the + /// byte-accounting *diagnostic* view of the same principle, and is what shows the one + /// residue they do not reach (an interior main-chain page carrying no image — see + /// [`trailing_extra`](Self::trailing_extra) and `STATUS.md`). pub ifd0_extra: Vec, /// Every unmodelled field of the raw IFD, verbatim. Empty when the raw image lives in IFD 0 /// itself (its extras are then in [`ifd0_extra`](Self::ifd0_extra)). pub raw_extra: Vec, + /// Every field of the **last directory of the main IFD chain**, verbatim, when that + /// directory is not surfaced anywhere else — i.e. when it is not IFD 0, not the raw IFD and + /// carries no image of its own, so it becomes no [`SubImage`]. + /// + /// C2PA 2.4 §A.3.6 is what makes such a directory ordinary rather than exotic: it permits a + /// manifest store to be "the only entity within a new IFD following the existing one", and a + /// directory holding just that entry has no pixels to make it a sub-image. Whatever it + /// carries reaches the caller here — including a tag-52545 field this decoder declined to + /// read as a store (wrong type, too short, or duplicated). + /// + /// Empty for every file this crate writes, which puts the entry in IFD 0. + /// + /// # The two residues of the "nothing is dropped" contract + /// + /// - **A duplicated tag keeps the last entry.** Every typed channel is built on the eager + /// [`Ifd`], which is last-wins, so where a directory carries two entries under one tag + /// only the second's value arrives. Such a file is malformed for a manifest store + /// (§A.3.6 allows one per asset). + /// - **An interior main-chain page is not covered.** A directory that is neither the first + /// nor the last of the chain, carries no image data, and is thus also no sub-image reaches + /// no channel at all — issue #525. That gap predates this field and is not what §A.3.6 + /// creates. + /// + /// [`deconstruct`](crate::deconstruct) accounts for the bytes in both cases. + pub trailing_extra: Vec, + /// Where the C2PA manifest store in [`metadata.c2pa`](DngMetadata::c2pa) sits in the file: + /// the two ranges a `c2pa.hash.data` binding excludes (C2PA 2.4 §18.5.5), located by + /// [`gamut_ifd::c2pa::locate`] in the last IFD of the main chain (§A.3.6). `Some` exactly + /// when the store is; a `C2PA` tag of a type other than `UNDEFINED` is not a store and stays + /// in [`ifd0_extra`](Self::ifd0_extra). + pub c2pa_exclusions: Option, } /// The verdict of [`DngDecoder::verify_new_raw_image_digest`]. @@ -229,7 +277,7 @@ impl DngDecoder { /// lossless storage only — whatever decoding the raw image reports. pub fn verify_new_raw_image_digest(&self, data: &[u8]) -> Result { let file = read(data)?; - let ifds = walk_ifds(&file, data); + let (ifds, _) = walk_ifds(&file, data); let raw_index = select_raw_ifd(&ifds)?; let tracked: Vec = ifds.iter().map(TrackedIfd::new).collect(); let Some(stored) = bytes_value(tracked[0].get(tags::NEW_RAW_IMAGE_DIGEST)) @@ -276,7 +324,7 @@ impl DngDecoder { let order = file.order; let variant = file.variant; - let ifds = walk_ifds(&file, data); + let (ifds, last_main) = walk_ifds(&file, data); let raw_index = select_raw_ifd(&ifds)?; // One consumption tracker per IFD; `walk_ifds` pushes IFD 0 first, so index 0 is IFD 0 // (and `raw_index == 0` means the raw image lives in IFD 0 itself). Tags the walk/select @@ -309,7 +357,21 @@ impl DngDecoder { }); let new_raw_image_digest = bytes_value(ifd0.get(tags::NEW_RAW_IMAGE_DIGEST)) .and_then(|b| <[u8; 16]>::try_from(b).ok()); - let metadata = decode_metadata(ifd0, data, order, variant); + // The C2PA store lives in the last IFD of the main chain (C2PA 2.4 §A.3.6) — IFD 0 for + // every file this crate writes, a trailing directory for the other form §A.3.6 allows. + // The ranges are located *first* and the bytes are then taken only if that succeeded, so + // the two surfaces cannot disagree about whether the file has a store: one rule, applied + // once. (Reading the bytes independently is what let a duplicated entry report bytes + // from the eager `Ifd`, which keeps the last duplicate, while the ranges reported none.) + let c2pa_exclusions = c2pa::locate(data)?; + let metadata = decode_metadata( + ifd0, + &tracked[last_main], + c2pa_exclusions.is_some(), + data, + order, + variant, + ); let gain_table_map = decode_gain_map(raw_ifd, tags::PROFILE_GAIN_TABLE_MAP, order)?; let gain_table_map2 = decode_gain_map(ifd0, tags::PROFILE_GAIN_TABLE_MAP2, order)?; let depth_info = decode_depth_info(ifd0); @@ -338,6 +400,23 @@ impl DngDecoder { } else { tracked[raw_index].remaining() }; + // The last directory of the main chain is where C2PA 2.4 §A.3.6 puts a manifest store, + // and such a directory legitimately carries no image at all — so it is not IFD 0, not + // the raw IFD, and not a sub-image, and without this its fields would reach no surface + // at all. That would break this decoder's standing promise that nothing in the file is + // silently dropped, for exactly the input the store's own placement rule invites. + // + // Stated as one membership test over the directories already surfaced rather than as a + // chain of `||`s: the disjuncts never disagree on a real file (a single-main-IFD DNG + // makes the first true, a trailing store directory makes all three false), so the + // operators between them decided nothing and no test could pin them. + let mut surfaced = vec![0, raw_index]; + surfaced.extend_from_slice(&sub_indices); + let trailing_extra = if surfaced.contains(&last_main) { + Vec::new() + } else { + tracked[last_main].remaining() + }; Ok(DecodedDng { raw, @@ -354,22 +433,46 @@ impl DngDecoder { new_raw_image_digest, ifd0_extra, raw_extra, + trailing_extra, + c2pa_exclusions, }) } } -/// Reconstructs embedded metadata from IFD 0 — the XMP/IPTC/ICC blocks and the EXIF sub-IFD. +/// Reconstructs embedded metadata from IFD 0 — the XMP/IPTC/ICC blocks and the EXIF sub-IFD — +/// and the C2PA manifest store from `store_ifd`, the last IFD of the main chain. /// /// The `ExifIFD` is handed over whole, as the shared [`Exif`] model's Exif sub-IFD: every entry /// the directory holds survives, so no field of it is "unmodelled" and none is dropped. The DNG's /// own IFD 0 is *not* copied into the model's 0th IFD — [`DecodedDng`] already carries those /// fields, typed or as [`ifd0_extra`](DecodedDng::ifd0_extra). +/// +/// The store is taken as the `UNDEFINED` bytes C2PA 2.4 §A.3.6 mandates, verbatim — the file's +/// byte order does not apply to them — and only when `located` says +/// [`gamut_ifd::c2pa::locate`] recognised a store in this file. That flag is the *whole* +/// admission rule (type, length, uniqueness, placement), so this cannot disagree with +/// [`DecodedDng::c2pa_exclusions`]: bytes and ranges are `Some` together or neither is. +/// +/// A tag-52545 field that is not an admissible store — wrong type, shorter than a JUMBF box +/// header, or one of several in a directory §A.3.6 allows only one store in — is put back for +/// the extras rather than returned, so it still reaches the caller and decode → encode still +/// works: every store this hands back is one the encoder will accept. fn decode_metadata( ifd0: &TrackedIfd, + store_ifd: &TrackedIfd, + located: bool, data: &[u8], order: ByteOrder, variant: Variant, ) -> DngMetadata { + let c2pa = match store_ifd.get(c2pa::C2PA_MANIFEST_STORE) { + Some(Value::Undefined(store)) if located => Some(store.clone()), + Some(_) => { + store_ifd.untouch(c2pa::C2PA_MANIFEST_STORE); + None + } + None => None, + }; let exif = ifd0 .get_u32(tags::EXIF_IFD) .and_then(|offset| read_ifd_at(data, u64::from(offset), order, variant).ok()) @@ -383,6 +486,7 @@ fn decode_metadata( xmp: bytes_value(ifd0.get(tags::XMP)), iptc: bytes_value(ifd0.get(tags::IPTC_NAA)), icc: bytes_value(ifd0.get(tags::ICC_PROFILE)), + c2pa, } } @@ -406,13 +510,18 @@ fn is_raw_ifd(ifd: &Ifd) -> bool { const MAX_SUBIFD_DEPTH: usize = 8; /// Collects every IFD in the file — the top-level chain plus, recursively, every `SubIFDs` -/// child — in encounter order. Lenient by design: an unreadable child is skipped rather than -/// failing the whole decode, while offset de-duplication and the depth cap terminate hostile -/// pointer cycles. -fn walk_ifds(file: &TiffFile, data: &[u8]) -> Vec { +/// child — in encounter order, plus the index of the **last main-chain directory** (where C2PA +/// 2.4 §A.3.6 places the manifest store). Lenient by design: an unreadable child is skipped +/// rather than failing the whole decode, while offset de-duplication and the depth cap terminate +/// hostile pointer cycles. +fn walk_ifds(file: &TiffFile, data: &[u8]) -> (Vec, usize) { let mut out = Vec::new(); let mut visited: Vec = Vec::new(); + let mut last_main = 0; for ifd in &file.ifds { + // Each main-chain directory is pushed before its descendants, so its index is the + // length so far; the last one is where C2PA 2.4 §A.3.6 puts the manifest store. + last_main = out.len(); collect_sub_ifds( ifd, data, @@ -423,7 +532,7 @@ fn walk_ifds(file: &TiffFile, data: &[u8]) -> Vec { &mut out, ); } - out + (out, last_main) } /// Pushes `ifd` and recurses into its `SubIFDs` children (see [`walk_ifds`]). @@ -1771,7 +1880,37 @@ mod tests { }) .expect("write"); let file = read(&bytes).expect("read"); - assert_eq!(walk_ifds(&file, &bytes).len(), MAX_SUBIFD_DEPTH); + assert_eq!(walk_ifds(&file, &bytes).0.len(), MAX_SUBIFD_DEPTH); + } + + /// The last main-chain index names the *last* top-level directory, not the last directory + /// collected: sub-IFDs of an earlier page are pushed before a later page, and the later + /// page's own sub-IFDs after it. + #[test] + fn walk_ifds_indexes_the_last_main_chain_directory() { + let mut child = Ifd::new(); + child.set(tags::IMAGE_WIDTH, Value::Short(vec![1])); + let mut page0 = Ifd::new(); + page0.set(tags::IMAGE_WIDTH, Value::Short(vec![2])); + page0.set_sub_ifd(tags::SUB_IFDS, vec![child.clone()]); + let mut page1 = Ifd::new(); + page1.set(tags::IMAGE_WIDTH, Value::Short(vec![3])); + page1.set_sub_ifd(tags::SUB_IFDS, vec![child]); + let bytes = gamut_ifd::write(&TiffFile { + order: ByteOrder::LittleEndian, + variant: Variant::Classic, + ifds: vec![page0, page1], + }) + .expect("write"); + let file = read(&bytes).expect("read"); + let (ifds, last_main) = walk_ifds(&file, &bytes); + // page0, its child, page1, its child. + assert_eq!(ifds.len(), 4); + assert_eq!(last_main, 2); + assert_eq!( + ifds[last_main].get(tags::IMAGE_WIDTH), + Some(&Value::Short(vec![3])) + ); } /// With several raw-photometry IFDs, the main image (`NewSubFileType` 0) wins even when a diff --git a/crates/gamut-dng/src/encoder.rs b/crates/gamut-dng/src/encoder.rs index 7d880545..94a7ded9 100644 --- a/crates/gamut-dng/src/encoder.rs +++ b/crates/gamut-dng/src/encoder.rs @@ -1,6 +1,9 @@ //! The DNG encoder. +use std::borrow::Cow; + use gamut_core::{Error, Result}; +use gamut_ifd::c2pa::{self, C2paExclusions}; use gamut_ifd::{ByteOrder, Ifd, Value, Variant}; use crate::gain_map::ProfileGainTableMap; @@ -30,6 +33,26 @@ pub struct DngEncoder { gain_table_map: Option, gain_table_map2: Option, metadata: DngMetadata, + c2pa_reserve: Option, +} + +/// What [`DngEncoder::encode_with_report`] produced: the byte count, and — when the file +/// carries a C2PA manifest store or a reservation for one — the two byte ranges an external +/// signer excludes from its `c2pa.hash.data` binding (C2PA 2.4 §18.5.5). +/// +/// `#[non_exhaustive]`: later encoder features may report more without a breaking change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct DngEncodeReport { + /// The number of bytes appended to the output — the whole DNG. + pub len: usize, + /// The C2PA exclusion ranges, as offsets from the first byte of this DNG (not of the + /// output buffer it was appended to). `None` when no store or reservation was requested. + /// + /// `store` is the last range of the file — the store is placed after everything else + /// (§A.3.6), so a signer overwriting a reservation in place, or replacing the store with one + /// of a different size, moves no other offset. + pub c2pa: Option, } impl Default for DngEncoder { @@ -51,6 +74,7 @@ impl Default for DngEncoder { gain_table_map: None, gain_table_map2: None, metadata: DngMetadata::default(), + c2pa_reserve: None, } } } @@ -167,13 +191,70 @@ impl DngEncoder { self } - /// Returns a copy of this encoder that embeds `metadata` (EXIF sub-IFD + XMP/IPTC/ICC blocks). + /// Returns a copy of this encoder that embeds `metadata` (EXIF sub-IFD + XMP/IPTC/ICC + /// blocks, and a caller-computed C2PA manifest store — see [`DngMetadata::c2pa`]). #[must_use] pub fn with_metadata(mut self, metadata: DngMetadata) -> Self { self.metadata = metadata; self } + /// Returns a copy of this encoder that reserves `len` zero bytes for a C2PA manifest store + /// an external signer will fill in afterwards. + /// + /// The reservation is written exactly where a store goes — the `C2PA` tag (52545) of IFD 0, + /// its value last in the file (C2PA 2.4 §A.3.6) — and + /// [`encode_with_report`](Self::encode_with_report) reports its two exclusion ranges + /// (§18.5.5). A signer hashes the file around those ranges and overwrites the reservation in + /// place; nothing else in the file moves. `len` must be at least + /// [`gamut_ifd::c2pa::MIN_STORE_LEN`] (a JUMBF box header), and a reservation cannot be + /// combined with a store supplied through [`with_metadata`](Self::with_metadata) — either is + /// a typed error at encode time. + #[must_use] + pub fn with_c2pa_reserved(mut self, len: usize) -> Self { + self.c2pa_reserve = Some(len); + self + } + + /// The C2PA manifest store to write, if any: the caller's, or a zero-filled reservation. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if both were requested, if the store is too short to be a + /// JUMBF box at all ([`c2pa::MIN_STORE_LEN`]), or if it would pack *inline* in this + /// encoder's container variant — BigTIFF's inline threshold is those same 8 bytes, so a + /// BigTIFF store must exceed them to be placeable at the end of the file. All caught here, + /// before any pixel work. + fn c2pa_store(&self) -> Result>> { + let store = match (&self.metadata.c2pa, self.c2pa_reserve) { + (Some(_), Some(_)) => { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "DNG: supply either a C2PA manifest store or a reservation, not both", + )); + } + (Some(store), None) => Cow::Borrowed(store.as_slice()), + (None, Some(len)) => Cow::Owned(vec![0; len]), + (None, None) => return Ok(None), + }; + if store.len() < c2pa::MIN_STORE_LEN { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "DNG: a C2PA manifest store is at least a JUMBF box header (8 bytes)", + )); + } + // A value no longer than the variant's inline threshold is packed into the entry rather + // than placed out of line, so it cannot be the run at the end of the file §A.3.6 wants. + // Only reachable on BigTIFF, whose threshold is the 8 bytes above. + if store.len() <= self.variant().inline_threshold() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "DNG: a BigTIFF C2PA manifest store must exceed 8 bytes, or it packs inline", + )); + } + Ok(Some(store)) + } + /// The container variant this encoder writes (BigTIFF when [`Self::with_big_tiff`] is set). fn variant(&self) -> Variant { if self.big_tiff { @@ -200,6 +281,30 @@ impl DngEncoder { profile: &CameraProfile, out: &mut Vec, ) -> Result { + self.encode_with_report(raw, profile, out) + .map(|report| report.len) + } + + /// [`encode`](Self::encode), also reporting where the C2PA manifest store (or its + /// reservation, [`with_c2pa_reserved`](Self::with_c2pa_reserved)) landed. + /// + /// The store is written last in the file and its entry lives in IFD 0 — the last (and only) + /// IFD of this encoder's main chain — as C2PA 2.4 §A.3.6 requires. The report's ranges are + /// offsets from the first byte of the DNG, so a caller appending to a non-empty `out` + /// rebases them by `out.len()` before the call. + /// + /// # Errors + /// + /// As [`encode`](Self::encode); additionally [`Error::InvalidInput`] if both a store and a + /// reservation were configured, or the store is shorter than + /// [`gamut_ifd::c2pa::MIN_STORE_LEN`]. + pub fn encode_with_report( + &self, + raw: &RawImage, + profile: &CameraProfile, + out: &mut Vec, + ) -> Result { + let store = self.c2pa_store()?; if color_plane_count(raw) != 3 { return Err(Error::unsupported( env!("CARGO_PKG_NAME"), @@ -284,6 +389,12 @@ impl DngEncoder { { ifd0.set_sub_ifd(tags::EXIF_IFD, vec![exif]); } + // The C2PA entry goes in the last IFD of the main chain — IFD 0 here — as an inline + // placeholder, so the directory layout is final while the store itself is placed after + // the image data, last in the file (§A.3.6). + if store.is_some() { + c2pa::reserve_entry(&mut ifd0); + } let raw_ifd = build_raw_ifd(self, raw)?; let preview_blocks = ImageBlocks { @@ -304,7 +415,7 @@ impl DngEncoder { }, }; - let bytes = write_cfa_dng( + let mut bytes = write_cfa_dng( self.order, self.variant(), ifd0, @@ -312,8 +423,15 @@ impl DngEncoder { raw_ifd, &raw_blocks, )?; + let c2pa = match store { + Some(store) => Some(c2pa::append_store(&mut bytes, &store)?), + None => None, + }; out.extend_from_slice(&bytes); - Ok(bytes.len()) + Ok(DngEncodeReport { + len: bytes.len(), + c2pa, + }) } /// Builds IFD 0: the RGB preview's image tags plus the DNG version, camera identity, and the diff --git a/crates/gamut-dng/src/lib.rs b/crates/gamut-dng/src/lib.rs index 0eb186b0..c44a24fb 100644 --- a/crates/gamut-dng/src/lib.rs +++ b/crates/gamut-dng/src/lib.rs @@ -40,8 +40,12 @@ //! gain-table maps ([`ProfileGainTableMap`], both tag versions) parse typed and re-serialise //! byte-exactly. Opcode lists are typed [`OpcodeList`] containers. //! - **Integrity & explicitness**: the encoder writes the SDK-bit-exact `NewRawImageDigest` -//! ([`RawImage::new_raw_image_digest`]), and the decoder surfaces every unmodelled field -//! verbatim as typed [`RawTag`]s — nothing in a file is silently dropped. +//! ([`RawImage::new_raw_image_digest`]), and the decoder surfaces unmodelled fields verbatim +//! as typed [`RawTag`]s — from IFD 0, the raw IFD, every image sub-IFD, and the last +//! directory of the main chain when it is none of those. Two documented residues aside — a +//! duplicated tag keeps the last entry (the IFD core's model), and an *interior* main-chain +//! page carrying no image reaches no typed channel (issue #525) — nothing in a file is +//! silently dropped, and [`deconstruct`] accounts for every byte either way. //! //! An **Apple ProRAW** DNG (1.7, JPEG XL, tiled, LinearRaw, semantic masks, gain map) therefore //! decodes fully. Full demosaicing and colour rendering are a raw *processor's* job and stay out @@ -97,13 +101,20 @@ pub use decoder::{DecodedDng, DigestCheck, DngDecoder, RawTag}; pub use deconstruct::{ Anomaly, DeconstructReport, Severity, UnknownFieldType, UnknownTag, deconstruct, }; -pub use encoder::DngEncoder; +pub use encoder::{DngEncodeReport, DngEncoder}; pub use gain_map::{GainValues, ProfileGainTableMap}; pub use gamut_core::{Dimensions, Error, Result}; +// The C2PA surface (C2PA 2.4 §A.3.6, §18.5.5) is `gamut-ifd`'s — the placement and exclusion +// rules are stated once there for every TIFF-based codec — re-exported whole so a signer +// reading `DngEncodeReport`/`DecodedDng`, or checking the tag and the minimum store length this +// crate's own docs name, needs no direct `gamut-ifd` dependency (the re-export closure of +// `STATUS.md`'s freeze decisions). +pub use gamut_ifd::c2pa::{C2PA_MANIFEST_STORE, C2paExclusions, MIN_STORE_LEN}; // `Value` is part of the decode surface: `RawTag` carries unmodelled fields as this typed enum; -// `Segment`/`SpanKind` are part of the preservation surface, naming the byte runs a real camera -// file carries that its own structures do not account for. -pub use gamut_ifd::{ByteOrder, Segment, SpanKind, Value}; +// `Segment`/`SpanKind`/`Range` are part of the preservation surface, naming the byte runs a +// real camera file carries that its own structures do not account for (`Range` is also what +// `C2paExclusions` measures in). +pub use gamut_ifd::{ByteOrder, Range, Segment, SpanKind, Value}; // The shared metadata facade supplies this crate's metadata models rather than a DNG-local // restatement of them: `DngMetadata::exif` *is* the facade's `Exif`, and `DngMetadata::blocks` // hands the byte carriers over as `MetadataBlock`s. Re-exported so a caller can build and read diff --git a/crates/gamut-dng/src/metadata.rs b/crates/gamut-dng/src/metadata.rs index dfc9f0bd..3268c84c 100644 --- a/crates/gamut-dng/src/metadata.rs +++ b/crates/gamut-dng/src/metadata.rs @@ -1,12 +1,18 @@ -//! Optional metadata embedded in a DNG: an EXIF sub-IFD plus XMP / IPTC / ICC blocks. +//! Optional metadata embedded in a DNG: an EXIF sub-IFD plus XMP / IPTC / ICC / C2PA blocks. //! //! The models are the workspace's, not this crate's. EXIF is //! [`gamut_metadata::exif::Exif`] — DNG's `ExifIFD` (34665) *is* an EXIF sub-IFD, so the facade's //! model describes it exactly and this crate no longer redefines a hand-picked subset of its -//! fields. XMP (700), IPTC-IIM (33723) and ICC (34675) are single opaque payloads in the file, so -//! they are carried verbatim as the byte blocks the facade consumes — the same shape -//! `gamut-png` and `gamut-webp` hand over — and [`DngMetadata::blocks`] presents them as -//! [`MetadataBlock`]s ready for [`gamut_metadata::Metadata::from_blocks`]. +//! fields. XMP (700), IPTC-IIM (33723), ICC (34675) and the C2PA manifest store (52545) are +//! single opaque payloads in the file, so they are carried verbatim as the byte blocks the +//! facade consumes — the same shape `gamut-png` and `gamut-webp` hand over — and +//! [`DngMetadata::blocks`] presents them as [`MetadataBlock`]s ready for +//! [`gamut_metadata::Metadata::from_blocks`]. +//! +//! The C2PA store is the one carrier with a placement rule of its own (C2PA 2.4 §A.3.6: the +//! last main IFD, at the end of the file) and a signer's exclusion contract (§18.5.5); both are +//! [`gamut_ifd::c2pa`]'s, and the encoder applies them — see +//! [`DngEncoder::encode_with_report`](crate::DngEncoder::encode_with_report). use gamut_ifd::{Ifd, Value}; use gamut_metadata::MetadataBlock; @@ -20,10 +26,10 @@ use crate::tags; /// emit a directory a conforming reader may reject. const DEFAULT_EXIF_VERSION: &[u8; 4] = b"0230"; -/// Metadata to embed in a DNG: an EXIF sub-IFD and/or opaque XMP / IPTC / ICC blocks. +/// Metadata to embed in a DNG: an EXIF sub-IFD and/or opaque XMP / IPTC / ICC / C2PA blocks. /// /// Construct one as a struct literal — it is deliberately exhaustive (see `STATUS.md`), so the -/// four carriers a DNG holds are visible at the point of use: +/// five carriers a DNG holds are visible at the point of use: /// /// ``` /// use gamut_dng::{ByteOrder, DngMetadata, Exif, ExifTag, Value}; @@ -37,6 +43,7 @@ const DEFAULT_EXIF_VERSION: &[u8; 4] = b"0230"; /// xmp: None, /// iptc: None, /// icc: None, +/// c2pa: None, /// }; /// ``` #[derive(Debug, Clone, Default)] @@ -61,10 +68,26 @@ pub struct DngMetadata { pub iptc: Option>, /// An ICC profile, stored in the `ICCProfile` tag (34675), verbatim. pub icc: Option>, + /// A C2PA manifest store, stored in the `C2PA` tag + /// ([`gamut_ifd::c2pa::C2PA_MANIFEST_STORE`], 52545, type `UNDEFINED`), verbatim. + /// + /// **Opaque, and bound to one exact file.** A manifest store is a signed hash over the + /// bytes *around* it (C2PA 2.4 §18.5), so the only store valid here is one an external + /// signer computed over this encoder's own output — through + /// [`DngEncoder::with_c2pa_reserved`](crate::DngEncoder::with_c2pa_reserved) and the + /// exclusion ranges [`DngEncoder::encode_with_report`](crate::DngEncoder::encode_with_report) + /// reports. A store copied out of another file is invalid by construction, which is why + /// [`gamut_metadata::Metadata::encode`] never hands one back + /// ([`gamut_metadata::C2paPolicy`]). The bytes are written exactly as given: the TIFF byte + /// order does not govern them (§A.3.6). On decode this is the store the file carries in the + /// last IFD of its main chain; its byte ranges are + /// [`DecodedDng::c2pa_exclusions`](crate::DecodedDng::c2pa_exclusions). + pub c2pa: Option>, } impl DngMetadata { - /// The byte-carried blocks — XMP, IPTC-IIM and ICC — as [`MetadataBlock`]s, ready for + /// The byte-carried blocks — XMP, IPTC-IIM, ICC and the C2PA store — as [`MetadataBlock`]s, + /// ready for /// [`gamut_metadata::Metadata::from_blocks`] or a /// [`MetadataExtractor`](gamut_metadata::MetadataExtractor) with a chosen /// [`ConflictPolicy`](gamut_metadata::ConflictPolicy). @@ -93,6 +116,9 @@ impl DngMetadata { if let Some(icc) = &self.icc { blocks.push(MetadataBlock::Icc(icc)); } + if let Some(c2pa) = &self.c2pa { + blocks.push(MetadataBlock::C2pa(c2pa)); + } blocks } @@ -106,10 +132,18 @@ impl DngMetadata { /// Whether there is nothing to embed. pub(crate) fn is_empty(&self) -> bool { - self.exif_ifd().is_none() && self.xmp.is_none() && self.iptc.is_none() && self.icc.is_none() + self.exif_ifd().is_none() + && self.xmp.is_none() + && self.iptc.is_none() + && self.icc.is_none() + && self.c2pa.is_none() } /// Writes the XMP / IPTC / ICC blocks into `ifd0` and returns the EXIF sub-IFD, if any. + /// + /// The C2PA store is deliberately **not** written here: its value must land at the end of + /// the file (C2PA 2.4 §A.3.6), after the image data, which only the encoder can arrange once + /// the rest of the file exists ([`gamut_ifd::c2pa::append_store`]). pub(crate) fn apply(&self, ifd0: &mut Ifd) -> Option { if let Some(xmp) = &self.xmp { ifd0.set(tags::XMP, Value::Byte(xmp.clone())); @@ -182,6 +216,7 @@ mod tests { exif: Some(exif), xmp: Some(b"".to_vec()), icc: Some(vec![0u8; 8]), + c2pa: Some(vec![0u8; 16]), ..Default::default() }; assert!(!meta.is_empty()); @@ -191,6 +226,8 @@ mod tests { Some(&Value::Byte(b"".to_vec())) ); assert!(ifd.get(tags::ICC_PROFILE).is_some()); + // The store is the encoder's to place, so `apply` leaves the directory without it. + assert!(ifd.get(gamut_ifd::c2pa::C2PA_MANIFEST_STORE).is_none()); assert_eq!( written.get(tags::ISO_SPEED_RATINGS), Some(&Value::Short(vec![400])) @@ -246,6 +283,10 @@ mod tests { icc: Some(vec![1]), ..Default::default() }, + DngMetadata { + c2pa: Some(vec![1]), + ..Default::default() + }, ]; for (i, meta) in singles.iter().enumerate() { assert!(!meta.is_empty(), "block {i} alone must be non-empty"); @@ -262,6 +303,7 @@ mod tests { xmp: Some(b"".to_vec()), iptc: Some(vec![0x1c, 0x02, 0x05]), icc: Some(vec![7u8; 4]), + c2pa: Some(b"\0\0\0\x14jumbc2pa".to_vec()), }; assert_eq!( meta.blocks(), @@ -269,6 +311,7 @@ mod tests { MetadataBlock::Xmp(b""), MetadataBlock::IptcIim(&[0x1c, 0x02, 0x05]), MetadataBlock::Icc(&[7u8; 4]), + MetadataBlock::C2pa(b"\0\0\0\x14jumbc2pa"), ] ); } diff --git a/crates/gamut-dng/src/tags.rs b/crates/gamut-dng/src/tags.rs index 8ccf1552..c14b8b74 100644 --- a/crates/gamut-dng/src/tags.rs +++ b/crates/gamut-dng/src/tags.rs @@ -341,6 +341,14 @@ pub const REDUCTION_MATRIX3: u16 = 52538; pub const RGB_TABLES: u16 = 52543; /// `ProfileGainTableMap2` (52544, 0xCD40) — the revised profile gain-table map (DNG 1.7). pub const PROFILE_GAIN_TABLE_MAP2: u16 = 52544; +/// The C2PA manifest store (52545, 0xCD41) — C2PA 2.4 §A.3.6, not a DNG tag. +/// +/// A DNG may carry a content-credentials manifest store, and this crate writes and reads one +/// ([`DngMetadata::c2pa`](crate::DngMetadata::c2pa)), so a file carrying it is *recognised* +/// rather than flagged as private by [`is_known_tag`]. The number itself is +/// [`gamut_ifd::c2pa::C2PA_MANIFEST_STORE`] — the clause is stated once, in the crate shared by +/// every TIFF-based codec — and is aliased here only so [`KNOWN_TAGS`] can name it. +pub const C2PA_MANIFEST_STORE: u16 = gamut_ifd::c2pa::C2PA_MANIFEST_STORE; /// `ColumnInterleaveFactor` (52547, 0xCD43) — column interleaving of the stored image data /// (DNG 1.7.1). pub const COLUMN_INTERLEAVE_FACTOR: u16 = 52547; @@ -543,6 +551,7 @@ const KNOWN_TAGS: &[u16] = &[ REDUCTION_MATRIX3, RGB_TABLES, PROFILE_GAIN_TABLE_MAP2, + C2PA_MANIFEST_STORE, COLUMN_INTERLEAVE_FACTOR, IMAGE_SEQUENCE_INFO, IMAGE_STATS, @@ -574,6 +583,17 @@ pub const MATRIX_3X3_TAGS: &[u16] = &[ /// A handful of common baseline TIFF tags a DNG may carry but the codec does not name /// (`HostComputer`, `Min`/`MaxSampleValue`) are also accepted so a valid file is not flagged for /// them. +/// +/// # What "known" means here +/// +/// **A tag this crate recognises** — a question about this crate's vocabulary, answered from the +/// tag number alone. It is deliberately *not* "a tag this decode consumed": that would make the +/// answer depend on the file's contents, so the same tag would be known in one file and unknown +/// in another, and `deconstruct`'s `UnknownTag` would stop meaning "a private or unrecognised +/// tag" and start meaning "a tag some code path declined". Several listed tags are recognised +/// but not modelled, and reach the caller verbatim as [`RawTag`](crate::RawTag)s; the C2PA +/// manifest store (52545) is recognised here even though its clause is C2PA's rather than DNG's, +/// because this crate writes and reads one. #[must_use] pub fn is_known_tag(tag: u16) -> bool { KNOWN_TAGS.contains(&tag) @@ -598,6 +618,9 @@ mod tests { // A couple of the matrix tags double as a spot-check that the list and accessors agree. assert!(is_known_tag(COLOR_MATRIX1)); assert!(is_known_tag(JXL_DECODE_SPEED)); + // The C2PA store is a tag this crate writes, so a file carrying one is not "private": + // `is_fully_accounted` must stay true for a DNG this encoder produced. + assert!(is_known_tag(gamut_ifd::c2pa::C2PA_MANIFEST_STORE)); } #[test] diff --git a/crates/gamut-dng/tests/c2pa.rs b/crates/gamut-dng/tests/c2pa.rs new file mode 100644 index 00000000..91398115 --- /dev/null +++ b/crates/gamut-dng/tests/c2pa.rs @@ -0,0 +1,582 @@ +//! C2PA manifest-store carriage (C2PA 2.4 §A.3.6, §18.5.5): the store is typed on both sides of +//! the codec, its entry sits in the last IFD of the main chain with its value last in the file, +//! its bytes cross verbatim whatever the TIFF byte order, the encoder reports the two disjoint +//! exclusion ranges a signer hashes around, a reservation of the same size is byte-identical to +//! a store outside those ranges, and the Adobe DNG SDK accepts the result. + +mod common; + +use gamut_dng::{ + ByteOrder, C2PA_MANIFEST_STORE, DngDecoder, DngEncodeReport, DngEncoder, DngMetadata, + MIN_STORE_LEN, Range, RawTag, Value, +}; +use gamut_ifd::{align_word, read_header}; + +/// `len` bytes that are neither a palindrome nor periodic at any small stride, so a byte-swapped, +/// shifted or truncated copy cannot equal the original. +fn store(len: usize) -> Vec { + (0..len).map(|i| (i * 37 + 11) as u8).collect() +} + +fn with_store(order: ByteOrder, bytes: Vec) -> DngEncoder { + DngEncoder::new() + .with_byte_order(order) + .with_metadata(DngMetadata { + c2pa: Some(bytes), + ..Default::default() + }) +} + +fn encode(encoder: &DngEncoder) -> (Vec, DngEncodeReport) { + let raw = common::sample_raw(32, 24, 16); + let mut dng = Vec::new(); + let report = encoder + .encode_with_report(&raw, &common::sample_profile(), &mut dng) + .expect("encode"); + assert_eq!(report.len, dng.len()); + (dng, report) +} + +fn slice(bytes: &[u8], range: Range) -> &[u8] { + &bytes[range.start as usize..range.end() as usize] +} + +/// The store is the file's last bytes, verbatim in both byte orders; the count field is the +/// 4-byte word inside IFD 0 holding the store's length in the file's byte order; the two ranges +/// are disjoint with the count field first; the decoder returns the same bytes and the same +/// ranges; and the Adobe DNG SDK accepts the file. +#[test] +fn store_is_written_last_verbatim_and_both_ranges_are_reported() { + for order in [ByteOrder::LittleEndian, ByteOrder::BigEndian] { + let bytes = store(100); + let (dng, report) = encode(&with_store(order, bytes.clone())); + let excl = report.c2pa.expect("a store was written"); + + assert_eq!( + excl.store.end(), + dng.len() as u64, + "{order:?}: the store is last" + ); + assert_eq!( + slice(&dng, excl.store), + bytes.as_slice(), + "{order:?}: verbatim" + ); + assert_eq!(excl.count_field.len, 4); + let count = order.u32(slice(&dng, excl.count_field).try_into().expect("4 bytes")); + assert_eq!( + u64::from(count), + excl.store.len, + "{order:?}: the count is the length" + ); + // Inside IFD 0's body, which starts at the header's first-IFD offset. + let (_, _, ifd0) = read_header(&dng).expect("header"); + assert!(excl.count_field.start > ifd0); + assert!( + excl.count_field.end() < excl.store.start, + "disjoint, count field first" + ); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, Some(bytes.clone()), "{order:?}"); + assert_eq!(decoded.c2pa_exclusions, Some(excl), "{order:?}"); + assert_eq!( + decoded.metadata.blocks(), + vec![gamut_dng::MetadataBlock::C2pa(&bytes)] + ); + assert!( + !decoded + .ifd0_extra + .iter() + .any(|t| t.tag == C2PA_MANIFEST_STORE), + "a typed store is not also an extra" + ); + + gamut_dng_oracle::validate_dng(&dng) + .expect("Adobe DNG SDK must accept a DNG carrying a C2PA manifest store"); + } +} + +/// A reservation is `len` zero bytes exactly where a store of `len` bytes goes: the two files +/// are byte-identical outside the store range, and their reports agree — so a signer can hash +/// the reserved file around the reported ranges and overwrite the reservation in place. +#[test] +fn a_reservation_is_zero_filled_and_otherwise_identical_to_a_store_of_its_size() { + let bytes = store(64); + let (with, report) = encode(&with_store(ByteOrder::LittleEndian, bytes.clone())); + let (reserved, reserved_report) = encode(&DngEncoder::new().with_c2pa_reserved(bytes.len())); + assert_eq!(reserved_report, report); + let excl = report.c2pa.expect("ranges"); + assert_eq!( + slice(&reserved, excl.store), + vec![0u8; bytes.len()].as_slice() + ); + assert_eq!( + &reserved[..excl.store.start as usize], + &with[..excl.store.start as usize] + ); + assert_eq!(reserved.len(), with.len()); + + // Filling the reservation in place yields the store-carrying file, byte for byte. + let mut filled = reserved; + filled[excl.store.start as usize..].copy_from_slice(&bytes); + assert_eq!(filled, with); + gamut_dng_oracle::validate_dng(&filled).expect("Adobe DNG SDK must accept the filled file"); +} + +/// §A.3.6's reason for "end of file": a store of a different size changes the count field and +/// nothing else before the store — every other offset in the file is untouched. +#[test] +fn a_store_of_a_different_size_moves_no_other_offset() { + let (small, small_report) = encode(&with_store(ByteOrder::LittleEndian, store(40))); + let (large, large_report) = encode(&with_store(ByteOrder::LittleEndian, store(4000))); + let (s, l) = ( + small_report.c2pa.expect("ranges"), + large_report.c2pa.expect("ranges"), + ); + assert_eq!(s.store.start, l.store.start); + assert_eq!(s.count_field, l.count_field); + let cf = s.count_field; + assert_eq!(&small[..cf.start as usize], &large[..cf.start as usize]); + assert_eq!( + &small[cf.end() as usize..s.store.start as usize], + &large[cf.end() as usize..l.store.start as usize] + ); + assert_ne!( + slice(&small, cf), + slice(&large, cf), + "only the count differs" + ); +} + +/// BigTIFF widens the count field to 8 bytes; the store is still found, decoded and accepted. +#[test] +fn bigtiff_count_field_is_eight_bytes_wide() { + let bytes = store(48); + let (dng, report) = + encode(&with_store(ByteOrder::BigEndian, bytes.clone()).with_big_tiff(true)); + let excl = report.c2pa.expect("ranges"); + assert_eq!(excl.count_field.len, 8); + let count = + ByteOrder::BigEndian.u64(slice(&dng, excl.count_field).try_into().expect("8 bytes")); + assert_eq!(count, excl.store.len); + assert_eq!(excl.store.end(), dng.len() as u64); + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, Some(bytes)); + assert_eq!(decoded.c2pa_exclusions, Some(excl)); + gamut_dng_oracle::validate_dng(&dng) + .expect("Adobe DNG SDK must accept a BigTIFF DNG carrying a C2PA manifest store"); +} + +/// Without a store or a reservation the report carries no ranges, and the file is what +/// `encode` writes. +#[test] +fn without_a_store_the_report_has_no_ranges() { + let raw = common::sample_raw(32, 24, 16); + let mut plain = Vec::new(); + let n = DngEncoder::new() + .encode(&raw, &common::sample_profile(), &mut plain) + .expect("encode"); + let (dng, report) = encode(&DngEncoder::new()); + assert_eq!(report.c2pa, None); + assert_eq!(report.len, n); + assert_eq!(dng, plain); + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, None); + assert_eq!(decoded.c2pa_exclusions, None); +} + +/// A store and a reservation together, and a store too short to be a JUMBF box, are typed +/// errors raised before any pixel work. +#[test] +fn a_conflicting_or_too_short_store_is_refused() { + let raw = common::sample_raw(32, 24, 16); + let both = with_store(ByteOrder::LittleEndian, store(16)).with_c2pa_reserved(16); + let error = both + .encode(&raw, &common::sample_profile(), &mut Vec::new()) + .expect_err("both"); + assert_eq!( + error.static_message(), + Some("DNG: supply either a C2PA manifest store or a reservation, not both") + ); + for encoder in [ + with_store(ByteOrder::LittleEndian, store(7)), + DngEncoder::new().with_c2pa_reserved(7), + ] { + let error = encoder + .encode(&raw, &common::sample_profile(), &mut Vec::new()) + .expect_err("short"); + assert_eq!( + error.static_message(), + Some("DNG: a C2PA manifest store is at least a JUMBF box header (8 bytes)") + ); + } + // Exactly the minimum is accepted. + assert!( + DngEncoder::new() + .with_c2pa_reserved(8) + .encode(&raw, &common::sample_profile(), &mut Vec::new()) + .is_ok() + ); +} + +/// A `C2PA` tag whose type is not `UNDEFINED` is not a manifest store (§A.3.6 fixes the type at +/// 7): it decodes as an unmodelled extra, with no store and no ranges. +#[test] +fn a_mistyped_c2pa_tag_is_an_extra_not_a_store() { + let bytes = store(24); + let (mut dng, report) = encode(&with_store(ByteOrder::LittleEndian, bytes.clone())); + let excl = report.c2pa.expect("ranges"); + // The entry's type word is the 2 bytes before its count field: 7 (UNDEFINED) -> 1 (BYTE), + // the same element size, so the value still decodes. + let type_at = excl.count_field.start as usize - 2; + assert_eq!(&dng[type_at..type_at + 2], &[7, 0]); + dng[type_at..type_at + 2].copy_from_slice(&[1, 0]); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, None); + assert_eq!(decoded.c2pa_exclusions, None); + assert!(decoded.ifd0_extra.contains(&RawTag { + tag: C2PA_MANIFEST_STORE, + value: Value::Byte(bytes), + })); +} + +/// Appends a directory holding only `entries` to the end of `dng` and links IFD 0's next-IFD +/// pointer to it — the "only entity within a new IFD following the existing one" form §A.3.6 +/// allows for a single-main-IFD asset. Little-endian classic TIFF; each entry is +/// `(tag, type, count, value/offset word)`. Returns the new directory's offset. +fn append_trailing_ifd(dng: &mut Vec, entries: &[(u16, u16, u32, u32)]) -> u64 { + let le = ByteOrder::LittleEndian; + let (_, _, ifd0) = read_header(dng).expect("header"); + let n0 = usize::from(le.u16(dng[ifd0 as usize..ifd0 as usize + 2].try_into().expect("2"))); + let next_at = ifd0 as usize + 2 + n0 * 12; + assert_eq!( + &dng[next_at..next_at + 4], + &[0, 0, 0, 0], + "IFD 0 was the last directory" + ); + + let body = align_word(dng.len() as u64); + dng.resize(body as usize, 0); + dng.extend_from_slice(&le.pack_u16(entries.len() as u16)); + for &(tag, ty, count, word) in entries { + dng.extend_from_slice(&le.pack_u16(tag)); + dng.extend_from_slice(&le.pack_u16(ty)); + dng.extend_from_slice(&le.pack_u32(count)); + dng.extend_from_slice(&le.pack_u32(word)); + } + dng.extend_from_slice(&[0, 0, 0, 0]); + dng[next_at..next_at + 4].copy_from_slice(&le.pack_u32(body as u32)); + body +} + +/// A store carried the other lawful way — as the only entry of a trailing IFD — is found there, +/// with ranges inside that directory, and the file is still fully classified. +#[test] +fn a_store_in_a_trailing_ifd_is_found_there() { + let bytes = store(50); + let (mut dng, _) = encode(&DngEncoder::new()); + // Body: count (2) + one entry (12) + next (4) = 18 bytes; the store follows it. + let plain_len = dng.len(); + let body = align_word(plain_len as u64); + let store_at = body + 18; + append_trailing_ifd( + &mut dng, + &[(C2PA_MANIFEST_STORE, 7, bytes.len() as u32, store_at as u32)], + ); + dng.extend_from_slice(&bytes); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, Some(bytes.clone())); + // `C2paExclusions` is `#[non_exhaustive]`, so the two ranges are compared field by field + // rather than against a literal. + let excl = decoded.c2pa_exclusions.expect("ranges"); + assert_eq!( + excl.store, + Range { + start: store_at, + len: bytes.len() as u64 + } + ); + assert_eq!( + excl.count_field, + Range { + start: body + 2 + 4, + len: 4 + } + ); + let report = gamut_dng::deconstruct(&dng).expect("deconstruct"); + assert!( + report.segments.is_fully_classified(), + "the trailing directory and its store are claimed: {report:?}" + ); +} + +/// The entry in IFD 0 of a file whose main chain continues past it breaks §A.3.6 ("the last +/// IFD of the main-IFD chain"): it is not the asset's store, and stays an IFD 0 extra. +#[test] +fn a_store_entry_before_the_last_main_ifd_is_not_the_store() { + let bytes = store(24); + let (mut dng, _) = encode(&with_store(ByteOrder::LittleEndian, bytes.clone())); + // Software (305) ASCII "x\0", inline: a harmless trailing page. + append_trailing_ifd(&mut dng, &[(305, 2, 2, u32::from_le_bytes(*b"x\0\0\0"))]); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, None); + assert_eq!(decoded.c2pa_exclusions, None); + assert!(decoded.ifd0_extra.contains(&RawTag { + tag: C2PA_MANIFEST_STORE, + value: Value::Undefined(bytes), + })); +} + +/// A store of exactly `MIN_STORE_LEN` is the smallest a classic-TIFF DNG can carry, and it must +/// read back as the *store* rather than as the offset word pointing at it. +/// +/// BigTIFF's inline threshold is those same 8 bytes, so there the entry would hold the value +/// itself and an appended run would be referenced by nothing. The encoder refuses that instead +/// of producing a file whose exclusion ranges cover bytes no reader reads back — nine bytes are +/// the smallest BigTIFF store, and they work. +#[test] +fn a_store_at_the_inline_threshold_is_written_out_of_line_or_refused() { + let raw = common::sample_raw(32, 24, 16); + let smallest = store(MIN_STORE_LEN); + + let (dng, report) = encode(&with_store(ByteOrder::LittleEndian, smallest.clone())); + let excl = report.c2pa.expect("classic accepts the smallest store"); + assert_eq!(excl.store.len, MIN_STORE_LEN as u64); + assert_eq!(excl.store.end(), dng.len() as u64); + assert_eq!(slice(&dng, excl.store), smallest.as_slice()); + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!( + decoded.metadata.c2pa, + Some(smallest.clone()), + "the entry must read back as the store, not as an offset" + ); + gamut_dng_oracle::validate_dng(&dng).expect("Adobe DNG SDK must accept the smallest store"); + + // BigTIFF, same length: refused, and refused before any pixel work. + for encoder in [ + with_store(ByteOrder::LittleEndian, smallest.clone()).with_big_tiff(true), + DngEncoder::new() + .with_big_tiff(true) + .with_c2pa_reserved(MIN_STORE_LEN), + ] { + let error = encoder + .encode(&raw, &common::sample_profile(), &mut Vec::new()) + .expect_err("8 bytes pack inline in BigTIFF"); + assert_eq!( + error.static_message(), + Some("DNG: a BigTIFF C2PA manifest store must exceed 8 bytes, or it packs inline") + ); + } + + // Nine bytes clear the threshold: the store lands last and reads back whole. + let nine = store(MIN_STORE_LEN + 1); + let (dng, report) = + encode(&with_store(ByteOrder::LittleEndian, nine.clone()).with_big_tiff(true)); + let excl = report.c2pa.expect("nine bytes are writable in BigTIFF"); + assert_eq!(excl.store.end(), dng.len() as u64); + assert_eq!( + DngDecoder::new() + .decode(&dng) + .expect("decode") + .metadata + .c2pa, + Some(nine) + ); +} + +/// §A.3.6 allows one store per asset, so a last IFD carrying **two** tag-52545 entries has no +/// admissible store — and both decode surfaces must say so *together*. +/// +/// Reading the bytes and the ranges through different paths is what made them disagree: the +/// eager `Ifd` keeps the last duplicate, so `metadata.c2pa` reported that entry's bytes while +/// `c2pa_exclusions` reported nothing. Re-encoding such a `DngMetadata` silently produced a +/// one-entry file carrying only the last duplicate. +#[test] +fn a_duplicated_store_entry_is_absent_from_both_decode_surfaces() { + let first = store(40); + let (mut dng, _) = encode(&DngEncoder::new()); + // Two entries in a trailing IFD: body is count (2) + 2 * 12 + next (4) = 30 bytes. + let body = append_trailing_ifd( + &mut dng, + &[ + (C2PA_MANIFEST_STORE, 7, first.len() as u32, 0), + (C2PA_MANIFEST_STORE, 7, first.len() as u32, 0), + ], + ); + let values_at = body + 30; + let second: Vec = first.iter().map(|b| !b).collect(); + let le = ByteOrder::LittleEndian; + for (i, entry) in [0usize, 1].iter().enumerate() { + let word_at = (body + 2 + *entry as u64 * 12 + 8) as usize; + dng[word_at..word_at + 4] + .copy_from_slice(&le.pack_u32((values_at + (i * first.len()) as u64) as u32)); + } + dng.extend_from_slice(&first); + dng.extend_from_slice(&second); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!( + decoded.c2pa_exclusions, None, + "two entries name no single store" + ); + assert_eq!( + decoded.metadata.c2pa, None, + "the bytes surface must agree with the ranges, not report the last duplicate" + ); + // Preservation, stated exactly: the eager `Ifd` the typed channels are built on keeps the + // LAST of several entries under one tag, so one field arrives and it carries the second + // duplicate's bytes. Asserting only that *a* tag-52545 field is present would pass on the + // first duplicate, on the second, or on both — i.e. on the very ambiguity under test. + let surfaced: Vec<&RawTag> = decoded + .trailing_extra + .iter() + .filter(|t| t.tag == C2PA_MANIFEST_STORE) + .collect(); + assert_eq!( + surfaced.len(), + 1, + "the last-wins IFD model yields exactly one field: {:?}", + decoded.trailing_extra + ); + assert_eq!( + surfaced[0].value, + Value::Undefined(second.clone()), + "and it is the last duplicate's bytes, not the first's" + ); + assert_ne!( + second, first, + "the fixture's duplicates must be distinguishable" + ); + // And re-encoding cannot smuggle one of the two duplicates back out as "the" store. + let mut re = Vec::new(); + let report = DngEncoder::new() + .with_metadata(decoded.metadata.clone()) + .encode_with_report(&decoded.raw, &common::sample_profile(), &mut re) + .expect("re-encode"); + assert_eq!(report.c2pa, None); +} + +/// A tag-52545 field this decoder declines to read as a store, in a **trailing** IFD that +/// carries no image, still reaches the caller: that directory is neither IFD 0, nor the raw +/// IFD, nor a sub-image, so without `trailing_extra` its fields would reach no surface at all +/// and the crate's "nothing is silently dropped" promise would be false. +#[test] +fn a_declined_store_in_a_trailing_ifd_is_still_surfaced() { + let short = store(MIN_STORE_LEN - 1); + let (mut dng, _) = encode(&DngEncoder::new()); + let body = append_trailing_ifd(&mut dng, &[(C2PA_MANIFEST_STORE, 7, short.len() as u32, 0)]); + // Body: count (2) + one entry (12) + next (4) = 18; point the entry at the bytes after it. + let word_at = (body + 2 + 8) as usize; + dng[word_at..word_at + 4] + .copy_from_slice(&ByteOrder::LittleEndian.pack_u32((body + 18) as u32)); + dng.extend_from_slice(&short); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, None, "7 bytes cannot be a JUMBF box"); + assert_eq!(decoded.c2pa_exclusions, None); + assert!( + decoded.trailing_extra.contains(&RawTag { + tag: C2PA_MANIFEST_STORE, + value: Value::Undefined(short), + }), + "the declined field must not vanish: {:?}", + decoded.trailing_extra + ); +} + +/// The other side of `trailing_extra`'s guard: when the last main-chain directory **is** IFD 0 +/// — every file this crate writes — its unmodelled fields belong to `ifd0_extra` and must not +/// also appear in `trailing_extra`. +/// +/// The fixture has to carry an unmodelled IFD-0 tag for this to say anything: with a clean file +/// both lists are empty, so "surfaced elsewhere" and "collected here" agree and the guard +/// decides nothing observable. +#[test] +fn a_single_main_ifd_files_extras_stay_in_ifd0_extra() { + // Retyping the store entry in place (7 -> 1, same element size, no offsets move) leaves + // IFD 0 holding a field the decoder does not model — the leftover this claim needs. + let bytes = store(24); + let (mut dng, report) = encode(&with_store(ByteOrder::LittleEndian, bytes.clone())); + let type_at = report.c2pa.expect("ranges").count_field.start as usize - 2; + dng[type_at..type_at + 2].copy_from_slice(&[1, 0]); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert!( + decoded + .ifd0_extra + .iter() + .any(|t| t.tag == C2PA_MANIFEST_STORE), + "the declined field belongs to ifd0_extra: {:?}", + decoded.ifd0_extra + ); + assert!( + decoded.trailing_extra.is_empty(), + "IFD 0 is surfaced already, so nothing is collected as trailing: {:?}", + decoded.trailing_extra + ); +} + +/// A store this decoder *does* accept is consumed, not also reported as an unmodelled field — +/// `trailing_extra` is the channel for what was declined, not a duplicate of what was read. +#[test] +fn an_accepted_store_is_not_also_a_trailing_extra() { + let bytes = store(50); + let (mut dng, _) = encode(&DngEncoder::new()); + let body = align_word(dng.len() as u64); + let store_at = body + 18; + append_trailing_ifd( + &mut dng, + &[(C2PA_MANIFEST_STORE, 7, bytes.len() as u32, store_at as u32)], + ); + dng.extend_from_slice(&bytes); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, Some(bytes)); + assert!(decoded.c2pa_exclusions.is_some()); + assert!( + !decoded + .trailing_extra + .iter() + .any(|t| t.tag == C2PA_MANIFEST_STORE), + "a consumed store is not an extra: {:?}", + decoded.trailing_extra + ); +} + +/// A foreign file whose tag-52545 value is too short to hold a JUMBF box header carries no +/// manifest store (`references/c2pa/README.md`): it decodes to `None`, and the decoded +/// `DngMetadata` therefore re-encodes — which it could not if the decoder had handed back a +/// store its own encoder refuses. +#[test] +fn a_too_short_store_decodes_as_absent_and_still_re_encodes() { + let short = store(MIN_STORE_LEN - 1); + let (mut dng, _) = encode(&DngEncoder::new()); + // Body: count (2) + one entry (12) + next (4) = 18; the 7-byte value follows it. + let body = align_word(dng.len() as u64); + append_trailing_ifd( + &mut dng, + &[( + C2PA_MANIFEST_STORE, + 7, + short.len() as u32, + (body + 18) as u32, + )], + ); + dng.extend_from_slice(&short); + + let decoded = DngDecoder::new().decode(&dng).expect("decode"); + assert_eq!(decoded.metadata.c2pa, None, "7 bytes cannot be a JUMBF box"); + assert_eq!(decoded.c2pa_exclusions, None); + + // Decode -> encode: the metadata the decoder produced is accepted as encoder input. + let mut re = Vec::new(); + let report = DngEncoder::new() + .with_metadata(decoded.metadata.clone()) + .encode_with_report(&decoded.raw, &common::sample_profile(), &mut re) + .expect("a decoded file's metadata must re-encode"); + assert_eq!(report.c2pa, None); +} diff --git a/crates/gamut-dng/tests/deconstruct.rs b/crates/gamut-dng/tests/deconstruct.rs index 2c5279a0..328edc4d 100644 --- a/crates/gamut-dng/tests/deconstruct.rs +++ b/crates/gamut-dng/tests/deconstruct.rs @@ -73,6 +73,54 @@ fn linear_raw_dng_is_accounted() { assert_clean(&report); } +/// A C2PA manifest store (C2PA 2.4 §A.3.6) is placed after the image data, last in the file, +/// and the byte accounting claims it as the value of IFD 0's tag-52545 entry — never as an +/// unclassified run or a trailer. The whole report is clean, on the same terms as every other +/// file this encoder writes: the store's tag is a tag this crate knows, so `is_fully_accounted` +/// stays true rather than reporting the file's own manifest store as a private tag. +#[test] +fn a_c2pa_store_at_the_end_of_the_file_is_the_entrys_value_span() { + use gamut_dng::{DngMetadata, Segment, SpanKind}; + use gamut_ifd::c2pa::C2PA_MANIFEST_STORE; + + for &order in &[ByteOrder::LittleEndian, ByteOrder::BigEndian] { + // An 8-bit 33×23 mosaic: an odd-length raw strip, so the store needs alignment filler. + let raw = common::sample_raw(33, 23, 8); + let mut dng = Vec::new(); + let report = DngEncoder::new() + .with_byte_order(order) + .with_metadata(DngMetadata { + c2pa: Some((0u8..40).collect()), + ..Default::default() + }) + .encode_with_report(&raw, &common::sample_profile(), &mut dng) + .expect("encode"); + let excl = report.c2pa.expect("ranges"); + let (_, _, ifd0) = gamut_ifd::read_header(&dng).expect("header"); + + let report = deconstruct(&dng).expect("deconstruct"); + assert_clean(&report); + assert!( + report.segments.segments.contains(&Segment { + range: excl.store, + kind: SpanKind::Value { + ifd: ifd0, + tag: C2PA_MANIFEST_STORE, + }, + }), + "{order:?}: the store is IFD 0's value span: {report:?}" + ); + assert!( + !report + .segments + .segments + .iter() + .any(|s| s.kind == SpanKind::Trailer), + "{order:?}: a store at the end of the file is not a trailer" + ); + } +} + #[test] fn decoder_deconstruct_returns_image_and_report() { let raw = common::sample_raw(16, 16, 16); diff --git a/crates/gamut-dng/tests/roundtrip.rs b/crates/gamut-dng/tests/roundtrip.rs index 3383f6ac..5c45a9c5 100644 --- a/crates/gamut-dng/tests/roundtrip.rs +++ b/crates/gamut-dng/tests/roundtrip.rs @@ -508,6 +508,7 @@ fn metadata_embeds_and_roundtrips() { xmp: Some(br#""#.to_vec()), iptc: Some(vec![0x1c, 0x02, 0x05, 0x00, 0x03, b'a', b'b', b'c']), icc: Some(vec![0u8; 16]), + c2pa: None, }; let mut dng = Vec::new(); DngEncoder::new() diff --git a/crates/gamut-ifd/STATUS.md b/crates/gamut-ifd/STATUS.md index e392dde7..5ae64135 100644 --- a/crates/gamut-ifd/STATUS.md +++ b/crates/gamut-ifd/STATUS.md @@ -34,6 +34,7 @@ read/write code paths byte-for-byte. | P8 | — | BigTIFF (8-byte offsets/counts, `Long8`/`SLong8`/`Ifd8`) — gated `bigtiff` feature, additive | ✅ done | | P9 | §2 | RAW-grade streaming: `ReadAt` sources (slice / `Read + Seek` / rebased), lazy `IfdReader`, structural `tags` | ✅ done (#252) | | P10 | §2 | **Byte completeness** (2.0 reshape): lossless `Value::Unknown` model, one-parser collapse, dual-ledger `Tracked`+`SegmentMap` audit, writer-declared padding + pinned spans | ✅ done (#263) | +| P11 | C2PA §A.3.6, §18.5.5 | **C2PA manifest-store carriage** (`c2pa`): the tag, the last-main-IFD placement rule, end-of-file placement by post-write relocation, the two-range exclusion set, and the read-side locator — shared by the TIFF-based codecs | ✅ done (#442) | P5's **write** side landed with the DNG codec (issue #109): [`write`](src/writer.rs) lays out the whole IFD *tree* — [`Ifd::set_sub_ifd`](src/entry.rs) attaches children under a pointer tag @@ -175,3 +176,77 @@ lossless. [`tests/hardening_audit.rs`](tests/hardening_audit.rs) remains the acc pinning the exact `Error::InvalidInput` string rawshift keys its `ParseError` mapping on for every checklist case; per the issue ("correctness verification, not an API ask") error granularity stays `InvalidInput(&'static str)` — no per-case error variants were added. + +## P11 — C2PA manifest-store carriage (issue #442, epic #239) + +The C2PA manifest store is the one cross-format payload a TIFF-based file carries *by tag* +(C2PA 2.4 §A.3.6: tag 52545 / `0xCD41`, type `UNDEFINED`), and its placement rule is unusual +enough — one store per asset, its entry in the **last IFD of the main chain**, its bytes at the +**end of the file** so a resize moves no other offset — that stating it once here, for both +`gamut-dng` (#442) and `gamut-tiff` (#446), beats two derivations. The [`c2pa`](src/c2pa.rs) +module is that one statement; `references/c2pa/README.md` is the clause map. The store stays +opaque bytes: nothing here parses the JUMBF interior or reaches a verdict. + +- **Placement is a post-write relocation, not a new writer mode.** A codec that appends image + data after `write`'s stream cannot get a value placed *after* that data from the value pool, + and a pinned span would need the data's extent before the layout it depends on exists. So + `reserve_entry` puts a one-byte inline placeholder in the last main IFD (the directory layout + is final; the pool is untouched), the codec writes its whole file, and `append_store` lands + the store at `align_word(len)`, patching only the entry's `count` and value/offset words. The + alternative — a `WriteOptions` directive placing a tag's value at the end of the *stream* — + would still sit before the codec's pixel data, which is not "the end of the file". +- **The exclusion set is two ranges** (§18.5.5): `C2paExclusions { store, count_field }`, the + count field being the offset-width word at entry offset 4 (4 bytes classic, 8 BigTIFF). They + are disjoint by construction, and reported in the crate's own `Range` (u64 start/len) — the + same measure the segment map uses — rather than `core::ops::Range`. +- **Read side.** `locate` walks the chain to its last directory over any `ReadAt` source and + reports the ranges for an out-of-line *or* inline store; a tag-52545 entry of another type, or + one in a non-last directory, is "no store" (never an error), so a decoder can still surface + it as an unmodelled field. A store declared past the end of the file is `InvalidInput`, the + verdict `read` gives the same file. +- **Endianness.** §A.3.6 says the header's `ByteOrder` "does not govern the endianness of the + embedded C2PA Manifest Store": the bytes cross verbatim in both directions, pinned on `MM` + fixtures whose store is asymmetric. +- **Minimum length, and the bound that actually keeps a store out of line.** Nothing shorter + than a JUMBF box header (8 bytes, `MIN_STORE_LEN`) can be a manifest store, and the two + directions treat that differently, as `references/c2pa/README.md` prescribes for a reader: + `locate` reports **absence** for a shorter value (so a foreign file stays readable and a + decode → encode cycle over it does not trip the encoder's own minimum), while `append_store` + **refuses** it (an encoder handed a store it cannot write must say so, not drop it). + Out-of-line-ness is a *separate* bound and is not implied by that constant: classic TIFF's + inline threshold is 4 bytes but **BigTIFF's is 8**, so a store of exactly `MIN_STORE_LEN` + packs inline in a BigTIFF entry. `append_store` therefore gates on the variant's own + `inline_threshold()` — the shortest writable BigTIFF store is nine bytes. Without that gate + the bytes would be appended at the end of the file while the entry read back as the offset + word pointing at them, leaving the store referenced by nothing and the exclusion ranges + covering bytes no reader returns. +- **One store per asset.** §A.3.6 admits exactly one, so a last IFD carrying *two* tag-52545 + entries names none and `locate` reports absence. Reporting the first would be worse than + reporting nothing: the eager `Ifd` keeps the **last** duplicate, so a caller taking bytes from + one path and ranges from this one would get two different byte runs under one name. +- **Byte accounting.** The store is the entry's `Value` span and the alignment filler before + it is `Padding`, so an audited read of the result is fully classified — a store at the end of + the file is never a `Trailer`. + +Deliberately not here: a `TiffFile`-level "reserve the entry in the right IFD" helper. The +codecs hold their last main IFD by hand (a DNG has exactly one), and picking it out of a chain +is a one-liner nobody would get wrong. + +`C2paExclusions` is `#[non_exhaustive]`: §18.5.5 names two ranges today, and a revision naming a +third must be additive rather than a `gamut-ifd` major. It also carries a public +`C2paExclusions::new` — the attribute alone would leave a host that places a store by its own +route unable to name the ranges §18.5.5 asks it to exclude, and extensible and constructible are +both available. + +**The read and write sides are deliberately asymmetric.** A BigTIFF store of exactly +`MIN_STORE_LEN` bytes packs inline; `locate` reports it (the file is lawful and its ranges are +well defined) while `append_store` refuses to *write* that shape, 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 for no gain. Liberal in, conservative out — stated at both +functions so it reads as a decision rather than an oversight. + +**Accepted duplication.** `gamut_heic::c2pa::JUMBF_HEADER_LEN` states the same 8-byte JUMBF box +header bound as `MIN_STORE_LEN` here. The dependency graph gives the two no shared home — +`gamut-ifd` sits *below* `gamut-heic` and neither may depend on the other — and both cite the +same clause (C2PA 2.4 §8.4.2.3's incidental description, recorded in +`references/c2pa/README.md`). Factoring it out would mean a new crate for one integer. diff --git a/crates/gamut-ifd/src/c2pa.rs b/crates/gamut-ifd/src/c2pa.rs new file mode 100644 index 00000000..3aeacc02 --- /dev/null +++ b/crates/gamut-ifd/src/c2pa.rs @@ -0,0 +1,1051 @@ +//! C2PA manifest-store carriage in a TIFF-based container (C2PA 2.4 §A.3.6, §18.5.5). +//! +//! C2PA (Coalition for Content Provenance and Authenticity) embeds its manifest store into a +//! TIFF-compatible file — TIFF/EP, DNG, any TIFF-based RAW — "as the data of a tag with ID 52545 +//! (decimal) or 0xCD41 (hexadecimal), with a tag type of 7" (§A.3.6). This module is the one +//! place the workspace states that clause: the tag ([`C2PA_MANIFEST_STORE`]), the placement +//! rule over a directory chain, the two-range exclusion set an external signer hashes around, +//! and the read-side locator that recovers those ranges from a file. `gamut-dng` and +//! `gamut-tiff` call it rather than each re-deriving §A.3.6. +//! +//! It is a **locator and placer only**: the store is opaque bytes. Nothing here parses the JUMBF +//! interior, verifies a hash, checks a signature or reaches a verdict — validation belongs to a +//! C2PA validator downstream (`references/c2pa/README.md`). +//! +//! # Placement (§A.3.6) +//! +//! > there shall only be one C2PA Manifest Store for the entire asset — not one per IFD. As +//! > such, the C2PA IFD Entry shall always be located within the **last IFD of the main-IFD +//! > chain**. For TIFF assets containing one main IFD, the C2PA IFD Entry shall be located +//! > within that IFD or be the only entity within a new IFD following the existing one. […] To +//! > support update manifests, the C2PA Manifest Store should be located at the **end of the +//! > file**; this ensures that changes to the size of the C2PA Manifest Store do not impact any +//! > of the other tag offsets. +//! +//! [`locate`] therefore consults only the last directory of the chain, and [`append_store`] +//! lands the store's bytes after everything else in the file, so a store of a different size +//! moves no other offset — the property that makes a reserve-then-sign flow work: a host writes +//! a zero-filled reservation, an external signer computes its hash over the exclusion ranges and +//! overwrites the reservation in place. +//! +//! # Endianness (§A.3.6) +//! +//! > The value of the ByteOrder field in the TIFF header does not govern the endianness of the +//! > embedded C2PA Manifest Store. +//! +//! The store is `UNDEFINED` bytes and is copied verbatim in both directions. Only the entry's +//! `count` and value/offset words follow the file's byte order. +//! +//! # The exclusion set has two ranges (§18.5.5) +//! +//! > When hashing a TIFF-based asset into which the C2PA Manifest will be embedded, the count +//! > field of the C2PA Manifest Store's IFD Entry (representing the length of the JUMBF data) +//! > should be included in the exclusion ranges. This is to support Update Manifests, which +//! > could change the size of the embedded C2PA Manifest Store. +//! +//! The entry sits in a directory body and the store sits elsewhere, so [`C2paExclusions`] is two +//! disjoint ranges, never one. §18.7.3.3 removed general box hash for TIFF, so `c2pa.hash.data` +//! over these exclusions is the only binding a TIFF-based asset has. + +use gamut_core::{Error, Result}; + +use crate::{ + ByteOrder, FieldType, Ifd, IfdReader, Range, RawEntry, RawIfd, ReadAt, Value, Variant, + align_word, +}; + +/// The tag carrying the C2PA manifest store: 52545 (`0xCD41`), type `UNDEFINED` (7) — C2PA 2.4 +/// §A.3.6. +pub const C2PA_MANIFEST_STORE: u16 = 52545; + +/// The length below which a value cannot be a manifest store: a JUMBF box header, being a +/// 4-byte `LBox` followed by a 4-byte `TBox` (C2PA 2.4 §8.4.2.3's incidental description of the +/// box framing; see `references/c2pa/README.md`). A manifest store is a JUMBF superbox, so +/// nothing shorter can be one, and a reservation shorter than this could never be filled with a +/// valid store. +/// +/// The bound is applied in both directions, but not symmetrically — `references/c2pa/README.md` +/// prescribes exactly this split for a reader: [`locate`] treats a shorter value as **not a +/// manifest store** and reports absence, while [`append_store`] **refuses** it, because an +/// encoder handed a store it cannot write must say so rather than silently drop it. +/// +/// **It does not by itself guarantee the value is out of line.** Classic TIFF's inline threshold +/// is 4 bytes, but BigTIFF's is 8, so a store of exactly this length packs *inline* in a BigTIFF +/// entry. [`append_store`] therefore gates on the variant's own +/// [`inline_threshold`](Variant::inline_threshold), not on this constant. +pub const MIN_STORE_LEN: usize = 8; + +/// The two byte ranges an external signer excludes from a `c2pa.hash.data` hard binding over a +/// TIFF-based asset (C2PA 2.4 §18.5.5): the manifest store itself and the `count` field of its +/// IFD entry. +/// +/// Both are absolute offsets into the file the store was located in (or, for an encoder's report, +/// into the bytes that encoder produced). **They never overlap, and neither is empty** — the +/// count field lies inside a directory body and the store outside it (or, for a foreign file +/// whose store packs inline, in the entry's value word, which follows the count field). That is +/// an invariant of the type, not a description of the usual case: every value comes from +/// [`locate`], [`append_store`] or [`new`](Self::new), and the last of those rejects anything +/// that would break it. +/// +/// `#[non_exhaustive]`: §18.5.5's exclusion set is the two ranges below today, and a later +/// revision naming a third must not be a breaking change. Construct one only by locating or +/// writing a store. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct C2paExclusions { + /// The manifest store's bytes: `len` is the entry's `count`, the store being `UNDEFINED` + /// (one byte per element). + pub store: Range, + /// The entry's `count` field: 4 bytes in classic TIFF, 8 in BigTIFF, at offset 4 of the + /// entry record (after the 2-byte tag and 2-byte type). + pub count_field: Range, +} + +impl C2paExclusions { + /// The exclusion set for a store at `store` whose IFD entry's `count` field is at + /// `count_field`. + /// + /// [`locate`] and [`append_store`] return one of these already, and that is the ordinary + /// way to get one. This constructor exists because the type is `#[non_exhaustive]`, which + /// would otherwise leave a host that places a store by some other route — its own writer, a + /// format this crate does not serialise — unable to name the ranges §18.5.5 asks it to + /// exclude. Extensible and constructible are both available, so the type is both. + /// + /// The set that comes back satisfies the invariant this type advertises: the two ranges are + /// non-empty and disjoint. Nothing here re-reads a file — whether the ranges truly describe + /// a manifest store is the caller's to get right — but a set that could not describe one + /// under any reading is refused rather than handed on, because what it feeds is a signer's + /// hard binding and a nonsensical exclusion set must not pass silently. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if either range is empty (nothing to exclude), or if the + /// two overlap. Ranges that merely *abut* are accepted: touching is not overlapping. + pub fn new(store: Range, count_field: Range) -> Result { + if store.len == 0 || count_field.len == 0 { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: a C2PA exclusion range is empty", + )); + } + if store.start < count_field.end() && count_field.start < store.end() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: the C2PA exclusion ranges overlap", + )); + } + Ok(Self { store, count_field }) + } +} + +/// Reserves the manifest-store entry in `ifd`, the directory that will be written as the last +/// IFD of the main chain (§A.3.6): a one-byte inline `UNDEFINED` placeholder under +/// [`C2PA_MANIFEST_STORE`]. +/// +/// The placeholder is what makes the layout final before the store is placed: the entry is in +/// the directory (so every structure after it lands where it will stay) while its value takes +/// no room in the value pool. Once the file is written, [`append_store`] puts the real store at +/// the end of the file and re-points this entry at it. +pub fn reserve_entry(ifd: &mut Ifd) { + ifd.set(C2PA_MANIFEST_STORE, Value::Undefined(vec![0])); +} + +/// The last directory of the main-IFD chain — where §A.3.6 puts the entry. +fn last_ifd(reader: &mut IfdReader) -> Result { + let mut last = None; + for ifd in reader.ifds() { + last = Some(ifd?); + } + last.ok_or_else(|| Error::invalid_input(env!("CARGO_PKG_NAME"), "TIFF: no IFD")) +} + +/// The manifest-store entry of `ifd`, if it carries exactly one of the mandated type. +/// +/// Two ways a tag-52545 entry is *not* the asset's manifest-store entry, both reported as +/// absence rather than as an error, so a decoder can still surface the field as unmodelled: +/// +/// - **Its type is not 7.** §A.3.6 fixes the type at `UNDEFINED`. +/// - **It is one of several.** §A.3.6 admits "only one C2PA Manifest Store for the entire +/// asset", so a directory carrying two tag-52545 entries names no single store. Reporting the +/// first would be worse than reporting none: the eager [`Ifd`] keeps the *last* duplicate +/// (see [`RawIfd::entry`]), so a caller reading the bytes through one path and the ranges +/// through this one would be handed two different byte runs under one name. +/// +/// The [`MIN_STORE_LEN`] bound is deliberately **not** applied here: it is a rule about a +/// *store*, and [`append_store`] uses this helper to find the one-byte placeholder +/// [`reserve_entry`] wrote, which is not a store yet. [`locate`] applies it. +fn store_entry(ifd: &RawIfd) -> Option<&RawEntry> { + let mut matching = ifd + .entries + .iter() + .filter(|entry| entry.tag == C2PA_MANIFEST_STORE); + let entry = matching.next()?; + if matching.next().is_some() { + return None; + } + (entry.field_type() == Some(FieldType::Undefined)).then_some(entry) +} + +/// The count field of `entry`: the offset-width word after the 2-byte tag and 2-byte type. +fn count_field(entry: &RawEntry, variant: Variant) -> Range { + Range { + start: entry.offset + 4, + len: variant.offset_size() as u64, + } +} + +/// Whether a file of `end` bytes has outgrown `variant`'s offset width — possible only for +/// classic TIFF's 32-bit offsets and counts. A pure predicate so the 4 GiB boundary is testable +/// without a 4 GiB allocation. +fn exceeds_offset_width(variant: Variant, end: u64) -> bool { + variant == Variant::Classic && end > u64::from(u32::MAX) +} + +/// Locates the C2PA manifest store of a TIFF-based file and reports its exclusion ranges. +/// +/// Walks the main-IFD chain to its **last** directory (§A.3.6) and looks there — and only +/// there — for an `UNDEFINED` entry under [`C2PA_MANIFEST_STORE`]. A store whose value packs +/// inline (only possible in BigTIFF, whose inline threshold is [`MIN_STORE_LEN`] itself) is +/// reported with `store` inside the entry's value word, after the count field. +/// +/// Returns `Ok(None)` — absence, never an error — when the last directory has no such entry, +/// when its type is not 7, when it is one of several tag-52545 entries, when its value is +/// shorter than a JUMBF box header ([`MIN_STORE_LEN`]) and so cannot be a store, or (a file that +/// breaks §A.3.6) when the entry sits in an earlier directory of the chain. Reporting absence +/// rather than erroring is what keeps a foreign file readable: the field still reaches a caller +/// as an unmodelled tag, and a decode → encode cycle over such a file does not trip the +/// encoder's own minimum. +/// +/// # This reads one shape [`append_store`] will not write, deliberately +/// +/// A BigTIFF store of exactly [`MIN_STORE_LEN`] bytes packs inline, and this reports it — the +/// file is lawful and its ranges are well defined. [`append_store`] nonetheless refuses to +/// *write* that shape, because an inline value is not the run at the end of the file this +/// crate's placement rule is built on, and admitting it would give a store two placements to +/// reason about for no gain. Liberal in what it accepts, conservative in what it emits; the +/// asymmetry is a decision, not an oversight. +/// +/// `src` is any [`ReadAt`] source — a `&[u8]`, or a [`StreamSource`](crate::StreamSource) over +/// a file handle, since a multi-hundred-MB RAW need not be read to find a 12-byte entry. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] if the container is unreadable (bad header, looping or +/// runaway chain, no IFD) or the store's declared extent lies outside the source — the same +/// verdicts [`read`](crate::read) gives such a file — or [`Error::Io`] if the source fails. +pub fn locate(src: S) -> Result> { + let mut reader = IfdReader::open(src)?; + let last = last_ifd(&mut reader)?; + let Some(entry) = store_entry(&last) else { + return Ok(None); + }; + // A value too short to hold a JUMBF box header is not a manifest store + // (`references/c2pa/README.md`), so it is absence here rather than a range a signer could + // hash — and the encoder's own refusal of such a store stays reachable only for stores a + // caller supplies, never for one this reader handed back. + if entry.count < MIN_STORE_LEN as u64 { + return Ok(None); + } + let variant = reader.variant(); + let count_field = count_field(entry, variant); + // Out of line at the offset the entry declares; inline, the value word follows the count. + let start = reader.value_offset(entry).unwrap_or(count_field.end()); + let end = start.checked_add(entry.count).ok_or_else(|| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: C2PA manifest store extent overflows", + ) + })?; + if end > reader.source_mut().len()? { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: C2PA manifest store lies outside the file", + )); + } + Ok(Some(C2paExclusions { + store: Range { + start, + len: entry.count, + }, + count_field, + })) +} + +/// Appends `store` at the (word-aligned) end of `file` and points the reserved entry at it, +/// returning the exclusion ranges. +/// +/// `file` is a complete TIFF-based file whose last main-chain IFD carries the entry +/// [`reserve_entry`] placed — a one-byte inline placeholder. The store lands at +/// [`align_word`]`(file.len())`, with one zero byte of alignment filler when the file was of odd +/// length, and the entry's `count` and value/offset words are rewritten in the file's byte order. +/// Nothing else moves: every other offset in the file is untouched, which is what lets a +/// zero-filled reservation of the same length be overwritten in place by an external signer. +/// The bytes of `store` are copied verbatim — the TIFF byte order does not apply to them +/// (§A.3.6). +/// +/// This writes exactly one placement: the store out of line, last in the file. A BigTIFF store +/// of exactly [`MIN_STORE_LEN`] bytes would pack *inline* instead, which is lawful — [`locate`] +/// reads that shape — but is not the placement this crate's reserve-then-sign flow is built on, +/// so it is refused here rather than emitted. The asymmetry with [`locate`] is deliberate; see +/// that function's docs. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] if `store` is shorter than [`MIN_STORE_LEN`]; if it is not +/// longer than the container variant's [`inline_threshold`](Variant::inline_threshold), since a +/// value that packs *inline* cannot be the run at the end of the file this function exists to +/// place (in BigTIFF that threshold is `MIN_STORE_LEN` itself, so the shortest writable BigTIFF +/// store is nine bytes); if the container is unreadable; if its last IFD carries no +/// [`C2PA_MANIFEST_STORE`] entry of type `UNDEFINED`, or carries more than one; if that entry +/// already points out of line (re-pointing it would orphan the bytes it points at — write a +/// placeholder instead); or if the appended store would put the file past the 4 GiB +/// classic-TIFF offset limit. +pub fn append_store(file: &mut Vec, store: &[u8]) -> Result { + if store.len() < MIN_STORE_LEN { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: C2PA manifest store is shorter than a JUMBF box header", + )); + } + let (order, variant, entry) = { + let mut reader = IfdReader::open(&file[..])?; + let last = last_ifd(&mut reader)?; + // `store_entry` reports absence for a duplicated entry as well as for a missing or + // mistyped one, and "carries no entry" would be a misleading thing to tell a caller + // whose directory carries two, so the two cases are distinguished here. + if last + .entries + .iter() + .filter(|e| e.tag == C2PA_MANIFEST_STORE) + .count() + > 1 + { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: the last IFD carries more than one C2PA manifest store entry", + )); + } + let entry = store_entry(&last) + .ok_or_else(|| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: the last IFD carries no reserved C2PA manifest store entry", + ) + })? + .clone(); + if reader.value_offset(&entry).is_some() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: the C2PA manifest store entry already points out of line", + )); + } + (reader.order(), reader.variant(), entry) + }; + // The container's own inline rule, not a constant: a value no longer than the variant's + // threshold is packed *into* the entry by every reader (`IfdReader::value_offset` compares + // `<=`), so appending its bytes at the end of the file and writing an offset into the value + // word would leave the appended run referenced by nothing while the entry read back as the + // offset itself. BigTIFF's threshold is 8 — exactly `MIN_STORE_LEN` — so this is reachable + // there, not in classic TIFF. + if store.len() <= variant.inline_threshold() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: C2PA manifest store packs inline in this variant, so it cannot be placed at the end of the file", + )); + } + + let start = align_word(file.len() as u64); + let len = store.len() as u64; + let end = start.checked_add(len).ok_or_else(|| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: C2PA manifest store extent overflows", + ) + })?; + if exceeds_offset_width(variant, end) { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: C2PA manifest store exceeds the 4 GiB classic-TIFF offset limit", + )); + } + let start_usize = usize::try_from(start) + .map_err(|_| Error::invalid_input(env!("CARGO_PKG_NAME"), "TIFF: layout overflows"))?; + file.resize(start_usize, 0); + file.extend_from_slice(store); + + let count_field = count_field(&entry, variant); + patch_entry(file, &count_field, len, start, order, variant); + Ok(C2paExclusions { + store: Range { start, len }, + count_field, + }) +} + +/// Rewrites the entry's `count` and value/offset words — the offset-width pair after the tag +/// and type — in the file's byte order. +fn patch_entry( + file: &mut [u8], + count_field: &Range, + count: u64, + offset: u64, + order: ByteOrder, + variant: Variant, +) { + let at = count_field.start as usize; + match variant { + Variant::Classic => { + file[at..at + 4].copy_from_slice(&order.pack_u32(count as u32)); + file[at + 4..at + 8].copy_from_slice(&order.pack_u32(offset as u32)); + } + #[cfg(feature = "bigtiff")] + Variant::Big => { + file[at..at + 8].copy_from_slice(&order.pack_u64(count)); + file[at + 8..at + 16].copy_from_slice(&order.pack_u64(offset)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + Segment, SegmentReport, SpanKind, TiffFile, WriteOptions, read, read_audited, read_header, + write, write_with, + }; + + /// A store whose bytes are not a palindrome and not repeated, so a byte-swapped or + /// misaligned copy cannot equal it. + fn store() -> Vec { + (0x10u8..0x30).collect() + } + + /// Directory fields around the store: an inline value below the tag, and out-of-line values + /// on both sides of it, so the store's value is neither first nor last in the pool. + fn sample_ifd() -> Ifd { + let mut ifd = Ifd::new(); + ifd.set(256, Value::Short(vec![640])); + ifd.set(258, Value::Short(vec![8, 8, 8])); // 6 bytes: out of line in classic TIFF + ifd.set(65000, Value::Undefined(vec![0xEE; 5])); // odd length, past the store's tag + ifd + } + + fn file(order: ByteOrder, variant: Variant, ifds: Vec) -> TiffFile { + TiffFile { + order, + variant, + ifds, + } + } + + /// The writer's own declaration of where the `Value { tag }` span of the sole directory + /// landed. + fn declared_value_span(report: &SegmentReport, tag: u16) -> Range { + report + .segments + .iter() + .find(|s| matches!(s.kind, SpanKind::Value { tag: t, .. } if t == tag)) + .map(|s| s.range) + .expect("the writer declares the value span") + } + + fn ifd_body_offset(report: &SegmentReport) -> u64 { + report + .segments + .iter() + .find_map(|s| match s.kind { + SpanKind::IfdBody { ifd } => Some(ifd), + _ => None, + }) + .expect("one directory body") + } + + /// `locate` reports exactly the span the writer put the store in, and the count field at + /// its hand-computed position in the entry — pinned in both byte orders by reading the + /// count back through the file's own byte order. + #[test] + fn locate_reports_the_store_span_and_the_count_field() { + for order in [ByteOrder::LittleEndian, ByteOrder::BigEndian] { + let mut ifd = sample_ifd(); + ifd.set(C2PA_MANIFEST_STORE, Value::Undefined(store())); + let (bytes, map) = write_with( + &file(order, Variant::Classic, vec![ifd]), + &WriteOptions::default(), + ) + .expect("write"); + let report = map.finish(None); + + let found = locate(&bytes[..]).expect("locate").expect("a store"); + assert_eq!( + found.store, + declared_value_span(&report, C2PA_MANIFEST_STORE) + ); + // Tags sort 256, 258, 52545, 65000: the store is entry 2 of the directory. Count + // field = body + 2 (entry count) + 2 * 12 + 4 (tag, type). + let body = ifd_body_offset(&report); + assert_eq!( + found.count_field, + Range { + start: body + 2 + 2 * 12 + 4, + len: 4 + }, + "{order:?}" + ); + let at = found.count_field.start as usize; + let count = order.u32(bytes[at..at + 4].try_into().expect("4 bytes")); + assert_eq!(u64::from(count), found.store.len, "{order:?}"); + assert_eq!( + &bytes[found.store.start as usize..found.store.end() as usize], + store().as_slice(), + "{order:?}: the store's bytes are verbatim, not byte-swapped" + ); + } + } + + /// A BigTIFF count field is 8 bytes wide and the store is still found by its declared + /// offset. + #[cfg(feature = "bigtiff")] + #[test] + fn locate_reads_bigtiff_count_and_offset_widths() { + let mut ifd = sample_ifd(); + ifd.set(C2PA_MANIFEST_STORE, Value::Undefined(store())); + let (bytes, map) = write_with( + &file(ByteOrder::BigEndian, Variant::Big, vec![ifd]), + &WriteOptions::default(), + ) + .expect("write"); + let report = map.finish(None); + let found = locate(&bytes[..]).expect("locate").expect("a store"); + assert_eq!( + found.store, + declared_value_span(&report, C2PA_MANIFEST_STORE) + ); + let body = ifd_body_offset(&report); + assert_eq!( + found.count_field, + Range { + start: body + 8 + 2 * 20 + 4, + len: 8 + } + ); + } + + /// No entry, an entry of the wrong type, and an entry in a directory that is not the last of + /// the chain are all "no store" — not errors. + #[test] + fn locate_reports_absence_for_a_missing_mistyped_or_misplaced_entry() { + let plain = write(&file( + ByteOrder::LittleEndian, + Variant::Classic, + vec![sample_ifd()], + )) + .expect("write"); + assert_eq!(locate(&plain[..]).expect("locate"), None); + + let mut mistyped = sample_ifd(); + mistyped.set(C2PA_MANIFEST_STORE, Value::Byte(store())); + let bytes = write(&file( + ByteOrder::LittleEndian, + Variant::Classic, + vec![mistyped], + )) + .expect("write"); + assert_eq!(locate(&bytes[..]).expect("locate"), None); + + // The entry in IFD 0 of a two-directory chain: §A.3.6 wants it in the last one. + let mut first = sample_ifd(); + first.set(C2PA_MANIFEST_STORE, Value::Undefined(store())); + let bytes = write(&file( + ByteOrder::LittleEndian, + Variant::Classic, + vec![first, sample_ifd()], + )) + .expect("write"); + assert_eq!(locate(&bytes[..]).expect("locate"), None); + } + + /// The entry in the last directory of a two-directory chain — the "new IFD following the + /// existing one" form §A.3.6 allows — is found there. + #[test] + fn locate_follows_the_chain_to_its_last_directory() { + let mut last = Ifd::new(); + last.set(C2PA_MANIFEST_STORE, Value::Undefined(store())); + let (bytes, map) = write_with( + &file( + ByteOrder::LittleEndian, + Variant::Classic, + vec![sample_ifd(), last], + ), + &WriteOptions::default(), + ) + .expect("write"); + let report = map.finish(None); + let found = locate(&bytes[..]).expect("locate").expect("a store"); + assert_eq!( + found.store, + declared_value_span(&report, C2PA_MANIFEST_STORE) + ); + // The second directory is the one whose body holds the count field. + let second_body = report + .segments + .iter() + .filter_map(|s| match s.kind { + SpanKind::IfdBody { ifd } => Some(ifd), + _ => None, + }) + .max() + .expect("two bodies"); + assert_eq!(found.count_field.start, second_body + 2 + 4); + } + + /// The one shape in which a real store packs inline: a BigTIFF entry whose 8-byte value is + /// exactly the inline threshold. It is reported inside the entry's value word, immediately + /// after the 8-byte count field — the branch `value_offset` returning `None` selects. + #[cfg(feature = "bigtiff")] + #[test] + fn locate_reports_an_inline_bigtiff_store_inside_its_entry() { + let inline = store()[..MIN_STORE_LEN].to_vec(); + let mut ifd = Ifd::new(); + ifd.set(C2PA_MANIFEST_STORE, Value::Undefined(inline.clone())); + let bytes = write(&file(ByteOrder::LittleEndian, Variant::Big, vec![ifd])).expect("write"); + let found = locate(&bytes[..]).expect("locate").expect("a store"); + // BigTIFF header 16, entry count 8: the entry starts at 24, its count at 28, its value + // word at 36. + assert_eq!(found.count_field, Range { start: 28, len: 8 }); + assert_eq!(found.store, Range { start: 36, len: 8 }); + assert_eq!(&bytes[36..44], inline.as_slice()); + assert_eq!(found.store.start, found.count_field.end()); + } + + /// A value too short to hold a JUMBF box header is not a manifest store + /// (`references/c2pa/README.md`): absence, not a range and not an error, so a foreign file + /// carrying one stays readable. The boundary is exact — one byte more is a store. + #[test] + fn locate_reports_absence_for_a_value_below_the_jumbf_header() { + let classic = |ifd| { + write(&file(ByteOrder::LittleEndian, Variant::Classic, vec![ifd])).expect("write") + }; + for len in [0usize, 1, MIN_STORE_LEN - 1] { + let mut ifd = Ifd::new(); + ifd.set( + C2PA_MANIFEST_STORE, + Value::Undefined(store()[..len].to_vec()), + ); + assert_eq!( + locate(&classic(ifd)[..]).expect("locate"), + None, + "len {len}" + ); + } + let mut ifd = Ifd::new(); + ifd.set( + C2PA_MANIFEST_STORE, + Value::Undefined(store()[..MIN_STORE_LEN].to_vec()), + ); + let found = locate(&classic(ifd)[..]) + .expect("locate") + .expect("exactly the header length is a store"); + assert_eq!(found.store.len, MIN_STORE_LEN as u64); + } + + /// §A.3.6 admits one store per asset, so a directory carrying two tag-52545 entries names + /// none: reporting the first would describe different bytes than the eager `Ifd` — which + /// keeps the *last* duplicate — hands back under the same name. + #[test] + fn locate_reports_absence_for_a_duplicated_entry() { + // `Ifd::set` de-duplicates, so the duplicate is built at the byte level: two entries + // with the same tag, pointing at different bytes. + let le = ByteOrder::LittleEndian; + let first = store(); + let second: Vec = first.iter().map(|b| !b).collect(); + let values_at = 8 + 2 + 2 * 12 + 4; + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"II\x2a\x00"); + bytes.extend_from_slice(&le.pack_u32(8)); // first IFD at 8 + bytes.extend_from_slice(&le.pack_u16(2)); // two entries + for i in 0..2 { + bytes.extend_from_slice(&le.pack_u16(C2PA_MANIFEST_STORE)); + bytes.extend_from_slice(&le.pack_u16(FieldType::Undefined.code())); + bytes.extend_from_slice(&le.pack_u32(first.len() as u32)); + bytes.extend_from_slice(&le.pack_u32((values_at + i * first.len()) as u32)); + } + bytes.extend_from_slice(&le.pack_u32(0)); // next IFD + bytes.extend_from_slice(&first); + bytes.extend_from_slice(&second); + + // Both entries are individually well-formed and in bounds... + assert_eq!(bytes.len(), values_at + first.len() + second.len()); + assert_eq!( + read(&bytes).expect("read").ifds[0].get(C2PA_MANIFEST_STORE), + Some(&Value::Undefined(second)), + "the eager reader keeps the last duplicate" + ); + // ...and precisely because those two answers differ, neither is reported. + assert_eq!(locate(&bytes[..]).expect("locate"), None); + } + + /// A store whose declared extent runs past the end of the file is a typed error, not a range + /// a signer could hash. + #[test] + fn locate_rejects_a_store_past_the_end_of_file() { + let mut ifd = Ifd::new(); + ifd.set(C2PA_MANIFEST_STORE, Value::Undefined(store())); + let mut bytes = + write(&file(ByteOrder::LittleEndian, Variant::Classic, vec![ifd])).expect("write"); + // Point the entry's offset word (at 18) far past the end. + bytes[18..22].copy_from_slice(&ByteOrder::LittleEndian.pack_u32(100_000)); + let error = locate(&bytes[..]).expect_err("out of bounds"); + assert_eq!( + error.static_message(), + Some("TIFF: C2PA manifest store lies outside the file") + ); + // Exactly at the end is in bounds: the check is `>`, not `>=`. + let mut ifd = Ifd::new(); + ifd.set(C2PA_MANIFEST_STORE, Value::Undefined(store())); + let bytes = + write(&file(ByteOrder::LittleEndian, Variant::Classic, vec![ifd])).expect("write"); + let found = locate(&bytes[..]).expect("locate").expect("a store"); + assert_eq!(found.store.end(), bytes.len() as u64); + } + + /// A chain with no directory at all is the container-level error `read` gives it. + #[test] + fn locate_rejects_an_empty_chain() { + let error = locate(&b"II\x2a\x00\x00\x00\x00\x00"[..]).expect_err("no IFD"); + assert_eq!(error.static_message(), Some("TIFF: no IFD")); + } + + /// The write-side flow: reserve, write, append. The store lands last, verbatim, at the + /// word-aligned end (one zero filler byte after an odd-length file); the entry reads back + /// as the store; the reported ranges are what `locate` finds; and an independent audit of + /// the result classifies every byte, with the store as the entry's value span and the + /// filler as padding — never a trailer. + #[test] + fn append_store_lands_the_store_last_and_repoints_the_entry() { + for order in [ByteOrder::LittleEndian, ByteOrder::BigEndian] { + let mut ifd = sample_ifd(); + reserve_entry(&mut ifd); + let mut bytes = write(&file(order, Variant::Classic, vec![ifd])).expect("write"); + // `sample_ifd`'s 5-byte value is last in the pool, so the file ends odd. + assert_eq!( + bytes.len() % 2, + 1, + "fixture must end odd to exercise alignment" + ); + let before = bytes.clone(); + + let excl = append_store(&mut bytes, &store()).expect("append"); + let start = align_word(before.len() as u64); + assert_eq!( + excl.store, + Range { + start, + len: store().len() as u64 + } + ); + assert_eq!(bytes.len() as u64, excl.store.end(), "the store is last"); + assert_eq!(bytes[before.len()], 0, "one zero byte of alignment filler"); + assert_eq!( + &bytes[start as usize..], + store().as_slice(), + "{order:?}: verbatim" + ); + // Nothing before the filler changed except the entry's count and offset words. + let cf = excl.count_field.start as usize; + assert_eq!(&bytes[..cf], &before[..cf]); + assert_eq!(&bytes[cf + 8..before.len()], &before[cf + 8..]); + assert_eq!( + order.u32(bytes[cf..cf + 4].try_into().expect("4")), + store().len() as u32 + ); + assert_eq!( + u64::from(order.u32(bytes[cf + 4..cf + 8].try_into().expect("4"))), + start + ); + + let parsed = read(&bytes).expect("read back"); + assert_eq!( + parsed.ifds[0].get(C2PA_MANIFEST_STORE), + Some(&Value::Undefined(store())) + ); + assert_eq!(locate(&bytes[..]).expect("locate"), Some(excl)); + + let (_, mut report) = read_audited(&bytes).expect("audit"); + report + .classify_padding(&mut (&bytes[..])) + .expect("classify"); + assert!(report.is_fully_classified(), "{order:?}: {report:?}"); + let (_, _, ifd0) = read_header(&bytes).expect("header"); + assert!(report.segments.contains(&Segment { + range: excl.store, + kind: SpanKind::Value { + ifd: ifd0, + tag: C2PA_MANIFEST_STORE + }, + })); + assert!(report.segments.contains(&Segment { + range: Range { + start: before.len() as u64, + len: 1 + }, + kind: SpanKind::Padding, + })); + } + } + + /// An even-length file needs no filler: the store starts exactly where the file ended. + #[test] + fn append_store_adds_no_filler_to_an_even_length_file() { + let mut ifd = Ifd::new(); + ifd.set(256, Value::Short(vec![640])); + reserve_entry(&mut ifd); + let mut bytes = + write(&file(ByteOrder::LittleEndian, Variant::Classic, vec![ifd])).expect("write"); + assert_eq!(bytes.len() % 2, 0); + let len = bytes.len() as u64; + let excl = append_store(&mut bytes, &store()).expect("append"); + assert_eq!(excl.store.start, len); + } + + /// BigTIFF: 8-byte count and offset words are rewritten, and the store reads back. + #[cfg(feature = "bigtiff")] + #[test] + fn append_store_rewrites_bigtiff_words() { + let mut ifd = sample_ifd(); + reserve_entry(&mut ifd); + let mut bytes = write(&file(ByteOrder::BigEndian, Variant::Big, vec![ifd])).expect("write"); + let excl = append_store(&mut bytes, &store()).expect("append"); + assert_eq!(excl.count_field.len, 8); + let cf = excl.count_field.start as usize; + assert_eq!( + ByteOrder::BigEndian.u64(bytes[cf..cf + 8].try_into().expect("8")), + store().len() as u64 + ); + assert_eq!( + ByteOrder::BigEndian.u64(bytes[cf + 8..cf + 16].try_into().expect("8")), + excl.store.start + ); + assert_eq!( + read(&bytes).expect("read").ifds[0].get(C2PA_MANIFEST_STORE), + Some(&Value::Undefined(store())) + ); + assert_eq!(locate(&bytes[..]).expect("locate"), Some(excl)); + } + + /// Each precondition failure is its own typed error: a short store, no reserved entry, a + /// mistyped entry, and an entry already pointing out of line. + #[test] + fn append_store_refuses_unsatisfiable_inputs() { + let le = |ifd| { + write(&file(ByteOrder::LittleEndian, Variant::Classic, vec![ifd])).expect("write") + }; + let mut reserved = sample_ifd(); + reserve_entry(&mut reserved); + + let error = append_store(&mut le(reserved.clone()), &store()[..7]).expect_err("short"); + assert_eq!( + error.static_message(), + Some("TIFF: C2PA manifest store is shorter than a JUMBF box header") + ); + // Exactly the minimum is accepted. + assert!(append_store(&mut le(reserved.clone()), &store()[..8]).is_ok()); + + let error = append_store(&mut le(sample_ifd()), &store()).expect_err("no entry"); + assert_eq!( + error.static_message(), + Some("TIFF: the last IFD carries no reserved C2PA manifest store entry") + ); + + let mut mistyped = sample_ifd(); + mistyped.set(C2PA_MANIFEST_STORE, Value::Byte(vec![0])); + let error = append_store(&mut le(mistyped), &store()).expect_err("mistyped"); + assert_eq!( + error.static_message(), + Some("TIFF: the last IFD carries no reserved C2PA manifest store entry") + ); + + let mut out_of_line = sample_ifd(); + out_of_line.set(C2PA_MANIFEST_STORE, Value::Undefined(store())); + let error = append_store(&mut le(out_of_line), &store()).expect_err("out of line"); + assert_eq!( + error.static_message(), + Some("TIFF: the C2PA manifest store entry already points out of line") + ); + } + + /// The 4 GiB guard's boundary on the pure predicate (a >4 GiB allocation is not testable): + /// the largest classic offset is representable, one past it is not, BigTIFF never overflows. + #[test] + fn offset_width_boundary() { + assert!(!exceeds_offset_width(Variant::Classic, 100)); + assert!(!exceeds_offset_width(Variant::Classic, u64::from(u32::MAX))); + assert!(exceeds_offset_width( + Variant::Classic, + u64::from(u32::MAX) + 1 + )); + #[cfg(feature = "bigtiff")] + assert!(!exceeds_offset_width(Variant::Big, u64::from(u32::MAX) + 1)); + } + + /// `append_store` tells a caller whose directory carries two store entries what is actually + /// wrong, rather than claiming there is none. + #[test] + fn append_store_names_a_duplicated_entry_as_the_problem() { + let le = ByteOrder::LittleEndian; + // Two placeholder entries, built at the byte level since `Ifd::set` de-duplicates. + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"II\x2a\x00"); + bytes.extend_from_slice(&le.pack_u32(8)); + bytes.extend_from_slice(&le.pack_u16(2)); + for _ in 0..2 { + bytes.extend_from_slice(&le.pack_u16(C2PA_MANIFEST_STORE)); + bytes.extend_from_slice(&le.pack_u16(FieldType::Undefined.code())); + bytes.extend_from_slice(&le.pack_u32(1)); + bytes.extend_from_slice(&le.pack_u32(0)); + } + bytes.extend_from_slice(&le.pack_u32(0)); + + let before = bytes.clone(); + let error = append_store(&mut bytes, &store()).expect_err("a duplicated entry"); + assert_eq!( + error.static_message(), + Some("TIFF: the last IFD carries more than one C2PA manifest store entry"), + "the message must name the duplication, not claim the entry is missing" + ); + assert_eq!(bytes, before, "a refused append leaves the file untouched"); + } + + /// The public constructor builds the same value `locate` reports, so a host placing a store + /// by its own means can name its exclusion set despite `#[non_exhaustive]`. + #[test] + fn exclusions_can_be_constructed_publicly() { + let mut ifd = sample_ifd(); + ifd.set(C2PA_MANIFEST_STORE, Value::Undefined(store())); + let bytes = + write(&file(ByteOrder::LittleEndian, Variant::Classic, vec![ifd])).expect("write"); + let found = locate(&bytes[..]).expect("locate").expect("a store"); + assert_eq!( + C2paExclusions::new(found.store, found.count_field).expect("a located set is valid"), + found, + "the constructor's fields land in the documented order" + ); + } + + /// The constructor enforces the invariant the type advertises, at each boundary: an empty + /// range on either side is refused, overlapping ranges are refused, and ranges that merely + /// abut are accepted — touching is not overlapping, and the two orders of abutment are the + /// inputs on which `<` and `<=` disagree. + #[test] + fn exclusions_new_rejects_empty_or_overlapping_ranges() { + let at = |start, len| Range { start, len }; + let empty = "TIFF: a C2PA exclusion range is empty"; + let overlap = "TIFF: the C2PA exclusion ranges overlap"; + + for (store, count_field) in [ + (at(0, 0), at(10, 4)), + (at(10, 4), at(0, 0)), + (at(0, 0), at(0, 0)), + ] { + assert_eq!( + C2paExclusions::new(store, count_field) + .expect_err("an empty range excludes nothing") + .static_message(), + Some(empty) + ); + } + + // Overlap by one byte, each way round, and full containment. + for (store, count_field) in [ + (at(10, 4), at(13, 4)), + (at(13, 4), at(10, 4)), + (at(10, 4), at(11, 1)), + ] { + assert_eq!( + C2paExclusions::new(store, count_field) + .expect_err("overlapping ranges") + .static_message(), + Some(overlap) + ); + } + + // Abutting is legal in both directions... + let abut_after = C2paExclusions::new(at(14, 4), at(10, 4)).expect("count then store"); + assert_eq!(abut_after.store, at(14, 4)); + assert!( + C2paExclusions::new(at(10, 4), at(14, 4)).is_ok(), + "store then count" + ); + // ...and so is the ordinary far-apart case a real file produces. + assert!(C2paExclusions::new(at(900, 40), at(30, 4)).is_ok()); + } + + /// `reserve_entry` places exactly the inline placeholder `append_store` requires — and a + /// file carrying only that placeholder reports **no store**, because one byte cannot be a + /// JUMBF box: the reservation is a slot, not yet a store. + #[test] + fn reserve_entry_places_a_one_byte_inline_placeholder() { + let mut ifd = Ifd::new(); + reserve_entry(&mut ifd); + assert_eq!( + ifd.get(C2PA_MANIFEST_STORE), + Some(&Value::Undefined(vec![0])) + ); + let bytes = + write(&file(ByteOrder::LittleEndian, Variant::Classic, vec![ifd])).expect("write"); + assert_eq!(locate(&bytes[..]).expect("locate"), None); + // And it is the slot `append_store` fills: the same file takes a store. + let mut bytes = bytes; + let excl = append_store(&mut bytes, &store()).expect("append"); + assert_eq!(locate(&bytes[..]).expect("locate"), Some(excl)); + } + + /// A store exactly at the variant's inline threshold, in both variants. Classic TIFF's + /// threshold is 4, so an 8-byte store is out of line and lands at the end of the file; + /// BigTIFF's is 8, so the same store would pack *into the entry* and is refused rather than + /// appended where nothing would reference it. + #[test] + fn append_store_refuses_a_store_that_would_pack_inline() { + let at_threshold = store()[..MIN_STORE_LEN].to_vec(); + + // Classic: 8 > 4, so this is the smallest writable store and it round-trips. + let mut ifd = Ifd::new(); + reserve_entry(&mut ifd); + let mut bytes = + write(&file(ByteOrder::LittleEndian, Variant::Classic, vec![ifd])).expect("write"); + let excl = append_store(&mut bytes, &at_threshold).expect("classic accepts 8 bytes"); + assert_eq!(excl.store.end(), bytes.len() as u64); + assert_eq!( + read(&bytes).expect("read").ifds[0].get(C2PA_MANIFEST_STORE), + Some(&Value::Undefined(at_threshold.clone())), + "the entry must read back as the store, not as an offset" + ); + + #[cfg(feature = "bigtiff")] + { + let mut ifd = Ifd::new(); + reserve_entry(&mut ifd); + let mut bytes = + write(&file(ByteOrder::LittleEndian, Variant::Big, vec![ifd])).expect("write"); + let before = bytes.clone(); + let error = append_store(&mut bytes, &at_threshold) + .expect_err("BigTIFF's inline threshold is 8, so 8 bytes cannot go out of line"); + assert_eq!( + error.static_message(), + Some( + "TIFF: C2PA manifest store packs inline in this variant, so it cannot be placed at the end of the file" + ) + ); + assert_eq!(bytes, before, "a refused append leaves the file untouched"); + + // Nine bytes clear the threshold and round-trip as the store itself. + let mut writable = store()[..MIN_STORE_LEN + 1].to_vec(); + writable[0] ^= 0xFF; // keep it asymmetric + let excl = append_store(&mut bytes, &writable).expect("nine bytes are writable"); + assert_eq!(excl.store.end(), bytes.len() as u64); + assert_eq!( + read(&bytes).expect("read").ifds[0].get(C2PA_MANIFEST_STORE), + Some(&Value::Undefined(writable)) + ); + assert_eq!(locate(&bytes[..]).expect("locate"), Some(excl)); + } + } +} diff --git a/crates/gamut-ifd/src/lib.rs b/crates/gamut-ifd/src/lib.rs index 4a05cfab..54974e29 100644 --- a/crates/gamut-ifd/src/lib.rs +++ b/crates/gamut-ifd/src/lib.rs @@ -14,7 +14,9 @@ //! §2). [`read`] / [`read_header`] parse a stream into a [`TiffFile`]; [`write()`] serialises one //! back, laying out the IFD chain and out-of-line value pool with the two-pass offset machinery. //! [`read_tree`] is `write`'s inverse over sub-IFD trees (given the pointer tags — the -//! well-known structural ones live in [`tags`]). For strict archival decoding, [`audit`] +//! well-known structural ones live in [`tags`]). The one cross-format payload a TIFF-based file +//! carries by tag — the C2PA manifest store — has its placement rule and exclusion ranges stated +//! once, in [`c2pa`], for every TIFF-based codec to share. For strict archival decoding, [`audit`] //! classifies **every byte** of a stream into typed segments ([`SegmentReport`]) under a //! dual-ledger cross-check: a [`Tracked`] source records what the parse physically read, and //! the claims must match — byte accounting as a machine-checked proof, not a promise @@ -71,6 +73,7 @@ mod audit; mod byte_order; +pub mod c2pa; mod entry; // Executable laws over this crate's own types, shared by the property tests and the fuzz tier // (`docs/testing.md`). Always available to the crate's own tests; `test-support` exposes it to an