diff --git a/crates/gamut-riff/README.md b/crates/gamut-riff/README.md index 39032938..93a4b588 100644 --- a/crates/gamut-riff/README.md +++ b/crates/gamut-riff/README.md @@ -32,11 +32,15 @@ directly. ## Status -**Stable at v1** — the public surface is frozen; see [`STATUS.md`](STATUS.md) for the full ledger of -what is covered, what is deliberately settled, and what is deferred. The simple-WebP container and -the extended format (`VP8X` plus `ALPH`, `ICCP`, `EXIF`, `XMP `, and unknown chunks) are +See [`STATUS.md`](STATUS.md) for the full ledger of what is covered, what is deliberately settled, +and what is deferred. The simple-WebP container and the extended format (`VP8X` plus `ALPH`, `ICCP`, +`EXIF`, `XMP `, the `C2PA` manifest-store chunk of C2PA 2.4 §A.3.7, and unknown chunks) are implemented, read and write, and validated against libwebp's demuxer as the differential oracle. +`MetadataChunks` is exhaustive by design, so the `c2pa` field it gained for the manifest store is a +breaking change: code that builds one with a struct literal adds `c2pa: None` (or +`..Default::default()`). Nothing else in the surface changed shape. + Animation (`ANIM`/`ANMF`) is **out of scope** — recognized FourCCs only, so an animated file is reported as unsupported rather than mis-parsed. Multi-frame sequences sit outside the image-first charter; see diff --git a/crates/gamut-riff/STATUS.md b/crates/gamut-riff/STATUS.md index 9402bddb..f0abc090 100644 --- a/crates/gamut-riff/STATUS.md +++ b/crates/gamut-riff/STATUS.md @@ -16,7 +16,13 @@ and `tests/oracle.rs` against libwebp's demuxer. ## Scope The authority is **RFC 9649 §2** (*WebP Image Format*) and the Google *WebP Container* specification -in [`references/webp/`](../../references/webp). The canonical RIFF document is *cited* by RFC 9649 +in [`references/webp/`](../../references/webp). One carrier comes from outside RFC 9649: the `C2PA` chunk of C2PA 2.4 §A.3.7, whose manifest +store is carried as opaquely as `ICCP`/`EXIF`/`XMP ` are. The specification is vendored in +[`references/c2pa/`](../../references/c2pa); §A.3.7 fixes the chunk's identifier and its place as +the last sub-chunk of the form, and §18.5 fixes what a `c2pa.hash.data` exclusion covers. Nothing +here parses, signs or validates a store. + +The canonical RIFF document is *cited* by RFC 9649 (as a Library of Congress FDD URL), not vendored, so the wider RIFF vocabulary it defines — `LIST`, arbitrary form types, the AVI/WAVE chunks — is out of scope and unimplemented. What this crate calls "RIFF" is precisely the subset WebP uses: a flat chunk list under a single `RIFF`/`WEBP` form. @@ -44,15 +50,19 @@ Dev-only — the shipped library links no C. | `RiffReader<'a>` | `new` + `Iterator>` + `trailing_bytes` | permissive; iteration ends after the first error | | `RiffWriter` | `new`/`write_chunk`/`finish`, all fallible past the size fields | private buffer — internal representation stays free | | `Vp8xHeader` | 5 feature flags + 1-based canvas, `to_payload`/`from_payload` | public fields; both directions validate the canvas | -| `MetadataChunks<'a>` | borrowed `icc`/`exif`/`xmp` + `read`/`is_empty` | public fields; payloads never parsed or reserialized | +| `MetadataChunks<'a>` | borrowed `icc`/`exif`/`xmp`/`c2pa` + `read`/`is_empty` | public fields; payloads never parsed or reserialized | | `WebpLayout<'a>` | `parse` + the sorted roles, `#[non_exhaustive]` | strict reader; new roles can be added non-breakingly | | `WebpChunkId` | fieldless variants + `Unknown(FourCc)`, `#[non_exhaustive]` | new chunk kinds can be recognised non-breakingly | | `write_simple_lossless` / `write_simple_lossy` | one bitstream chunk, §2.5-§2.6 | free functions returning `Result>` | | `write_extended` / `write_extended_with_metadata` / `write_extended_preserving` | the extended format at three levels of assistance | as above | -| `VP8X_PAYLOAD_LEN`, `MAX_CANVAS_DIMENSION` | documented spec constants | literals the surface's own docs name | +| `c2pa_span` | the `C2PA` chunk's whole byte span in a file, or `None` | free function returning `Result>>` | +| `VP8X_PAYLOAD_LEN`, `MAX_CANVAS_DIMENSION`, `C2PA_FOURCC` | documented spec constants | literals the surface's own docs name | Adding chunk kinds, layout roles, writer helpers, or trait impls stays backward-compatible; -removing or reshaping any of the above would not. +removing or reshaping any of the above would not. `MetadataChunks` is the one exhaustive struct +here, deliberately so — a new carrier should make every writer of a struct literal look at it — and +adding `c2pa` for the manifest store is therefore a **breaking** change, the crate's first since v1. +The migration is one line: `c2pa: None`, or `..Default::default()`. ## Container coverage @@ -74,6 +84,8 @@ whose declared owner is this crate. Every row there is ✅ or ⊘ as of v1. | `ICCP` colour profile, verbatim | §2.7.1.4 | ✅ | | `EXIF` / `XMP ` metadata, verbatim, first of each kind wins | §2.7.1.5 | ✅ | | Unknown chunks: ignored on read, order preserved, re-emittable | §2.7.1.6 | ✅ | +| `C2PA` manifest store, verbatim, written as the last sub-chunk of the form | C2PA §A.3.7 | ✅ | +| `c2pa.hash.data` exclusion span: whole chunk, pad byte excluded | C2PA §18.5 | ✅ | | `ANIM` / `ANMF` animation | §2.7.1.1 | ⊘ out of scope | ## Settled design decisions (intentional, not gaps) @@ -94,6 +106,35 @@ whose declared owner is this crate. Every row there is ✅ or ⊘ as of v1. - **`ICCP` is ordered, `EXIF`/`XMP ` are not.** §2.7 lists `ICCP` among the chunks that MUST appear in order and §2.7.1.4 adds "MUST appear before the image data", while the same paragraph exempts metadata and unknown chunks. The asymmetry is the spec's, not an oversight. +- **The `C2PA` chunk is placed by the writer, not policed by the reader.** §A.3.7 says the chunk + "shall appear as the last sub-chunk of the first RIFF header chunk", so `write_extended_preserving` + emits it after `EXIF`, `XMP ` and even the preserved unknown chunks. The readers accept it + anywhere, because §2.7 does not list `C2PA` among the chunks whose order a reader may fail a file + over, and a store found in a file gamut did not write is still a store. It is *recognised* + (`WebpChunkId::C2pa`) rather than left unknown, so a read/modify/write cycle re-emits it once, in + its mandated place, instead of twice. +- **A `C2PA` chunk never rides along among the unknown chunks.** `write_extended_preserving` writes + at most one, always in the store's place at the end. With `MetadataChunks::c2pa` set, a copy in + `unknown` is **dropped**: two would be resolved by "first of each kind wins" in favour of the + passed-through copy, and `c2pa_span` would then report a range over bytes the caller never + configured — exactly the range a signer excludes from its hash. With no store configured the copy + is **kept** and written in the store's place, because in the file it came from it *is* the store; + dropping it would lose a foreign manifest store with no signal. `write_extended` is the unfiltered + escape hatch for a caller assembling a file by hand. +- **The layers are deliberately asymmetric.** This function is total — it always produces a + conformant file — while `gamut-webp`'s `WebpEncoder::with_unknown_chunks` *rejects* the same input + with a typed error. The high-level builder can name the offending call, which is the better + diagnostic; the low-level writer has no caller to blame and stays usable for a re-wrap that must + not fail. +- **No `VP8X` feature flag advertises a store.** RFC 9649 §2.5's flag byte defines no C2PA bit and + the reserved bits "MUST be 0", so presence is decided by the chunk alone — the same rule the crate + already applies to `ICCP`/`EXIF`/`XMP `, where flags are advisory. +- **The reported span is the whole chunk, minus the pad byte.** `c2pa_span` covers the identifier + and the size field as well as the payload, because an update manifest may resize the store and + that changes the size field's value (§18.5). The RIFF pad byte after an odd-length store stays + outside it: §2.3 makes the byte framing the container adds, which is why `Chunk::payload` excludes + it too. (§18.7.3.5's *general box hash* draws the boundary the other way, "to the padding byte, if + any, inclusive" — that is `c2pa.hash.boxes`, a different assertion this crate does not serve.) - **A non-zero pad byte fails its chunk.** The byte "MUST be 0 to conform with RIFF" (§2.3) and is attacker-controlled otherwise; a chunk whose framing is already known bad is never handed out. A pad byte *absent* from a final chunk still parses — there is then nothing to check. diff --git a/crates/gamut-riff/src/lib.rs b/crates/gamut-riff/src/lib.rs index 1767ce04..a5c15fd8 100644 --- a/crates/gamut-riff/src/lib.rs +++ b/crates/gamut-riff/src/lib.rs @@ -8,7 +8,9 @@ //! specification in `references/webp/`; the crate's `STATUS.md` ledgers the v1 surface, and //! `gamut-webp/STATUS.md` section A is the per-requirement conformance table this crate owns. //! Metadata chunks (`ICCP`/`EXIF`/`XMP `) are carried verbatim through [`MetadataChunks`] and -//! [`write_extended_with_metadata`], never parsed or reserialized. +//! [`write_extended_with_metadata`], never parsed or reserialized, as is the `C2PA` manifest store +//! of C2PA 2.4 §A.3.7 — the one carrier RFC 9649 does not define, placed last as §A.3.7 requires +//! and located by [`c2pa_span`]. //! //! # Reading //! @@ -17,7 +19,7 @@ //! | Reader | Yields | Rejects | //! | ------ | ------ | ------- | //! | [`RiffReader`] | every chunk, in file order | only what it cannot frame | -//! | [`MetadataChunks::read`] | the `ICCP`/`EXIF`/`XMP ` triple | malformed framing | +//! | [`MetadataChunks::read`] | the `ICCP`/`EXIF`/`XMP `/`C2PA` payloads | malformed framing | //! | [`WebpLayout::parse`] | every chunk sorted into its role | + chunks out of the spec's order | //! //! Animation (`ANIM`/`ANMF`) is out of scope: the FourCCs are recognised, so an animated file is @@ -46,8 +48,8 @@ pub use chunk::Chunk; pub use fourcc::FourCc; pub use reader::RiffReader; pub use webp::{ - MAX_CANVAS_DIMENSION, MetadataChunks, VP8X_PAYLOAD_LEN, Vp8xHeader, WebpChunkId, WebpLayout, - write_extended, write_extended_preserving, write_extended_with_metadata, write_simple_lossless, - write_simple_lossy, + C2PA_FOURCC, MAX_CANVAS_DIMENSION, MetadataChunks, VP8X_PAYLOAD_LEN, Vp8xHeader, WebpChunkId, + WebpLayout, c2pa_span, write_extended, write_extended_preserving, write_extended_with_metadata, + write_simple_lossless, write_simple_lossy, }; pub use writer::RiffWriter; diff --git a/crates/gamut-riff/src/webp.rs b/crates/gamut-riff/src/webp.rs index 6b94589e..413bd4ef 100644 --- a/crates/gamut-riff/src/webp.rs +++ b/crates/gamut-riff/src/webp.rs @@ -1,9 +1,18 @@ //! WebP-specific helpers over the generic RIFF layer: classifying WebP chunks, the [`Vp8xHeader`] -//! extended-format feature header, the [`MetadataChunks`] passthrough for `ICCP`/`EXIF`/`XMP `, and -//! writing the simple (single-bitstream) and extended file formats (RFC 9649 §2.5-§2.7). +//! extended-format feature header, the [`MetadataChunks`] passthrough for +//! `ICCP`/`EXIF`/`XMP `/`C2PA`, and writing the simple (single-bitstream) and extended file formats +//! (RFC 9649 §2.5-§2.7). //! //! The remaining extended-format chunks (`ANIM`/`ANMF`) are tracked in `gamut-webp/STATUS.md` //! section A and are out of scope under the image-first charter. +//! +//! The `C2PA` chunk is the one carrier here that RFC 9649 does not define: it is C2PA 2.4 §A.3.7's, +//! and to the WebP container it is an unknown chunk readers ignore (§2.7.1.6). It is modelled +//! anyway — [`C2PA_FOURCC`], [`WebpChunkId::C2pa`], [`MetadataChunks::c2pa`] and [`c2pa_span`] — +//! because §A.3.7 constrains *where* it goes, which the generic unknown-chunk passthrough cannot +//! express. + +use core::ops::Range; use gamut_core::{Error, Result}; @@ -19,6 +28,17 @@ pub const VP8X_PAYLOAD_LEN: usize = 10; /// 1-based in 24 bits, so `1..=2^24` (RFC 9649 §2.7). pub const MAX_CANVAS_DIMENSION: u32 = 1 << 24; +/// The FourCC of the chunk that carries a C2PA manifest store: `C2PA` (C2PA 2.4 §A.3.7). +/// +/// Deliberately not one of [`FourCc`]'s associated constants, which are RFC 9649's chunk +/// vocabulary. This identifier belongs to the C2PA specification instead, so it is named beside the +/// code that places it — and the WebP container itself only ever sees an unknown chunk (§2.7.1.6). +pub const C2PA_FOURCC: FourCc = FourCc(*b"C2PA"); + +/// Byte length of a RIFF/WebP file header: the `RIFF` magic, the `uint32` file size, and the `WEBP` +/// form type (RFC 9649 §2.4). Chunks begin here. +const RIFF_HEADER_LEN: usize = 12; + /// The extended-format feature header carried by a `VP8X` chunk (RFC 9649 §2.7): which optional /// features the file uses, plus the 1-based canvas dimensions. A simple (single-bitstream) file has no /// `VP8X` chunk; one is required as soon as the file carries alpha, an ICC profile, metadata, or @@ -149,7 +169,8 @@ pub fn write_extended(header: &Vp8xHeader, chunks: &[(FourCc, &[u8])]) -> Result } /// The metadata chunks an extended WebP file may carry, **borrowed** rather than copied: the `ICCP` -/// colour profile and the `EXIF` / `XMP ` metadata payloads (RFC 9649 §2.7.1.4-§2.7.1.5). +/// colour profile, the `EXIF` / `XMP ` metadata payloads (RFC 9649 §2.7.1.4-§2.7.1.5), and the +/// `C2PA` manifest store (C2PA 2.4 §A.3.7). /// /// The container assigns these payloads no meaning — each is carried verbatim, so metadata survives /// a read/write cycle byte for byte with no reserialization. Use [`MetadataChunks::read`] to collect @@ -162,6 +183,10 @@ pub struct MetadataChunks<'a> { pub exif: Option<&'a [u8]>, /// The `XMP ` chunk payload: an XMP packet. pub xmp: Option<&'a [u8]>, + /// The `C2PA` chunk payload: a C2PA manifest store, opaque here — nothing in this crate parses, + /// validates or signs it. A writer places it last, as §A.3.7 requires; [`c2pa_span`] reports + /// where it landed. The pad byte RIFF adds after an odd-length store is framing, not store. + pub c2pa: Option<&'a [u8]>, } impl<'a> MetadataChunks<'a> { @@ -169,6 +194,8 @@ impl<'a> MetadataChunks<'a> { /// /// The spec allows at most one chunk of each kind and lets readers "ignore all except the first /// one" (RFC 9649 §2.7.1.4-§2.7.1.5), so the **first** `ICCP` / `EXIF` / `XMP ` chunk wins. The + /// `C2PA` chunk gets the same policy: C2PA 2.4 §A.3.7 admits one manifest store per file, so a + /// second chunk is malformed and picking the first is as good a recovery as any. The /// `VP8X` feature flags are advisory here: a payload is reported because its chunk is present, /// never because a flag claims it is — so a flag set over a missing chunk yields `None`, and a /// chunk a non-conformant writer left unflagged is still recovered. @@ -185,6 +212,7 @@ impl<'a> MetadataChunks<'a> { WebpChunkId::Iccp => &mut found.icc, WebpChunkId::Exif => &mut found.exif, WebpChunkId::Xmp => &mut found.xmp, + WebpChunkId::C2pa => &mut found.c2pa, _ => continue, }; slot.get_or_insert(chunk.payload); @@ -196,19 +224,72 @@ impl<'a> MetadataChunks<'a> { /// file into the extended format. #[must_use] pub const fn is_empty(&self) -> bool { - self.icc.is_none() && self.exif.is_none() && self.xmp.is_none() + self.icc.is_none() && self.exif.is_none() && self.xmp.is_none() && self.c2pa.is_none() + } +} + +/// Locates the `C2PA` chunk of the WebP file in `data`, returning the byte range its **whole** +/// on-disk span occupies: the four identifier bytes, the four-byte size field, and the payload. +/// +/// That whole span is the range a `c2pa.hash.data` assertion excludes (C2PA 2.4 §18.5), not just +/// the payload: an update manifest may resize the manifest store, and resizing it changes the value +/// of the size field as surely as it changes the bytes after it, so a hash that covered the size +/// field could not survive the update the exclusion exists to permit. +/// +/// The pad byte RIFF appends after an odd-length payload (RFC 9649 §2.3) is **outside** the range. +/// It is framing the container adds around the chunk, not part of the chunk's data — the same +/// reason [`Chunk::payload`] excludes it. A caller who needs the padded span can add +/// `range.len() % 2`: the 8-byte chunk header is even, so the span's parity is the store's. +/// +/// The **first** `C2PA` chunk wins, as it does in [`MetadataChunks::read`]; a conformant file has at +/// most one, placed last (§A.3.7), which is where [`write_extended_preserving`] puts it. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] if `data` is not a valid RIFF/WebP file, or if a chunk's declared +/// size runs past the end of the data. +/// +/// # Example +/// +/// ``` +/// use gamut_riff::{MetadataChunks, Vp8xHeader, c2pa_span, write_extended_with_metadata}; +/// +/// let store = b"a C2PA manifest store"; +/// let header = Vp8xHeader { canvas_width: 1, canvas_height: 1, ..Default::default() }; +/// let metadata = MetadataChunks { c2pa: Some(store), ..Default::default() }; +/// let file = write_extended_with_metadata(&header, &metadata, &[])?; +/// +/// let span = c2pa_span(&file)?.expect("the store was embedded"); +/// assert_eq!(&file[span.start..span.start + 4], b"C2PA"); +/// assert_eq!(span.len(), 8 + store.len()); +/// # Ok::<(), gamut_core::Error>(()) +/// ``` +pub fn c2pa_span(data: &[u8]) -> Result>> { + let mut offset = RIFF_HEADER_LEN; + for chunk in RiffReader::new(data)? { + let chunk = chunk?; + let span = CHUNK_HEADER_LEN + chunk.payload.len(); + if WebpChunkId::from(chunk.fourcc) == WebpChunkId::C2pa { + return Ok(Some(offset..offset + span)); + } + // Skip the pad byte too. The reader framed this payload from a `uint32` size field, so the + // cast is exact. + offset += span + pad_len(chunk.payload.len() as u32); } + Ok(None) } /// Writes an extended WebP file carrying `metadata`, placing every chunk in the canonical order the /// spec mandates: `VP8X`, `ICCP`, the image data (an optional `ALPH` then the `VP8 `/`VP8L` -/// bitstream), then `EXIF` and `XMP ` (RFC 9649 §2.7 — readers "SHOULD fail" when the chunks needed -/// for reconstruction and colour correction are out of order, and metadata follows the image data). +/// bitstream), then `EXIF`, `XMP ` and last of all `C2PA` (RFC 9649 §2.7 — readers "SHOULD fail" +/// when the chunks needed for reconstruction and colour correction are out of order, and metadata +/// follows the image data; C2PA 2.4 §A.3.7 for the store's place at the very end). /// -/// The three metadata feature flags of `header` are **derived** from `metadata`, so a chunk can -/// never be emitted without its flag nor a flag without its chunk; `alpha`, `animation`, and the -/// canvas size are taken as given. Ordering *within* `image_data` is the caller's responsibility, as -/// in [`write_extended`]. +/// The three RFC 9649 metadata feature flags of `header` are **derived** from `metadata`, so a chunk +/// can never be emitted without its flag nor a flag without its chunk; `alpha`, `animation`, and the +/// canvas size are taken as given. The `C2PA` chunk has no feature flag to derive — §2.5's flag byte +/// defines none — so its presence is decided by the chunk alone. Ordering *within* `image_data` is +/// the caller's responsibility, as in [`write_extended`]. /// /// # Errors /// @@ -230,6 +311,29 @@ pub fn write_extended_with_metadata( /// the file and lets them "appear out of order" relative to metadata, so emitting them last is /// conforming regardless of where they sat in the original. /// +/// A `C2PA` manifest store is the one exception to "unknown chunks last": C2PA 2.4 §A.3.7 requires +/// the `C2PA` chunk to "appear as the last sub-chunk of the first RIFF header chunk", so it is +/// emitted after the preserved chunks, not before them. +/// +/// A file this function writes carries **at most one** `C2PA` chunk, and it is always in the +/// store's place at the end. A `C2PA` chunk among `unknown` is never emitted where it sits: +/// +/// - with [`MetadataChunks::c2pa`] set, the field owns the chunk and the copy in `unknown` is +/// **dropped**. Emitting both would put the copy first, and every reader here takes the *first* of +/// a repeated chunk — so it would win, and [`c2pa_span`] would report a range over bytes the +/// caller never configured, which is exactly the range a signer excludes from its hash; +/// - with no store configured, the copy **is** the store of the file it came from, so it is kept and +/// written in the store's place. Dropping it would lose a foreign manifest store with no signal, +/// and leaving it among the unknown chunks could put it somewhere §A.3.7 does not allow. +/// +/// Where `unknown` holds several, the first wins, as everywhere else in this crate. A caller who +/// read the file with [`WebpLayout::parse`] never sees a `C2PA` chunk in `unknown` at all — this +/// crate recognises it ([`WebpChunkId::C2pa`]) — so this handles chunks collected by a reader that +/// does not, such as a third-party one or this crate before the chunk was modelled. +/// +/// [`write_extended`] does no filtering: it writes the chunks it is given, and is the escape hatch +/// for a caller who means to hand-assemble a file. +/// /// # Errors /// /// As [`write_extended`]: an inexpressible canvas or an over-large payload or file. @@ -245,7 +349,7 @@ pub fn write_extended_preserving( xmp_metadata: metadata.xmp.is_some(), ..*header }; - let mut chunks: Vec<(FourCc, &[u8])> = Vec::with_capacity(image_data.len() + 3 + unknown.len()); + let mut chunks: Vec<(FourCc, &[u8])> = Vec::with_capacity(image_data.len() + 4 + unknown.len()); if let Some(icc) = metadata.icc { chunks.push((FourCc::ICCP, icc)); } @@ -256,7 +360,21 @@ pub fn write_extended_preserving( if let Some(xmp) = metadata.xmp { chunks.push((FourCc::XMP, xmp)); } - chunks.extend(unknown.iter().map(|c| (c.fourcc, c.payload))); + // A `C2PA` chunk never goes out among the unknown chunks: either the configured store owns that + // slot, or — with none configured — this one *is* the store and belongs in the store's place. + // First wins, as everywhere else here. + let carried = unknown.iter().find(|c| c.fourcc == C2PA_FOURCC); + chunks.extend( + unknown + .iter() + .filter(|c| c.fourcc != C2PA_FOURCC) + .map(|c| (c.fourcc, c.payload)), + ); + // C2PA 2.4 §A.3.7: the manifest store's chunk is the *last* sub-chunk of the RIFF/WEBP form — + // after the preserved unknown chunks, not merely somewhere past the image data. + if let Some(c2pa) = metadata.c2pa.or(carried.map(|c| c.payload)) { + chunks.push((C2PA_FOURCC, c2pa)); + } write_extended(&header, &chunks) } @@ -264,7 +382,9 @@ pub fn write_extended_preserving( /// `ANIM`, then the image data (`ALPH` before the bitstream) — RFC 9649 §2.7. /// /// `None` marks a chunk the ordering rule does not constrain: metadata (`EXIF`/`XMP `) and unknown -/// chunks, which the spec says "MAY appear out of order". +/// chunks, which the spec says "MAY appear out of order". `C2PA` joins them: §2.7 knows nothing of +/// it, and C2PA 2.4 §A.3.7's "last sub-chunk" is a rule this crate's *writer* honours rather than +/// one a reader may impose on a file it did not write. const fn reconstruction_rank(id: WebpChunkId) -> Option { match id { WebpChunkId::Vp8x => Some(0), @@ -272,7 +392,7 @@ const fn reconstruction_rank(id: WebpChunkId) -> Option { WebpChunkId::Anim => Some(2), WebpChunkId::Anmf | WebpChunkId::Alpha => Some(3), WebpChunkId::Vp8 | WebpChunkId::Vp8l => Some(4), - WebpChunkId::Exif | WebpChunkId::Xmp | WebpChunkId::Unknown(_) => None, + WebpChunkId::Exif | WebpChunkId::Xmp | WebpChunkId::C2pa | WebpChunkId::Unknown(_) => None, } } @@ -300,7 +420,8 @@ const fn reconstruction_rank(id: WebpChunkId) -> Option { pub struct WebpLayout<'a> { /// The parsed `VP8X` feature header, or `None` for a simple (single-bitstream) file. pub vp8x: Option, - /// The `ICCP`, `EXIF`, and `XMP ` payloads, first of each kind winning as the spec permits. + /// The `ICCP`, `EXIF`, `XMP ` and `C2PA` payloads, first of each kind winning as the spec + /// permits. pub metadata: MetadataChunks<'a>, /// The `ALPH` chunk payload, when the file carries lossy alpha. pub alph: Option<&'a [u8]>, @@ -337,7 +458,7 @@ impl<'a> WebpLayout<'a> { }; // Rank of the last reconstruction chunk seen; the sequence must never regress. let mut last_rank = 0; - let mut offset = 12; + let mut offset = RIFF_HEADER_LEN; for chunk in reader { let chunk = chunk?; let id = WebpChunkId::from(chunk.fourcc); @@ -366,6 +487,9 @@ impl<'a> WebpLayout<'a> { WebpChunkId::Xmp => { layout.metadata.xmp.get_or_insert(chunk.payload); } + WebpChunkId::C2pa => { + layout.metadata.c2pa.get_or_insert(chunk.payload); + } WebpChunkId::Alpha => { layout.alph.get_or_insert(chunk.payload); } @@ -408,6 +532,8 @@ pub enum WebpChunkId { Exif, /// XMP metadata (`XMP `). Xmp, + /// C2PA manifest store (`C2PA`) — C2PA 2.4 §A.3.7, not an RFC 9649 chunk. + C2pa, /// Global animation parameters (`ANIM`). Anim, /// Animation frame (`ANMF`). @@ -426,6 +552,7 @@ impl From for WebpChunkId { b"ICCP" => Self::Iccp, b"EXIF" => Self::Exif, b"XMP " => Self::Xmp, + b"C2PA" => Self::C2pa, b"ANIM" => Self::Anim, b"ANMF" => Self::Anmf, _ => Self::Unknown(fourcc), @@ -474,6 +601,7 @@ mod tests { assert_eq!(WebpChunkId::from(FourCc::ICCP), WebpChunkId::Iccp); assert_eq!(WebpChunkId::from(FourCc::EXIF), WebpChunkId::Exif); assert_eq!(WebpChunkId::from(FourCc::XMP), WebpChunkId::Xmp); + assert_eq!(WebpChunkId::from(C2PA_FOURCC), WebpChunkId::C2pa); assert_eq!(WebpChunkId::from(FourCc::ANIM), WebpChunkId::Anim); assert_eq!(WebpChunkId::from(FourCc::ANMF), WebpChunkId::Anmf); let weird = FourCc::from(*b"XYZW"); @@ -731,6 +859,7 @@ mod tests { icc: Some(icc), exif: Some(exif), xmp: Some(xmp), + c2pa: None, }; let file = write_extended_with_metadata( &header, @@ -777,6 +906,7 @@ mod tests { icc: Some(&[1]), exif: Some(&[2]), xmp: Some(&[3]), + c2pa: None, }; let image: &[(FourCc, &[u8])] = &[(FourCc::VP8L, &[0x2f])]; @@ -884,6 +1014,10 @@ mod tests { xmp: Some(&[0]), ..Default::default() }, + MetadataChunks { + c2pa: Some(&[0]), + ..Default::default() + }, ] { assert!(!chunks.is_empty(), "{chunks:?} carries a payload"); } @@ -929,6 +1063,7 @@ mod tests { icc: None, exif: None, xmp: Some(&b""[..]), + c2pa: None, } ); } @@ -1218,4 +1353,316 @@ mod tests { file.extend_from_slice(b"motion photo stream"); assert_eq!(WebpLayout::parse(&file).unwrap().trailing_bytes, 19); } + + /// C2PA 2.4 §A.3.7: "this C2PA chunk shall appear as the **last sub-chunk of the first RIFF + /// header chunk**" — for WebP, the last sub-chunk of the `RIFF`/`WEBP` form. + /// + /// "Last" is stricter than "after the image data": the store goes behind `EXIF`, behind `XMP `, + /// and behind the unknown chunks §2.7.1.6 asks writers to preserve, which + /// `write_extended_preserving` otherwise emits at the end. Nothing else pins that the store + /// outranks the preserved chunks. + #[test] + fn the_c2pa_chunk_is_written_last_of_all() { + let header = Vp8xHeader { + canvas_width: 16, + canvas_height: 16, + ..Default::default() + }; + let metadata = MetadataChunks { + icc: Some(b"icc"), + exif: Some(b"exif"), + xmp: Some(b""), + c2pa: Some(b"store"), + }; + let unknown = [Chunk { + fourcc: FourCc::from(*b"XYZW"), + payload: &[0xaa], + }]; + let file = + write_extended_preserving(&header, &metadata, &[(FourCc::VP8L, &[0x2f])], &unknown) + .unwrap(); + assert_eq!( + chunk_ids(&file), + vec![ + FourCc::VP8X, + FourCc::ICCP, + FourCc::VP8L, + FourCc::EXIF, + FourCc::XMP, + FourCc::from(*b"XYZW"), + C2PA_FOURCC, + ] + ); + assert_eq!(MetadataChunks::read(&file).unwrap(), metadata); + } + + /// The `VP8X` feature byte has no C2PA bit (RFC 9649 §2.5), so a store must neither set one nor + /// disturb the three flags that do exist: presence is decided by the chunk alone. + #[test] + fn a_c2pa_store_sets_no_vp8x_feature_flag() { + let header = Vp8xHeader { + canvas_width: 4, + canvas_height: 4, + ..Default::default() + }; + let image: &[(FourCc, &[u8])] = &[(FourCc::VP8L, &[0x2f])]; + let with_store = write_extended_with_metadata( + &header, + &MetadataChunks { + c2pa: Some(b"store"), + ..Default::default() + }, + image, + ) + .unwrap(); + assert_eq!(vp8x_header_of(&with_store), header); + // Byte for byte the file is a store-free one with the chunk appended: nothing ahead of the + // store moved, and no flag byte changed. + let without = + write_extended_with_metadata(&header, &MetadataChunks::default(), image).unwrap(); + assert_eq!(&with_store[12..without.len()], &without[12..]); + } + + /// `c2pa_span` reports the chunk's whole span — identifier, size field and payload — because + /// that is what a `c2pa.hash.data` exclusion covers (C2PA 2.4 §18.5). The RIFF pad byte after an + /// odd-length store (§2.3) is framing, so it stays outside both the span and the payload. + #[test] + fn c2pa_span_covers_the_whole_chunk_but_not_the_pad_byte() { + let store = b"odd length store!"; // 17 bytes -> one pad byte + assert_eq!(store.len() % 2, 1, "the fixture must exercise the pad byte"); + let file = write_extended_with_metadata( + &Vp8xHeader { + canvas_width: 4, + canvas_height: 4, + ..Default::default() + }, + &MetadataChunks { + c2pa: Some(store), + ..Default::default() + }, + &[(FourCc::VP8L, &[0x2f])], + ) + .unwrap(); + + let span = c2pa_span(&file).unwrap().expect("the store was embedded"); + assert_eq!(&file[span.start..span.start + 4], b"C2PA", "identifier"); + assert_eq!( + &file[span.start + 4..span.start + 8], + &(store.len() as u32).to_le_bytes(), + "size field" + ); + assert_eq!(&file[span.start + 8..span.end], store, "payload"); + assert_eq!(span.end, file.len() - 1, "the pad byte is outside the span"); + assert_eq!(file[file.len() - 1], 0, "§2.3: the pad byte is zero"); + } + + /// The span is an offset into the whole file, so the pad byte of every *preceding* odd-length + /// chunk has to be counted as well. + #[test] + fn c2pa_span_counts_the_padding_of_the_chunks_before_it() { + let mut w = RiffWriter::new(); + w.write_chunk(FourCc::VP8L, &[0x2f, 0x00, 0x00]).unwrap(); // odd -> padded + w.write_chunk(FourCc::EXIF, b"odd").unwrap(); // odd -> padded + w.write_chunk(C2PA_FOURCC, b"store").unwrap(); + let file = w.finish().unwrap(); + let span = c2pa_span(&file).unwrap().expect("the store was embedded"); + assert_eq!(&file[span.start..span.start + 4], b"C2PA"); + // 12-byte file header, then two chunks of 8 header + 3 payload + 1 pad bytes each; the + // store's own chunk is its 8-byte header plus its five payload bytes. + let start = 12 + (8 + 3 + 1) + (8 + 3 + 1); + assert_eq!(span, start..start + 8 + 5); + } + + /// A file with no store has no span to report, rather than an empty range at some offset a + /// caller might hash around. + #[test] + fn c2pa_span_is_none_without_a_store() { + let file = write_simple_lossless(&[0x2f]).unwrap(); + assert_eq!(c2pa_span(&file).unwrap(), None); + } + + /// The store is *not* an unknown chunk to this crate: `WebpLayout::parse` routes it to + /// `MetadataChunks::c2pa`, so `write_extended_preserving` cannot emit it twice — once out of + /// `unknown` and once out of `metadata` — when a file is read, modified and written back. + #[test] + fn a_store_read_back_is_metadata_and_survives_a_rewrite_exactly_once() { + let original = raw_file(&[ + (FourCc::VP8X, &vp8x(false)), + (FourCc::VP8L, &[0x2f]), + (C2PA_FOURCC, b"store"), + ]); + let layout = WebpLayout::parse(&original).unwrap(); + assert_eq!(layout.metadata.c2pa, Some(&b"store"[..])); + assert!(layout.unknown.is_empty(), "a store is not an unknown chunk"); + + let rewritten = write_extended_preserving( + &layout.vp8x.unwrap(), + &layout.metadata, + &[(FourCc::VP8L, layout.bitstream.unwrap().1)], + &layout.unknown, + ) + .unwrap(); + assert_eq!(rewritten, original); + } + + /// §A.3.7 constrains a *writer*. A reader must not turn §2.7's ordering rule against a store it + /// finds early, since §2.7 does not list `C2PA` among the reconstruction chunks. + #[test] + fn a_store_ahead_of_the_bitstream_still_parses() { + let file = raw_file(&[ + (FourCc::VP8X, &vp8x(false)), + (C2PA_FOURCC, b"store"), + (FourCc::VP8L, &[0x2f]), + ]); + let layout = WebpLayout::parse(&file).unwrap(); + assert_eq!(layout.metadata.c2pa, Some(&b"store"[..])); + // 12-byte header + the 18-byte VP8X chunk, then 8 + 5 bytes of store chunk. + assert_eq!(c2pa_span(&file).unwrap(), Some(30..43)); + } + + /// The store joins the "first of each kind wins" policy the crate applies everywhere, and the + /// span reported is the same chunk the payload came from. + #[test] + fn metadata_chunks_read_keeps_the_first_store() { + let mut w = RiffWriter::new(); + w.write_chunk(FourCc::VP8L, &[0x2f]).unwrap(); + w.write_chunk(C2PA_FOURCC, b"first").unwrap(); + w.write_chunk(C2PA_FOURCC, b"second").unwrap(); + let file = w.finish().unwrap(); + assert_eq!( + MetadataChunks::read(&file).unwrap().c2pa, + Some(&b"first"[..]) + ); + let span = c2pa_span(&file).unwrap().expect("the store was embedded"); + assert_eq!(&file[span.start + 8..span.end], b"first"); + } + + /// `metadata.c2pa` owns the `C2PA` chunk: a copy handed in through `unknown` is dropped, so the + /// file carries exactly one store and it is the configured one. + /// + /// Emitting both put the stale copy *first*, and every reader here takes the first — so + /// `c2pa_span` reported a range over bytes the caller never configured, which is precisely the + /// range a signer would exclude from its hash. + #[test] + fn a_c2pa_chunk_in_unknown_never_displaces_the_configured_store() { + let header = Vp8xHeader { + canvas_width: 4, + canvas_height: 4, + ..Default::default() + }; + let file = write_extended_preserving( + &header, + &MetadataChunks { + c2pa: Some(b"FRESH"), + ..Default::default() + }, + &[(FourCc::VP8L, &[0x2f])], + &[ + Chunk { + fourcc: C2PA_FOURCC, + payload: b"STALE", + }, + Chunk { + fourcc: FourCc::from(*b"XYZW"), + payload: b"kept", + }, + ], + ) + .unwrap(); + + assert_eq!( + chunk_ids(&file), + vec![ + FourCc::VP8X, + FourCc::VP8L, + FourCc::from(*b"XYZW"), + C2PA_FOURCC, + ], + "one store chunk, last, and the genuinely unknown chunk survives" + ); + assert_eq!( + MetadataChunks::read(&file).unwrap().c2pa, + Some(&b"FRESH"[..]) + ); + let span = c2pa_span(&file).unwrap().expect("the store was embedded"); + assert_eq!(&file[span.start + 8..span.end], b"FRESH"); + } + + /// With no store configured there is nothing for a carried `C2PA` chunk to displace, so it is + /// kept rather than dropped: it is the manifest store of the file it was read out of, and losing + /// it silently would be worse than carrying it. It still goes in the store's place (§A.3.7) + /// rather than wherever the caller's list happened to put it. + #[test] + fn a_carried_c2pa_chunk_is_kept_as_the_store_when_none_is_configured() { + let header = Vp8xHeader { + canvas_width: 4, + canvas_height: 4, + ..Default::default() + }; + let file = write_extended_preserving( + &header, + &MetadataChunks::default(), + &[(FourCc::VP8L, &[0x2f])], + &[ + Chunk { + fourcc: C2PA_FOURCC, + payload: b"foreign", + }, + Chunk { + fourcc: FourCc::from(*b"XYZW"), + payload: b"kept", + }, + ], + ) + .unwrap(); + + assert_eq!( + chunk_ids(&file), + vec![ + FourCc::VP8X, + FourCc::VP8L, + FourCc::from(*b"XYZW"), + C2PA_FOURCC, + ], + "the carried store moves to the end; the private chunk keeps its order" + ); + assert_eq!( + MetadataChunks::read(&file).unwrap().c2pa, + Some(&b"foreign"[..]), + "and it reads back as the store, which is what it is" + ); + } + + /// `write_extended` is the unfiltered escape hatch: it writes what it is given, so a caller who + /// means to hand-assemble a file still can. + #[test] + fn write_extended_does_not_filter_a_c2pa_chunk() { + let header = Vp8xHeader { + canvas_width: 4, + canvas_height: 4, + ..Default::default() + }; + let file = write_extended(&header, &[(C2PA_FOURCC, b"store")]).unwrap(); + assert_eq!(chunk_ids(&file), vec![FourCc::VP8X, C2PA_FOURCC]); + } + + /// `c2pa_span` walks the same framing every reader here does, so it rejects a file whose framing + /// is bad rather than reporting a range into it — the `# Errors` contract its docs state. + #[test] + fn c2pa_span_rejects_a_malformed_file() { + assert_eq!( + c2pa_span(b"not a RIFF file at all").unwrap_err().kind(), + ErrorKind::InvalidInput + ); + + // Well-formed header, then a chunk whose declared size runs past the end of the data. + let mut file = write_simple_lossless(&[0x2f, 0x00]).unwrap(); + let len = file.len(); + file[16..20].copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(file.len(), len, "only the size field was edited"); + assert_eq!( + c2pa_span(&file).unwrap_err().kind(), + ErrorKind::InvalidInput + ); + } } diff --git a/crates/gamut-riff/tests/oracle.rs b/crates/gamut-riff/tests/oracle.rs index a5199b00..7d2b1f4b 100644 --- a/crates/gamut-riff/tests/oracle.rs +++ b/crates/gamut-riff/tests/oracle.rs @@ -8,7 +8,7 @@ mod common; use common::{libwebp_demux, libwebp_encode_lossless, rgb_image}; use gamut_riff::{ - Chunk, FourCc, MetadataChunks, RiffReader, Vp8xHeader, WebpChunkId, WebpLayout, + Chunk, FourCc, MetadataChunks, RiffReader, Vp8xHeader, WebpChunkId, WebpLayout, c2pa_span, write_extended_preserving, write_simple_lossless, }; @@ -78,6 +78,7 @@ fn libwebp_agrees_on_the_extended_container_and_its_metadata() { icc: Some(icc), exif: Some(exif), xmp: Some(xmp), + c2pa: None, }, &[(FourCc::VP8L, &vp8l)], &[], @@ -170,6 +171,65 @@ fn an_odd_sized_payload_round_trips_through_libwebp() { ); } +#[test] +fn libwebp_reads_a_file_carrying_a_c2pa_store_unchanged() { + // C2PA 2.4 §A.3.7 puts the manifest store in a `C2PA` chunk, which RFC 9649 does not define — + // so to the reference demuxer it is an unknown chunk it "SHOULD ignore" (§2.7.1.6). Embedding + // one must therefore change nothing libwebp sees: same canvas, same metadata chunks, and the + // store-free file byte for byte as the prefix, so nothing ahead of the store moved. + let (w, h) = (20u32, 7u32); + let (vp8l, _, _) = libwebp_vp8l(w, h); + let header = Vp8xHeader { + canvas_width: w, + canvas_height: h, + ..Default::default() + }; + let metadata = MetadataChunks { + icc: Some(b"an ICC profile"), + exif: Some(b"exif payload"), + xmp: Some(b""), + c2pa: None, + }; + let without = write_extended_preserving(&header, &metadata, &[(FourCc::VP8L, &vp8l)], &[]) + .expect("write"); + let store = &b"a C2PA manifest store"[..]; + let with_store = write_extended_preserving( + &header, + &MetadataChunks { + c2pa: Some(store), + ..metadata + }, + &[(FourCc::VP8L, &vp8l)], + &[], + ) + .expect("write"); + + assert_eq!( + with_store[12..without.len()], + without[12..], + "the store is appended; nothing before it moves" + ); + + let plain = libwebp_demux(&without).expect("libwebp accepts the store-free file"); + let signed = libwebp_demux(&with_store).expect("libwebp accepts the file carrying the store"); + assert_eq!( + (signed.canvas_width, signed.canvas_height), + (plain.canvas_width, plain.canvas_height), + "the canvas is untouched" + ); + assert_eq!( + signed.metadata, plain.metadata, + "libwebp recovers the same metadata chunks, and never the store among them" + ); + + // And the store is exactly where gamut-riff says it is. + let span = c2pa_span(&with_store) + .expect("parse") + .expect("the store was embedded"); + assert_eq!(&with_store[span.start..span.start + 4], b"C2PA"); + assert_eq!(&with_store[span.start + 8..span.end], store); +} + #[test] fn both_implementations_reject_a_file_size_that_overruns_the_buffer() { // Where gamut-riff is strict, the reference parser should be too — otherwise the strictness is diff --git a/crates/gamut-riff/tests/robustness.rs b/crates/gamut-riff/tests/robustness.rs index e3aab5d8..36dcf077 100644 --- a/crates/gamut-riff/tests/robustness.rs +++ b/crates/gamut-riff/tests/robustness.rs @@ -30,6 +30,7 @@ fn seed() -> Vec { icc: Some(b"icc"), // odd -> padded exif: Some(b"exif"), xmp: Some(b""), + c2pa: None, }, &[ (FourCc::ALPH, &[0x00, 0x11]), diff --git a/crates/gamut-webp/README.md b/crates/gamut-webp/README.md index 8940e178..9e80f058 100644 --- a/crates/gamut-webp/README.md +++ b/crates/gamut-webp/README.md @@ -59,6 +59,16 @@ a typed `ImageRef` and returning a typed `ImageBuf`, for RGB and RGBA: they can be borrowed straight into [`gamut-metadata`](../gamut-metadata)'s `MetadataBlock`. Embedding promotes a simple file to the extended format, derives the `VP8X` feature flags from the chunks present, and emits everything in the spec's canonical order. +- **C2PA manifest stores** — `WebpEncoder::with_c2pa` embeds a finished manifest store as a `C2PA` + chunk (C2PA 2.4 §A.3.7) and `with_c2pa_reserved` leaves room for one that cannot exist yet, + because its hard binding digests the finished file. `encode_with_report` returns the file together + with the chunk's byte range — the range a `c2pa.hash.data` assertion excludes (§18.5) — and + `gamut_webp::c2pa_span` recovers that range from any WebP file. The chunk goes last, as §A.3.7 + requires, and no `VP8X` flag advertises it, RFC 9649 defining no C2PA feature bit. A file carries + exactly one store: `with_unknown_chunks` refuses every FourCC the container defines, the container + writer gives the configured store that slot, and `encode_with_report` re-reads its own output and + will not report a range that is not over the configured bytes. gamut carries the store; building, + hashing, signing and validating it belong to a C2PA implementation. ### Pluggable codestream backends @@ -81,8 +91,8 @@ directions** (gamut↔libwebp, at the YUV-plane level for lossy), backed by inte round-trips, the in-crate decoder, and a malformed-input robustness corpus. **Non-core feature paths** are decided in [`STATUS.md`](STATUS.md#scope-decisions--non-core-feature-paths): -alpha/transparency (`VP8X` + `ALPH`) and color/metadata chunks (`ICCP` ICC profiles, `EXIF`, `XMP `) -are **in scope** — embedded on encode and preserved on decode. Animation (`ANIM`/`ANMF`) is **out of +alpha/transparency (`VP8X` + `ALPH`) and color/metadata chunks (`ICCP` ICC profiles, `EXIF`, `XMP `, +and the `C2PA` manifest store) are **in scope** — embedded on encode and preserved on decode. Animation (`ANIM`/`ANMF`) is **out of scope** under the image-first charter (each frame is an independent keyframe, but multi-frame sequences don't fit the single-image API); its chunks are tracked only for container completeness. diff --git a/crates/gamut-webp/STATUS.md b/crates/gamut-webp/STATUS.md index f4b73360..7c268515 100644 --- a/crates/gamut-webp/STATUS.md +++ b/crates/gamut-webp/STATUS.md @@ -41,7 +41,14 @@ container-completeness only). **Milestone (M)** is indicative sequencing, not a reads them back without decoding pixels; libwebp's own muxer is the oracle in both directions. Read-side chunk-order enforcement and unknown-chunk round-trip preservation closed with `gamut-riff` v1 (issue #186): `gamut_riff::WebpLayout::parse` is the single container walk behind - both decode paths, and `WebpEncoder::with_unknown_chunks` re-emits preserved chunks. + both decode paths, and `WebpEncoder::with_unknown_chunks` re-emits preserved chunks — refusing any + FourCC `gamut_riff::WebpChunkId` classifies, so no chunk this crate writes itself can also be + passed through and written twice. The C2PA + manifest store joined them on the same verbatim terms (issue #445): `WebpEncoder::with_c2pa` / + `with_c2pa_reserved` embed or reserve it, `encode_with_report` and `gamut_webp::c2pa_span` report + the byte range a `c2pa.hash.data` assertion excludes, and `WebpMetadata::c2pa` reads it back. + gamut carries the store and never parses, hashes, signs or validates it; the behavioural `c2pa-rs` + oracle is issue #447. - **M5** — Animation: `ANIM` / `ANMF` — **out of scope** (decision 2026-06-09). Multi-frame sequences fall outside the image-first charter and the single-image `gamut_core` traits; WebP animation needs no codec work (each frame is an independent keyframe) but does need a non-trait @@ -71,6 +78,7 @@ a settled charter rather than a wish-list. `gamut-riff` already recognizes every | Extended container | `VP8X` | **In scope** | ✅ | ✅ | M3 | Required enabler for lossy alpha, ICC, and metadata. Emitted only when a feature needs it (simple→extended promotion). | | Color profile | `ICCP` | **In scope** | ✅ embed | ✅ preserve | M4 | Color correctness on wide-gamut images. | | Metadata | `EXIF`, `XMP ` | **In scope** | ✅ embed | ✅ preserve | M4 | Cheap round-trip passthrough; preserved across decode→encode. | +| C2PA manifest store | `C2PA` | **In scope** | ✅ embed / reserve | ✅ preserve | M4 | Provenance passthrough on the same verbatim terms; C2PA 2.4 §A.3.7 places the chunk, §18.5 defines the exclusion range. Validation is out of scope — see #447. | | Animation | `ANIM`, `ANMF` | **Out of scope** (tracked only) | ✕ | deferred | M5 | Sequence content, against the image-first charter ("no video sequences") and the single-image `gamut_core` traits. Each `ANMF` frame is an independent keyframe — no codec work needed — but assembly requires a non-trait multi-frame API. Rows kept for container-completeness; a decode-only path may be revisited later. | Markers: ✅ shipped · ✕ not planned · *deferred* = possible later, no commitment now. @@ -96,13 +104,15 @@ Owner: [`gamut-riff`](../gamut-riff). | simple→extended promotion (emit `VP8X` when a feature needs it) | §2.7 | ✅ | M3 | | `ICCP` color profile chunk | §2.7.1.4 | ✅ | M4 | | `EXIF` / `XMP ` metadata chunks | §2.7.1.5 | ✅ | M4 | -| canonical chunk order on **write** (`VP8X`, `ICCP`, image data, `EXIF`, `XMP `) | §2.7 | ✅ | M4 | +| canonical chunk order on **write** (`VP8X`, `ICCP`, image data, `EXIF`, `XMP `, unknown, `C2PA`) | §2.7 | ✅ | M4 | | chunk ordering enforcement on **read** (reject out-of-order reconstruction chunks) | §2.7 | ✅ | M4 | | canvas bounds: dimensions in `1..=2^24`, width × height ≤ `2^32 - 1` | §2.7 | ✅ | M4 | | pad byte MUST be zero; trailing data past *File Size* surfaced | §2.3/§2.4 | ✅ | M4 | | `ANIM` global animation parameters (bg color, loop count) | §2.7.1.1 | ⊘ | M5 | | `ANMF` per-frame chunk + frame disposal/blend, canvas assembly | §2.7.1.1 | ⊘ | M5 | | unknown-chunk passthrough (preserve order) | §2.7.1.6 | ✅ | M4 | +| `C2PA` manifest store, verbatim, as the **last** sub-chunk of the form | C2PA §A.3.7 | ✅ | M4 | +| `c2pa.hash.data` exclusion range: whole chunk, pad byte excluded | C2PA §18.5 | ✅ | M4 | ## B. VP8L bitstream header (RFC 9649 §3.4; Google *Lossless Bitstream*) diff --git a/crates/gamut-webp/src/encoder.rs b/crates/gamut-webp/src/encoder.rs index b2dee592..ef63d7d5 100644 --- a/crates/gamut-webp/src/encoder.rs +++ b/crates/gamut-webp/src/encoder.rs @@ -6,14 +6,15 @@ //! impls; transparent lossy images use the extended (`VP8X`) format with a raw `ALPH` alpha chunk, //! as does any image carrying embedded metadata. +use core::ops::Range; use std::fmt; use std::sync::{Arc, Mutex}; use gamut_color::{ColorRange, Yuv420}; -use gamut_core::{Dimensions, EncodeImage, ImageRef, Result, Rgb8, Rgba8}; +use gamut_core::{Dimensions, EncodeImage, Error, ImageRef, Pixel, Result, Rgb8, Rgba8}; use gamut_riff::{ - Chunk, FourCc, MetadataChunks, Vp8xHeader, write_extended_preserving, write_simple_lossless, - write_simple_lossy, + Chunk, FourCc, MetadataChunks, Vp8xHeader, WebpChunkId, c2pa_span, write_extended_preserving, + write_simple_lossless, write_simple_lossy, }; use crate::alpha; @@ -34,6 +35,34 @@ fn quality_to_quant(quality: u8) -> u8 { ((100 - q) * 127 / 100) as u8 } +/// Whether `fourcc` names a chunk that is genuinely *unknown* — the only kind +/// [`WebpEncoder::with_unknown_chunks`] carries through. +/// +/// The classification is `gamut-riff`'s and is **asked for, not restated**: [`WebpChunkId::from`] +/// knows every chunk the WebP container defines (RFC 9649 §2.5-§2.7) plus the `C2PA` chunk of +/// C2PA 2.4 §A.3.7, and each of those is one this crate writes itself — from a dedicated setter, or +/// from the image. A hand-written list of them is a copy of that table that drifts the moment +/// `gamut-riff` recognises one more, which is exactly what happened: an eight-name list omitted +/// `ANIM` and `ANMF`, so an animation chunk was accepted and produced a file +/// [`gamut_riff::WebpLayout::parse`] then rejected as out of order. +fn is_unknown_chunk(fourcc: FourCc) -> bool { + matches!(WebpChunkId::from(fourcc), WebpChunkId::Unknown(_)) +} + +/// Narrows a C2PA reservation to the `uint32` a RIFF chunk's size field holds (RFC 9649 §2.3). +/// +/// Split out of [`WebpEncoder::with_c2pa_reserved`] so the limit can be tested without allocating +/// the 4 GiB it would take to reach it — the same reason `gamut-riff`'s writer splits out its own +/// `chunk_size_field`. +fn reservation_len(len: usize) -> Result { + u32::try_from(len).map_err(|_| { + Error::unsupported( + env!("CARGO_PKG_NAME"), + "WebP: C2PA reservation exceeds the uint32 chunk size field", + ) + }) +} + /// Encodes 8-bit RGB images to WebP. /// /// Construct with [`WebpEncoder::new`] (lossless), [`WebpEncoder::lossless`], or @@ -41,7 +70,9 @@ fn quality_to_quant(quality: u8) -> u8 { /// /// Embedded metadata is attached with [`with_exif`](Self::with_exif) / [`with_xmp`](Self::with_xmp) /// / [`with_icc_profile`](Self::with_icc_profile), which promote the output to the extended (`VP8X`) -/// format automatically. +/// format automatically. A C2PA manifest store is attached with [`with_c2pa`](Self::with_c2pa) or +/// reserved with [`with_c2pa_reserved`](Self::with_c2pa_reserved), and +/// [`encode_with_report`](Self::encode_with_report) reports where in the finished file it landed. /// /// The codestream itself may be produced by a pluggable backend installed with /// [`push_backend`](Self::push_backend); with none installed (the default) the crate's own @@ -57,6 +88,9 @@ pub struct WebpEncoder { xmp: Option>, /// The `ICCP` chunk payload (ICC colour profile) to embed, verbatim. icc: Option>, + /// The `C2PA` chunk payload — a C2PA manifest store, or a reservation of zero bytes to be + /// filled in once it has been computed over the finished file. + c2pa: Option>, /// Unknown chunks to re-emit after the metadata, in the order given (RFC 9649 §2.7.1.6). unknown: Vec<(FourCc, Vec)>, /// Pluggable codestream encoders, tried in push order ahead of the built-in tails. @@ -72,6 +106,7 @@ impl fmt::Debug for WebpEncoder { .field("exif", &self.exif.as_ref().map(Vec::len)) .field("xmp", &self.xmp.as_ref().map(Vec::len)) .field("icc", &self.icc.as_ref().map(Vec::len)) + .field("c2pa", &self.c2pa.as_ref().map(Vec::len)) .field("backends", &self.backends.len()) .finish() } @@ -139,6 +174,62 @@ impl WebpEncoder { self } + /// Embeds a C2PA manifest store as a `C2PA` chunk (C2PA 2.4 §A.3.7), promoting the output to + /// the extended (`VP8X`) format. + /// + /// The store is written **verbatim** and placed as the last sub-chunk of the `RIFF`/`WEBP` form, + /// which is where §A.3.7 requires it — behind `EXIF`, `XMP ` and any chunk passed to + /// [`with_unknown_chunks`](Self::with_unknown_chunks). No `VP8X` feature flag advertises it: + /// RFC 9649 §2.5 defines no C2PA bit, so presence is decided by the chunk alone. + /// + /// gamut carries the store; it does not build, hash, sign or validate one — that is a C2PA + /// implementation's job (`c2pa-rs`). Use [`encode_with_report`](Self::encode_with_report) to + /// learn the byte range the store's chunk occupies, which is what a `c2pa.hash.data` assertion + /// excludes (§18.5). + /// + /// The last of [`with_c2pa`](Self::with_c2pa) / [`with_c2pa_reserved`](Self::with_c2pa_reserved) + /// wins; a file carries exactly one store. + #[must_use] + pub fn with_c2pa(mut self, store: &[u8]) -> Self { + self.c2pa = Some(store.to_vec()); + self + } + + /// Reserves `len` zero bytes for a C2PA manifest store not yet computed. + /// + /// A store cannot be handed to the encoder complete, because its hard binding digests the + /// finished file (C2PA 2.4 §15.12.1.1) — which does not exist until the encoder has run. The + /// reserve-then-fill flow §18.5 asks for is three steps: + /// + /// 1. encode with the reservation, through + /// [`encode_with_report`](Self::encode_with_report), and keep the reported range; + /// 2. hash the returned file with that **whole** range excluded, and build the store; + /// 3. encode again with [`with_c2pa`](Self::with_c2pa) and a store of the **same length**, which + /// reproduces the same file with the reserved bytes replaced. + /// + /// The reservation is `len` bytes exactly — no slack is added — so ask for what the signer says + /// it needs. A store shorter than the reservation would move every byte after it and invalidate + /// the hash, which is why step 3 must match the length rather than merely fit inside it. + /// + /// No upper bound is imposed beyond what the container can express: a signer's `reserve_size` is + /// its own business, and neither `gamut-avif` nor `gamut-png` caps one either. + /// + /// The last of [`with_c2pa`](Self::with_c2pa) / [`with_c2pa_reserved`](Self::with_c2pa_reserved) + /// wins; a file carries exactly one store. + /// + /// # Errors + /// + /// Returns [`Error::Unsupported`](gamut_core::Error) if `len` exceeds the `uint32` a RIFF chunk's + /// size field holds (RFC 9649 §2.3) — the same limit + /// [`gamut_riff::RiffWriter::write_chunk`] enforces, checked here so the reservation is refused + /// rather than allocated: `vec![0; len]` panics on a `len` no allocator could serve, and a + /// library path must return a typed error instead. + pub fn with_c2pa_reserved(mut self, len: usize) -> Result { + reservation_len(len)?; + self.c2pa = Some(vec![0; len]); + Ok(self) + } + /// Sets the compression [`Effort`] — libwebp's `method` dial, `0..=6`. /// /// Applies to both modes. Higher effort spends more time searching for a smaller file; it @@ -200,13 +291,36 @@ impl WebpEncoder { /// decode/re-encode cycle instead of dropping them. Any unknown chunk promotes the output to /// the extended (`VP8X`) format, since only that format has a place to put one. Calling this /// twice keeps the last list. - #[must_use] - pub fn with_unknown_chunks(mut self, chunks: &[(FourCc, &[u8])]) -> Self { + /// + /// This is the only setter that takes a FourCC from the caller rather than just a payload, so it + /// is the only one with an invalid input to reject — which is why it is fallible where the rest + /// of the builder is not. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`](gamut_core::Error) if `chunks` names any chunk the WebP + /// container defines (RFC 9649 §2.5-§2.7) or the `C2PA` chunk of C2PA 2.4 §A.3.7 — that is, + /// anything [`gamut_riff::WebpChunkId`] classifies as something other than + /// [`Unknown`](gamut_riff::WebpChunkId::Unknown). Each is written by this crate itself, from a + /// dedicated setter or from the image, so passing one through would emit it twice and the + /// pass-through copy would win: `gamut-riff`'s readers take the *first* of a repeated chunk. The + /// error names the offending FourCC in its detail, escaping any non-printable byte. + /// Use [`with_icc_profile`](Self::with_icc_profile), [`with_exif`](Self::with_exif), + /// [`with_xmp`](Self::with_xmp) or [`with_c2pa`](Self::with_c2pa) instead. + pub fn with_unknown_chunks(mut self, chunks: &[(FourCc, &[u8])]) -> Result { + if let Some((fourcc, _)) = chunks.iter().find(|(fourcc, _)| !is_unknown_chunk(*fourcc)) { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "WebP: this chunk is written by the encoder itself and cannot be passed through as \ + an unknown chunk", + ) + .with_detail(format!("{fourcc}"))); + } self.unknown = chunks .iter() .map(|(fourcc, payload)| (*fourcc, payload.to_vec())) .collect(); - self + Ok(self) } /// Installs a codestream encoder backend, returning `&mut self` so pushes chain. @@ -269,6 +383,7 @@ impl WebpEncoder { icc: self.icc.as_deref(), exif: self.exif.as_deref(), xmp: self.xmp.as_deref(), + c2pa: self.c2pa.as_deref(), } } @@ -277,7 +392,8 @@ impl WebpEncoder { /// With nothing that needs the extended format — no metadata and no separate `ALPH` chunk — this /// is the simple format: the `RIFF`/`WEBP` header plus the lone `VP8 `/`VP8L` chunk. Otherwise /// the file is promoted to extended, and the chunks go out in the spec's canonical order: - /// `VP8X`, `ICCP`, `ALPH`, the bitstream, `EXIF`, `XMP `. + /// `VP8X`, `ICCP`, `ALPH`, the bitstream, `EXIF`, `XMP `, the preserved unknown chunks, and last + /// of all `C2PA` (C2PA 2.4 §A.3.7). /// /// `has_alpha` records transparency for the `VP8X` feature flag independently of `alph`, because /// a `VP8L` bitstream carries its own alpha and so needs no `ALPH` chunk. @@ -410,6 +526,90 @@ impl WebpEncoder { } } +/// Where the things an encode *placed* ended up in the file it produced. +/// +/// Returned by [`WebpEncoder::encode_with_report`]. Construct nothing here — the encoder fills it +/// in. Marked `#[non_exhaustive]` so a later revision can report a further region without a +/// breaking change. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[non_exhaustive] +pub struct WebpEncodeReport { + /// The byte range the `C2PA` chunk occupies in the encoded file, or `None` when no manifest + /// store was configured. + /// + /// The range covers the chunk's **whole** span — the four identifier bytes, the four-byte size + /// field and the payload — because that is what a `c2pa.hash.data` assertion excludes (C2PA 2.4 + /// §18.5): an update manifest may resize the store, which changes the size field's value as + /// well as the bytes after it. The RIFF pad byte that follows an odd-length store (RFC 9649 + /// §2.3) is outside the range; it is framing the container adds, not store. + pub c2pa: Option>, +} + +impl WebpEncoder { + /// Encodes `image` and reports where the encoder placed what it was asked to place. + /// + /// The bytes are exactly the bytes [`EncodeImage::encode_image`] produces for the same encoder + /// and image — this is the same code path, not a second one — so the report can be taken as a + /// description of any file this encoder writes. It is a separate entry point because the + /// object-safe `EncodeImage` seam carries no channel for one. + /// + /// # Errors + /// + /// As [`EncodeImage::encode_image`], plus [`Error::InvalidInput`](gamut_core::Error) if the + /// `C2PA` chunk read back out of the finished file is not the store that was configured. That + /// check is what makes the reported range trustworthy: a range is only returned once the bytes + /// inside it have been confirmed to be the caller's own store, so a signer can never be handed a + /// span over somebody else's. + /// + /// That last error is **defence in depth** and no caller should expect to observe it: no input + /// this API accepts can make the encoder write a `C2PA` chunk that is not the configured store — + /// [`with_unknown_chunks`](Self::with_unknown_chunks) refuses one and + /// [`gamut_riff::write_extended_preserving`] gives the configured store the slot. It is kept as + /// a live check rather than a `debug_assert!` because this is a signing path, where a release + /// build is exactly where the protection is worth its one walk of the chunk list. + /// + /// # Example + /// + /// ``` + /// use gamut_core::{Dimensions, ImageRef, Rgb8}; + /// use gamut_webp::WebpEncoder; + /// + /// let pixels = [10u8, 20, 30]; + /// let image = ImageRef::::new(&pixels, Dimensions::new(1, 1)?)?; + /// let (file, report) = WebpEncoder::lossless() + /// .with_c2pa_reserved(64)? + /// .encode_with_report(image)?; + /// + /// let span = report.c2pa.expect("a store was reserved"); + /// assert_eq!(&file[span.start..span.start + 4], b"C2PA"); + /// assert_eq!(span.len(), 8 + 64); + /// # Ok::<(), gamut_core::Error>(()) + /// ``` + pub fn encode_with_report( + &self, + image: ImageRef<'_, P>, + ) -> Result<(Vec, WebpEncodeReport)> + where + Self: EncodeImage

