From 21602edd3c84b2aea5e369e4dbd80b8239f21d62 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 04:17:57 -0400 Subject: [PATCH 01/10] feat(png): carry the C2PA manifest store in the caBX chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read and write the C2PA manifest store (C2PA 2.4 §A.3.2) as one more raw, MetadataBlock-ready ancillary payload, the way eXIf/iCCP/XMP already travel. Decode: `DecodedPng::c2pa` / `PngMetadata::c2pa` carry the first `caBX` verbatim and uncompressed; a later `caBX` is counted in `c2pa_duplicates` (saturating), never concatenated, since PNG has no multi-chunk store. The store is charged to the cumulative `with_max_metadata_bytes` budget like every other attacker-sized payload — skipped past the remainder, not an error. Encode: `with_c2pa(store)` embeds a caller-computed store and `with_c2pa_reserved(len)` writes `len` zero bytes in its place, as the last chunk before the first IDAT (after PLTE/tRNS and every other ancillary chunk) so a reservation is filled by a second equal-length encode that changes no byte outside the chunk. `encode_with_report` and `PngReport::c2pa` name the chunk's whole span — length, type, payload and CRC — as `C2paSpan`, the `c2pa.hash.data` exclusion §18.5.4 asks for. `EncodeImage` is untouched. The chunk type is spelled once, `chunk::CABX`, with its property bits asserted per PNG §5.4 Table 6: ancillary and private set, reserved clear, and unsafe-to-copy *clear* on the fourth byte — the polarity the issue's prose had backwards. That bit is the container's own enforcement of the facade's no-copy-forward law (`C2paPolicy`). libpng carries `caBX` as an unknown chunk, which is the framing proof: for the same payload it frames the same length/type/CRC bytes as gamut, decodes gamut's file pixel-exact with the chunk in place, and gamut reads the store back from libpng's file. The behavioural oracle (c2pa-rs) is #447. Refs #440 --- crates/gamut-png/README.md | 17 +- crates/gamut-png/STATUS.md | 52 ++++ crates/gamut-png/src/ancillary.rs | 53 +++- crates/gamut-png/src/chunk.rs | 119 ++++++++ crates/gamut-png/src/decoded.rs | 116 +++++++- crates/gamut-png/src/decoder.rs | 23 +- crates/gamut-png/src/deconstruct.rs | 32 +- crates/gamut-png/src/encoder.rs | 92 +++++- crates/gamut-png/src/lib.rs | 21 +- crates/gamut-png/tests/c2pa.rs | 445 ++++++++++++++++++++++++++++ crates/gamut-png/tests/metadata.rs | 6 + 11 files changed, 943 insertions(+), 33 deletions(-) create mode 100644 crates/gamut-png/tests/c2pa.rs diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index ab58f048..22c6bb2b 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -14,10 +14,16 @@ Graphics, W3C 3rd edition) images: concern at higher levels. - **Spec-compliant decoding** (issue #249). Every colour type and bit depth, Adam7 interlacing, all five filters, and ancillary metadata surfaced as raw payloads (eXIf, inflated iCCP, XMP, - tEXt/zTXt/iTXt) ready for `gamut_metadata::MetadataBlock`, plus parsed gAMA/cHRM/sRGB/cICP - values. Hostile input is bounded: configurable dimension caps and byte budgets guard every - allocation, and zlib bombs (IDAT or metadata) fail cleanly. Inflation uses `miniz_oxide`, the - workspace's blessed decode-side inflate. + tEXt/zTXt/iTXt, and the C2PA manifest store in `caBX`) ready for + `gamut_metadata::MetadataBlock`, plus parsed gAMA/cHRM/sRGB/cICP values. Hostile input is + bounded: configurable dimension caps and byte budgets guard every allocation, and zlib bombs + (IDAT or metadata) fail cleanly. Inflation uses `miniz_oxide`, the workspace's blessed + decode-side inflate. +- **C2PA carriage** (issue #440). The manifest store is located, bounded, carried and reserved — + never parsed or judged. `with_c2pa` / `with_c2pa_reserved` put it as the last chunk before + `IDAT`, and `encode_with_report` / `PngReport::c2pa` name the chunk's whole span (length, type, + payload, CRC) for the `c2pa.hash.data` exclusion, so a reservation is filled by a second encode + that changes no byte outside it. Validation is `c2pa-rs`'s. - **Memory-safe.** 100% safe Rust (`#![deny(unsafe_code)]`). ## Usage @@ -50,7 +56,8 @@ Built incrementally; each phase is conformance-checked against libpng (see [STAT Encoder scope: all five colour types, bit depths 1/2/4/8/16, palette, the five scanline filters, lossless reductions over every input layout (palette, grey, alpha drop, sub-byte grey packing, 16→8 demotion), the standard colour/text ancillary chunks, and embedded metadata -(eXIf/iCCP/iTXt). Decoder scope: everything above plus Adam7 **decoding** and decode limits. +(eXIf/iCCP/iTXt, and the C2PA manifest store with a reserve-then-fill slot). Decoder scope: +everything above plus Adam7 **decoding** and decode limits. Out of scope: Adam7 *encoding* and animation (APNG decodes as its default image). ## Validation diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 8e72d11e..b3ec162e 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -39,6 +39,7 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | P9 | §4.5 | **Space opt:** lossless palette/gray/alpha-drop reduction (size-estimate chosen) + brute-force filter strategy; extended to grey/grey-alpha/16-bit inputs with lossless 16→8 demotion and sub-byte grey packing (#338) | ✅ done | | P10 | — | CLI `gamut convert → .png`; umbrella `png` feature; final API review | ✅ done | | E1 | #224 | **Efficiency:** `deconstruct` byte accounting; divan size/bpp + per-stage bench; libpng-9 size contract; opt-in transparent cleanup; palette-vs-native race; `crc32fast` and restructured filter kernels (see [Efficiency](#efficiency-issue-224)) | ✅ done | +| C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first wins, duplicates counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | ## Decoder phases (issue #249) @@ -52,6 +53,57 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | D6 | — | libpng differential conformance suite over generated fixtures; malformed-input rejection corpus; mutation-gap closure | ✅ done | | D7 | §5, §11.3 | Pixel-free metadata entry point (issue #379): `metadata()` / `PngDecoder::metadata()` → `PngMetadata`, sharing one chunk-classification predicate with `decode()`; IDAT skipped by length, never read or inflated. Mirrors `gamut_jpeg::metadata` / `gamut_webp::metadata` | ✅ done | +## C2PA manifest store (issue #440) + +Part of epic #239. gamut **locates, bounds, carries and reserves** the C2PA manifest store; it +never parses or judges it. The store is opaque bytes plus byte ranges here, and validation is +`c2pa-rs`'s (`references/c2pa/README.md` draws the boundary). + +**Carriage.** The store is the data of a `caBX` chunk, uncompressed (C2PA 2.4 §A.3.2). The chunk +type is spelled once, in `chunk::CABX`, and its *property bits* are asserted rather than only its +letters: ancillary and private (bit 5 set on bytes 0 and 1), reserved bit clear, and — the point — +**unsafe to copy** (bit 5 *clear* on byte 3; PNG §5.4 Table 6 gives that polarity, and the issue's +prose had it backwards). A PNG editor that rewrites the image must drop an unrecognised +unsafe-to-copy chunk, which is the container enforcing the same no-copy-forward law +`gamut-metadata`'s `C2paPolicy` states for the facade: a store is bound to the bytes it was signed +over, so one copied forward into a rewritten file is invalid by construction. + +**Decode.** `DecodedPng::c2pa` / `PngMetadata::c2pa` carry the first `caBX` verbatim, ready for +`MetadataBlock::C2pa`. Exactly one store per file: a later `caBX` is counted in `c2pa_duplicates` +(saturating at 255), never concatenated — PNG has no multi-chunk store, unlike JPEG's APP11 run. +The store is attacker-sized like every ancillary payload, so its bytes are charged to the one +cumulative `with_max_metadata_bytes` budget; a store past the remainder is skipped, not an error, +and — skipped — is still the file's first store, so a smaller one after it is a duplicate rather +than a substitute. A `caBX` whose CRC does not match is skipped (§13.1) on both the decode and the +byte-accounting side, so the two agree on which chunk is the store. + +**Encode.** `with_c2pa(store)` embeds a store computed for this file; `with_c2pa_reserved(len)` +writes `len` zero bytes in its place. Either is emitted as the **last** chunk before the first +`IDAT` — after `PLTE`/`tRNS` and every other ancillary chunk — so the chunk's offset depends only +on what precedes it and every later byte is `IDAT`/`IEND`. §A.3.2 asks only that it precede +`IDAT`; last-before-`IDAT` is what makes the reserve-then-fill flow a no-move: encode with the +reservation, hash with the chunk's span excluded, then encode again with the finished store of the +same length — the output is byte-reproducible, so the second file differs from the first only in +the payload and the chunk CRC. `tests/c2pa.rs` pins that as an exact-byte diff. + +**Exclusion span.** `encode_with_report` (for the file just written) and `PngReport::c2pa` (for +any file) name the chunk's **whole** span — length, type, payload and CRC — as `C2paSpan`, with +the payload bracketed inside it. §18.5.4 says the length and type go inside the exclusion; the CRC +must too, since it changes with the payload, and a `c2pa.hash.data` computed over any of them +breaks on the store's first write. The span is derived from the same chunk walk the byte +accounting uses, so it is always one of the report's claimed segments. + +**Oracle.** libpng has no C2PA support and carries `caBX` as an unknown chunk — which is exactly +the proof needed for framing: for the same payload it must produce the same length, type and CRC +bytes as gamut, it must decode gamut's file pixel-exact with the chunk in place, and gamut must read +the store from a libpng-written file. The behavioural oracle (`c2pa-rs`, against which a store's +hash assertion can be checked over the excluded span) is issue #447. + +**Not done, by design.** No in-place fill helper: a reservation is filled by a second encode, which +costs a second encode. No JUMBF parsing, not even of the outer box length. `gamut convert` does not +carry a store across a re-encode (that is the facade's `C2paPolicy` law, and the CLI's own path is +#448/#483). + ## Efficiency (issue #224) Correctness was settled long before efficiency was measured. This section is the measured state: diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index ef861412..7c697e8e 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -3,6 +3,12 @@ //! These are optional. The encoder accumulates whatever the caller sets and emits the chunks in the //! order PNG requires (Table 7): colour-space chunks before `PLTE`, the rest before `IDAT`. //! +//! One chunk here is not PNG's own: the C2PA manifest store, `caBX` (C2PA 2.4 §A.3.2). It is +//! emitted **last** of everything before `IDAT`, so that its offset depends only on the chunks +//! that precede it and every byte after it is `IDAT` or `IEND` — which is what lets a reserved +//! store be filled in place by a second encode of equal length without moving a byte outside +//! the chunk. §A.3.2 asks only that it precede `IDAT`. +//! //! Two of them, `bKGD` and `sBIT`, have a payload whose shape is the image's colour type, and the //! encoder does not always write the colour type the caller set them for: auto-reduce may write a //! palette, a greyscale or a colour-keyed truecolour image in place of the input's layout, and the @@ -115,6 +121,9 @@ pub(crate) struct Ancillary { pub iccp: Option<(String, Vec)>, /// eXIf: raw EXIF/TIFF bytes (the chunk payload starts with the TIFF byte-order marker). pub exif: Option>, + /// caBX: the C2PA manifest store, raw and uncompressed (C2PA 2.4 §A.3.2) — or a run of zero + /// bytes reserving its place. Emitted last, immediately before the first `IDAT`. + pub c2pa: Option>, /// tEXt / zTXt / iTXt entries, emitted in insertion order. texts: Vec, } @@ -189,7 +198,8 @@ impl Ancillary { } } - /// Emits the remaining ancillary chunks that precede `IDAT` (after any `PLTE`/`tRNS`). + /// Emits the remaining ancillary chunks that precede `IDAT` (after any `PLTE`/`tRNS`), the + /// C2PA manifest store last of all so that it is the chunk immediately before `IDAT`. /// `effort` is the encoder's [`Level::Best`] budget, applied to compressed `zTXt` payloads; /// `written` is the IHDR (and palette) these chunks sit under, which `bKGD` must agree with. pub(crate) fn write_post_plte( @@ -221,6 +231,11 @@ impl Ancillary { for entry in &self.texts { write_text(out, entry, effort); } + // Last, so nothing whose size could shift the store follows it: a reservation filled by + // a second encode of equal length keeps every offset outside this chunk. + if let Some(store) = &self.c2pa { + chunk::write_chunk(out, chunk::CABX, store); + } } } @@ -517,6 +532,42 @@ mod tests { assert_eq!(find_chunk(&out, b"tEXt").unwrap(), b"Title\0hi".to_vec()); } + /// The manifest store is the last chunk the pre-IDAT pass writes, after every text entry + /// added before or after it was set, and it is written raw: no keyword, no compression byte. + #[test] + fn the_c2pa_store_is_written_raw_and_last_before_idat() { + let mut a = Ancillary::default(); + a.add_text_latin1("Before", "set first"); + a.c2pa = Some(b"\0\0\0\x1fjumb".to_vec()); + a.add_text_compressed("After", "set later"); + a.set_time(2026, 9, 6, 0, 0, 0); + let mut out = vec![0u8; 8]; + a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + + let mut types = Vec::new(); + let mut i = 8; + while i + 12 <= out.len() { + let len = u32::from_be_bytes([out[i], out[i + 1], out[i + 2], out[i + 3]]) as usize; + types.push(out[i + 4..i + 8].to_vec()); + i += 12 + len; + } + assert_eq!(types.last().map(Vec::as_slice), Some(&b"caBX"[..])); + assert_eq!( + types.iter().filter(|t| t.as_slice() == b"caBX").count(), + 1, + "exactly one store" + ); + assert_eq!( + find_chunk(&out, b"caBX"), + Some(b"\0\0\0\x1fjumb".to_vec()), + "the payload is the store verbatim" + ); + // Unset, no chunk at all. + let mut none = vec![0u8; 8]; + Ancillary::default().write_post_plte(&mut none, DeflateEncoder::DEFAULT_EFFORT, RGB8); + assert_eq!(find_chunk(&none, b"caBX"), None); + } + #[test] fn iccp_and_exif_framing() { let a = Ancillary { diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 67e9cbb5..4ef6e715 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -13,6 +13,17 @@ use crate::crc32::Crc32; /// The 8-byte PNG file signature (`\x89PNG\r\n\x1a\n`). pub(crate) const SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; +/// The C2PA manifest-store chunk type (C2PA 2.4 §A.3.2), spelled for its property bits (PNG +/// §5.4, Table 6): `c` ancillary, `a` private, `B` reserved bit clear, `X` **unsafe to copy**. +/// +/// The last bit is the point. A PNG editor that rewrites the image must drop an unrecognised +/// unsafe-to-copy chunk (§14.2), and a C2PA manifest store is bound to the exact bytes it was +/// signed over (§18.5), so a store copied forward into a rewritten file is invalid by +/// construction. That is the same no-copy-forward law `gamut_metadata::C2paPolicy` states for +/// the facade, here enforced by the container's own naming convention — which is why the type is +/// spelled in exactly one place and its *bits* are asserted, not only its letters. +pub(crate) const CABX: [u8; 4] = *b"caBX"; + /// Appends a complete chunk (`length`, `type`, `data`, `CRC`) to `out`. pub(crate) fn write_chunk(out: &mut Vec, chunk_type: [u8; 4], data: &[u8]) { out.extend_from_slice(&(data.len() as u32).to_be_bytes()); @@ -45,6 +56,52 @@ impl RawChunk<'_> { } } +/// Where a C2PA manifest store sits in a PNG: the `caBX` chunk's whole span and, inside it, the +/// store's own bytes. Reported by +/// [`PngEncoder::encode_with_report`](crate::PngEncoder::encode_with_report) for a file just +/// written and by [`PngReport::c2pa`](crate::PngReport::c2pa) for any file. +/// +/// Non-exhaustive: a later revision may name a further range without a breaking change. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct C2paSpan { + /// The whole chunk — length, type, payload **and CRC**, `12 + payload` bytes. The range a + /// `c2pa.hash.data` exclusion must cover (C2PA 2.4 §18.5.4): the store's bytes change when it + /// is written, the length field when it is resized, and the CRC with either, so a hash that + /// keeps any of them breaks on the store's first update. + pub chunk: Range, + /// The store's bytes alone — the chunk's payload, `chunk.start + 8 .. chunk.end - 4`. What a + /// signer overwrites when it fills a reservation. + pub payload: Range, +} + +impl C2paSpan { + /// The span of a `caBX` chunk occupying `chunk` (framing included), single-sourcing the + /// framing arithmetic for both reports. + pub(crate) fn of(chunk: Range) -> Self { + Self { + payload: chunk.start + 8..chunk.end - 4, + chunk, + } + } +} + +/// Locates the manifest store in a PNG: the first CRC-valid `caBX` chunk, or `None`. +/// +/// The first CRC-valid one, because that is the chunk the decoder surfaces as its `c2pa` payload +/// (§13.1 skips a CRC mismatch), so the span a caller excludes from a hash is the store it reads. +/// Stops at end of input or at the first chunk that does not frame; a stream that is not a PNG +/// has no store. +pub(crate) fn find_c2pa(png: &[u8]) -> Option { + let mut reader = ChunkReader::new(png).ok()?; + while let Ok(Some(chunk)) = reader.next_chunk() { + if chunk.chunk_type == CABX && chunk.crc_ok { + return Some(C2paSpan::of(chunk.range)); + } + } + None +} + /// Iterates the chunks of a PNG stream after validating the signature (§5.2). pub(crate) struct ChunkReader<'a> { rest: &'a [u8], @@ -204,6 +261,68 @@ mod tests { assert!(chunk.is_ancillary()); } + /// The property bits of `caBX` (PNG §5.4, Table 6), asserted on the constant rather than on + /// its letters: bit 5 of each byte is the property, and a typo that flips one — `cABX`, a + /// public chunk; `caBx`, one an editor may copy forward — still reads as a plausible name. + /// C2PA §A.3.2 requires ancillary, private and not safe to copy; PNG §5.4 requires the + /// reserved bit clear. Note the polarity of the fourth byte: **clear** (uppercase) is unsafe + /// to copy. + #[test] + fn cabx_property_bits_are_ancillary_private_reserved_clear_and_unsafe_to_copy() { + const PROPERTY: u8 = 0x20; + assert_ne!(CABX[0] & PROPERTY, 0, "byte 0: ancillary (lowercase)"); + assert_ne!(CABX[1] & PROPERTY, 0, "byte 1: private (lowercase)"); + assert_eq!( + CABX[2] & PROPERTY, + 0, + "byte 2: reserved bit clear (uppercase)" + ); + assert_eq!(CABX[3] & PROPERTY, 0, "byte 3: unsafe to copy (uppercase)"); + assert_eq!(CABX, [0x63, 0x61, 0x42, 0x58]); + } + + /// The span arithmetic at known offsets: a chunk at `33..52` (a 7-byte payload after the + /// signature and IHDR) has its payload at `41..48`. Every byte of the framing is accounted + /// — 4 length, 4 type ahead of the payload, 4 CRC behind it. + #[test] + fn a_c2pa_span_names_the_whole_chunk_and_the_payload_inside_it() { + let mut png = SIGNATURE.to_vec(); + write_chunk(&mut png, *b"IHDR", &[0; 13]); + write_chunk(&mut png, CABX, b"jumbf!!"); + write_chunk(&mut png, *b"IEND", &[]); + let span = find_c2pa(&png).expect("a caBX chunk"); + assert_eq!(span.chunk, 33..52); + assert_eq!(span.payload, 41..48); + assert_eq!(&png[span.payload.clone()], b"jumbf!!"); + assert_eq!(&png[span.chunk.start + 4..span.chunk.start + 8], b"caBX"); + // Nothing but the chunk: the span ends exactly where IEND's length field begins. + assert_eq!(&png[span.chunk.end + 4..span.chunk.end + 8], b"IEND"); + } + + /// The store the span names is the one the decoder reads: a `caBX` whose CRC does not match + /// is skipped on decode (§13.1), so it is skipped here too, and the CRC-valid one after it + /// is the store. A stream with no `caBX`, or no signature, has none. + #[test] + fn find_c2pa_skips_a_crc_mismatch_and_names_the_first_valid_store() { + let mut png = SIGNATURE.to_vec(); + write_chunk(&mut png, *b"IHDR", &[0; 13]); + write_chunk(&mut png, CABX, b"corrupt"); + let last = png.len() - 1; + png[last] ^= 0xFF; // the first store's CRC no longer matches + let valid_start = png.len(); + write_chunk(&mut png, CABX, b"valid"); + write_chunk(&mut png, *b"IEND", &[]); + let span = find_c2pa(&png).expect("the CRC-valid caBX"); + assert_eq!(span.chunk, valid_start..valid_start + 12 + 5); + assert_eq!(&png[span.payload], b"valid"); + + let mut none = SIGNATURE.to_vec(); + write_chunk(&mut none, *b"IHDR", &[0; 13]); + write_chunk(&mut none, *b"IEND", &[]); + assert_eq!(find_c2pa(&none), None); + assert_eq!(find_c2pa(b"not a png"), None); + } + #[test] fn reader_rejects_oversized_length() { let mut png = SIGNATURE.to_vec(); diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 903d7c55..32114a7e 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -2,19 +2,22 @@ //! §11.3) — the read-side twin of the `ancillary` writers. //! //! Metadata payloads are surfaced **raw** — `eXIf` bytes, the inflated ICC profile, the XMP -//! packet — precisely so callers can hand them to `gamut_metadata::MetadataBlock` -//! (`Exif`/`Icc`/`Xmp`) without gamut-png depending on the metadata stack. Fixed-layout -//! colour-space chunks (`gAMA`/`cHRM`/`sRGB`/`cICP`) are additionally parsed into values, in the -//! same ×100 000 fixed-point units the encoder accepts. Ancillary payloads are attacker -//! territory: a malformed payload skips that chunk (§13.1) rather than failing the image, and -//! compressed payloads (iCCP/zTXt/iTXt) inflate under one cumulative byte budget so a metadata -//! zlib bomb cannot exhaust memory. +//! packet, the C2PA manifest store — precisely so callers can hand them to +//! `gamut_metadata::MetadataBlock` (`Exif`/`Icc`/`Xmp`/`C2pa`) without gamut-png depending on +//! the metadata stack. Fixed-layout colour-space chunks (`gAMA`/`cHRM`/`sRGB`/`cICP`) are +//! additionally parsed into values, in the same ×100 000 fixed-point units the encoder accepts. +//! Ancillary payloads are attacker territory: a malformed payload skips that chunk (§13.1) +//! rather than failing the image, and compressed payloads (iCCP/zTXt/iTXt) inflate under one +//! cumulative byte budget so a metadata zlib bomb cannot exhaust memory. The manifest store +//! (`caBX`, C2PA 2.4 §A.3.2) is uncompressed but attacker-sized, so its bytes are charged to the +//! same budget rather than copied beside it. use gamut_core::{ Gray8, Gray16, GrayAlpha8, GrayAlpha16, ImageBuf, Indexed8, Rgb8, Rgb16, Rgba8, Rgba16, }; use crate::ancillary::SrgbIntent; +use crate::chunk::CABX; use crate::color::ColorType; use crate::decoder::TransparencyKey; use crate::inflate; @@ -142,6 +145,16 @@ pub struct DecodedPng { /// The XMP packet (the `XML:com.adobe.xmp` iTXt, §11.3.3.2), decompressed if stored /// compressed. Feed as `MetadataBlock::Xmp`. pub xmp: Option>, + /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim: the JUMBF bytes, + /// uncompressed, exactly as the chunk carries them — opaque here, never parsed or judged. + /// Feed as `MetadataBlock::C2pa`. The first `caBX` in the file, and only when it fits the + /// metadata budget; see [`c2pa_duplicates`](Self::c2pa_duplicates). + pub c2pa: Option>, + /// How many further `caBX` chunks followed the first, saturating at 255. A file carries + /// exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's APP11 run — so + /// any value above zero marks a malformed file whose extra stores were ignored rather than + /// concatenated. + pub c2pa_duplicates: u8, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, /// gAMA: image gamma × 100 000 (§11.3.2.2) — the unit the encoder's `with_gamma` writes. @@ -165,7 +178,8 @@ pub struct DecodedPng { /// /// Unlike `gamut_jpeg::JpegMetadata` and `gamut_webp::WebpMetadata`, which carry only /// EXIF/XMP/ICC, this also surfaces PNG's parsed colour chunks — `cICP`, `sRGB`, `gAMA`, `cHRM` — -/// because those are uncompressed and answer "what colour space is this?" on their own. +/// because those are uncompressed and answer "what colour space is this?" on their own, and the +/// C2PA manifest store (`caBX`), raw. /// /// Marked `#[non_exhaustive]` so further ancillary chunks can be added without a breaking change. /// @@ -198,6 +212,16 @@ pub struct PngMetadata { /// The XMP packet (the `XML:com.adobe.xmp` iTXt, §11.3.3.2), decompressed if stored /// compressed. Feed as `MetadataBlock::Xmp`. pub xmp: Option>, + /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim and uncompressed — + /// opaque bytes, never parsed or judged. Feed as `MetadataBlock::C2pa`. The first `caBX` in + /// the file, and only when it fits the metadata budget; see + /// [`c2pa_duplicates`](Self::c2pa_duplicates). + pub c2pa: Option>, + /// How many further `caBX` chunks followed the first, saturating at 255. A file carries + /// exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's APP11 run — so + /// any value above zero marks a malformed file whose extra stores were ignored rather than + /// concatenated. + pub c2pa_duplicates: u8, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, /// gAMA: image gamma × 100 000 (§11.3.2.2). @@ -211,15 +235,30 @@ pub struct PngMetadata { } /// Parses the metadata-bearing ancillary chunks collected from the stream (in file order). -/// Malformed payloads skip their chunk (§13.1); compressed payloads share `budget` bytes of -/// inflated output, and a payload that would bust the remainder is skipped, not an error. -/// Once-only chunks keep their first occurrence. +/// Malformed payloads skip their chunk (§13.1); compressed payloads — and the uncompressed but +/// attacker-sized `caBX` store — share `budget` bytes of output, and a payload that would bust +/// the remainder is skipped, not an error. Once-only chunks keep their first occurrence; a +/// second `caBX` is additionally counted, since exactly one store is the rule (C2PA §A.3.2). pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata { let mut meta = PngMetadata::default(); let mut budget = budget; + // Whether a `caBX` has been seen at all, admitted or not: the first is the store (or is + // skipped for its size), every later one is a duplicate — never promoted into its place. + let mut seen_c2pa = false; for &(chunk_type, data) in chunks { match &chunk_type { b"eXIf" if meta.exif.is_none() => meta.exif = Some(data.to_vec()), + _ if chunk_type == CABX => { + if seen_c2pa { + meta.c2pa_duplicates = meta.c2pa_duplicates.saturating_add(1); + } else { + seen_c2pa = true; + if data.len() <= budget { + budget -= data.len(); + meta.c2pa = Some(data.to_vec()); + } + } + } b"iCCP" if meta.icc_profile.is_none() => { meta.icc_profile = parse_iccp(data, &mut budget); } @@ -520,6 +559,61 @@ mod tests { assert_eq!(meta.cicp.unwrap().color_primaries, 9); } + /// Exactly one store per file: the first `caBX` is the store and every later one is counted, + /// never concatenated onto it and never promoted into its place — PNG has no multi-chunk + /// store, unlike JPEG's APP11 run (C2PA §A.3.2). + #[test] + fn the_first_cabx_is_the_store_and_later_ones_are_counted_not_concatenated() { + let meta = collect( + &[(CABX, b"first store"), (CABX, b"second"), (CABX, b"third")], + 1024, + ); + assert_eq!(meta.c2pa.as_deref(), Some(&b"first store"[..])); + assert_eq!(meta.c2pa_duplicates, 2); + + let single = collect(&[(CABX, b"only")], 1024); + assert_eq!(single.c2pa.as_deref(), Some(&b"only"[..])); + assert_eq!(single.c2pa_duplicates, 0); + assert_eq!(collect(&[], 1024).c2pa, None); + } + + /// The duplicate count saturates rather than wrapping: 256 further stores read as 255, not + /// as none at all. + #[test] + fn the_cabx_duplicate_count_saturates_at_255() { + let chunks: Vec<([u8; 4], &[u8])> = (0..257).map(|_| (CABX, &b"s"[..])).collect(); + let meta = collect(&chunks, 1024); + assert_eq!(meta.c2pa_duplicates, 255); + } + + /// `caBX` is attacker-sized like every other ancillary payload, so it is charged to the one + /// cumulative budget rather than copied beside it: a store exactly the size of the remaining + /// budget is admitted and leaves nothing for a compressed payload after it; one byte larger + /// is skipped — and, skipped, it is still the file's one store, so a smaller `caBX` after it + /// is a duplicate rather than a substitute. + #[test] + fn the_cabx_store_is_charged_to_the_metadata_budget() { + let ztxt: Vec = [b"kw\0\0".to_vec(), deflated(b"x")].concat(); + let store = [7u8; 10]; + let fits = collect(&[(CABX, &store), (*b"zTXt", &ztxt)], 10); + assert_eq!( + fits.c2pa.as_deref(), + Some(&store[..]), + "exactly the budget fits" + ); + assert!( + fits.texts.is_empty(), + "the store consumed the budget, so the zTXt after it is skipped" + ); + + let busts = collect(&[(CABX, &store), (CABX, b"tiny")], 9); + assert_eq!(busts.c2pa, None, "one byte over the budget is skipped"); + assert_eq!( + busts.c2pa_duplicates, 1, + "the skipped store is still the first; the next is a duplicate, not a substitute" + ); + } + #[test] fn chrm_coordinates_map_position_for_position() { // Every byte distinct, so any index-arithmetic slip changes some coordinate. diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index a1a42942..0f4c7222 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -39,7 +39,8 @@ use crate::{adam7, inflate, pack}; /// `pub(crate)` because [`crate::deconstruct`] reports against the same budget: a file this /// decoder decodes is one the report walk will inflate to count filters. pub(crate) const DEFAULT_MAX_IMAGE_BYTES: usize = 64 << 20; -/// Default cumulative cap on inflated metadata (iCCP/zTXt/iTXt) payloads: 16 MiB. +/// Default cumulative cap on metadata payloads — inflated iCCP/zTXt/iTXt plus the raw `caBX` +/// manifest store: 16 MiB. const DEFAULT_MAX_METADATA_BYTES: usize = 16 << 20; /// The spec's own dimension bound (§11.2.1): width and height are 1 ..= 2³¹ − 1. const SPEC_MAX_DIMENSION: u32 = i32::MAX as u32; @@ -138,9 +139,10 @@ impl PngDecoder { self } - /// Caps the *cumulative* inflated size of compressed metadata payloads — iCCP, zTXt, and - /// compressed iTXt together (default 16 MiB). Payloads past the budget are skipped, not - /// errors; the typed [`DecodeImage`] path never inflates metadata at all. + /// Caps the *cumulative* size of metadata payloads — the inflated iCCP, zTXt and compressed + /// iTXt, plus the raw C2PA manifest store (`caBX`), which is uncompressed but sized by the + /// file, together (default 16 MiB). Payloads past the budget are skipped, not errors; the + /// typed [`DecodeImage`] path never inflates or copies metadata at all. #[must_use] pub fn with_max_metadata_bytes(mut self, bytes: usize) -> Self { self.max_metadata_bytes = bytes; @@ -388,7 +390,7 @@ impl PngDecoder { /// Decodes a PNG into its native layout together with the ancillary metadata — the rich /// counterpart of the typed [`DecodeImage`] implementations, and the only way to reach the /// palette of an indexed image, the tRNS colour key, and the raw metadata payloads - /// (eXIf/ICC/XMP/text, plus parsed gAMA/cHRM/sRGB/cICP values). + /// (eXIf/ICC/XMP/text and the C2PA manifest store, plus parsed gAMA/cHRM/sRGB/cICP values). /// /// # Errors /// @@ -415,6 +417,8 @@ impl PngDecoder { exif: meta.exif, icc_profile: meta.icc_profile, xmp: meta.xmp, + c2pa: meta.c2pa, + c2pa_duplicates: meta.c2pa_duplicates, texts: meta.texts, gamma: meta.gamma, chromaticities: meta.chromaticities, @@ -610,10 +614,11 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { /// Reads a PNG's ancillary metadata without decoding any pixels. /// /// Walks the chunk stream, collecting the metadata-bearing ancillary chunks and inflating only -/// the compressed ones (iCCP, zTXt, and compressed iTXt) under one cumulative 16 MiB budget. -/// IDAT is skipped by length, so no pixel data is read, copied, or inflated — which makes this -/// cheap enough for a probe on a large file. `cICP`, `sRGB`, `gAMA` and `cHRM` are uncompressed -/// and cost nothing beyond the walk. +/// the compressed ones (iCCP, zTXt, and compressed iTXt) under one cumulative 16 MiB budget, +/// which the raw C2PA manifest store (`caBX`) is charged to as well. IDAT is skipped by length, +/// so no pixel data is read, copied, or inflated — which makes this cheap enough for a probe on +/// a large file. `cICP`, `sRGB`, `gAMA` and `cHRM` are uncompressed and cost nothing beyond the +/// walk. /// /// The result matches [`PngDecoder::decode`] field for field on the same file. Use /// [`PngDecoder::metadata`] instead if you need a metadata budget other than the default. diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 3ad0bb7e..b6b4fcd5 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -29,7 +29,7 @@ use std::collections::HashMap; use gamut_core::{Error, Result}; -use crate::chunk::{ChunkReader, RawChunk, SIGNATURE}; +use crate::chunk::{C2paSpan, CABX, ChunkReader, RawChunk, SIGNATURE}; use crate::decoded::PngHeader; use crate::decoder::DEFAULT_MAX_IMAGE_BYTES; use crate::filter::FilterType; @@ -391,6 +391,36 @@ impl PngReport { ) } + /// The C2PA manifest store's carriage: the whole span of the first CRC-valid `caBX` chunk — + /// length, type, payload **and CRC** — which is what a `c2pa.hash.data` assertion must exclude + /// (C2PA 2.4 §18.5.4): the store's bytes change when it is written, the length field when it + /// is resized, and the CRC with either, so a hash that keeps any of them breaks on the store's + /// first update. The store's own bytes are the span's `payload`. `None` when the file carries + /// no such chunk. + /// + /// The first CRC-valid one, so this names the chunk [`PngDecoder::decode`] surfaces as its + /// `c2pa` payload (§13.1 skips a CRC mismatch on both sides). A further `caBX` is a malformed + /// file's duplicate — counted by [`chunk`](Self::chunk)`(b"caBX")` and by the decoder's + /// `c2pa_duplicates`, never merged into the span. + /// + /// [`PngDecoder::decode`]: crate::PngDecoder::decode + #[must_use] + pub fn c2pa(&self) -> Option { + self.segments + .iter() + .find(|segment| { + matches!( + segment.kind, + SegmentKind::Chunk { + chunk_type: CABX, + crc_ok: true, + .. + } + ) + }) + .map(|segment| C2paSpan::of(segment.range.clone())) + } + /// The stats for one chunk type, if the file carries it. /// /// A linear scan of [`chunks`](Self::chunks), so it costs O(distinct chunk types) per call — diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 499bf061..244dde38 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -12,7 +12,7 @@ use crate::ancillary::{ Ancillary, PaletteOrigin, PhysicalUnit, SrgbIntent, WrittenHeader, WrittenPalette, }; use crate::backend::{IdatDeflater, IdatInfo, Registry, run_deflaters}; -use crate::chunk::{self, SIGNATURE}; +use crate::chunk::{self, C2paSpan, SIGNATURE}; use crate::color::ColorType; use crate::filter::{self, FilterStrategy, FilterType}; use crate::palette::PngPalette; @@ -42,6 +42,21 @@ const BRUTE_FORCE_STRATEGIES: [FilterStrategy; 7] = [ FilterStrategy::MinBigrams, ]; +/// What [`PngEncoder::encode_with_report`] found in the bytes it wrote. +/// +/// Non-exhaustive: a later revision may report a further region without a breaking change. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PngEncodeReport { + /// Where the C2PA manifest-store chunk landed — the whole `caBX` span a `c2pa.hash.data` + /// exclusion must cover, and the payload a signer fills — when [`with_c2pa`] or + /// [`with_c2pa_reserved`] was set; `None` when neither was. + /// + /// [`with_c2pa`]: PngEncoder::with_c2pa + /// [`with_c2pa_reserved`]: PngEncoder::with_c2pa_reserved + pub c2pa: Option, +} + /// A reusable PNG encoder. #[derive(Debug, Clone)] pub struct PngEncoder { @@ -336,6 +351,76 @@ impl PngEncoder { self } + /// Embeds a C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2), verbatim and + /// uncompressed, as the last chunk before `IDAT`. + /// + /// `store` is the JUMBF manifest store computed **for this file** by an external signer such + /// as `c2pa-rs`. It is written exactly where [`with_c2pa_reserved`](Self::with_c2pa_reserved) + /// puts a placeholder of the same length, so a store built against a reserved encode drops + /// into the same bytes — see there for the reserve-then-fill flow. The bytes are not parsed + /// or validated: gamut carries the store, `c2pa-rs` judges it. + /// + /// A store is bound to the bytes it was signed over, which is why no gamut re-encode helper — + /// and never the `gamut-metadata` facade — hands one to this setter: a store copied forward + /// into a rewritten file is invalid by construction, and `caBX` is *unsafe to copy* for the + /// same reason. Set only a store computed for the output this encoder is about to write. + /// + /// The last of `with_c2pa` / `with_c2pa_reserved` wins; a file carries exactly one store. + #[must_use] + pub fn with_c2pa(mut self, store: &[u8]) -> Self { + self.ancillary.c2pa = Some(store.to_vec()); + self + } + + /// Reserves `len` bytes for a C2PA manifest store: a `caBX` chunk whose payload is `len` zero + /// bytes, as the last chunk before `IDAT`. + /// + /// The reserve-then-fill flow an external signer needs (C2PA 2.4 §18.5): + /// + /// 1. encode with the reservation, via [`encode_with_report`](Self::encode_with_report), which + /// names the chunk's span; + /// 2. hash the output with that **whole** span excluded — length, type, payload and CRC + /// (§18.5.4) — and have the signer build the store against it; + /// 3. encode again with [`with_c2pa`](Self::with_c2pa) and the finished store of the **same + /// length**. The encoder's output is byte-reproducible and the store is the last chunk + /// before `IDAT`, so the second file differs from the first only inside that span: the + /// payload and the chunk CRC. Every other byte, and every offset, is unchanged. + /// + /// The reservation is `len` bytes exactly — no slack is added — so ask for what the signer + /// says it needs (`c2pa-rs` reports a `reserve_size`). + /// + /// The last of `with_c2pa` / `with_c2pa_reserved` wins; a file carries exactly one store. + #[must_use] + pub fn with_c2pa_reserved(mut self, len: usize) -> Self { + self.ancillary.c2pa = Some(vec![0; len]); + self + } + + /// Encodes `image` as [`EncodeImage::encode_to_vec`] does and reports where the C2PA + /// manifest-store chunk landed, for the reserve-then-fill flow described at + /// [`with_c2pa_reserved`](Self::with_c2pa_reserved). + /// + /// The report is read back from the bytes written — the same walk + /// [`PngReport::c2pa`](crate::PngReport::c2pa) performs — so it cannot disagree with what a + /// later [`deconstruct`](crate::deconstruct) of the same bytes reports, and an indexed image + /// encoded through [`encode_indexed8`](Self::encode_indexed8) gets the same answer from + /// `deconstruct(&png)?.c2pa()`. + /// + /// # Errors + /// + /// As [`EncodeImage::encode_image`]. + pub fn encode_with_report( + &self, + image: ImageRef<'_, P>, + ) -> Result<(Vec, PngEncodeReport)> + where + Self: EncodeImage

