diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index ab58f048..084b698c 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -14,10 +14,18 @@ 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`; `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`; 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 @@ -50,7 +58,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 9bfb83cb..260f098a 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 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) @@ -52,6 +53,88 @@ 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. + +**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`. 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 +`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, 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 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 +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 +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 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) 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..da7a0cd6 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -3,6 +3,22 @@ //! 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 ([`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 //! palette, a greyscale or a colour-keyed truecolour image in place of the input's layout, and the @@ -115,6 +131,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 +208,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 +241,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 +542,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..72378efc 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,73 @@ 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, 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)] +#[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 **before the first +/// `IDAT`**, or `None`. +/// +/// 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)); + } + } + None +} + /// Iterates the chunks of a PNG stream after validating the signature (§5.2). pub(crate) struct ChunkReader<'a> { rest: &'a [u8], @@ -130,6 +208,116 @@ 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, 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 +/// +/// ``` +/// 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")); + } + // 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 + // 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 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", + )); + } + 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::*; @@ -204,6 +392,274 @@ 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"); + } + + /// 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 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()) + ); + + // 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 + /// 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] + 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. + #[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. + #[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..0b41153a 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,25 @@ 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 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 **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 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, /// gAMA: image gamma × 100 000 (§11.3.2.2) — the unit the encoder's `with_gamma` writes. @@ -165,7 +187,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 +221,25 @@ 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 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 **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 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, /// gAMA: image gamma × 100 000 (§11.3.2.2). @@ -211,15 +253,42 @@ 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; 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 +/// [`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; + // 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 { + // 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; + } + } + } b"iCCP" if meta.icc_profile.is_none() => { meta.icc_profile = parse_iccp(data, &mut budget); } @@ -520,6 +589,62 @@ 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_ignored, 2); + + let single = collect(&[(CABX, b"only")], 1024); + assert_eq!(single.c2pa.as_deref(), Some(&b"only"[..])); + assert_eq!(single.c2pa_ignored, 0); + assert_eq!(collect(&[], 1024).c2pa, None); + } + + /// 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_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_ignored, 300); + } + + /// `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_ignored, 2, + "both are ignored: the store busted the budget, and the next is 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..8a595eb4 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}; @@ -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; @@ -177,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 @@ -223,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; @@ -320,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)); + } } } _ => { @@ -351,6 +367,7 @@ impl PngDecoder { trns, idat, ancillary, + c2pa_after_idat, }) } @@ -388,7 +405,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 /// @@ -397,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, @@ -415,6 +436,8 @@ impl PngDecoder { exif: meta.exif, icc_profile: meta.icc_profile, xmp: meta.xmp, + c2pa: meta.c2pa, + c2pa_ignored: meta.c2pa_ignored, texts: meta.texts, gamma: meta.gamma, chromaticities: meta.chromaticities, @@ -454,8 +477,8 @@ 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. @@ -525,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. @@ -532,7 +560,12 @@ 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 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> { let mut reader = ChunkReader::new(data)?; let first = reader .next_chunk()? @@ -554,6 +587,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 { @@ -580,11 +615,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)); + } } } _ => { @@ -604,16 +644,28 @@ 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. /// /// 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. @@ -661,8 +713,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 1b970d5d..04c07a9d 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; @@ -401,6 +401,60 @@ impl PngReport { ) } + /// 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`. + /// + /// 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 { + 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. /// /// 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 dd4ed258..b855978b 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -29,7 +29,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; @@ -59,6 +59,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 { @@ -353,6 +368,91 @@ 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. + /// + /// 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 + /// 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 (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. 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 { + 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, 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 + /// + /// 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. /// @@ -562,7 +662,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 @@ -588,7 +689,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..9ddb73a7 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,20 @@ //! 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). [`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 //! //! The PNG codestream is the concatenated-IDAT **zlib stream**, and it is where PNG spends its @@ -72,6 +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, fill_c2pa}; pub use color::ColorType; pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, @@ -81,7 +97,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..fc58e84f --- /dev/null +++ b/crates/gamut-png/tests/c2pa.rs @@ -0,0 +1,642 @@ +//! 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. 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; + +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, + fill_c2pa, +}; + +/// 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_ignored, 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_ignored, 0); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa, meta.c2pa); + assert_eq!(decoded.c2pa_ignored, 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_ignored, 1); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa.as_deref(), Some(&b"first"[..])); + assert_eq!(decoded.c2pa_ignored, 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` 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) + ); +} + +/// 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 +/// 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_ignored, 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..e63c8d94 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_ignored, decoded.c2pa_ignored); assert_eq!(meta.texts, decoded.texts); assert_eq!(meta.gamma, decoded.gamma); assert_eq!(meta.chromaticities, decoded.chromaticities);