, + { + let mut file = Vec::new(); + self.encode_image(image, &mut file)?; + // The range is read back out of the finished bytes with the very locator the read side uses + // (`gamut_riff::c2pa_span`), so a writer and a reader can never disagree about it. Reading + // the payload back through the *other* reader as well turns that into a checked claim: the + // range is returned only once the bytes inside it are known to be the configured store, so + // a stray `C2PA` chunk could never make the report name somebody else's bytes. + let c2pa = c2pa_span(&file)?; + if MetadataChunks::read(&file)?.c2pa != self.c2pa.as_deref() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "WebP: the C2PA chunk in the encoder's own output is not the configured store", + )); + } + Ok((file, WebpEncodeReport { c2pa })) + } +} + impl EncodeImage for WebpEncoder { fn encode_image(&self, image: ImageRef<'_, Rgb8>, out: &mut Vec) -> Result { self.encode_rgb8_inner(image.as_samples(), image.dimensions(), out) @@ -424,7 +624,8 @@ impl EncodeImage for WebpEncoder { #[cfg(test)] mod tests { - use gamut_core::{DecodeImage, ImageBuf}; + use gamut_core::{DecodeImage, ErrorKind, ImageBuf}; + use gamut_riff::C2PA_FOURCC; use super::*; @@ -620,4 +821,259 @@ mod tests { assert_eq!(written, out.len()); assert_eq!(&out[0..4], b"RIFF"); } + + /// The reservation is `len` zero bytes exactly — no slack, no framing. A signer sizes its store + /// against this number, so a reservation that were merely "at least `len`" would be useless. + #[test] + fn with_c2pa_reserved_is_exactly_len_zero_bytes() { + assert_eq!( + WebpEncoder::lossless().with_c2pa_reserved(0).unwrap().c2pa, + Some(vec![]) + ); + assert_eq!( + WebpEncoder::lossless().with_c2pa_reserved(5).unwrap().c2pa, + Some(vec![0, 0, 0, 0, 0]) + ); + assert_eq!( + WebpEncoder::lossless().c2pa, + None, + "unconfigured by default" + ); + } + + /// The reservation limit is the RIFF chunk size field itself, admitted right up to its ceiling + /// and refused one past it. Testing the narrowing function rather than the builder is what makes + /// the boundary reachable at all: pinning it through `with_c2pa_reserved` would mean allocating + /// 4 GiB to watch the accepted side succeed. + #[test] + fn a_reservation_is_narrowed_to_the_uint32_size_field() { + assert_eq!(reservation_len(0).expect("empty"), 0); + assert_eq!( + reservation_len(u32::MAX as usize).expect("the ceiling is admitted"), + u32::MAX + ); + // One past the ceiling is only expressible where `usize` is wider than `u32`; on a 32-bit + // target (wasm32) no `usize` can exceed it, so there is nothing to refuse. + if let Ok(past) = usize::try_from(u64::from(u32::MAX) + 1) { + let err = reservation_len(past).expect_err("one past the ceiling is refused"); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert!(err.to_string().contains("uint32 chunk size field"), "{err}"); + } + } + + /// And the builder refuses such a length instead of reaching `vec![0; len]`, which panics with + /// "capacity overflow" on a length no allocator can serve — CLAUDE.md forbids a panic on a + /// library path. + #[test] + fn with_c2pa_reserved_refuses_a_length_it_cannot_represent() { + let Ok(too_big) = usize::try_from(u64::from(u32::MAX) + 1) else { + return; // 32-bit target: unreachable, as above. + }; + let err = WebpEncoder::lossless() + .with_c2pa_reserved(too_big) + .expect_err("a reservation past the uint32 size field is refused"); + assert_eq!(err.kind(), ErrorKind::Unsupported); + } + + /// Every FourCC `gamut-riff` classifies is refused, so the mistake is caught at the call that + /// made it rather than resolved silently in favour of the pass-through copy. + /// + /// `ANIM` and `ANMF` are in the list deliberately. A hand-written table of "chunks with their + /// own setter" left them out, and the encoder then accepted an `ANIM` chunk and wrote a file + /// `WebpLayout::parse` refuses as having its reconstruction chunks out of order — an encoder + /// steered into producing a file it cannot read back. + #[test] + fn with_unknown_chunks_refuses_every_chunk_the_container_defines() { + let classified = [ + FourCc::VP8X, + FourCc::VP8, + FourCc::VP8L, + FourCc::ALPH, + FourCc::ICCP, + FourCc::EXIF, + FourCc::XMP, + FourCc::ANIM, + FourCc::ANMF, + C2PA_FOURCC, + ]; + for reserved in classified { + let err = WebpEncoder::lossless() + .with_unknown_chunks(&[(reserved, b"payload")]) + .err() + .unwrap_or_else(|| panic!("{reserved} must be refused")); + assert_eq!(err.kind(), ErrorKind::InvalidInput, "{reserved}"); + assert!( + err.to_string().contains(&reserved.to_string()), + "{reserved}: the error names the offending FourCC, got {err}" + ); + } + // A genuinely unknown FourCC is still accepted, and the check does not depend on position. + let private = FourCc::from(*b"XYZW"); + let ok = WebpEncoder::lossless() + .with_unknown_chunks(&[(private, b"payload")]) + .expect("a private chunk is accepted"); + assert_eq!(ok.unknown, vec![(private, b"payload".to_vec())]); + assert!( + WebpEncoder::lossless() + .with_unknown_chunks(&[(private, b"a"), (C2PA_FOURCC, b"b")]) + .is_err(), + "a reserved FourCC is refused wherever it sits in the list" + ); + } + + /// A file carries exactly one store, so the two setters share one slot and the last call wins — + /// including when the two kinds are mixed, which is the case a per-setter "last wins" would miss. + #[test] + fn the_last_c2pa_call_wins_whichever_kind_it_is() { + assert_eq!( + WebpEncoder::lossless() + .with_c2pa(b"first") + .with_c2pa(b"second") + .c2pa, + Some(b"second".to_vec()) + ); + assert_eq!( + WebpEncoder::lossless() + .with_c2pa_reserved(4) + .unwrap() + .with_c2pa(b"store") + .c2pa, + Some(b"store".to_vec()) + ); + assert_eq!( + WebpEncoder::lossless() + .with_c2pa(b"store") + .with_c2pa_reserved(2) + .unwrap() + .c2pa, + Some(vec![0, 0]) + ); + } + + /// The debug rendering names the store by length only: a manifest store is large and is not + /// something a log should spill. + #[test] + fn debug_reports_the_store_length_not_its_bytes() { + let rendered = format!("{:?}", WebpEncoder::lossless().with_c2pa(b"a store")); + assert!(rendered.contains("c2pa: Some(7)"), "{rendered}"); + assert!(!rendered.contains("a store"), "{rendered}"); + } + + /// `encode_with_report` is `encode_image` plus a report — not a second encoding path — and it + /// reports nothing when nothing was placed. + #[test] + fn encode_with_report_is_encode_image_plus_a_report() { + let rgb = [0x10, 0x20, 0x30].repeat(4); + let encoder = WebpEncoder::lossless(); + let image = || ImageRef::::new(&rgb, dims(2, 2)).unwrap(); + + let mut expected = Vec::new(); + encoder + .encode_image(image(), &mut expected) + .expect("encode"); + let (file, report) = encoder.encode_with_report(image()).expect("encode"); + assert_eq!(file, expected); + assert_eq!(report, WebpEncodeReport::default()); + assert_eq!(report.c2pa, None, "no store was configured"); + } + + /// The reported range is the chunk's whole span (C2PA 2.4 §18.5): identifier, size field and + /// payload, with the RIFF pad byte of an odd-length store left outside it. + #[test] + fn encode_with_report_names_the_whole_chunk_and_not_its_pad_byte() { + let store = b"an odd-length manifest store!!"; // sized to an odd length below + let store = &store[..29]; + assert_eq!(store.len() % 2, 1, "the fixture must exercise the pad byte"); + let rgb = [9u8, 8, 7].repeat(4); + let (file, report) = WebpEncoder::lossless() + .with_c2pa(store) + .encode_with_report(ImageRef::::new(&rgb, dims(2, 2)).unwrap()) + .expect("encode"); + + let span = report.c2pa.expect("a store was configured"); + assert_eq!(&file[span.start..span.start + 4], b"C2PA", "identifier"); + assert_eq!( + &file[span.start + 4..span.start + 8], + &(store.len() as u32).to_le_bytes(), + "size field" + ); + assert_eq!(&file[span.start + 8..span.end], store, "payload"); + assert_eq!(span.end, file.len() - 1, "the pad byte is outside the span"); + assert_eq!( + file[file.len() - 1], + 0, + "RFC 9649 §2.3: the pad byte is zero" + ); + } + + /// The reported range is only handed back once the bytes inside it have been confirmed to be the + /// configured store, so a signer can never be given a span over somebody else's bytes. The + /// encoder cannot be made to write a second `C2PA` chunk — `with_unknown_chunks` refuses one and + /// `write_extended_preserving` filters one — so this pins the checked claim from the inside: for + /// every store the encoder accepts, the span it reports contains exactly that store. + #[test] + fn the_reported_span_always_contains_the_configured_store() { + let rgb = [1u8, 2, 3].repeat(4); + for store in [&b""[..], &b"x"[..], &b"even"[..], &[0xff; 64][..]] { + let (file, report) = WebpEncoder::lossless() + .with_c2pa(store) + .encode_with_report(ImageRef::::new(&rgb, dims(2, 2)).unwrap()) + .expect("encode"); + let span = report.c2pa.expect("a store was configured"); + assert_eq!(span.len(), 8 + store.len(), "{store:?}: whole-chunk span"); + assert_eq!(&file[span.start + 8..span.end], store, "{store:?}: payload"); + assert_eq!( + crate::metadata(&file).unwrap().c2pa.as_deref(), + Some(store), + "{store:?}: and the reader agrees" + ); + } + } + + /// The reserve-then-fill flow only works if filling a reservation disturbs nothing else: two + /// equal-length stores must give two files that differ in exactly the reported span, so a hash + /// taken with that span excluded survives the substitution. + #[test] + fn filling_a_reservation_changes_only_the_reported_span() { + let rgb = [3u8, 5, 7].repeat(9); + let image = || ImageRef::::new(&rgb, dims(3, 3)).unwrap(); + let encode = |store: &[u8]| { + WebpEncoder::lossless() + .with_c2pa(store) + .encode_with_report(image()) + .expect("encode") + }; + + let (reserved, report) = WebpEncoder::lossless() + .with_c2pa_reserved(8) + .unwrap() + .encode_with_report(image()) + .expect("encode"); + let span = report.c2pa.expect("a store was reserved"); + let (first, first_report) = encode(b"11111111"); + let (second, second_report) = encode(b"22222222"); + + assert_eq!(first_report.c2pa, Some(span.clone())); + assert_eq!(second_report.c2pa, Some(span.clone())); + assert_eq!(first.len(), reserved.len()); + assert_eq!(second.len(), reserved.len()); + for (label, filled) in [ + ("reserved", &reserved), + ("first", &first), + ("second", &second), + ] { + assert_eq!( + filled[..span.start], + reserved[..span.start], + "{label}: before" + ); + assert_eq!(filled[span.end..], reserved[span.end..], "{label}: after"); + } + assert_eq!( + &reserved[span.start + 8..span.end], + &[0; 8], + "the reservation is zeros" + ); + assert_ne!(first[span.clone()], second[span], "the stores differ"); + } } diff --git a/crates/gamut-webp/src/lib.rs b/crates/gamut-webp/src/lib.rs index 6a05f6fb..1013c47e 100644 --- a/crates/gamut-webp/src/lib.rs +++ b/crates/gamut-webp/src/lib.rs @@ -26,7 +26,9 @@ //! ignored on decode, as RFC 9649 §2.7.1.6 asks of readers. Preserving one across a //! decode→encode cycle is opt-in and takes two steps: read the chunks with //! [`gamut_riff::WebpLayout::parse`] and hand them back via -//! [`WebpEncoder::with_unknown_chunks`]. The pixel API alone does not thread them through. +//! [`WebpEncoder::with_unknown_chunks`], which refuses any FourCC +//! [`gamut_riff::WebpChunkId`] classifies rather than emitting that chunk twice. The pixel API +//! alone does not thread them through. //! - **Animation** — `ANIM` / `ANMF` multi-frame sequences are out of scope under the image-first //! charter. Each frame is an independent key frame, but assembling them needs a non-trait API. //! - **Lossy quality** — the `0..=100` quality maps coarsely onto the VP8 base quantizer. The @@ -69,6 +71,27 @@ //! straight into `gamut-metadata`'s `MetadataBlock` (the still-image [`gamut_core`] traits carry no //! metadata channel, which is why this is a separate entry point rather than a decode result field). //! +//! # C2PA manifest stores +//! +//! A C2PA manifest store rides in a `C2PA` chunk (C2PA 2.4 §A.3.7), carried as opaquely as the three +//! chunks above: gamut never builds, hashes, signs or validates one. [`WebpEncoder::with_c2pa`] +//! embeds a finished store and [`WebpEncoder::with_c2pa_reserved`] leaves room for one that cannot +//! exist yet, because its hard binding digests the finished file. Either way the chunk goes last — +//! §A.3.7 requires it as the last sub-chunk of the `RIFF`/`WEBP` form, behind even the preserved +//! unknown chunks — and no `VP8X` feature flag advertises it, RFC 9649 §2.5 defining no C2PA bit. +//! +//! [`WebpEncoder::encode_with_report`] returns the file together with the chunk's byte range, and +//! [`c2pa_span`] recovers that range from any WebP file. The range is the chunk's whole span, which +//! is what a `c2pa.hash.data` assertion excludes (§18.5); [`metadata`] surfaces the store's bytes +//! themselves as [`WebpMetadata::c2pa`]. +//! +//! A file carries **exactly one** store, and that is enforced rather than assumed: the `C2PA` chunk +//! is refused by [`WebpEncoder::with_unknown_chunks`], never emitted among the preserved chunks by +//! [`gamut_riff::write_extended_preserving`], and `encode_with_report` reads the store back out of +//! its own output and refuses to report a range that does not cover the configured bytes. Without +//! that, a stale `C2PA` chunk carried forward from a reader that did not recognise it would be +//! written first, win every "first chunk of its kind" rule, and be the one a signer excluded. +//! //! # Pluggable codestream backends //! //! The RIFF container and the coded picture are separable: [`backend`] exposes one trait pair — @@ -102,6 +125,6 @@ pub use backend::{ }; pub use config::{Effort, NearLossless, WebpConfig, WebpMode}; pub use decoder::WebpDecoder; -pub use encoder::WebpEncoder; +pub use encoder::{WebpEncodeReport, WebpEncoder}; pub use gamut_core::Dimensions; -pub use metadata::{WebpMetadata, metadata}; +pub use metadata::{WebpMetadata, c2pa_span, metadata}; diff --git a/crates/gamut-webp/src/metadata.rs b/crates/gamut-webp/src/metadata.rs index 917b9b5f..125f1c30 100644 --- a/crates/gamut-webp/src/metadata.rs +++ b/crates/gamut-webp/src/metadata.rs @@ -1,13 +1,16 @@ //! Embedded metadata: the `ICCP` colour profile and the `EXIF` / `XMP ` chunks of a WebP file -//! (RFC 9649 §2.7.2-§2.7.3). +//! (RFC 9649 §2.7.2-§2.7.3), plus the `C2PA` manifest store of C2PA 2.4 §A.3.7. //! //! Payloads cross this boundary **verbatim**. The crate neither parses nor re-serializes them, so a //! profile or packet read by [`metadata`] is byte-for-byte the one a writer embedded — the property //! the typed metadata crates (`gamut-exif`, `gamut-icc`, `gamut-xmp`, and the `gamut-metadata` //! facade) need in order to borrow the bytes without a copy or a re-frame. Encoding is the mirror //! image: [`WebpEncoder::with_exif`](crate::WebpEncoder::with_exif), -//! [`with_xmp`](crate::WebpEncoder::with_xmp), and -//! [`with_icc_profile`](crate::WebpEncoder::with_icc_profile). +//! [`with_xmp`](crate::WebpEncoder::with_xmp), +//! [`with_icc_profile`](crate::WebpEncoder::with_icc_profile) and +//! [`with_c2pa`](crate::WebpEncoder::with_c2pa). + +use core::ops::Range; use gamut_core::Result; use gamut_riff::MetadataChunks; @@ -50,12 +53,17 @@ pub struct WebpMetadata { /// The `ICCP` chunk payload: an ICC colour profile. `None` means sRGB is assumed (§2.7.2). Feed /// as `MetadataBlock::Icc`. pub icc: Option>, + /// The `C2PA` chunk payload: a C2PA manifest store, carried opaquely (C2PA 2.4 §A.3.7). Nothing + /// in gamut parses, hashes, signs or validates it; [`c2pa_span`] reports the byte range a + /// `c2pa.hash.data` assertion excludes so a C2PA implementation can. + pub c2pa: Option>, } /// Reads a WebP file's embedded metadata chunks without decoding any pixels. /// -/// Walks the top-level RIFF chunks and collects the three metadata payloads — `EXIF` and `XMP ` -/// metadata (§2.7.3) and the `ICCP` colour profile (§2.7.2) — copying each out verbatim. The spec +/// Walks the top-level RIFF chunks and collects the four metadata payloads — `EXIF` and `XMP ` +/// metadata (§2.7.3), the `ICCP` colour profile (§2.7.2) and the `C2PA` manifest store (C2PA 2.4 +/// §A.3.7) — copying each out verbatim. The spec /// permits at most one chunk of each kind and lets readers keep only the first, which is what this /// does; the `VP8X` feature flags are advisory, so a payload is surfaced because its chunk is /// present rather than because a flag advertises it. A simple (single-bitstream) file, which cannot @@ -85,5 +93,47 @@ pub fn metadata(data: &[u8]) -> Result { exif: chunks.exif.map(<[u8]>::to_vec), xmp: chunks.xmp.map(<[u8]>::to_vec), icc: chunks.icc.map(<[u8]>::to_vec), + c2pa: chunks.c2pa.map(<[u8]>::to_vec), }) } + +/// Reports the byte range the `C2PA` chunk occupies in `data`, or `None` if the file carries no +/// manifest store. +/// +/// The range covers the chunk's **whole** span — the four identifier bytes, the four-byte size +/// field and the payload — because that is what a `c2pa.hash.data` assertion excludes (C2PA 2.4 +/// §18.5): an update manifest may resize the store, which changes the size field's value as well as +/// the bytes after it. The RIFF pad byte that follows an odd-length store (RFC 9649 §2.3) is +/// outside the range; it is framing the container adds, not store. +/// +/// This is the read-side twin of the range +/// [`WebpEncoder::encode_with_report`](crate::WebpEncoder::encode_with_report) returns — the same +/// walk over the same bytes — so a file's exclusion range does not depend on who computed it. No +/// pixels are decoded. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if `data` is not a valid +/// RIFF/WebP file, or if a chunk's declared size runs past the end of the data. +/// +/// # Example +/// +/// ``` +/// use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +/// use gamut_webp::WebpEncoder; +/// +/// let store = b"an opaque C2PA manifest store"; +/// let pixels = [1u8, 2, 3]; +/// let image = ImageRef::::new(&pixels, Dimensions::new(1, 1)?)?; +/// let mut file = Vec::new(); +/// WebpEncoder::lossless() +/// .with_c2pa(store) +/// .encode_image(image, &mut file)?; +/// +/// let span = gamut_webp::c2pa_span(&file)?.expect("the store was embedded"); +/// assert_eq!(&file[span.start + 8..span.end], store.as_slice()); +/// # Ok::<(), gamut_core::Error>(()) +/// ``` +pub fn c2pa_span(data: &[u8]) -> Result>> { + gamut_riff::c2pa_span(data) +} diff --git a/crates/gamut-webp/tests/metadata.rs b/crates/gamut-webp/tests/metadata.rs index 7a032f08..f8ffb549 100644 --- a/crates/gamut-webp/tests/metadata.rs +++ b/crates/gamut-webp/tests/metadata.rs @@ -1,5 +1,5 @@ //! Embedded-metadata round-trips: the `ICCP` colour profile and the `EXIF` / `XMP ` chunks -//! (RFC 9649 §2.7.2-§2.7.3). +//! (RFC 9649 §2.7.2-§2.7.3), plus the `C2PA` manifest store (C2PA 2.4 §A.3.7). //! //! The contract under test is byte fidelity plus container conformance — a payload handed to //! `WebpEncoder::with_*` comes back from `gamut_webp::metadata` unchanged, the `VP8X` feature flags @@ -17,6 +17,8 @@ const ICC: &[u8] = &[0x5a; 200]; const EXIF: &[u8] = b"II\x2a\x00\x08\x00\x00\x00exif-payload"; /// An XMP packet, likewise odd-length. const XMP: &[u8] = b""; +/// A stand-in C2PA manifest store, of odd length so the RIFF pad byte cannot leak into it. +const C2PA: &[u8] = b"opaque C2PA manifest store"; fn dims(width: u32, height: u32) -> Dimensions { Dimensions { width, height } @@ -423,6 +425,7 @@ fn unknown_chunks_survive_a_decode_re_encode_cycle() { WebpEncoder::lossless() .with_exif(b"exif payload") .with_unknown_chunks(&[(odd, b"private payload")]) + .expect("a private FourCC is accepted") .encode_image(image, &mut original) .expect("encode"); @@ -445,6 +448,7 @@ fn unknown_chunks_survive_a_decode_re_encode_cycle() { WebpEncoder::lossless() .with_exif(b"exif payload") .with_unknown_chunks(&carried) + .expect("carried chunks are private") .encode_image( ImageRef::::new(decoded.as_samples(), decoded.dimensions()).unwrap(), &mut rewritten, @@ -471,6 +475,7 @@ fn an_unknown_chunk_alone_promotes_a_file_to_the_extended_format() { let mut file = Vec::new(); WebpEncoder::lossless() .with_unknown_chunks(&[(FourCc::from(PRIVATE), b"payload")]) + .expect("a private FourCC is accepted") .encode_image(image, &mut file) .expect("encode"); @@ -487,6 +492,7 @@ fn no_unknown_chunks_leaves_a_simple_file_simple() { let mut file = Vec::new(); WebpEncoder::lossless() .with_unknown_chunks(&[]) + .expect("an empty list is accepted") .encode_image(image, &mut file) .expect("encode"); @@ -494,3 +500,134 @@ fn no_unknown_chunks_leaves_a_simple_file_simple() { assert!(layout.vp8x.is_none(), "still the simple format"); assert!(layout.unknown.is_empty()); } + +#[test] +fn a_store_alone_promotes_a_file_to_the_extended_format_and_goes_last() { + // Only the extended format has a place for a `C2PA` chunk, and C2PA 2.4 §A.3.7 puts it at the + // very end of the form — behind the metadata and behind the preserved unknown chunks, which is + // the placement nothing else in this crate's chunk order would produce. + for (label, encoder) in encoders() { + let file = encode_rgb( + &encoder + .clone() + .with_exif(EXIF) + .with_xmp(XMP) + .with_icc_profile(ICC) + .with_c2pa(C2PA) + .with_unknown_chunks(&[(FourCc::from(*b"XYZW"), b"private")]) + .expect("a private FourCC is accepted"), + &rgb(16, 16), + dims(16, 16), + ); + let ids = chunks(&file); + assert_eq!( + ids.last().map(|f| *f.as_bytes()), + Some(*b"C2PA"), + "{label}: the store is the last sub-chunk" + ); + assert_eq!( + ids.iter().filter(|f| f.as_bytes() == b"C2PA").count(), + 1, + "{label}: exactly one store chunk" + ); + } +} + +#[test] +fn the_store_round_trips_byte_exactly_and_sets_no_vp8x_flag() { + // The store crosses the boundary verbatim — the pad byte its odd length forces must not be read + // back as part of it — and RFC 9649 §2.5 defines no C2PA feature bit, so a store must leave the + // `VP8X` flag byte exactly as a store-free file has it. + assert_eq!( + C2PA.len() % 2, + 0, + "sanity: adjust the fixture if this changes" + ); + let odd = &C2PA[..C2PA.len() - 1]; + assert_eq!(odd.len() % 2, 1); + for (label, encoder) in encoders() { + let file = encode_rgb(&encoder.clone().with_c2pa(odd), &rgb(16, 16), dims(16, 16)); + let meta = gamut_webp::metadata(&file).expect("read metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(odd), "{label}: verbatim"); + assert_eq!( + read(&file), + want(None, None, None), + "{label}: no other carrier" + ); + + let flagged = encode_rgb( + &encoder + .clone() + .with_unknown_chunks(&[(FourCc::from(*b"XYZW"), b"x")]) + .expect("a private FourCC is accepted"), + &rgb(16, 16), + dims(16, 16), + ); + assert_eq!( + vp8x(&file), + vp8x(&flagged), + "{label}: a store sets no feature flag" + ); + } +} + +#[test] +fn c2pa_span_reads_back_the_range_the_store_occupies() { + // The read-side accessor must find the store in a finished file — including one whose earlier + // chunks are odd-length, whose pad bytes it therefore has to count — and report the chunk's + // whole span, not just the payload. + let file = encode_rgb( + &WebpEncoder::lossless().with_exif(EXIF).with_c2pa(C2PA), + &rgb(8, 8), + dims(8, 8), + ); + let span = gamut_webp::c2pa_span(&file) + .expect("parse") + .expect("the store was embedded"); + assert_eq!(&file[span.start..span.start + 4], b"C2PA"); + assert_eq!(&file[span.start + 8..span.end], C2PA); + assert_eq!(span.len(), 8 + C2PA.len()); +} + +#[test] +fn a_file_without_a_store_reports_no_span_and_no_store() { + // The converse: an ordinary file must not be handed a range to exclude. + let file = encode_rgb( + &WebpEncoder::lossless().with_exif(EXIF), + &rgb(8, 8), + dims(8, 8), + ); + assert_eq!(gamut_webp::c2pa_span(&file).expect("parse"), None); + assert_eq!(gamut_webp::metadata(&file).expect("read").c2pa, None); +} + +#[test] +fn a_store_survives_a_decode_re_encode_cycle_exactly_once() { + // A `C2PA` chunk is not an unknown chunk, so a caller who carries unknown chunks forward with + // `WebpLayout::parse` + `with_unknown_chunks` must not also carry the store and duplicate it. + let file = encode_rgb( + &WebpEncoder::lossless().with_c2pa(C2PA), + &rgb(8, 8), + dims(8, 8), + ); + let layout = WebpLayout::parse(&file).expect("parse"); + assert!( + layout.unknown.is_empty(), + "the store is not an unknown chunk" + ); + + let carried: Vec<(FourCc, &[u8])> = layout + .unknown + .iter() + .map(|c| (c.fourcc, c.payload)) + .collect(); + let again = encode_rgb( + &WebpEncoder::lossless() + .with_c2pa(layout.metadata.c2pa.expect("the store was read back")) + .with_unknown_chunks(&carried) + .expect("carried chunks are private"), + &rgb(8, 8), + dims(8, 8), + ); + assert_eq!(again, file); +} diff --git a/crates/gamut-webp/tests/oracle.rs b/crates/gamut-webp/tests/oracle.rs index 71692b8c..80ab8cc3 100644 --- a/crates/gamut-webp/tests/oracle.rs +++ b/crates/gamut-webp/tests/oracle.rs @@ -135,6 +135,66 @@ fn assert_gamut_encode_libwebp_decode(rgb: &[u8], w: u32, h: u32, label: &str) { assert_eq!(rgba_to_rgb(&decoded.rgba), rgb, "pixels for {label}"); } +#[test] +fn libwebp_decodes_a_file_carrying_a_c2pa_store_to_the_source_pixels() { + // A `C2PA` chunk (C2PA 2.4 §A.3.7) is not an RFC 9649 chunk, so to the reference decoder it is + // an unknown chunk it "SHOULD ignore" (§2.7.1.6). Embedding a manifest store must therefore be + // invisible to it: the same pixels out, and the same file as an unsigned encode with the store's + // chunk appended — nothing ahead of it moved. + let (w, h) = (32u32, 24u32); + let rgb = rgba_to_rgb(&pattern_rgba(w, h)); + let image = || { + ImageRef::::new( + &rgb, + Dimensions { + width: w, + height: h, + }, + ) + .unwrap() + }; + let store = &b"an opaque C2PA manifest store"[..]; + // Both files carry Exif, so both are already in the extended format and the only difference + // between them is the store's own chunk rather than the `VP8X` promotion. + let mut plain = Vec::new(); + WebpEncoder::lossless() + .with_exif(b"exif payload") + .encode_image(image(), &mut plain) + .expect("gamut encode"); + let (signed, report) = WebpEncoder::lossless() + .with_exif(b"exif payload") + .with_c2pa(store) + .encode_with_report(image()) + .expect("gamut encode"); + + let decoded = libwebp_decode_rgba(&signed); + assert_eq!( + (decoded.width, decoded.height), + (w, h), + "libwebp reads the canvas" + ); + assert_eq!( + rgba_to_rgb(&decoded.rgba), + rgb, + "libwebp recovers the source pixels through the store" + ); + + // The store is the only thing added, and it is where the report says it is. + let span = report.c2pa.expect("a store was configured"); + assert_eq!(&signed[span.start..span.start + 4], b"C2PA"); + assert_eq!(&signed[span.start + 8..span.end], store); + assert_eq!( + signed.len() - plain.len(), + span.len() + span.len() % 2, + "only the store's chunk and its pad byte were added" + ); + assert_eq!( + signed[12..span.start], + plain[12..], + "everything ahead of the store is byte-identical" + ); +} + #[test] fn libwebp_decodes_every_gamut_encoder_path() { // Each image steers gamut's encoder down a different path; libwebp must decode them all.