, + { + let png = self.encode_to_vec(image)?; + let c2pa = chunk::find_c2pa(&png); + Ok((png, PngEncodeReport { c2pa })) + } + /// Encodes an 8-bit indexed (palette) image. Indexed colour does not fit the single-buffer /// [`EncodeImage`] shape because it needs a separate palette, so it is an inherent method. /// @@ -548,7 +633,8 @@ impl PngEncoder { ) } - /// Shared back end: signature → IHDR → `pre_idat` chunks (e.g. PLTE/tRNS) → filtered + + /// Shared back end: signature → IHDR → `pre_idat` chunks (e.g. PLTE/tRNS) → the remaining + /// ancillary chunks, the C2PA store last → filtered + /// DEFLATE-compressed scanlines as IDAT(s) → IEND. `sample_bytes` is the image in PNG storage /// order; the stride is derived from `written`'s colour type and bit depth. `written` also /// carries the palette `pre_idat` writes for an indexed image, which `bKGD` is resolved @@ -574,7 +660,7 @@ impl PngEncoder { // Colour-space chunks precede PLTE. self.ancillary.write_pre_plte(out, self.effort, written); pre_idat(out); // PLTE + tRNS (indexed only) - // Background / physical / timing / text. + // Background / physical / timing / text, and the C2PA store last: immediately before IDAT. self.ancillary.write_post_plte(out, self.effort, written); let idat = self.compress_scanlines( diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index b2419d53..74def69f 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -6,8 +6,9 @@ //! for output sizes on par with the best PNG encoders, trading encode time for size at higher //! levels. The decoder ([`PngDecoder`], issue #249) covers the full still-image spec — every //! colour type and bit depth, Adam7 interlacing, all filters — behind hostile-input limits, and -//! surfaces ancillary metadata (EXIF/ICC/XMP/text) as raw payloads. Animation (APNG) is out of -//! scope. Correctness in both directions is proven differentially against a vendored libpng. +//! surfaces ancillary metadata (EXIF/ICC/XMP/text, and the C2PA manifest store) as raw payloads. +//! Animation (APNG) is out of scope. Correctness in both directions is proven differentially +//! against a vendored libpng. //! //! # Reading metadata without the pixels //! @@ -17,6 +18,19 @@ //! and `gamut_webp::metadata`, and what a colour-space probe should call. //! [`PngDecoder::metadata`] is the same walk with a configurable inflation budget. //! +//! # C2PA manifest store +//! +//! A C2PA manifest store travels in the `caBX` chunk (C2PA 2.4 §A.3.2: ancillary, private, +//! **unsafe to copy**), raw and uncompressed. gamut locates, bounds, carries and reserves it and +//! never judges it: the store is opaque bytes here, and validation is `c2pa-rs`'s. On read it is +//! [`DecodedPng::c2pa`] / [`PngMetadata::c2pa`], the first `caBX` in the file, under the same +//! metadata budget as every other ancillary payload. On write, [`PngEncoder::with_c2pa`] embeds a +//! store computed for this file and [`PngEncoder::with_c2pa_reserved`] reserves its place, as the +//! last chunk before `IDAT`; [`PngEncoder::encode_with_report`] and [`PngReport::c2pa`] name the +//! chunk's **whole** span — length, type, payload and CRC — which is what a `c2pa.hash.data` +//! assertion excludes (§18.5.4), and a reservation is filled by a second encode of equal length +//! that changes no byte outside it. +//! //! # Pluggable IDAT backends //! //! The PNG codestream is the concatenated-IDAT **zlib stream**, and it is where PNG spends its @@ -72,6 +86,7 @@ pub mod stages; pub use abi::{AbiDeflater, AbiInflater, CODEC_ID_ZLIB, PIXEL_FORMAT_FILTERED_BYTES}; pub use ancillary::{PhysicalUnit, SrgbIntent}; pub use backend::{IdatDeflater, IdatInflater, IdatInfo}; +pub use chunk::C2paSpan; pub use color::ColorType; pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, @@ -81,7 +96,7 @@ pub use deconstruct::{ ChunkStats, DEFAULT_MAX_CHUNKS, DeconstructLimits, FilterHistogram, FilterScan, PassStats, PngReport, Segment, SegmentKind, SkippedFilterScan, deconstruct, deconstruct_with_limits, }; -pub use encoder::PngEncoder; +pub use encoder::{PngEncodeReport, PngEncoder}; pub use filter::{FilterStrategy, FilterType}; /// The DEFLATE compression level, accepted by [`PngEncoder::with_compression`]. pub use gamut_deflate::Level; diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs new file mode 100644 index 00000000..f655018a --- /dev/null +++ b/crates/gamut-png/tests/c2pa.rs @@ -0,0 +1,445 @@ +//! The C2PA manifest store's carriage in the `caBX` chunk (issue #440; C2PA 2.4 §A.3.2, +//! §18.5.4). Exact-byte: where the encoder puts the chunk, that a reservation is filled without +//! moving a byte outside it, and that both reports name the same whole-chunk span at known +//! offsets. Differential: libpng frames the same payload into the same bytes, decodes gamut's +//! file pixel-exact with the chunk in place, and gamut reads the store back from a libpng-written +//! file. The store is opaque bytes throughout — its behavioural oracle, `c2pa-rs`, is issue +//! #447's. + +mod common; + +use common::{ + chunk, ihdr_payload, libpng_with_extra_chunks, png_from_chunks, sample_bytes, tiny_exif, + tiny_icc_profile, zlib, +}; +use gamut_core::{DecodeImage, Dimensions, EncodeImage, ImageBuf, ImageRef, Indexed8, Rgb8, Rgba8}; +use gamut_png::{ + PhysicalUnit, PngDecoder, PngEncoder, PngPalette, SegmentKind, SrgbIntent, deconstruct, +}; + +/// A stand-in manifest store of `len` bytes: not all zero, no two runs alike, so a fill is +/// visible byte for byte. Opaque here, as every store is. +fn store(len: usize) -> Vec { + (0..len) + .map(|i| (i.wrapping_mul(37) ^ 0x5A) as u8) + .collect() +} + +/// Chunk types in file order (after the signature). +fn chunk_types(png: &[u8]) -> Vec<[u8; 4]> { + let mut types = Vec::new(); + let mut i = 8; + while i + 12 <= png.len() { + let len = u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]) as usize; + types.push([png[i + 4], png[i + 5], png[i + 6], png[i + 7]]); + i += 12 + len; + } + types +} + +/// The first chunk of type `ty`, framing included: length, type, payload, CRC. +fn framed_chunk(png: &[u8], ty: &[u8; 4]) -> Option> { + let mut i = 8; + while i + 12 <= png.len() { + let len = u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]) as usize; + if &png[i + 4..i + 8] == ty { + return Some(png[i..i + 12 + len].to_vec()); + } + i += 12 + len; + } + None +} + +/// A 12×9 RGB8 source with enough structure to filter and compress. +fn rgb_source() -> (Vec, Dimensions) { + ( + sample_bytes(12, 9, libpng_oracle::COLOR_RGB, 8, 11), + Dimensions::new(12, 9).expect("valid"), + ) +} + +/// The encoder configured with every other ancillary chunk this crate writes, so the store's +/// placement is asserted against all of them at once. +fn everything_else() -> PngEncoder { + PngEncoder::new() + .with_gamma(1.0 / 2.2) + .with_srgb(SrgbIntent::Perceptual) + .with_chromaticities((0.3127, 0.3290), (0.64, 0.33), (0.30, 0.60), (0.15, 0.06)) + .with_icc_profile("Tiny", &tiny_icc_profile()) + .with_significant_bits(&[8, 8, 8, 8]) + .with_background_rgb(0, 0, 0) + .with_physical_dimensions(2835, 2835, PhysicalUnit::Meter) + .with_time(2026, 9, 6, 1, 2, 3) + .with_text("Title", "placement") + .with_compressed_text("Comment", "zlib body") + .with_international_text("Note", "utf-8") + .with_exif(&tiny_exif()) + .with_xmp("") +} + +/// §A.3.2 asks that `caBX` precede `IDAT`; the encoder puts it *immediately* before the first +/// `IDAT`, after every other ancillary chunk — colour, metadata, text, `PLTE` and `tRNS` — so +/// that nothing whose size could shift the store follows it. Exactly one store, whatever else +/// is set and whichever candidate the auto-reduce race keeps. +#[test] +fn cabx_is_the_chunk_immediately_before_idat_after_every_other_chunk() { + // Few colours with transparency: the palette candidate is in play. + let rgba: Vec = (0..64u8) + .flat_map(|i| { + [ + i % 4 * 60, + 200, + i % 3 * 90, + if i % 5 == 0 { 0 } else { 255 }, + ] + }) + .collect(); + let image = + ImageRef::::new(&rgba, Dimensions::new(8, 8).expect("valid")).expect("image"); + let png = everything_else() + .with_auto_reduce(true) + .with_c2pa(&store(48)) + .encode_to_vec(image) + .expect("encode"); + let types = chunk_types(&png); + let idat = types.iter().position(|t| t == b"IDAT").expect("IDAT"); + assert_eq!(types[idat - 1], *b"caBX", "{types:?}"); + assert_eq!(types.iter().filter(|t| *t == b"caBX").count(), 1); + assert_eq!(types.first(), Some(b"IHDR")); + assert_eq!(types.last(), Some(b"IEND")); + + // The indexed path, whose PLTE and tRNS the caller supplies, orders the same way. + let palette = PngPalette::with_transparency(&[[1, 2, 3], [4, 5, 6]], &[9]).expect("palette"); + let indices = [0u8, 1, 1, 0, 1, 0]; + let image = + ImageRef::::new(&indices, Dimensions::new(3, 2).expect("valid")).expect("image"); + let mut png = Vec::new(); + everything_else() + .with_c2pa(&store(16)) + .encode_indexed8(image, &palette, &mut png) + .expect("encode"); + let types = chunk_types(&png); + let idat = types.iter().position(|t| t == b"IDAT").expect("IDAT"); + assert_eq!(types[idat - 1], *b"caBX", "{types:?}"); + let plte = types.iter().position(|t| t == b"PLTE").expect("PLTE"); + let trns = types.iter().position(|t| t == b"tRNS").expect("tRNS"); + assert!(plte < trns && trns < idat - 1, "{types:?}"); + // No `encode_with_report` on this path: the deconstruct report names the same chunk. + let span = deconstruct(&png) + .expect("deconstruct") + .c2pa() + .expect("span"); + assert_eq!(&png[span.payload], &store(16)[..]); +} + +/// A reservation is a `caBX` whose payload is exactly `len` zero bytes — no slack — and it is +/// byte-identical to embedding `len` explicit zeros. Zero is a length too. +#[test] +fn a_reservation_is_a_zero_payload_of_exactly_the_requested_length() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let (png, report) = PngEncoder::new() + .with_c2pa_reserved(40) + .encode_with_report(image) + .expect("encode"); + let span = report.c2pa.expect("a reservation is reported"); + assert_eq!(span.payload.len(), 40); + assert_eq!(span.chunk.len(), 40 + 12); + assert!(png[span.payload.clone()].iter().all(|&b| b == 0)); + assert_eq!( + &png[span.chunk.start..span.chunk.start + 4], + &40u32.to_be_bytes() + ); + assert_eq!(&png[span.chunk.start + 4..span.chunk.start + 8], b"caBX"); + + let explicit = PngEncoder::new() + .with_c2pa(&[0; 40]) + .encode_to_vec(image) + .expect("encode"); + assert_eq!(png, explicit, "a reservation is an explicit all-zero store"); + + let (empty, report) = PngEncoder::new() + .with_c2pa_reserved(0) + .encode_with_report(image) + .expect("encode"); + let span = report.c2pa.expect("an empty reservation is still a chunk"); + assert_eq!(span.payload.len(), 0); + assert_eq!(&empty[span.chunk.clone()][..8], b"\0\0\0\0caBX"); +} + +/// The reserve-then-fill contract: encoding again with a store of the reserved length changes +/// **only** bytes inside the chunk's span — the payload and its CRC — and not the length, the +/// type, or any byte before or after the chunk. Two different equal-length stores likewise +/// differ only there. This is what makes a hash computed over the reserved file, with the span +/// excluded (§18.5.4), still hold over the filled one. +#[test] +fn filling_a_reservation_changes_only_the_chunk_span() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let (reserved, report) = everything_else() + .with_c2pa_reserved(64) + .encode_with_report(image) + .expect("encode"); + let span = report.c2pa.expect("span"); + let first = store(64); + let second: Vec = first.iter().map(|b| !b).collect(); + let filled = everything_else() + .with_c2pa(&first) + .encode_to_vec(image) + .expect("encode"); + let refilled = everything_else() + .with_c2pa(&second) + .encode_to_vec(image) + .expect("encode"); + + for (label, a, b) in [ + ("reserved vs filled", &reserved, &filled), + ("filled vs refilled", &filled, &refilled), + ] { + assert_eq!(a.len(), b.len(), "{label}: equal lengths"); + let differing: Vec = (0..a.len()).filter(|&i| a[i] != b[i]).collect(); + assert!(!differing.is_empty(), "{label}: the stores differ"); + assert!( + differing.iter().all(|i| span.chunk.contains(i)), + "{label}: bytes outside the caBX span changed at {:?}", + differing + .iter() + .filter(|i| !span.chunk.contains(i)) + .collect::>() + ); + assert_eq!( + a[span.chunk.start..span.chunk.start + 8], + b[span.chunk.start..span.chunk.start + 8], + "{label}: length and type are unchanged" + ); + assert_ne!( + a[span.chunk.end - 4..span.chunk.end], + b[span.chunk.end - 4..span.chunk.end], + "{label}: the CRC follows the payload" + ); + } + assert_eq!(&filled[span.payload.clone()], &first[..]); + assert_eq!(&refilled[span.payload.clone()], &second[..]); + // The filled file's own report names the very same span. + assert_eq!( + deconstruct(&filled).expect("deconstruct").c2pa(), + Some(span) + ); +} + +/// The exclusion span is the **whole** chunk — length, type, payload and CRC — at offsets a +/// reader can compute by hand: after the signature (8) and the framed IHDR (25), a 7-byte store +/// occupies `33..52` with its payload at `41..48`. The span is one of the report's claimed +/// segments, and the payload it brackets is what the decoder surfaces. +#[test] +fn the_exclusion_span_is_the_whole_chunk_at_known_offsets() { + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"caBX", b"jumbf!!"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let report = deconstruct(&png).expect("deconstruct"); + let span = report.c2pa().expect("caBX"); + assert_eq!(span.chunk, 33..52); + assert_eq!(span.payload, 41..48); + assert_eq!( + &png[span.chunk.start..span.chunk.start + 4], + &7u32.to_be_bytes() + ); + assert_eq!(&png[span.chunk.start + 4..span.chunk.start + 8], b"caBX"); + assert_eq!(&png[span.payload.clone()], b"jumbf!!"); + assert!( + report.segments.iter().any(|s| s.range == span.chunk + && matches!( + s.kind, + SegmentKind::Chunk { + chunk_type: [b'c', b'a', b'B', b'X'], + payload_len: 7, + crc_ok: true, + } + )), + "the span is a claimed segment: {:?}", + report.segments + ); + assert_eq!( + gamut_png::metadata(&png).expect("metadata").c2pa.as_deref(), + Some(&b"jumbf!!"[..]) + ); +} + +/// Neither setter set: no chunk, no span from either report, nothing surfaced on decode. +#[test] +fn without_a_store_there_is_no_chunk_and_no_span() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let (png, report) = everything_else().encode_with_report(image).expect("encode"); + assert_eq!(report.c2pa, None); + assert!(!chunk_types(&png).contains(b"caBX")); + assert_eq!(deconstruct(&png).expect("deconstruct").c2pa(), None); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa, None); + assert_eq!(decoded.c2pa_duplicates, 0); +} + +/// Both read entry points surface the store byte for byte, and the last of the two setters +/// wins — a file carries exactly one store. +#[test] +fn decode_and_metadata_surface_the_store_verbatim_and_the_last_setter_wins() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let store = store(300); + let png = PngEncoder::new() + .with_c2pa(&store) + .encode_to_vec(image) + .expect("encode"); + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&store[..])); + assert_eq!(meta.c2pa_duplicates, 0); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa, meta.c2pa); + assert_eq!(decoded.c2pa_duplicates, 0); + + let reserved_last = PngEncoder::new() + .with_c2pa(&store) + .with_c2pa_reserved(5) + .encode_to_vec(image) + .expect("encode"); + assert_eq!( + gamut_png::metadata(&reserved_last).expect("metadata").c2pa, + Some(vec![0; 5]) + ); + let store_last = PngEncoder::new() + .with_c2pa_reserved(5) + .with_c2pa(&store) + .encode_to_vec(image) + .expect("encode"); + assert_eq!( + gamut_png::metadata(&store_last).expect("metadata").c2pa, + Some(store) + ); +} + +/// Exactly one store per file: the first `caBX` is the store, a second is counted, never +/// concatenated onto the first and never merged into its span. +#[test] +fn the_first_store_wins_and_a_second_is_counted_not_merged() { + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"caBX", b"first"), + chunk(b"caBX", b"second"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&b"first"[..])); + assert_eq!(meta.c2pa_duplicates, 1); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa.as_deref(), Some(&b"first"[..])); + assert_eq!(decoded.c2pa_duplicates, 1); + + let report = deconstruct(&png).expect("deconstruct"); + let span = report.c2pa().expect("span"); + assert_eq!(span.chunk, 33..50, "the first chunk, 5 + 12 bytes"); + assert_eq!(&png[span.payload], b"first"); + assert_eq!(report.chunk(b"caBX").expect("stats").count, 2); +} + +/// A `caBX` whose CRC does not match is skipped on decode (§13.1) — it is not the store and +/// it is not a duplicate either, since it never reaches the metadata pass — and the exclusion +/// span names the CRC-valid store the decoder actually surfaces, not the damaged bytes before +/// it. The damage is still visible: the report accounts both chunks and is not intact. +#[test] +fn a_cabx_with_a_bad_crc_is_neither_the_store_nor_the_exclusion_span() { + let mut damaged = chunk(b"caBX", b"corrupt"); + let last = damaged.len() - 1; + damaged[last] ^= 0xFF; + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + damaged, + chunk(b"caBX", b"valid"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&b"valid"[..])); + assert_eq!(meta.c2pa_duplicates, 0); + + let report = deconstruct(&png).expect("deconstruct"); + let span = report.c2pa().expect("the valid store"); + assert_eq!(&png[span.payload], b"valid"); + assert_eq!(span.chunk.start, 33 + 12 + 7, "after the damaged chunk"); + assert_eq!(report.chunk(b"caBX").expect("stats").count, 2); + assert!(!report.is_intact()); +} + +/// The store is attacker-sized like every ancillary payload, so it is charged to the decoder's +/// cumulative metadata budget: a budget of exactly its length admits it, one byte less skips +/// it — without error, and without touching the pixels. +#[test] +fn a_store_past_the_metadata_budget_is_skipped_not_an_error() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let store = store(1000); + let png = PngEncoder::new() + .with_c2pa(&store) + .encode_to_vec(image) + .expect("encode"); + + let exact = PngDecoder::new().with_max_metadata_bytes(1000); + assert_eq!( + exact.metadata(&png).expect("metadata").c2pa.as_deref(), + Some(&store[..]) + ); + let tight = PngDecoder::new().with_max_metadata_bytes(999); + assert_eq!(tight.metadata(&png).expect("metadata").c2pa, None); + let decoded = tight.decode(&png).expect("decode still succeeds"); + assert_eq!(decoded.c2pa, None); + let typed: ImageBuf = tight.decode_image(&png).expect("typed decode"); + assert_eq!(typed.as_samples(), pixels); +} + +/// The libpng oracle. libpng has no C2PA support and carries `caBX` as an unknown chunk, which +/// is exactly what proves the framing: for the same payload it must produce the same twelve +/// framing bytes — length, type and CRC — around the same store, or one of the two is wrong +/// about §5.3. It then decodes gamut's file pixel-exact with the chunk in place, and gamut reads +/// the store back from libpng's file, where libpng frames unknown chunks right after IHDR. +#[test] +fn gamut_frames_cabx_byte_for_byte_as_libpng_does() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let store = store(77); + let gamut = PngEncoder::new() + .with_c2pa(&store) + .encode_to_vec(image) + .expect("encode"); + let reference = libpng_with_extra_chunks(12, 9, &[(*b"caBX", &store)]); + + let ours = framed_chunk(&gamut, b"caBX").expect("gamut wrote the chunk"); + let theirs = framed_chunk(&reference, b"caBX").expect("libpng wrote the chunk"); + assert_eq!( + ours, theirs, + "length, type, payload and CRC agree with libpng" + ); + assert_eq!(ours.len(), 12 + 77); + + let decoded = libpng_oracle::decode(&gamut); + assert_eq!((decoded.width, decoded.height), (12, 9)); + assert_eq!(decoded.pixels, pixels, "libpng decodes past the chunk"); + + let meta = gamut_png::metadata(&reference).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&store[..])); + let span = deconstruct(&reference) + .expect("deconstruct") + .c2pa() + .expect("span"); + assert_eq!( + span.chunk, + 33..33 + 12 + 77, + "libpng put it right after IHDR" + ); + let typed: ImageBuf = PngDecoder::new() + .decode_image(&reference) + .expect("gamut decodes libpng's file"); + assert_eq!(typed.as_samples(), pixels); +} diff --git a/crates/gamut-png/tests/metadata.rs b/crates/gamut-png/tests/metadata.rs index 01e76200..c1b10acc 100644 --- a/crates/gamut-png/tests/metadata.rs +++ b/crates/gamut-png/tests/metadata.rs @@ -40,10 +40,12 @@ fn every_carrier_round_trips_byte_exact() { let exif = tiny_exif(); let icc = tiny_icc_profile(); let xmp = ""; + let c2pa = b"\0\0\0\x14jumbopaque store"; let png = encode(|e| { e.with_exif(&exif) .with_icc_profile("Tiny", &icc) .with_xmp(xmp) + .with_c2pa(c2pa) .with_text("Author", "nobody") .with_compressed_text("Comment", "compressed comment") .with_international_text("Title", "international title") @@ -63,6 +65,7 @@ fn every_carrier_round_trips_byte_exact() { assert_eq!(profile.name, "Tiny"); assert_eq!(profile.profile, icc); assert_eq!(meta.xmp.as_deref(), Some(xmp.as_bytes())); + assert_eq!(meta.c2pa.as_deref(), Some(&c2pa[..])); assert_eq!(meta.gamma, Some(45_455)); assert_eq!(meta.srgb, Some(SrgbIntent::RelativeColorimetric)); let chrm = meta.chromaticities.expect("cHRM present"); @@ -87,6 +90,7 @@ fn metadata_agrees_with_decode_field_for_field() { e.with_exif(&exif) .with_icc_profile("Tiny", &icc) .with_xmp("") + .with_c2pa(b"\0\0\0\x10jumbc2pa") .with_text("Author", "nobody") .with_gamma(1.0 / 2.2) .with_srgb(SrgbIntent::Perceptual) @@ -98,6 +102,8 @@ fn metadata_agrees_with_decode_field_for_field() { assert_eq!(meta.exif, decoded.exif); assert_eq!(meta.icc_profile, decoded.icc_profile); assert_eq!(meta.xmp, decoded.xmp); + assert_eq!(meta.c2pa, decoded.c2pa); + assert_eq!(meta.c2pa_duplicates, decoded.c2pa_duplicates); assert_eq!(meta.texts, decoded.texts); assert_eq!(meta.gamma, decoded.gamma); assert_eq!(meta.chromaticities, decoded.chromaticities); From cb30ea80fd98bc0fe945107b1ff0cc172e59a60a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:11:39 -0400 Subject: [PATCH 02/10] fix(png): end the C2PA store walk with the datastream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find_c2pa` walked to end of input, unlike every other chunk walk in the crate (`parse_stream`, `walk_metadata_chunks` and `deconstruct` all stop at IEND). Latent today, since its only caller is the encoder reading back its own fresh output, but it made the "the encoder's report and the file's report cannot disagree" claim false for any later caller: a `caBX` appended after IEND — bytes §13.2 calls a trailer, outside the datastream — would have been reported as the file's manifest store. The walk now stops at the first IDAT or at IEND, whichever comes first, which states the store rule in one place: the first CRC-valid `caBX` before the first IDAT. Stopping at IDAT is C2PA 2.4 §A.3.2's placement — the store precedes IDAT and data after it is bad-form — and it is what keeps a chunk appended to a finished file from being taken as a store the file does not carry. --- crates/gamut-png/src/chunk.rs | 54 +++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 4ef6e715..9efefa37 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -86,15 +86,30 @@ impl C2paSpan { } } -/// Locates the manifest store in a PNG: the first CRC-valid `caBX` chunk, or `None`. +/// Locates the manifest store in a PNG: the first CRC-valid `caBX` chunk **before the first +/// `IDAT`**, or `None`. /// -/// The first CRC-valid one, because that is the chunk the decoder surfaces as its `c2pa` payload -/// (§13.1 skips a CRC mismatch), so the span a caller excludes from a hash is the store it reads. -/// Stops at end of input or at the first chunk that does not frame; a stream that is not a PNG -/// has no store. +/// The one definition of "the store", shared by every reader in this crate so they cannot +/// disagree — [`PngReport::c2pa`](crate::PngReport::c2pa) and the decoder's metadata walks apply +/// the same rule. Three parts, each load-bearing: +/// +/// - **CRC-valid**, because §13.1 makes a mismatching ancillary chunk skippable, and the decoder +/// skips it — so a span a caller excludes from a hash names the store the decoder read; +/// - **the first**, because a PNG carries exactly one store (C2PA 2.4 §A.3.2); a later one is a +/// malformed file's extra chunk, never merged in; +/// - **before the first `IDAT`**, because §A.3.2 places the store there and calls data after +/// `IDAT` bad-form. A `caBX` appended to a finished file is therefore not the store, which is +/// what stops an appender turning a file that carries none into one that appears to. +/// +/// The walk stops at the first `IDAT` or at `IEND`, whichever comes first: `IEND` ends the +/// datastream (§5.6), and bytes after it are a trailer, not chunks (§13.2). It also stops at the +/// first chunk that does not frame, so a stream that is not a PNG simply has no store. pub(crate) fn find_c2pa(png: &[u8]) -> Option { let mut reader = ChunkReader::new(png).ok()?; while let Ok(Some(chunk)) = reader.next_chunk() { + if chunk.chunk_type == *b"IDAT" || chunk.chunk_type == *b"IEND" { + return None; + } if chunk.chunk_type == CABX && chunk.crc_ok { return Some(C2paSpan::of(chunk.range)); } @@ -299,6 +314,35 @@ mod tests { assert_eq!(&png[span.chunk.end + 4..span.chunk.end + 8], b"IEND"); } + /// The walk ends with the datastream. A `caBX` after `IDAT` is bad-form carriage (C2PA + /// §A.3.2) and one after `IEND` is not in the datastream at all (§13.2) — neither is the + /// store, so an appender cannot inject one into a file that carries none. + #[test] + fn find_c2pa_stops_at_the_first_idat_and_at_iend() { + let mut after_idat = SIGNATURE.to_vec(); + write_chunk(&mut after_idat, *b"IHDR", &[0; 13]); + write_chunk(&mut after_idat, *b"IDAT", b"zz"); + write_chunk(&mut after_idat, CABX, b"appended"); + write_chunk(&mut after_idat, *b"IEND", &[]); + assert_eq!(find_c2pa(&after_idat), None, "a caBX after IDAT is not the store"); + + let mut after_iend = SIGNATURE.to_vec(); + write_chunk(&mut after_iend, *b"IHDR", &[0; 13]); + write_chunk(&mut after_iend, *b"IEND", &[]); + write_chunk(&mut after_iend, CABX, b"trailing"); + assert_eq!(find_c2pa(&after_iend), None, "a caBX after IEND is not the store"); + + // ...while the same chunk one position earlier — before IDAT — is the store, so the + // stop is what decides, not the payload. + let mut before_idat = SIGNATURE.to_vec(); + write_chunk(&mut before_idat, *b"IHDR", &[0; 13]); + write_chunk(&mut before_idat, CABX, b"appended"); + write_chunk(&mut before_idat, *b"IDAT", b"zz"); + write_chunk(&mut before_idat, *b"IEND", &[]); + let span = find_c2pa(&before_idat).expect("a store before IDAT"); + assert_eq!(&before_idat[span.payload], b"appended"); + } + /// The store the span names is the one the decoder reads: a `caBX` whose CRC does not match /// is skipped on decode (§13.1), so it is skipped here too, and the CRC-valid one after it /// is the store. A stream with no `caBX`, or no signature, has none. From 03424733fe634cbbab9eeb4006d2232a146be3a9 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:13:13 -0400 Subject: [PATCH 03/10] feat(png): fill a reserved C2PA store in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fill_c2pa(&mut png, &span, store)` writes a finished manifest store into the `caBX` chunk a span names, rewriting the payload and the chunk CRC and nothing else. It is the second half of the reserve-then-fill flow C2PA 2.4 §18.5 describes, and the shape that flow actually needs: a signer hashes the reserved file with the chunk's span excluded, then fills it. Until now the only way to fill a reservation was to encode again with `with_c2pa`. That reaches the same bytes but costs a second full encode — at `Level::Best` with `FilterStrategy::BruteForce`, the whole brute-force set again — and it makes the signature depend on the encoder reproducing its output byte for byte. Filling in place is O(store) and depends on nothing but the chunk's own bytes, and it is the only route at all for a file gamut did not write. Every argument is validated before the first byte is written, with a distinct typed error each: the span must lie inside the image, frame a chunk (payload exactly `chunk.start + 8 .. chunk.end - 4`), name a `caBX`, and receive a store of exactly the reserved length. A store of the wrong length is rejected rather than resized: resizing would move every byte after the chunk and invalidate the hash the signer signed. --- crates/gamut-png/src/chunk.rs | 177 ++++++++++++++++++++++++++++++++++ crates/gamut-png/src/lib.rs | 7 +- 2 files changed, 181 insertions(+), 3 deletions(-) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 9efefa37..75861bb8 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -202,6 +202,93 @@ impl<'a> ChunkReader<'a> { } } +/// Writes a finished C2PA manifest store into the `caBX` chunk `span` names, **in place**. +/// +/// The second half of the reserve-then-fill flow (C2PA 2.4 §18.5): encode once with +/// [`PngEncoder::with_c2pa_reserved`](crate::PngEncoder::with_c2pa_reserved), hash the output with +/// `span.chunk` excluded, have the signer build a store of exactly the reserved length, then call +/// this. Only the payload and the chunk's CRC change; the length field, the type, and every byte +/// outside `span.chunk` — every offset in the file — are untouched, so the hash the signer signed +/// still describes the filled file. +/// +/// **This is the supported way to put a store into a file this encoder is not re-encoding**, and +/// the only one for a file gamut did not write. Re-encoding with +/// [`PngEncoder::with_c2pa`](crate::PngEncoder::with_c2pa) reaches the same bytes, but it costs a +/// second full encode (at [`Level::Best`](crate::Level) with +/// [`FilterStrategy::BruteForce`](crate::FilterStrategy) that is the whole brute-force set again) +/// and it makes the signature depend on the encoder reproducing its output byte for byte. Filling +/// in place depends on nothing but these twelve-plus-`n` bytes. +/// +/// The span comes from [`PngEncodeReport::c2pa`](crate::PngEncodeReport::c2pa) for a file this +/// encoder just wrote, or from [`PngReport::c2pa`](crate::PngReport::c2pa) for any file — which is +/// also the route for an indexed image, since +/// [`encode_indexed8`](crate::PngEncoder::encode_indexed8) has no report of its own. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] and leaves `png` **unmodified** if `span` runs past the end of +/// `png`, if it does not frame a chunk (its payload must be `chunk.start + 8 .. chunk.end - 4`), +/// if the bytes it names are not a `caBX` chunk, or if `store` is not exactly the reserved +/// length. Every check runs before the first byte is written, so a rejected call cannot leave a +/// half-filled chunk behind — and a store of the wrong length is rejected rather than resized, +/// because resizing would move every byte after the chunk and invalidate the signer's hash. +/// +/// # Example +/// +/// ``` +/// use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +/// use gamut_png::{PngEncoder, fill_c2pa}; +/// +/// # fn main() -> Result<(), Box> { +/// let pixels = vec![0u8; 3 * 4]; +/// let image = ImageRef::::new(&pixels, Dimensions::new(2, 2)?)?; +/// let (mut png, report) = PngEncoder::new() +/// .with_c2pa_reserved(16) +/// .encode_with_report(image)?; +/// let span = report.c2pa.expect("a reservation was made"); +/// +/// // ... hash `png` with `span.chunk` excluded, sign, and receive a 16-byte store ... +/// fill_c2pa(&mut png, &span, &[7u8; 16])?; +/// +/// assert_eq!(gamut_png::metadata(&png)?.c2pa.as_deref(), Some(&[7u8; 16][..])); +/// # Ok(()) +/// # } +/// ``` +pub fn fill_c2pa(png: &mut [u8], span: &C2paSpan, store: &[u8]) -> Result<()> { + let invalid = |message: &'static str| Error::invalid_input(env!("CARGO_PKG_NAME"), message); + if span.chunk.end > png.len() { + return Err(invalid("PNG: the C2PA span runs past the end of the image")); + } + // The span must frame a chunk: 4 length bytes and 4 type bytes ahead of the payload, 4 CRC + // bytes behind it. Checked rather than assumed because a caller can build a `C2paSpan`. + let frames = span + .chunk + .start + .checked_add(8) + .zip(span.chunk.end.checked_sub(4)) + .is_some_and(|(payload_start, payload_end)| { + span.payload.start == payload_start + && span.payload.end == payload_end + && payload_start <= payload_end + }); + if !frames { + return Err(invalid("PNG: the C2PA span does not frame a chunk")); + } + if png[span.chunk.start + 4..span.payload.start] != CABX { + return Err(invalid("PNG: the C2PA span does not name a caBX chunk")); + } + if store.len() != span.payload.len() { + return Err(invalid("PNG: the C2PA store is not the reserved length")); + } + + png[span.payload.clone()].copy_from_slice(store); + let mut crc = Crc32::new(); + crc.update(&CABX); + crc.update(store); + png[span.payload.end..span.chunk.end].copy_from_slice(&crc.finish().to_be_bytes()); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -314,6 +401,96 @@ mod tests { assert_eq!(&png[span.chunk.end + 4..span.chunk.end + 8], b"IEND"); } + /// A PNG carrying a `len`-byte reservation, plus the span naming it. + fn reserved(len: usize) -> (Vec, C2paSpan) { + let mut png = SIGNATURE.to_vec(); + write_chunk(&mut png, *b"IHDR", &[0; 13]); + write_chunk(&mut png, CABX, &vec![0; len]); + write_chunk(&mut png, *b"IDAT", b"zz"); + write_chunk(&mut png, *b"IEND", &[]); + let span = find_c2pa(&png).expect("a reservation"); + (png, span) + } + + /// Filling rewrites the payload and the CRC, and nothing else: every byte outside the span + /// is untouched, the length and type inside it are untouched, and the chunk still frames — + /// `find_c2pa` re-reads the filled store, which it can only do if the CRC was recomputed. + #[test] + fn filling_a_reservation_rewrites_the_payload_and_its_crc_alone() { + let (mut png, span) = reserved(6); + let before = png.clone(); + fill_c2pa(&mut png, &span, b"jumbf!").expect("fill"); + + assert_eq!(&png[span.payload.clone()], b"jumbf!"); + assert_eq!(png.len(), before.len()); + for i in (0..png.len()).filter(|i| !span.chunk.contains(i)) { + assert_eq!(png[i], before[i], "byte {i} outside the span changed"); + } + assert_eq!( + png[span.chunk.start..span.payload.start], + before[span.chunk.start..span.payload.start], + "the length and type fields are untouched" + ); + assert_ne!( + png[span.payload.end..span.chunk.end], + before[span.payload.end..span.chunk.end], + "the CRC followed the payload" + ); + // The CRC is not merely different, it is right: the walk only returns a CRC-valid chunk. + let refound = find_c2pa(&png).expect("the filled chunk still verifies"); + assert_eq!(refound, span); + assert_eq!(&png[refound.payload], b"jumbf!"); + } + + /// Every argument is validated before a byte is written, each with its own message, and a + /// rejected call leaves the image exactly as it was. + #[test] + fn filling_validates_its_span_and_length_before_writing() { + let (png, span) = reserved(4); + + let mut short = png.clone(); + let error = fill_c2pa(&mut short, &span, b"abc").expect_err("one byte short"); + assert!( + error.to_string().contains("not the reserved length"), + "{error}" + ); + assert_eq!(short, png, "a rejected fill writes nothing"); + let mut long = png.clone(); + assert!(fill_c2pa(&mut long, &span, b"abcde").is_err(), "one byte long"); + assert_eq!(long, png); + + // A span past the end of the buffer. + let mut truncated = png[..span.chunk.end - 1].to_vec(); + let error = fill_c2pa(&mut truncated, &span, b"abcd").expect_err("past the end"); + assert!(error.to_string().contains("runs past the end"), "{error}"); + + // A span whose payload does not sit inside its framing. + let mut mine = png.clone(); + let skewed = C2paSpan { + chunk: span.chunk.clone(), + payload: span.payload.start + 1..span.payload.end, + }; + let error = fill_c2pa(&mut mine, &skewed, b"abc").expect_err("not framed"); + assert!(error.to_string().contains("does not frame a chunk"), "{error}"); + assert_eq!(mine, png); + + // A well-framed span naming some other chunk: the IHDR right before it. + let ihdr = C2paSpan::of(8..8 + 12 + 13); + let error = fill_c2pa(&mut mine, &ihdr, &[0; 13]).expect_err("not a caBX"); + assert!(error.to_string().contains("does not name a caBX"), "{error}"); + assert_eq!(mine, png); + } + + /// A zero-length reservation is a legal chunk, and filling it with nothing is a no-op that + /// still verifies — the boundary where payload start and end coincide. + #[test] + fn filling_an_empty_reservation_is_lawful() { + let (mut png, span) = reserved(0); + assert_eq!(span.payload.len(), 0); + fill_c2pa(&mut png, &span, &[]).expect("fill"); + assert_eq!(find_c2pa(&png), Some(span)); + } + /// The walk ends with the datastream. A `caBX` after `IDAT` is bad-form carriage (C2PA /// §A.3.2) and one after `IEND` is not in the datastream at all (§13.2) — neither is the /// store, so an appender cannot inject one into a file that carries none. diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index 74def69f..9ddb73a7 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -28,8 +28,9 @@ //! store computed for this file and [`PngEncoder::with_c2pa_reserved`] reserves its place, as the //! last chunk before `IDAT`; [`PngEncoder::encode_with_report`] and [`PngReport::c2pa`] name the //! chunk's **whole** span — length, type, payload and CRC — which is what a `c2pa.hash.data` -//! assertion excludes (§18.5.4), and a reservation is filled by a second encode of equal length -//! that changes no byte outside it. +//! assertion excludes (§18.5.4). [`fill_c2pa`] then writes the finished store into that span in +//! place, rewriting only the payload and the chunk CRC, so the file the signer hashed is the file +//! it signed. //! //! # Pluggable IDAT backends //! @@ -86,7 +87,7 @@ pub mod stages; pub use abi::{AbiDeflater, AbiInflater, CODEC_ID_ZLIB, PIXEL_FORMAT_FILTERED_BYTES}; pub use ancillary::{PhysicalUnit, SrgbIntent}; pub use backend::{IdatDeflater, IdatInflater, IdatInfo}; -pub use chunk::C2paSpan; +pub use chunk::{C2paSpan, fill_c2pa}; pub use color::ColorType; pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, From 704fa2538a42fd4d5de9711d8f6a00f56c61ea8a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:19:40 -0400 Subject: [PATCH 04/10] fix(png): never read a caBX after IDAT as the manifest store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2PA 2.4 §A.3.2 places the manifest store before IDAT and calls data after it bad-form, but the decode surfaced the first CRC-valid `caBX` wherever it sat. That let anyone append a `caBX` to a finished PNG and have it read back as that file's provenance — including into a file that carries no store at all, where the appended chunk became the only answer. The encoder never writes there, so nothing gamut produces was affected; the exposure was on read. The store is now the first CRC-valid `caBX` before the first IDAT, in all three readers that answer the question — `decode`, both `metadata` entry points, and `PngReport::c2pa` — with the addition done in one place so they cannot drift apart. An ignored chunk stays visible rather than being silently dropped: the counter now covers both reasons a `caBX` is not the store, a later one and one after IDAT, so `c2pa == None` with a non-zero count is exactly the shape of an appended store. It is renamed `c2pa_ignored` to say that, since "duplicates" is false when the file carries no original, and it becomes a `usize`: as a saturating `u8` a file with 300 ignored chunks reported 255, a number the file does not contain. --- crates/gamut-png/src/decoded.rs | 64 ++++++++----- crates/gamut-png/src/decoder.rs | 79 ++++++++++++--- crates/gamut-png/src/deconstruct.rs | 70 +++++++++----- crates/gamut-png/tests/c2pa.rs | 144 ++++++++++++++++++++++++++-- crates/gamut-png/tests/metadata.rs | 2 +- 5 files changed, 290 insertions(+), 69 deletions(-) diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 32114a7e..b3a593c5 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -147,14 +147,19 @@ pub struct DecodedPng { pub xmp: Option>, /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim: the JUMBF bytes, /// uncompressed, exactly as the chunk carries them — opaque here, never parsed or judged. - /// Feed as `MetadataBlock::C2pa`. The first `caBX` in the file, and only when it fits the - /// metadata budget; see [`c2pa_duplicates`](Self::c2pa_duplicates). + /// Feed as `MetadataBlock::C2pa`. The first CRC-valid `caBX` before the first `IDAT`, and + /// only when it fits the metadata budget; see [`c2pa_ignored`](Self::c2pa_ignored). pub c2pa: Option>, - /// How many further `caBX` chunks followed the first, saturating at 255. A file carries - /// exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's APP11 run — so - /// any value above zero marks a malformed file whose extra stores were ignored rather than - /// concatenated. - pub c2pa_duplicates: u8, + /// How many CRC-valid `caBX` chunks the file carries that were **not** surfaced as the + /// store: any after the first, and any positioned after `IDAT`. + /// + /// A file carries exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's + /// APP11 run — so a non-zero count marks a malformed file whose extra chunks were ignored + /// rather than concatenated. The post-`IDAT` case is worth its own attention: §A.3.2 places + /// the store before `IDAT` and calls data after it bad-form, so a `caBX` appended to a + /// finished file is never read as the store. A file whose `c2pa` is `None` while this is + /// non-zero is exactly that shape — someone appended a store to a file that carries none. + pub c2pa_ignored: usize, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, /// gAMA: image gamma × 100 000 (§11.3.2.2) — the unit the encoder's `with_gamma` writes. @@ -213,15 +218,20 @@ pub struct PngMetadata { /// compressed. Feed as `MetadataBlock::Xmp`. pub xmp: Option>, /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim and uncompressed — - /// opaque bytes, never parsed or judged. Feed as `MetadataBlock::C2pa`. The first `caBX` in - /// the file, and only when it fits the metadata budget; see - /// [`c2pa_duplicates`](Self::c2pa_duplicates). + /// opaque bytes, never parsed or judged. Feed as `MetadataBlock::C2pa`. The first CRC-valid + /// `caBX` before the first `IDAT`, and only when it fits the metadata budget; see + /// [`c2pa_ignored`](Self::c2pa_ignored). pub c2pa: Option>, - /// How many further `caBX` chunks followed the first, saturating at 255. A file carries - /// exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's APP11 run — so - /// any value above zero marks a malformed file whose extra stores were ignored rather than - /// concatenated. - pub c2pa_duplicates: u8, + /// How many CRC-valid `caBX` chunks the file carries that were **not** surfaced as the + /// store: any after the first, and any positioned after `IDAT`. + /// + /// A file carries exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's + /// APP11 run — so a non-zero count marks a malformed file whose extra chunks were ignored + /// rather than concatenated. The post-`IDAT` case is worth its own attention: §A.3.2 places + /// the store before `IDAT` and calls data after it bad-form, so a `caBX` appended to a + /// finished file is never read as the store. A file whose `c2pa` is `None` while this is + /// non-zero is exactly that shape — someone appended a store to a file that carries none. + pub c2pa_ignored: usize, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, /// gAMA: image gamma × 100 000 (§11.3.2.2). @@ -239,6 +249,11 @@ pub struct PngMetadata { /// attacker-sized `caBX` store — share `budget` bytes of output, and a payload that would bust /// the remainder is skipped, not an error. Once-only chunks keep their first occurrence; a /// second `caBX` is additionally counted, since exactly one store is the rule (C2PA §A.3.2). +/// +/// `chunks` holds only chunks in a position where a store may appear: the caller's walk drops a +/// `caBX` after `IDAT` before it gets here and counts it into +/// [`PngMetadata::c2pa_ignored`](PngMetadata::c2pa_ignored) itself, so this function never has to +/// know where in the file a chunk sat. pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata { let mut meta = PngMetadata::default(); let mut budget = budget; @@ -250,7 +265,7 @@ pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata b"eXIf" if meta.exif.is_none() => meta.exif = Some(data.to_vec()), _ if chunk_type == CABX => { if seen_c2pa { - meta.c2pa_duplicates = meta.c2pa_duplicates.saturating_add(1); + meta.c2pa_ignored += 1; } else { seen_c2pa = true; if data.len() <= budget { @@ -569,21 +584,22 @@ mod tests { 1024, ); assert_eq!(meta.c2pa.as_deref(), Some(&b"first store"[..])); - assert_eq!(meta.c2pa_duplicates, 2); + assert_eq!(meta.c2pa_ignored, 2); let single = collect(&[(CABX, b"only")], 1024); assert_eq!(single.c2pa.as_deref(), Some(&b"only"[..])); - assert_eq!(single.c2pa_duplicates, 0); + assert_eq!(single.c2pa_ignored, 0); assert_eq!(collect(&[], 1024).c2pa, None); } - /// The duplicate count saturates rather than wrapping: 256 further stores read as 255, not - /// as none at all. + /// The count is a plain `usize`: it reports what the file carries however many that is. A + /// saturating `u8` here read 300 ignored chunks as 255, which is a number the file does not + /// contain. #[test] - fn the_cabx_duplicate_count_saturates_at_255() { - let chunks: Vec<([u8; 4], &[u8])> = (0..257).map(|_| (CABX, &b"s"[..])).collect(); + fn the_ignored_count_reports_every_chunk_not_a_saturated_ceiling() { + let chunks: Vec<([u8; 4], &[u8])> = (0..301).map(|_| (CABX, &b"s"[..])).collect(); let meta = collect(&chunks, 1024); - assert_eq!(meta.c2pa_duplicates, 255); + assert_eq!(meta.c2pa_ignored, 300); } /// `caBX` is attacker-sized like every other ancillary payload, so it is charged to the one @@ -609,7 +625,7 @@ mod tests { let busts = collect(&[(CABX, &store), (CABX, b"tiny")], 9); assert_eq!(busts.c2pa, None, "one byte over the budget is skipped"); assert_eq!( - busts.c2pa_duplicates, 1, + busts.c2pa_ignored, 1, "the skipped store is still the first; the next is a duplicate, not a substitute" ); } diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index 0f4c7222..4441d261 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -26,7 +26,7 @@ use gamut_core::{ }; use crate::backend::{IdatInflater, IdatInfo, Registry, run_inflaters}; -use crate::chunk::ChunkReader; +use crate::chunk::{CABX, ChunkReader}; use crate::color::ColorType; use crate::decoded::{self, DecodedPng, PngHeader, PngImage, PngMetadata}; use crate::filter::{self, FilterType}; @@ -179,8 +179,14 @@ struct Parsed<'a> { trns: Option<&'a [u8]>, /// All IDAT payloads, concatenated (§5.6 requires them consecutive). idat: Vec, - /// Metadata-bearing ancillary chunks in file order (populated only when requested). + /// Metadata-bearing ancillary chunks in file order (populated only when requested), holding + /// only chunks in a position where a manifest store may appear — see `c2pa_after_idat`. ancillary: Vec<([u8; 4], &'a [u8])>, + /// CRC-valid `caBX` chunks found *after* `IDAT`. C2PA §A.3.2 places the store before `IDAT` + /// and calls data after it bad-form, so these are never the store; they are counted rather + /// than dropped silently, because a `caBX` appended to a finished file is what an injection + /// attempt looks like. + c2pa_after_idat: usize, } /// Decoded samples in the file's native value range: one byte per sample for depths ≤ 8 @@ -225,6 +231,7 @@ impl PngDecoder { let mut trns: Option<&[u8]> = None; let mut idat = Vec::new(); let mut ancillary = Vec::new(); + let mut c2pa_after_idat = 0usize; let mut seen_idat = false; let mut idat_done = false; let mut seen_iend = false; @@ -322,7 +329,14 @@ impl PngDecoder { // These are borrowed slices, so an unrecognised chunk costs a fat pointer, // not a copy of its payload. if want_metadata && chunk.crc_ok { - ancillary.push((chunk.chunk_type, chunk.data)); + // The one chunk type whose *position* decides whether it is metadata at + // all: a manifest store precedes IDAT (§A.3.2), so one after IDAT is + // counted as ignored rather than offered as the store. + if chunk.chunk_type == CABX && seen_idat { + c2pa_after_idat += 1; + } else { + ancillary.push((chunk.chunk_type, chunk.data)); + } } } _ => { @@ -353,6 +367,7 @@ impl PngDecoder { trns, idat, ancillary, + c2pa_after_idat, }) } @@ -399,7 +414,11 @@ impl PngDecoder { /// payloads are not errors: the affected chunk is skipped (§13.1) and its field stays empty. pub fn decode(&self, data: &[u8]) -> Result { let parsed = self.parse_stream(data, true)?; - let meta = decoded::collect(&parsed.ancillary, self.max_metadata_bytes); + let meta = collected( + &parsed.ancillary, + self.max_metadata_bytes, + parsed.c2pa_after_idat, + ); let native = self.decode_parsed(&parsed)?; let header = PngHeader { width: native.header.width, @@ -418,7 +437,7 @@ impl PngDecoder { icc_profile: meta.icc_profile, xmp: meta.xmp, c2pa: meta.c2pa, - c2pa_duplicates: meta.c2pa_duplicates, + c2pa_ignored: meta.c2pa_ignored, texts: meta.texts, gamma: meta.gamma, chromaticities: meta.chromaticities, @@ -458,8 +477,12 @@ impl PngDecoder { /// # } /// ``` pub fn metadata(&self, data: &[u8]) -> Result { - let chunks = walk_metadata_chunks(data)?; - Ok(decoded::collect(&chunks, self.max_metadata_bytes)) + let (chunks, c2pa_after_idat) = walk_metadata_chunks(data)?; + Ok(collected( + &chunks, + self.max_metadata_bytes, + c2pa_after_idat, + )) } /// Runs the typed pipeline: parse (without metadata) → decode. @@ -536,7 +559,11 @@ impl PngDecoder { /// Deliberately not `parse_stream` itself: that accumulates every IDAT payload into an owned /// `Vec` and then requires at least one, neither of which a metadata read should do. Here IDAT /// (and PLTE) is skipped by length, so the pixel data is never touched or copied. -fn walk_metadata_chunks(data: &[u8]) -> Result> { +/// +/// Returns the chunks together with the number of CRC-valid `caBX` chunks seen *after* `IDAT`, +/// which are never the store (§A.3.2) and so are counted rather than returned — the same split +/// [`PngDecoder::parse_stream`] makes, so the two entry points agree on what the store is. +fn walk_metadata_chunks(data: &[u8]) -> Result<(Vec<([u8; 4], &[u8])>, usize)> { let mut reader = ChunkReader::new(data)?; let first = reader .next_chunk()? @@ -558,6 +585,8 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { ihdr::parse(first.data)?; let mut chunks = Vec::new(); + let mut c2pa_after_idat = 0usize; + let mut seen_idat = false; let mut seen_iend = false; while let Some(chunk) = reader.next_chunk()? { match &chunk.chunk_type { @@ -584,11 +613,16 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { break; } // The pixel-bearing critical chunks. Skipped by length — never read, never copied. - b"IDAT" | b"PLTE" => {} + b"IDAT" | b"PLTE" => seen_idat |= &chunk.chunk_type == b"IDAT", _ if chunk.is_ancillary() => { // §13.1: a CRC mismatch skips the chunk rather than failing the image. if chunk.crc_ok { - chunks.push((chunk.chunk_type, chunk.data)); + // A store precedes IDAT (§A.3.2); one after it is counted, not offered. + if chunk.chunk_type == CABX && seen_idat { + c2pa_after_idat += 1; + } else { + chunks.push((chunk.chunk_type, chunk.data)); + } } } _ => { @@ -608,7 +642,22 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { "PNG: missing IEND", )); } - Ok(chunks) + Ok((chunks, c2pa_after_idat)) +} + +/// Assembles the metadata from a walk's two results: the chunks in a store-bearing position, and +/// the count of `caBX` chunks the walk found after `IDAT`. +/// +/// The one place that addition happens, so `decode` and both `metadata` entry points cannot come +/// to report different counts for the same file. +fn collected( + chunks: &[([u8; 4], &[u8])], + budget: usize, + c2pa_after_idat: usize, +) -> PngMetadata { + let mut meta = decoded::collect(chunks, budget); + meta.c2pa_ignored += c2pa_after_idat; + meta } /// Reads a PNG's ancillary metadata without decoding any pixels. @@ -666,8 +715,12 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { /// # } /// ``` pub fn metadata(data: &[u8]) -> Result { - let chunks = walk_metadata_chunks(data)?; - Ok(decoded::collect(&chunks, DEFAULT_MAX_METADATA_BYTES)) + let (chunks, c2pa_after_idat) = walk_metadata_chunks(data)?; + Ok(collected( + &chunks, + DEFAULT_MAX_METADATA_BYTES, + c2pa_after_idat, + )) } /// Validates PLTE presence/shape and tRNS shape against the colour type (§11.2.2, §11.3.1.1), diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 7601847e..04c07a9d 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -401,34 +401,58 @@ impl PngReport { ) } - /// The C2PA manifest store's carriage: the whole span of the first CRC-valid `caBX` chunk — - /// length, type, payload **and CRC** — which is what a `c2pa.hash.data` assertion must exclude - /// (C2PA 2.4 §18.5.4): the store's bytes change when it is written, the length field when it - /// is resized, and the CRC with either, so a hash that keeps any of them breaks on the store's - /// first update. The store's own bytes are the span's `payload`. `None` when the file carries - /// no such chunk. + /// Where a C2PA manifest store is **carried**: the whole span of the first CRC-valid `caBX` + /// chunk before the first `IDAT` — length, type, payload **and CRC** — or `None` when the file + /// carries none there. The store's own bytes are the span's `payload`. /// - /// The first CRC-valid one, so this names the chunk [`PngDecoder::decode`] surfaces as its - /// `c2pa` payload (§13.1 skips a CRC mismatch on both sides). A further `caBX` is a malformed - /// file's duplicate — counted by [`chunk`](Self::chunk)`(b"caBX")` and by the decoder's - /// `c2pa_duplicates`, never merged into the span. + /// This is the range a `c2pa.hash.data` assertion must exclude (C2PA 2.4 §18.5.4): the store's + /// bytes change when it is written, the length field when it is resized, and the CRC with + /// either, so a hash that keeps any of them breaks on the store's first update. It is also the + /// span [`fill_c2pa`](crate::fill_c2pa) fills. + /// + /// The rule is [`crate::PngDecoder::decode`]'s: the *first* chunk, since a PNG carries exactly + /// one store (§A.3.2); *CRC-valid*, since §13.1 makes a mismatch skippable; *before `IDAT`*, + /// since data after it is bad-form and an appended chunk must not become the file's store. + /// + /// # This is carriage, not the decoded payload + /// + /// A span here does **not** promise that [`PngDecoder::decode`] surfaced a `c2pa` payload for + /// the same file, and the two answer different questions: + /// + /// - this walk reports what the file *carries*, and has no byte budget; + /// - [`PngDecoder::with_max_metadata_bytes`] bounds what a decode *admits*, so a store past + /// that budget is skipped and `decode().c2pa` is `None` while this still names its span. + /// + /// That is deliberate — a report that hid a chunk because some other reader's budget was too + /// small would not be a byte accounting — but it means a caller must not gate on one and read + /// the other. Exclude the span this returns; read the bytes `decode` returns. + /// + /// For the same reason, this does not agree with [`chunk`](Self::chunk)`(b"caBX")`'s count, + /// which counts every `caBX` including CRC-invalid ones and any after `IDAT`, nor with the + /// decoder's `c2pa_ignored`, which counts only the CRC-valid ones it declined to surface. + /// Each number answers its own question. /// /// [`PngDecoder::decode`]: crate::PngDecoder::decode + /// [`PngDecoder::with_max_metadata_bytes`]: crate::PngDecoder::with_max_metadata_bytes #[must_use] pub fn c2pa(&self) -> Option { - self.segments - .iter() - .find(|segment| { - matches!( - segment.kind, - SegmentKind::Chunk { - chunk_type: CABX, - crc_ok: true, - .. - } - ) - }) - .map(|segment| C2paSpan::of(segment.range.clone())) + for segment in &self.segments { + match segment.kind { + SegmentKind::Chunk { + chunk_type: CABX, + crc_ok: true, + .. + } => return Some(C2paSpan::of(segment.range.clone())), + // The datastream's store sits before the pixels; nothing from here on is one. + SegmentKind::Chunk { chunk_type, .. } + if &chunk_type == b"IDAT" || &chunk_type == b"IEND" => + { + return None; + } + _ => {} + } + } + None } /// The stats for one chunk type, if the file carries it. diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs index f655018a..29c4c69f 100644 --- a/crates/gamut-png/tests/c2pa.rs +++ b/crates/gamut-png/tests/c2pa.rs @@ -3,8 +3,9 @@ //! moving a byte outside it, and that both reports name the same whole-chunk span at known //! offsets. Differential: libpng frames the same payload into the same bytes, decodes gamut's //! file pixel-exact with the chunk in place, and gamut reads the store back from a libpng-written -//! file. The store is opaque bytes throughout — its behavioural oracle, `c2pa-rs`, is issue -//! #447's. +//! file. It also pins the two rules that decide what *is* the store: exactly one per file, and +//! never one positioned after `IDAT`. The store is opaque bytes throughout — its behavioural +//! oracle, `c2pa-rs`, is issue #447's. mod common; @@ -15,6 +16,7 @@ use common::{ use gamut_core::{DecodeImage, Dimensions, EncodeImage, ImageBuf, ImageRef, Indexed8, Rgb8, Rgba8}; use gamut_png::{ PhysicalUnit, PngDecoder, PngEncoder, PngPalette, SegmentKind, SrgbIntent, deconstruct, + fill_c2pa, }; /// A stand-in manifest store of `len` bytes: not all zero, no two runs alike, so a fill is @@ -279,7 +281,7 @@ fn without_a_store_there_is_no_chunk_and_no_span() { assert_eq!(deconstruct(&png).expect("deconstruct").c2pa(), None); let decoded = PngDecoder::new().decode(&png).expect("decode"); assert_eq!(decoded.c2pa, None); - assert_eq!(decoded.c2pa_duplicates, 0); + assert_eq!(decoded.c2pa_ignored, 0); } /// Both read entry points surface the store byte for byte, and the last of the two setters @@ -295,10 +297,10 @@ fn decode_and_metadata_surface_the_store_verbatim_and_the_last_setter_wins() { .expect("encode"); let meta = gamut_png::metadata(&png).expect("metadata"); assert_eq!(meta.c2pa.as_deref(), Some(&store[..])); - assert_eq!(meta.c2pa_duplicates, 0); + assert_eq!(meta.c2pa_ignored, 0); let decoded = PngDecoder::new().decode(&png).expect("decode"); assert_eq!(decoded.c2pa, meta.c2pa); - assert_eq!(decoded.c2pa_duplicates, 0); + assert_eq!(decoded.c2pa_ignored, 0); let reserved_last = PngEncoder::new() .with_c2pa(&store) @@ -333,10 +335,10 @@ fn the_first_store_wins_and_a_second_is_counted_not_merged() { ]); let meta = gamut_png::metadata(&png).expect("metadata"); assert_eq!(meta.c2pa.as_deref(), Some(&b"first"[..])); - assert_eq!(meta.c2pa_duplicates, 1); + assert_eq!(meta.c2pa_ignored, 1); let decoded = PngDecoder::new().decode(&png).expect("decode"); assert_eq!(decoded.c2pa.as_deref(), Some(&b"first"[..])); - assert_eq!(decoded.c2pa_duplicates, 1); + assert_eq!(decoded.c2pa_ignored, 1); let report = deconstruct(&png).expect("deconstruct"); let span = report.c2pa().expect("span"); @@ -345,6 +347,132 @@ fn the_first_store_wins_and_a_second_is_counted_not_merged() { assert_eq!(report.chunk(b"caBX").expect("stats").count, 2); } +/// A `caBX` positioned **after** `IDAT` is never the store: §A.3.2 puts the store before `IDAT` +/// and calls data after it bad-form, so accepting one would let anybody append a store to a +/// finished file and have it read back as that file's provenance. It is ignored as the store but +/// not hidden — the decode counts it, and the byte accounting still shows the chunk. +#[test] +fn a_cabx_after_idat_is_never_the_store_but_is_still_visible() { + let appended = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"caBX", b"appended store"), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&appended).expect("metadata"); + assert_eq!(meta.c2pa, None, "an appended chunk is not the file's store"); + assert_eq!(meta.c2pa_ignored, 1, "but it is counted, not hidden"); + let decoded = PngDecoder::new().decode(&appended).expect("decode"); + assert_eq!(decoded.c2pa, None); + assert_eq!(decoded.c2pa_ignored, 1); + + // The byte accounting still sees the chunk; it just does not call it the store. + let report = deconstruct(&appended).expect("deconstruct"); + assert_eq!(report.c2pa(), None, "no store span for an appended chunk"); + assert_eq!(report.chunk(b"caBX").expect("stats").count, 1); + + // A real store before IDAT is unaffected by a second one appended after it: the first wins + // and the appended one is counted, so the two rules compose. + let both = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"caBX", b"real store"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"caBX", b"appended"), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&both).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&b"real store"[..])); + assert_eq!(meta.c2pa_ignored, 1); + let span = deconstruct(&both).expect("deconstruct").c2pa().expect("span"); + assert_eq!(&both[span.payload], b"real store"); +} + +/// The two ignore-reasons add up rather than replacing one another: a duplicate before `IDAT` +/// and two chunks after it are three ignored chunks, counted the same way by both entry points. +#[test] +fn ignored_stores_before_and_after_idat_are_counted_together() { + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"caBX", b"the store"), + chunk(b"caBX", b"duplicate"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"caBX", b"appended one"), + chunk(b"caBX", b"appended two"), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&b"the store"[..])); + assert_eq!(meta.c2pa_ignored, 3, "one duplicate plus two appended"); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa_ignored, 3, "decode agrees with metadata"); +} + +/// The reserve-then-fill flow end to end, the way a signer runs it: reserve, take the span, +/// fill in place, read the store back. The filled file is byte-identical to one encoded with +/// the store set from the start, which is what makes the two routes interchangeable — and the +/// signer's hash, taken over the reserved file with the span excluded, still describes it. +#[test] +fn a_reserved_store_is_filled_in_place_to_the_same_bytes_as_a_second_encode() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let finished = store(64); + + let (mut reserved, report) = everything_else() + .with_c2pa_reserved(64) + .encode_with_report(image) + .expect("encode"); + let span = report.c2pa.expect("span"); + let outside_before: Vec = (0..reserved.len()) + .filter(|i| !span.chunk.contains(i)) + .map(|i| reserved[i]) + .collect(); + + fill_c2pa(&mut reserved, &span, &finished).expect("fill"); + + let reencoded = everything_else() + .with_c2pa(&finished) + .encode_to_vec(image) + .expect("encode"); + assert_eq!(reserved, reencoded, "filling in place lands on the encoder's own bytes"); + + let outside_after: Vec = (0..reserved.len()) + .filter(|i| !span.chunk.contains(i)) + .map(|i| reserved[i]) + .collect(); + assert_eq!(outside_before, outside_after, "no byte outside the span moved"); + + // The filled store reads back through the ordinary decode path, so its CRC is right. + assert_eq!( + gamut_png::metadata(&reserved).expect("metadata").c2pa, + Some(finished) + ); + let typed: ImageBuf = PngDecoder::new().decode_image(&reserved).expect("decode"); + assert_eq!(typed.as_samples(), pixels, "the pixels are untouched"); +} + +/// The indexed route (`encode_indexed8` has no report of its own): take the span from the byte +/// accounting, fill in place, read it back. +#[test] +fn an_indexed_encode_reserves_and_fills_through_the_report() { + let palette = PngPalette::new(&[[1, 2, 3], [4, 5, 6]]).expect("palette"); + let indices = [0u8, 1, 1, 0, 1, 0]; + let image = + ImageRef::::new(&indices, Dimensions::new(3, 2).expect("valid")).expect("image"); + let mut png = Vec::new(); + PngEncoder::new() + .with_c2pa_reserved(24) + .encode_indexed8(image, &palette, &mut png) + .expect("encode"); + + let span = deconstruct(&png).expect("deconstruct").c2pa().expect("span"); + let finished = store(24); + fill_c2pa(&mut png, &span, &finished).expect("fill"); + assert_eq!( + gamut_png::metadata(&png).expect("metadata").c2pa, + Some(finished) + ); +} + /// A `caBX` whose CRC does not match is skipped on decode (§13.1) — it is not the store and /// it is not a duplicate either, since it never reaches the metadata pass — and the exclusion /// span names the CRC-valid store the decoder actually surfaces, not the damaged bytes before @@ -363,7 +491,7 @@ fn a_cabx_with_a_bad_crc_is_neither_the_store_nor_the_exclusion_span() { ]); let meta = gamut_png::metadata(&png).expect("metadata"); assert_eq!(meta.c2pa.as_deref(), Some(&b"valid"[..])); - assert_eq!(meta.c2pa_duplicates, 0); + assert_eq!(meta.c2pa_ignored, 0); let report = deconstruct(&png).expect("deconstruct"); let span = report.c2pa().expect("the valid store"); diff --git a/crates/gamut-png/tests/metadata.rs b/crates/gamut-png/tests/metadata.rs index c1b10acc..e63c8d94 100644 --- a/crates/gamut-png/tests/metadata.rs +++ b/crates/gamut-png/tests/metadata.rs @@ -103,7 +103,7 @@ fn metadata_agrees_with_decode_field_for_field() { assert_eq!(meta.icc_profile, decoded.icc_profile); assert_eq!(meta.xmp, decoded.xmp); assert_eq!(meta.c2pa, decoded.c2pa); - assert_eq!(meta.c2pa_duplicates, decoded.c2pa_duplicates); + assert_eq!(meta.c2pa_ignored, decoded.c2pa_ignored); assert_eq!(meta.texts, decoded.texts); assert_eq!(meta.gamma, decoded.gamma); assert_eq!(meta.chromaticities, decoded.chromaticities); From a4f9e7f372ca09ad758b74723c3fe67afcea4db7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:27:38 -0400 Subject: [PATCH 05/10] refactor(png): name what a metadata walk returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `walk_metadata_chunks` grew a second result — the count of `caBX` chunks found after IDAT — and the bare tuple tripped `clippy::type_complexity`. The alias carries the explanation the tuple could not: which chunks come back, and why the post-IDAT ones are a number rather than chunks. --- crates/gamut-png/src/decoder.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index 4441d261..8a595eb4 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -478,11 +478,7 @@ impl PngDecoder { /// ``` pub fn metadata(&self, data: &[u8]) -> Result { let (chunks, c2pa_after_idat) = walk_metadata_chunks(data)?; - Ok(collected( - &chunks, - self.max_metadata_bytes, - c2pa_after_idat, - )) + Ok(collected(&chunks, self.max_metadata_bytes, c2pa_after_idat)) } /// Runs the typed pipeline: parse (without metadata) → decode. @@ -552,6 +548,11 @@ impl PngDecoder { } } +/// What one metadata walk found: the chunks in a position where their type may appear, and how +/// many CRC-valid `caBX` chunks sat after `IDAT` — never the store (C2PA §A.3.2), so counted +/// rather than returned among the chunks. +type MetadataWalk<'a> = (Vec<([u8; 4], &'a [u8])>, usize); + /// Walks the chunk stream collecting the CRC-valid ancillary chunks, for [`decoded::collect`] to /// classify — the same handoff [`PngDecoder::parse_stream`] makes, so the two entry points cannot /// disagree about which chunks carry metadata. @@ -560,10 +561,11 @@ impl PngDecoder { /// `Vec` and then requires at least one, neither of which a metadata read should do. Here IDAT /// (and PLTE) is skipped by length, so the pixel data is never touched or copied. /// -/// Returns the chunks together with the number of CRC-valid `caBX` chunks seen *after* `IDAT`, +/// Returns a [`MetadataWalk`]: the chunks together with the number of CRC-valid `caBX` chunks +/// seen *after* `IDAT`, /// which are never the store (§A.3.2) and so are counted rather than returned — the same split /// [`PngDecoder::parse_stream`] makes, so the two entry points agree on what the store is. -fn walk_metadata_chunks(data: &[u8]) -> Result<(Vec<([u8; 4], &[u8])>, usize)> { +fn walk_metadata_chunks(data: &[u8]) -> Result> { let mut reader = ChunkReader::new(data)?; let first = reader .next_chunk()? @@ -650,11 +652,7 @@ fn walk_metadata_chunks(data: &[u8]) -> Result<(Vec<([u8; 4], &[u8])>, usize)> { /// /// The one place that addition happens, so `decode` and both `metadata` entry points cannot come /// to report different counts for the same file. -fn collected( - chunks: &[([u8; 4], &[u8])], - budget: usize, - c2pa_after_idat: usize, -) -> PngMetadata { +fn collected(chunks: &[([u8; 4], &[u8])], budget: usize, c2pa_after_idat: usize) -> PngMetadata { let mut meta = decoded::collect(chunks, budget); meta.c2pa_ignored += c2pa_after_idat; meta From 35957e05fd59b3033ecacb569dd246b79143a491 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:27:50 -0400 Subject: [PATCH 06/10] docs(png): separate C2PA carriage from what a decode admits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The span docs claimed the report names the chunk the decode surfaces as its payload. That is false under a budget: `PngReport::c2pa` has no byte budget, so a store past `with_max_metadata_bytes` is still spanned while `decode().c2pa` is `None` — a caller gating on the report could get a `None` it had been told could not happen. The same block claimed the ignored count agrees with `chunk(b"caBX")`, which it deliberately does not, since that counts CRC-invalid and post-IDAT chunks too. Both now say what is true: the report answers *where the bytes are* and has no budget, the decode answers *what was admitted*, and each count answers its own question. Exclude the span from a hash; read the payload from the decode. Also states two things the code already did but the docs did not. The reserve-then-fill flow's step 3 is `fill_c2pa`, with re-encoding named as the costlier alternative rather than the route. And "last chunk before IDAT" is this writer's guarantee about files it produces, not a property of the format: PNG §14.3.2 says ordering relative to other *ancillary* chunks is never assumable and an editor may insert one after ours, so readers assume only "before IDAT" — which is exactly what they do. --- crates/gamut-png/README.md | 7 ++-- crates/gamut-png/STATUS.md | 63 +++++++++++++++++++++---------- crates/gamut-png/src/ancillary.rs | 14 ++++++- crates/gamut-png/src/chunk.rs | 35 ++++++++++++++--- crates/gamut-png/src/encoder.rs | 33 +++++++++++----- crates/gamut-png/tests/c2pa.rs | 20 ++++++++-- 6 files changed, 129 insertions(+), 43 deletions(-) diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index 22c6bb2b..b62432c3 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -21,9 +21,10 @@ Graphics, W3C 3rd edition) images: decode-side inflate. - **C2PA carriage** (issue #440). The manifest store is located, bounded, carried and reserved — never parsed or judged. `with_c2pa` / `with_c2pa_reserved` put it as the last chunk before - `IDAT`, and `encode_with_report` / `PngReport::c2pa` name the chunk's whole span (length, type, - payload, CRC) for the `c2pa.hash.data` exclusion, so a reservation is filled by a second encode - that changes no byte outside it. Validation is `c2pa-rs`'s. + `IDAT`; `encode_with_report` / `PngReport::c2pa` name the chunk's whole span (length, type, + payload, CRC) for the `c2pa.hash.data` exclusion; and `fill_c2pa` writes the signed store into + that span in place, changing no byte outside it. On read the store is the first CRC-valid `caBX` + before `IDAT` — an appended one is counted, never surfaced. Validation is `c2pa-rs`'s. - **Memory-safe.** 100% safe Rust (`#![deny(unsafe_code)]`). ## Usage diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 4d7b0d64..649e9a5c 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -39,7 +39,7 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | P9 | §4.5 | **Space opt:** lossless palette/gray/alpha-drop reduction (size-estimate chosen) + brute-force filter strategy; extended to grey/grey-alpha/16-bit inputs with lossless 16→8 demotion and sub-byte grey packing (#338) | ✅ done | | P10 | — | CLI `gamut convert → .png`; umbrella `png` feature; final API review | ✅ done | | E1 | #224 | **Efficiency:** `deconstruct` byte accounting; divan size/bpp + per-stage bench; libpng-9 size contract; opt-in transparent cleanup; palette-vs-native race; `crc32fast` and restructured filter kernels (see [Efficiency](#efficiency-issue-224)) | ✅ done | -| C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first wins, duplicates counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | +| C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first CRC-valid chunk before `IDAT` wins, ignored ones counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa`, filled in place by `fill_c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | ## Decoder phases (issue #249) @@ -68,14 +68,21 @@ unsafe-to-copy chunk, which is the container enforcing the same no-copy-forward `gamut-metadata`'s `C2paPolicy` states for the facade: a store is bound to the bytes it was signed over, so one copied forward into a rewritten file is invalid by construction. -**Decode.** `DecodedPng::c2pa` / `PngMetadata::c2pa` carry the first `caBX` verbatim, ready for -`MetadataBlock::C2pa`. Exactly one store per file: a later `caBX` is counted in `c2pa_duplicates` -(saturating at 255), never concatenated — PNG has no multi-chunk store, unlike JPEG's APP11 run. -The store is attacker-sized like every ancillary payload, so its bytes are charged to the one -cumulative `with_max_metadata_bytes` budget; a store past the remainder is skipped, not an error, -and — skipped — is still the file's first store, so a smaller one after it is a duplicate rather -than a substitute. A `caBX` whose CRC does not match is skipped (§13.1) on both the decode and the -byte-accounting side, so the two agree on which chunk is the store. +**What counts as the store.** One rule, in every reader: the **first CRC-valid `caBX` before the +first `IDAT`**. First, because a file carries exactly one store (§A.3.2) — PNG has no multi-chunk +store, unlike JPEG's APP11 run. CRC-valid, because §13.1 makes a mismatch skippable and the decode +skips it. Before `IDAT`, because §A.3.2 puts it there and calls data after it bad-form: a `caBX` +appended to a finished file is not that file's provenance, and accepting one would let an appender +give a store to a file that carries none. + +**Decode.** `DecodedPng::c2pa` / `PngMetadata::c2pa` carry that chunk verbatim, ready for +`MetadataBlock::C2pa`. Every CRC-valid `caBX` that is *not* the store — a later one, or any after +`IDAT` — is counted in `c2pa_ignored` (a `usize`; the file's real number, not a saturated +ceiling), never concatenated. `c2pa == None` with a non-zero count is exactly the appended-store +shape. The store is attacker-sized like every ancillary payload, so its bytes are charged to the +one cumulative `with_max_metadata_bytes` budget; a store past the remainder is skipped, not an +error, and — skipped — is still the file's first store, so a smaller one after it is ignored +rather than substituted. **Encode.** `with_c2pa(store)` embeds a store computed for this file; `with_c2pa_reserved(len)` writes `len` zero bytes in its place. Either is emitted as the **last** chunk before the first @@ -86,12 +93,31 @@ reservation, hash with the chunk's span excluded, then encode again with the fin same length — the output is byte-reproducible, so the second file differs from the first only in the payload and the chunk CRC. `tests/c2pa.rs` pins that as an exact-byte diff. -**Exclusion span.** `encode_with_report` (for the file just written) and `PngReport::c2pa` (for -any file) name the chunk's **whole** span — length, type, payload and CRC — as `C2paSpan`, with -the payload bracketed inside it. §18.5.4 says the length and type go inside the exclusion; the CRC -must too, since it changes with the payload, and a `c2pa.hash.data` computed over any of them -breaks on the store's first write. The span is derived from the same chunk walk the byte -accounting uses, so it is always one of the report's claimed segments. +**Exclusion span, and filling it.** `encode_with_report` (for the file just written) and +`PngReport::c2pa` (for any file, including an indexed encode) name the chunk's **whole** span — +length, type, payload and CRC — as `C2paSpan`, with the payload bracketed inside it. §18.5.4 says +the length and type go inside the exclusion; the CRC must too, since it changes with the payload, +and a `c2pa.hash.data` computed over any of them breaks on the store's first write. The span is +derived from the same chunk walk the byte accounting uses, so it is always one of the report's +claimed segments. + +`fill_c2pa(&mut png, &span, store)` then writes the finished store into that span in place, +rewriting the payload and the chunk CRC and nothing else — O(store) rather than the O(encode) of a +second `with_c2pa` pass, and without tying the signature to the encoder reproducing its output. +Its arguments are validated first (span inside the image, framing a chunk, naming a `caBX`, store +exactly the reserved length), so a rejected call leaves the file untouched rather than half +filled. + +A span is **carriage**, not a decode result. The report has no byte budget, so a store past +`with_max_metadata_bytes` is still spanned here while `decode().c2pa` is `None`; likewise +`chunk(b"caBX").count` counts CRC-invalid and post-`IDAT` chunks that `c2pa_ignored` does not. +Each number answers its own question, and the docs say so rather than promising they agree. + +**Placement is ours, not the format's.** The store is written last before `IDAT` so its offset +depends only on what precedes it — the property the reserve-then-fill flow rests on. PNG §14.3.2 +warns that ordering relative to *other ancillary chunks* is never guaranteed and an editor may +insert one after ours, so "last" describes files as this encoder wrote them; readers assume only +"before `IDAT`". **Oracle.** libpng has no C2PA support and carries `caBX` as an unknown chunk — which is exactly the proof needed for framing: for the same payload it must produce the same length, type and CRC @@ -99,10 +125,9 @@ bytes as gamut, it must decode gamut's file pixel-exact with the chunk in place, the store from a libpng-written file. The behavioural oracle (`c2pa-rs`, against which a store's hash assertion can be checked over the excluded span) is issue #447. -**Not done, by design.** No in-place fill helper: a reservation is filled by a second encode, which -costs a second encode. No JUMBF parsing, not even of the outer box length. `gamut convert` does not -carry a store across a re-encode (that is the facade's `C2paPolicy` law, and the CLI's own path is -#448/#483). +**Not done, by design.** No JUMBF parsing, not even of the outer box length. No validation verdict +of any kind. `gamut convert` does not carry a store across a re-encode (that is the facade's +`C2paPolicy` law, and the CLI's own path is #448/#483). ## Efficiency (issue #224) diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 7c697e8e..da7a0cd6 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -6,8 +6,18 @@ //! One chunk here is not PNG's own: the C2PA manifest store, `caBX` (C2PA 2.4 §A.3.2). It is //! emitted **last** of everything before `IDAT`, so that its offset depends only on the chunks //! that precede it and every byte after it is `IDAT` or `IEND` — which is what lets a reserved -//! store be filled in place by a second encode of equal length without moving a byte outside -//! the chunk. §A.3.2 asks only that it precede `IDAT`. +//! store be filled in place ([`crate::fill_c2pa`]) without moving a byte outside the chunk. +//! §A.3.2 asks only that it precede `IDAT`. +//! +//! "Last" is this writer's guarantee about the files it produces, **not** a property that +//! survives other tools. PNG §14.3.2 is explicit that an unsafe-to-copy chunk's ordering +//! requirements are relative to the *critical* chunks only, that "it is never valid to assume +//! that a specific ancillary chunk type occurs with any particular positioning relative to other +//! ancillary chunks", and that a PNG editor may insert another ancillary chunk after one an +//! application always writes last. So a reader must assume no more than "before `IDAT`" — which +//! is exactly what [`crate::PngReport::c2pa`] and the decoder assume — while a *reservation* +//! whose offsets a signer depends on holds only for a file that has not been edited since this +//! encoder wrote it. //! //! Two of them, `bKGD` and `sBIT`, have a payload whose shape is the image's colour type, and the //! encoder does not always write the colour type the caller set them for: auto-reduce may write a diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 75861bb8..b04d90a1 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -59,7 +59,13 @@ impl RawChunk<'_> { /// Where a C2PA manifest store sits in a PNG: the `caBX` chunk's whole span and, inside it, the /// store's own bytes. Reported by /// [`PngEncoder::encode_with_report`](crate::PngEncoder::encode_with_report) for a file just -/// written and by [`PngReport::c2pa`](crate::PngReport::c2pa) for any file. +/// written and by [`PngReport::c2pa`](crate::PngReport::c2pa) for any file, and consumed by +/// [`fill_c2pa`] to write the finished store into it. +/// +/// A span describes **carriage** — where the bytes sit — and says nothing about whether a decode +/// surfaced them: [`PngDecoder::with_max_metadata_bytes`](crate::PngDecoder::with_max_metadata_bytes) +/// can skip a store this span still names, since a byte accounting has no budget and does not +/// borrow another reader's. Exclude the span from a hash; read the payload from the decode. /// /// Non-exhaustive: a later revision may name a further range without a breaking change. #[derive(Debug, Clone, PartialEq, Eq)] @@ -456,7 +462,10 @@ mod tests { ); assert_eq!(short, png, "a rejected fill writes nothing"); let mut long = png.clone(); - assert!(fill_c2pa(&mut long, &span, b"abcde").is_err(), "one byte long"); + assert!( + fill_c2pa(&mut long, &span, b"abcde").is_err(), + "one byte long" + ); assert_eq!(long, png); // A span past the end of the buffer. @@ -471,13 +480,19 @@ mod tests { payload: span.payload.start + 1..span.payload.end, }; let error = fill_c2pa(&mut mine, &skewed, b"abc").expect_err("not framed"); - assert!(error.to_string().contains("does not frame a chunk"), "{error}"); + assert!( + error.to_string().contains("does not frame a chunk"), + "{error}" + ); assert_eq!(mine, png); // A well-framed span naming some other chunk: the IHDR right before it. let ihdr = C2paSpan::of(8..8 + 12 + 13); let error = fill_c2pa(&mut mine, &ihdr, &[0; 13]).expect_err("not a caBX"); - assert!(error.to_string().contains("does not name a caBX"), "{error}"); + assert!( + error.to_string().contains("does not name a caBX"), + "{error}" + ); assert_eq!(mine, png); } @@ -501,13 +516,21 @@ mod tests { write_chunk(&mut after_idat, *b"IDAT", b"zz"); write_chunk(&mut after_idat, CABX, b"appended"); write_chunk(&mut after_idat, *b"IEND", &[]); - assert_eq!(find_c2pa(&after_idat), None, "a caBX after IDAT is not the store"); + assert_eq!( + find_c2pa(&after_idat), + None, + "a caBX after IDAT is not the store" + ); let mut after_iend = SIGNATURE.to_vec(); write_chunk(&mut after_iend, *b"IHDR", &[0; 13]); write_chunk(&mut after_iend, *b"IEND", &[]); write_chunk(&mut after_iend, CABX, b"trailing"); - assert_eq!(find_c2pa(&after_iend), None, "a caBX after IEND is not the store"); + assert_eq!( + find_c2pa(&after_iend), + None, + "a caBX after IEND is not the store" + ); // ...while the same chunk one position earlier — before IDAT — is the store, so the // stop is what decides, not the payload. diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 2b8353f5..b855978b 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -377,6 +377,10 @@ impl PngEncoder { /// into the same bytes — see there for the reserve-then-fill flow. The bytes are not parsed /// or validated: gamut carries the store, `c2pa-rs` judges it. /// + /// To put a finished store into a file that has **already** been encoded, prefer + /// [`fill_c2pa`](crate::fill_c2pa): it rewrites the reserved chunk in place, where this + /// setter re-runs the whole encode. + /// /// A store is bound to the bytes it was signed over, which is why no gamut re-encode helper — /// and never the `gamut-metadata` facade — hands one to this setter: a store copied forward /// into a rewritten file is invalid by construction, and `caBX` is *unsafe to copy* for the @@ -395,17 +399,26 @@ impl PngEncoder { /// The reserve-then-fill flow an external signer needs (C2PA 2.4 §18.5): /// /// 1. encode with the reservation, via [`encode_with_report`](Self::encode_with_report), which - /// names the chunk's span; + /// names the chunk's span (for an indexed image, `deconstruct(&png)?.c2pa()` names the same + /// span — see [`encode_indexed8`](Self::encode_indexed8)); /// 2. hash the output with that **whole** span excluded — length, type, payload and CRC /// (§18.5.4) — and have the signer build the store against it; - /// 3. encode again with [`with_c2pa`](Self::with_c2pa) and the finished store of the **same - /// length**. The encoder's output is byte-reproducible and the store is the last chunk - /// before `IDAT`, so the second file differs from the first only inside that span: the - /// payload and the chunk CRC. Every other byte, and every offset, is unchanged. + /// 3. write the finished store into the span with [`fill_c2pa`](crate::fill_c2pa). Only the + /// payload and the chunk CRC change, so every other byte — and every offset — is the one + /// the signer hashed. + /// + /// Re-encoding with [`with_c2pa`](Self::with_c2pa) and a store of the same length reaches the + /// same bytes, because the output is byte-reproducible and the store is the last chunk before + /// `IDAT`; it costs a second full encode and ties the signature to that reproducibility, which + /// is why the in-place fill is the documented step 3. /// /// The reservation is `len` bytes exactly — no slack is added — so ask for what the signer /// says it needs (`c2pa-rs` reports a `reserve_size`). /// + /// The offsets hold for the file as this encoder wrote it. A PNG editor may lawfully insert + /// another ancillary chunk after the store (PNG §14.3.2), so reserve, hash and fill without + /// passing the file through one. + /// /// The last of `with_c2pa` / `with_c2pa_reserved` wins; a file carries exactly one store. #[must_use] pub fn with_c2pa_reserved(mut self, len: usize) -> Self { @@ -418,10 +431,12 @@ impl PngEncoder { /// [`with_c2pa_reserved`](Self::with_c2pa_reserved). /// /// The report is read back from the bytes written — the same walk - /// [`PngReport::c2pa`](crate::PngReport::c2pa) performs — so it cannot disagree with what a - /// later [`deconstruct`](crate::deconstruct) of the same bytes reports, and an indexed image - /// encoded through [`encode_indexed8`](Self::encode_indexed8) gets the same answer from - /// `deconstruct(&png)?.c2pa()`. + /// [`PngReport::c2pa`](crate::PngReport::c2pa) performs, over the same rule (the first + /// CRC-valid `caBX` before the first `IDAT`) — so it cannot disagree with what a later + /// [`deconstruct`](crate::deconstruct) of the same bytes reports. There is deliberately no + /// indexed twin of this method: [`encode_indexed8`](Self::encode_indexed8) needs a palette and + /// does not fit this shape, and `deconstruct(&png)?.c2pa()` gives an indexed caller the same + /// span, which [`fill_c2pa`](crate::fill_c2pa) then fills. /// /// # Errors /// diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs index 29c4c69f..b1229c9c 100644 --- a/crates/gamut-png/tests/c2pa.rs +++ b/crates/gamut-png/tests/c2pa.rs @@ -383,7 +383,10 @@ fn a_cabx_after_idat_is_never_the_store_but_is_still_visible() { let meta = gamut_png::metadata(&both).expect("metadata"); assert_eq!(meta.c2pa.as_deref(), Some(&b"real store"[..])); assert_eq!(meta.c2pa_ignored, 1); - let span = deconstruct(&both).expect("deconstruct").c2pa().expect("span"); + let span = deconstruct(&both) + .expect("deconstruct") + .c2pa() + .expect("span"); assert_eq!(&both[span.payload], b"real store"); } @@ -433,13 +436,19 @@ fn a_reserved_store_is_filled_in_place_to_the_same_bytes_as_a_second_encode() { .with_c2pa(&finished) .encode_to_vec(image) .expect("encode"); - assert_eq!(reserved, reencoded, "filling in place lands on the encoder's own bytes"); + assert_eq!( + reserved, reencoded, + "filling in place lands on the encoder's own bytes" + ); let outside_after: Vec = (0..reserved.len()) .filter(|i| !span.chunk.contains(i)) .map(|i| reserved[i]) .collect(); - assert_eq!(outside_before, outside_after, "no byte outside the span moved"); + assert_eq!( + outside_before, outside_after, + "no byte outside the span moved" + ); // The filled store reads back through the ordinary decode path, so its CRC is right. assert_eq!( @@ -464,7 +473,10 @@ fn an_indexed_encode_reserves_and_fills_through_the_report() { .encode_indexed8(image, &palette, &mut png) .expect("encode"); - let span = deconstruct(&png).expect("deconstruct").c2pa().expect("span"); + let span = deconstruct(&png) + .expect("deconstruct") + .c2pa() + .expect("span"); let finished = store(24); fill_c2pa(&mut png, &span, &finished).expect("fill"); assert_eq!( From 60aec6421af4441972dc48725422b41d347fd5df Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:47:06 -0400 Subject: [PATCH 07/10] test(png): pin the exact fit in the C2PA fill's bounds check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutation gate caught the gap: `span.chunk.end > png.len()` could be weakened to `>=` and every test still passed, because each fixture put an IDAT and an IEND after the store, so the chunk never ended at the buffer's end. `fill_c2pa` takes a `&mut [u8]`, so the exact fit is a legitimate call — the prefix of a file up to the end of its store, or a file whose store is its last chunk — and under `>=` every such call is refused. --- crates/gamut-png/src/chunk.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index b04d90a1..5452d984 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -496,6 +496,18 @@ mod tests { assert_eq!(mine, png); } + /// The bounds check admits the exact fit: a buffer that ends exactly where the chunk does is + /// in range, not past it. `fill_c2pa` takes a `&mut [u8]`, so a caller may legitimately hand + /// it the prefix of a file up to the end of the store — and a file whose store happens to be + /// its last chunk is the same shape. Off by one here and every such call is refused. + #[test] + fn a_buffer_ending_exactly_where_the_chunk_does_is_in_range() { + let (mut png, span) = reserved(4); + let end = span.chunk.end; + fill_c2pa(&mut png[..end], &span, b"abcd").expect("the chunk ends at the buffer's end"); + assert_eq!(&png[span.payload], b"abcd"); + } + /// A zero-length reservation is a legal chunk, and filling it with nothing is a no-op that /// still verifies — the boundary where payload start and end coincide. #[test] From eca33fa5abda4e0749c697cb3e2e405834cb2128 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 18:37:33 -0400 Subject: [PATCH 08/10] fix(png): reject a C2PA span the chunk's own length contradicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fill_c2pa` validated the span against itself and against the buffer's bounds, then read exactly four bytes of `png` — the chunk type — and wrote. It never compared the span with the length the chunk itself declares, so a span naming a longer chunk than the one actually there passed every guard: the payload and the recomputed CRC went over whatever followed, in practice the IDAT, and the call returned `Ok(())`. The mirror case, a span shorter than the chunk declares, planted the CRC inside the real payload, leaving a chunk `find_c2pa` then skips — the store silently not carried. Reaching it needs only a mismatched span, which the API allows by construction: `C2paSpan`'s fields are `pub`, `#[non_exhaustive]` blocks literal construction but not field assignment, and both hand-out points return owned values. Taking a span from one file and filling another is supported on purpose — the exact-fit case does it — which is precisely why the declared length has to be checked rather than assumed. No panic and no memory-safety issue: every write stayed inside the slice. But the documented contract says a span whose bytes are not a `caBX` chunk is rejected, and these bytes are not one. --- crates/gamut-png/src/chunk.rs | 73 +++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 5452d984..b4be1659 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -234,10 +234,15 @@ impl<'a> ChunkReader<'a> { /// /// Returns [`Error::InvalidInput`] and leaves `png` **unmodified** if `span` runs past the end of /// `png`, if it does not frame a chunk (its payload must be `chunk.start + 8 .. chunk.end - 4`), -/// if the bytes it names are not a `caBX` chunk, or if `store` is not exactly the reserved -/// length. Every check runs before the first byte is written, so a rejected call cannot leave a -/// half-filled chunk behind — and a store of the wrong length is rejected rather than resized, -/// because resizing would move every byte after the chunk and invalidate the signer's hash. +/// if the bytes it names are not a `caBX` chunk, if the span's payload length disagrees with the +/// length the chunk itself declares, or if `store` is not exactly the reserved length. Every check +/// runs before the first byte is written, so a rejected call cannot leave a half-filled chunk +/// behind — and a store of the wrong length is rejected rather than resized, because resizing +/// would move every byte after the chunk and invalidate the signer's hash. +/// +/// `png` need not be the buffer the span came from — filling a copy, or a buffer that ends where +/// the chunk does, is supported — which is exactly why the chunk's declared length is checked +/// against the span rather than assumed to match it. /// /// # Example /// @@ -283,6 +288,22 @@ pub fn fill_c2pa(png: &mut [u8], span: &C2paSpan, store: &[u8]) -> Result<()> { if png[span.chunk.start + 4..span.payload.start] != CABX { return Err(invalid("PNG: the C2PA span does not name a caBX chunk")); } + // The span must agree with the chunk's *own* length field, not merely with itself. Until this + // check the only bytes of `png` read were the four type bytes, so a span taken from one file + // and applied to another — which the `&mut [u8]` signature deliberately allows — could name a + // longer chunk than the one that is there and write the payload and CRC over whatever follows + // it, most likely IDAT, and report success. + let declared = u32::from_be_bytes([ + png[span.chunk.start], + png[span.chunk.start + 1], + png[span.chunk.start + 2], + png[span.chunk.start + 3], + ]); + if u64::try_from(span.payload.len()) != Ok(u64::from(declared)) { + return Err(invalid( + "PNG: the C2PA span disagrees with the chunk's declared length", + )); + } if store.len() != span.payload.len() { return Err(invalid("PNG: the C2PA store is not the reserved length")); } @@ -496,6 +517,50 @@ mod tests { assert_eq!(mine, png); } + /// A span must agree with the chunk's own length field, in both directions. `png` need not + /// be the buffer the span was taken from, so a span from a file whose store is long, applied + /// to a file whose store is short, would otherwise write the payload and a CRC straight over + /// the bytes that follow — here the `IDAT` — and report success. + #[test] + fn filling_rejects_a_span_the_chunks_declared_length_contradicts() { + // A caBX declaring 4 payload bytes, followed by a long IDAT. + let mut png = SIGNATURE.to_vec(); + write_chunk(&mut png, *b"IHDR", &[0; 13]); + let chunk_start = png.len(); + write_chunk(&mut png, CABX, &[0; 4]); + write_chunk(&mut png, *b"IDAT", &[0xEE; 40]); + write_chunk(&mut png, *b"IEND", &[]); + let untouched = png.clone(); + + // A span claiming a 40-byte payload at the same offset: every earlier guard passes — it + // is in bounds, it frames a chunk, and it starts at a real caBX. + let overlong = C2paSpan::of(chunk_start..chunk_start + 12 + 40); + let error = fill_c2pa(&mut png, &overlong, &[7; 40]).expect_err("longer than declared"); + assert!(error.to_string().contains("declared length"), "{error}"); + assert_eq!(png, untouched, "nothing was written over the IDAT"); + + // ...and the mirror: a span shorter than the chunk declares would plant the CRC inside + // the real payload, leaving a chunk no reader accepts. + let mut long_store = SIGNATURE.to_vec(); + write_chunk(&mut long_store, *b"IHDR", &[0; 13]); + let start = long_store.len(); + write_chunk(&mut long_store, CABX, &[0; 40]); + write_chunk(&mut long_store, *b"IEND", &[]); + let before = long_store.clone(); + let short = C2paSpan::of(start..start + 12 + 4); + let error = fill_c2pa(&mut long_store, &short, &[7; 4]).expect_err("shorter than declared"); + assert!(error.to_string().contains("declared length"), "{error}"); + assert_eq!(long_store, before); + + // The matching span still fills, so the check rejects disagreement, not every span. + let exact = C2paSpan::of(chunk_start..chunk_start + 12 + 4); + fill_c2pa(&mut png, &exact, b"good").expect("the declared length matches"); + assert_eq!( + find_c2pa(&png).map(|s| png[s.payload].to_vec()), + Some(b"good".to_vec()) + ); + } + /// The bounds check admits the exact fit: a buffer that ends exactly where the chunk does is /// in range, not past it. `fill_c2pa` takes a `&mut [u8]`, so a caller may legitimately hand /// it the prefix of a file up to the end of the store — and a file whose store happens to be From c49be4c42e29254fde22714ea5bb17f966d03c2e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 18:39:30 -0400 Subject: [PATCH 09/10] fix(png): count every ignored C2PA store, and say what is not counted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `c2pa_ignored`'s docs claimed that `c2pa == None` with a non-zero count meant someone had appended a store to a file carrying none. The unit test forty lines below asserted the counter-example: two `caBX` chunks both *before* IDAT, the first over the metadata budget, gives exactly that pair with nothing appended. The converse failed too — the canonical append is a `caBX` after IEND, which is a trailer neither walk reaches, so it counted zero. A caller gating injection detection on the field got both a false positive and a false negative on the one case the sentence named. The count now covers every CRC-valid `caBX` in the datastream that was not surfaced as the store, which adds the budget-skipped store-position chunk `collect` already saw and previously passed over. First-wins is unchanged: the first chunk still claims the store position whether or not it is admitted, so an oversized store cannot be substituted by a smaller one after it. The docs — the two struct fields, STATUS.md and the README — now state the three cases it counts, that it does not distinguish them, and that a chunk after IEND is outside the datastream and therefore outside the count, with `deconstruct`'s trailer segment named as where that shape is visible instead. Both claims are pinned by tests. --- crates/gamut-png/README.md | 3 +- crates/gamut-png/STATUS.md | 24 ++++++++------ crates/gamut-png/src/decoded.rs | 51 ++++++++++++++++++----------- crates/gamut-png/tests/c2pa.rs | 57 +++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 28 deletions(-) diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index b62432c3..084b698c 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -24,7 +24,8 @@ Graphics, W3C 3rd edition) images: `IDAT`; `encode_with_report` / `PngReport::c2pa` name the chunk's whole span (length, type, payload, CRC) for the `c2pa.hash.data` exclusion; and `fill_c2pa` writes the signed store into that span in place, changing no byte outside it. On read the store is the first CRC-valid `caBX` - before `IDAT` — an appended one is counted, never surfaced. Validation is `c2pa-rs`'s. + before `IDAT`; every other one in the datastream is counted in `c2pa_ignored`, never surfaced. + Validation is `c2pa-rs`'s. - **Memory-safe.** 100% safe Rust (`#![deny(unsafe_code)]`). ## Usage diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 649e9a5c..260f098a 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -76,13 +76,18 @@ appended to a finished file is not that file's provenance, and accepting one wou give a store to a file that carries none. **Decode.** `DecodedPng::c2pa` / `PngMetadata::c2pa` carry that chunk verbatim, ready for -`MetadataBlock::C2pa`. Every CRC-valid `caBX` that is *not* the store — a later one, or any after -`IDAT` — is counted in `c2pa_ignored` (a `usize`; the file's real number, not a saturated -ceiling), never concatenated. `c2pa == None` with a non-zero count is exactly the appended-store -shape. The store is attacker-sized like every ancillary payload, so its bytes are charged to the -one cumulative `with_max_metadata_bytes` budget; a store past the remainder is skipped, not an -error, and — skipped — is still the file's first store, so a smaller one after it is ignored -rather than substituted. +`MetadataBlock::C2pa`. The store is attacker-sized like every ancillary payload, so its bytes are +charged to the one cumulative `with_max_metadata_bytes` budget; a store past the remainder is +skipped, not an error, and — skipped — is still the file's first store, so a smaller one after it +is ignored rather than substituted. + +`c2pa_ignored` counts every CRC-valid `caBX` **in the datastream** that was not surfaced as the +store (a `usize`: the file's real number, not a saturated ceiling). That is three cases — a chunk +later than the first, one positioned after `IDAT`, and the store-position chunk itself when it +busted the budget — and the count deliberately does not say which: `c2pa == None` with a non-zero +count is any of them, not evidence of an appended store. What it does **not** cover is a `caBX` +after `IEND`, which is a trailer rather than part of the datastream (§13.2) and which neither +metadata walk reaches; that shape is visible in `deconstruct`'s report, as a trailer segment. **Encode.** `with_c2pa(store)` embeds a store computed for this file; `with_c2pa_reserved(len)` writes `len` zero bytes in its place. Either is emitted as the **last** chunk before the first @@ -110,8 +115,9 @@ filled. A span is **carriage**, not a decode result. The report has no byte budget, so a store past `with_max_metadata_bytes` is still spanned here while `decode().c2pa` is `None`; likewise -`chunk(b"caBX").count` counts CRC-invalid and post-`IDAT` chunks that `c2pa_ignored` does not. -Each number answers its own question, and the docs say so rather than promising they agree. +`chunk(b"caBX").count` counts CRC-invalid chunks and chunks in the trailer, which `c2pa_ignored` +does not. Each number answers its own question, and the docs say so rather than promising they +agree. **Placement is ours, not the format's.** The store is written last before `IDAT` so its offset depends only on what precedes it — the property the reserve-then-fill flow rests on. PNG §14.3.2 diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index b3a593c5..0b41153a 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -150,15 +150,19 @@ pub struct DecodedPng { /// Feed as `MetadataBlock::C2pa`. The first CRC-valid `caBX` before the first `IDAT`, and /// only when it fits the metadata budget; see [`c2pa_ignored`](Self::c2pa_ignored). pub c2pa: Option>, - /// How many CRC-valid `caBX` chunks the file carries that were **not** surfaced as the - /// store: any after the first, and any positioned after `IDAT`. + /// How many CRC-valid `caBX` chunks **in the datastream** were not surfaced as the store: + /// a chunk later than the first, a chunk positioned after `IDAT`, and the store-position + /// chunk itself when it did not fit the metadata budget. /// /// A file carries exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's - /// APP11 run — so a non-zero count marks a malformed file whose extra chunks were ignored - /// rather than concatenated. The post-`IDAT` case is worth its own attention: §A.3.2 places - /// the store before `IDAT` and calls data after it bad-form, so a `caBX` appended to a - /// finished file is never read as the store. A file whose `c2pa` is `None` while this is - /// non-zero is exactly that shape — someone appended a store to a file that carries none. + /// APP11 run — so a non-zero count marks a file whose extra chunks were ignored rather than + /// concatenated. It does **not** identify why: a store past the budget and a chunk appended + /// after `IDAT` both land here, and `c2pa == None` with a non-zero count is either. + /// + /// A `caBX` after `IEND` is **not** counted. Bytes after `IEND` are a trailer rather than + /// part of the datastream (§13.2), and neither metadata walk reads them. To see one — the + /// shape of a chunk appended to a finished file — use + /// [`deconstruct`](crate::deconstruct), whose report accounts the trailer as a segment. pub c2pa_ignored: usize, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, @@ -222,15 +226,19 @@ pub struct PngMetadata { /// `caBX` before the first `IDAT`, and only when it fits the metadata budget; see /// [`c2pa_ignored`](Self::c2pa_ignored). pub c2pa: Option>, - /// How many CRC-valid `caBX` chunks the file carries that were **not** surfaced as the - /// store: any after the first, and any positioned after `IDAT`. + /// How many CRC-valid `caBX` chunks **in the datastream** were not surfaced as the store: + /// a chunk later than the first, a chunk positioned after `IDAT`, and the store-position + /// chunk itself when it did not fit the metadata budget. /// /// A file carries exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's - /// APP11 run — so a non-zero count marks a malformed file whose extra chunks were ignored - /// rather than concatenated. The post-`IDAT` case is worth its own attention: §A.3.2 places - /// the store before `IDAT` and calls data after it bad-form, so a `caBX` appended to a - /// finished file is never read as the store. A file whose `c2pa` is `None` while this is - /// non-zero is exactly that shape — someone appended a store to a file that carries none. + /// APP11 run — so a non-zero count marks a file whose extra chunks were ignored rather than + /// concatenated. It does **not** identify why: a store past the budget and a chunk appended + /// after `IDAT` both land here, and `c2pa == None` with a non-zero count is either. + /// + /// A `caBX` after `IEND` is **not** counted. Bytes after `IEND` are a trailer rather than + /// part of the datastream (§13.2), and neither metadata walk reads them. To see one — the + /// shape of a chunk appended to a finished file — use + /// [`deconstruct`](crate::deconstruct), whose report accounts the trailer as a segment. pub c2pa_ignored: usize, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, @@ -247,8 +255,9 @@ pub struct PngMetadata { /// Parses the metadata-bearing ancillary chunks collected from the stream (in file order). /// Malformed payloads skip their chunk (§13.1); compressed payloads — and the uncompressed but /// attacker-sized `caBX` store — share `budget` bytes of output, and a payload that would bust -/// the remainder is skipped, not an error. Once-only chunks keep their first occurrence; a -/// second `caBX` is additionally counted, since exactly one store is the rule (C2PA §A.3.2). +/// the remainder is skipped, not an error. Once-only chunks keep their first occurrence; every +/// CRC-valid `caBX` this walk does not surface as the store — a later one, or the first when it +/// busts the budget — is counted, since exactly one store is the rule (C2PA §A.3.2). /// /// `chunks` holds only chunks in a position where a store may appear: the caller's walk drops a /// `caBX` after `IDAT` before it gets here and counts it into @@ -265,12 +274,18 @@ pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata b"eXIf" if meta.exif.is_none() => meta.exif = Some(data.to_vec()), _ if chunk_type == CABX => { if seen_c2pa { + // A second store-position chunk: never the store, whatever became of the + // first, so an oversized store cannot be substituted by a smaller one. meta.c2pa_ignored += 1; } else { seen_c2pa = true; if data.len() <= budget { budget -= data.len(); meta.c2pa = Some(data.to_vec()); + } else { + // In store position but not admitted: counted, like every other + // CRC-valid `caBX` this walk declines to surface. + meta.c2pa_ignored += 1; } } } @@ -625,8 +640,8 @@ mod tests { let busts = collect(&[(CABX, &store), (CABX, b"tiny")], 9); assert_eq!(busts.c2pa, None, "one byte over the budget is skipped"); assert_eq!( - busts.c2pa_ignored, 1, - "the skipped store is still the first; the next is a duplicate, not a substitute" + busts.c2pa_ignored, 2, + "both are ignored: the store busted the budget, and the next is not a substitute" ); } diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs index b1229c9c..fc58e84f 100644 --- a/crates/gamut-png/tests/c2pa.rs +++ b/crates/gamut-png/tests/c2pa.rs @@ -485,6 +485,63 @@ fn an_indexed_encode_reserves_and_fills_through_the_report() { ); } +/// The budget-skipped store is counted like every other chunk the walk declines to surface, so +/// the count means "CRC-valid `caBX` chunks in the datastream that are not the store" and not +/// "chunks somebody appended". Both entry points agree. +#[test] +fn a_store_past_the_budget_is_counted_among_the_ignored() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let png = PngEncoder::new() + .with_c2pa(&store(1000)) + .encode_to_vec(image) + .expect("encode"); + + let generous = PngDecoder::new().with_max_metadata_bytes(1000); + let meta = generous.metadata(&png).expect("metadata"); + assert!(meta.c2pa.is_some()); + assert_eq!(meta.c2pa_ignored, 0, "an admitted store is not ignored"); + + let tight = PngDecoder::new().with_max_metadata_bytes(999); + let meta = tight.metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa, None); + assert_eq!( + meta.c2pa_ignored, 1, + "the store the budget skipped is counted" + ); + let decoded = tight.decode(&png).expect("decode"); + assert_eq!(decoded.c2pa_ignored, 1, "decode agrees with metadata"); +} + +/// What the count deliberately does **not** see: a `caBX` after `IEND` is a trailer, not part of +/// the datastream (§13.2), so neither metadata walk reaches it and it is counted zero. The byte +/// accounting is where that shape shows up, as a trailer segment — which is what the field's +/// docs point a caller at. +#[test] +fn a_cabx_after_iend_is_a_trailer_the_report_sees_and_the_count_does_not() { + let mut png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let datastream_len = png.len(); + png.extend_from_slice(&chunk(b"caBX", b"appended after IEND")); + + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa, None); + assert_eq!(meta.c2pa_ignored, 0, "a trailer is not in the datastream"); + + let report = deconstruct(&png).expect("deconstruct"); + assert_eq!(report.c2pa(), None, "a trailer is not the store either"); + let trailer = report + .segments + .iter() + .find(|segment| segment.kind == SegmentKind::Trailer) + .expect("the appended bytes are accounted as a trailer"); + assert_eq!(trailer.range, datastream_len..png.len()); + assert!(report.is_fully_classified()); +} + /// A `caBX` whose CRC does not match is skipped on decode (§13.1) — it is not the store and /// it is not a duplicate either, since it never reaches the metadata pass — and the exclusion /// span names the CRC-valid store the decoder actually surfaces, not the damaged bytes before From e1dddb7660f9206e3ea9b6f46bae2cea0c4bfc88 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:05:06 -0400 Subject: [PATCH 10/10] refactor(png): read the C2PA chunk header as bytes, not offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared-length check read the length field through four indexed reads, `start`, `start + 1`, `start + 2`, `start + 3`. The mutation gate found the first of those offsets unkillable: for any payload under 64 KiB the top two length bytes are both zero, so reading one in place of the other changes nothing a fixture of that size can observe. The offsets are gone rather than papered over with a 64 KiB fixture. The eight header bytes are taken as one borrow and split where §5.3 splits them, and the length field is compared as bytes against the span's own payload length in network order — so there is no offset arithmetic left to get wrong, and a wrong split fails the type check that follows. The test gains the case the byte comparison earns: a 300-byte store, where a span agreeing only in the low byte (44) is rejected, which no single-byte length could have shown. --- crates/gamut-png/src/chunk.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index b4be1659..72378efc 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -285,7 +285,11 @@ pub fn fill_c2pa(png: &mut [u8], span: &C2paSpan, store: &[u8]) -> Result<()> { if !frames { return Err(invalid("PNG: the C2PA span does not frame a chunk")); } - if png[span.chunk.start + 4..span.payload.start] != CABX { + // The chunk's eight header bytes, split where §5.3 splits them. Taken as one borrow rather + // than by indexed reads so that no byte offset is computed here twice: `frames` above already + // established that these eight bytes are inside the buffer. + let (declared, kind) = png[span.chunk.start..span.payload.start].split_at(4); + if kind != CABX { return Err(invalid("PNG: the C2PA span does not name a caBX chunk")); } // The span must agree with the chunk's *own* length field, not merely with itself. Until this @@ -293,13 +297,11 @@ pub fn fill_c2pa(png: &mut [u8], span: &C2paSpan, store: &[u8]) -> Result<()> { // and applied to another — which the `&mut [u8]` signature deliberately allows — could name a // longer chunk than the one that is there and write the payload and CRC over whatever follows // it, most likely IDAT, and report success. - let declared = u32::from_be_bytes([ - png[span.chunk.start], - png[span.chunk.start + 1], - png[span.chunk.start + 2], - png[span.chunk.start + 3], - ]); - if u64::try_from(span.payload.len()) != Ok(u64::from(declared)) { + let agrees = matches!( + u32::try_from(span.payload.len()), + Ok(payload_len) if declared == payload_len.to_be_bytes() + ); + if !agrees { return Err(invalid( "PNG: the C2PA span disagrees with the chunk's declared length", )); @@ -559,6 +561,20 @@ mod tests { find_c2pa(&png).map(|s| png[s.payload].to_vec()), Some(b"good".to_vec()) ); + + // A length that does not fit one byte: every byte of the field is compared, so a span + // agreeing only in the low byte (300 vs 44) is rejected, and the true span fills. + let mut wide = SIGNATURE.to_vec(); + write_chunk(&mut wide, *b"IHDR", &[0; 13]); + let wide_start = wide.len(); + write_chunk(&mut wide, CABX, &[0; 300]); + write_chunk(&mut wide, *b"IEND", &[]); + let low_byte_only = C2paSpan::of(wide_start..wide_start + 12 + 44); + let error = fill_c2pa(&mut wide, &low_byte_only, &[7; 44]).expect_err("300 is not 44"); + assert!(error.to_string().contains("declared length"), "{error}"); + let whole = C2paSpan::of(wide_start..wide_start + 12 + 300); + fill_c2pa(&mut wide, &whole, &[9; 300]).expect("the declared length matches"); + assert_eq!(find_c2pa(&wide).map(|s| wide[s.payload].len()), Some(300)); } /// The bounds check admits the exact fit: a buffer that ends exactly where the chunk does is