From c09d7c6957ffc014bc7aee70af7bb093d7f2c42f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 04:14:14 -0400 Subject: [PATCH 01/15] feat(metadata): add the per-format capability query Add `gamut_metadata::capability`: `Format`, `Carrier` and `Direction` enums (`repr(u8)`, append-only discriminants, `ALL` constants) with a `const fn supports(format, carrier, direction)` answering whether the format crate can locate or write a carrier as a raw payload, and `const fn typed_wiring(format)` saying whether that crate also exposes the facade's typed models behind its `metadata` feature. The table is a transcription of each crate's STATUS.md; every arm cites the row that justifies it, and a full-matrix test pins every cell. It is a const table rather than a runtime registry because the format set is the workspace's own and the release topology forbids the facade depending on a format crate. Refs #420, #216 --- crates/gamut-metadata/README.md | 53 +++- crates/gamut-metadata/src/capability.rs | 327 ++++++++++++++++++++++++ crates/gamut-metadata/src/lib.rs | 17 ++ 3 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 crates/gamut-metadata/src/capability.rs diff --git a/crates/gamut-metadata/README.md b/crates/gamut-metadata/README.md index 5acbf910..f7f35fe6 100644 --- a/crates/gamut-metadata/README.md +++ b/crates/gamut-metadata/README.md @@ -205,11 +205,58 @@ Use `Metadata::default()` plus field assignment when you also need `extensions`, arm to any exhaustive `match` on `MetadataBlock`. Nothing else changed: every existing method keeps its signature and behaviour. +## Capability query + +The facade never parses a container, so it cannot say whether a *file* carries metadata. What it +can say — statically, from a crate that depends on no format crate — is whether gamut's crate for a +format can **locate** or **write** a given carrier at all. `gamut_metadata::capability` is that +table, per (format × carrier × direction), transcribed from each crate's `STATUS.md` with the row +cited on every cell: + +| Format | EXIF | XMP | ICC | IPTC-IIM | C2PA | typed wiring | +| --- | --- | --- | --- | --- | --- | --- | +| JPEG | r/w | r/w | r/w | — | — | ✅ (`metadata` feature) | +| PNG | r/w | r/w | r/w | — | — | — | +| WebP | r/w | r/w | r/w | — | — | — | +| AVIF | r/w | r/w | r/w | — | — | — | +| HEIC | r | r | r | — | r | ✅ (`metadata` feature) | +| JPEG XL | r/w | r/w | r/w | — | — | ✅ (`metadata` feature) | +| TIFF | — | — | — | — | — | — | +| DNG | r/w | r/w | r/w | r/w | — | ✅ | + +```rust +use gamut_metadata::capability::{Carrier, Direction, Format, supports, typed_wiring}; + +assert!(supports(Format::Jpeg, Carrier::Exif, Direction::Write)); +assert!(!supports(Format::Heic, Carrier::Exif, Direction::Write)); // decode-only crate +assert!(typed_wiring(Format::Jpeg)); // `blocks()` / `metadata()` / `with_metadata` exist there +assert!(!typed_wiring(Format::Png)); // raw bytes handed to `Metadata::from_blocks` by hand +``` + +`supports` describes the crate's **raw** surface, which every format crate ships unconditionally; +`typed_wiring` says whether it also exposes this crate's models directly, behind that crate's +`metadata` Cargo feature. C2PA is read-only everywhere by construction — no embedder copies a +manifest store forward (see above). The enums are `#[repr(u8)]` with append-only discriminants and +carry `ALL` constants for enumeration, since `Format` and `Carrier` are `#[non_exhaustive]`. The +audio/video half of the same question is outside an image-first workspace and stays with issue +#216. + ## Consumer integration -The format crates (`gamut-avif`/`gamut-webp`/`gamut-heic`/…) gaining a `gamut-metadata` dependency to -read, preserve, and embed metadata is tracked in their own milestones; the dependency direction -`format → gamut-metadata → per-format crates` is settled here. +The dependency direction is `format → gamut-metadata → per-format crates`, settled here. A format +crate wires the facade in behind an optional `metadata` Cargo feature (a normal, not dev, +dependency — release ordering follows it): its decoded metadata type gains `blocks()` (the located +payloads as `MetadataBlock`s, e.g. a JPEG's EXIF with the `Exif\0\0` signature already stripped, +an ISOBMFF `Exif` item with its `exif_tiff_header_offset` already applied) and `metadata()` +(`Metadata::from_blocks` over them), and its encoder gains `with_metadata(&Metadata)` — embedding +through `MetadataEmbedder::new()` and routing each `EncodedMetadata` field to the crate's raw +setter — plus `with_encoded_metadata(&EncodedMetadata)` for a caller who chose the embedder's +policies. A carrier the container cannot write is a typed `Unsupported` error there, never a silent +drop, and a manifest store is never copied forward. + +Wired today: `gamut-dng` (its `DngMetadata` holds the facade's `Exif` by value), `gamut-jpeg`, +`gamut-jxl` and `gamut-heic` (decode-only). The remaining format crates hand their payloads over as +raw bytes — see the capability table above. ## License diff --git a/crates/gamut-metadata/src/capability.rs b/crates/gamut-metadata/src/capability.rs new file mode 100644 index 00000000..1e9bc2bc --- /dev/null +++ b/crates/gamut-metadata/src/capability.rs @@ -0,0 +1,327 @@ +//! Which metadata carrier each gamut format crate can locate or write, as a queryable table. +//! +//! The facade is container-agnostic, so it cannot *do* anything with a format; what it can do is +//! answer, statically and without pulling any format crate in, the question a caller asks before +//! reaching for one: "can gamut read (or write) EXIF in a WebP?". The real matrix is not uniform — +//! HEIC is decode-only, TIFF locates nothing yet, DNG alone carries a legacy IPTC-IIM block — so the +//! model is per **(format × carrier × direction)** rather than a flat per-format flag. +//! +//! Two questions, two functions: +//! +//! - [`supports`] — can the format crate **locate** ([`Direction::Read`]) or **write** +//! ([`Direction::Write`]) the carrier as a raw payload? This is the crate's own surface +//! (`metadata()` / `with_exif`-style setters), independent of any feature. +//! - [`typed_wiring`] — does the format crate also expose the facade's typed models directly +//! (`blocks()` / `metadata()` accessors and a `with_metadata` encoder builder), behind that +//! crate's `metadata` Cargo feature? +//! +//! The table is a transcription of each crate's `STATUS.md` **as of this facade version**; every +//! arm below cites the row that justifies it, and the cell changes in the pull request that changes +//! the row. It is deliberately a `const` table rather than a runtime registry: the set of formats is +//! the workspace's, fixed at build time, and a query must be answerable from a crate that depends on +//! no format crate at all (the release topology forbids the reverse edge). +//! +//! The audio/video half of the same question — which *media* containers carry which metadata — is +//! outside an image-first workspace and stays with the issue that asked for it. +//! +//! ``` +//! use gamut_metadata::capability::{Carrier, Direction, Format, supports, typed_wiring}; +//! +//! // HEIC is decode-only: EXIF can be located but never written. +//! assert!(supports(Format::Heic, Carrier::Exif, Direction::Read)); +//! assert!(!supports(Format::Heic, Carrier::Exif, Direction::Write)); +//! +//! // Only DNG carries the legacy IPTC-IIM block; everywhere else IPTC rides inside XMP. +//! assert!(supports(Format::Dng, Carrier::IptcIim, Direction::Write)); +//! assert!(!supports(Format::Jpeg, Carrier::IptcIim, Direction::Write)); +//! +//! // A typed `Metadata` accessor exists on the JPEG crate (behind its `metadata` feature)... +//! assert!(typed_wiring(Format::Jpeg)); +//! // ...but not yet on PNG, whose payloads are still handed over as raw bytes. +//! assert!(!typed_wiring(Format::Png)); +//! ``` + +/// A still-image container format with a gamut crate. +/// +/// `#[non_exhaustive]` and `#[repr(u8)]` with **permanent, append-only** discriminants: a later +/// format is added at the end and never renumbers an existing one, so the value is stable across +/// the C ABI. Match with a wildcard arm; iterate with [`Format::ALL`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +#[repr(u8)] +pub enum Format { + /// JPEG-1 (ISO/IEC 10918-1), `gamut-jpeg`. + Jpeg = 0, + /// PNG (W3C, 3rd edition), `gamut-png`. + Png = 1, + /// WebP (RIFF), `gamut-webp`. + WebP = 2, + /// AVIF (ISOBMFF/HEIF over AV1), `gamut-avif`. + Avif = 3, + /// HEIC/HEIF (ISOBMFF over HEVC), `gamut-heic` — decode-only. + Heic = 4, + /// JPEG XL (ISO/IEC 18181), `gamut-jxl`. + Jxl = 5, + /// TIFF 6.0, `gamut-tiff`. + Tiff = 6, + /// DNG 1.7.1 (a TIFF/EP profile), `gamut-dng`. + Dng = 7, +} + +impl Format { + /// Every format, in discriminant order — the way to enumerate a `#[non_exhaustive]` enum. + pub const ALL: [Self; 8] = [ + Self::Jpeg, + Self::Png, + Self::WebP, + Self::Avif, + Self::Heic, + Self::Jxl, + Self::Tiff, + Self::Dng, + ]; + + /// The Cargo package that implements the format. + #[must_use] + pub const fn crate_name(self) -> &'static str { + match self { + Self::Jpeg => "gamut-jpeg", + Self::Png => "gamut-png", + Self::WebP => "gamut-webp", + Self::Avif => "gamut-avif", + Self::Heic => "gamut-heic", + Self::Jxl => "gamut-jxl", + Self::Tiff => "gamut-tiff", + Self::Dng => "gamut-dng", + } + } +} + +/// A metadata carrier: one genuinely distinct serialization a container holds, matching the +/// variants of [`MetadataBlock`](crate::MetadataBlock). +/// +/// `#[non_exhaustive]` and `#[repr(u8)]` with permanent, append-only discriminants, like +/// [`Format`]. Iterate with [`Carrier::ALL`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +#[repr(u8)] +pub enum Carrier { + /// An EXIF blob (a TIFF stream, with or without the `Exif\0\0` marker). + Exif = 0, + /// An XMP packet — which is also where IPTC Core/Extension lives. + Xmp = 1, + /// An ICC profile. + Icc = 2, + /// The legacy binary IPTC-IIM dataset stream. + IptcIim = 3, + /// A C2PA manifest store (JUMBF superbox). Read-only by nature everywhere: the facade never + /// copies a store forward (see [`C2paPolicy`](crate::C2paPolicy)), so no format writes one. + C2pa = 4, +} + +impl Carrier { + /// Every carrier, in discriminant order. + pub const ALL: [Self; 5] = [Self::Exif, Self::Xmp, Self::Icc, Self::IptcIim, Self::C2pa]; +} + +/// Which way the metadata moves. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum Direction { + /// Locating the carrier's payload in an existing file (the crate's `metadata()` / lens). + Read = 0, + /// Writing the carrier into a file the crate encodes (the crate's `with_*` builders). + Write = 1, +} + +impl Direction { + /// Both directions. + pub const ALL: [Self; 2] = [Self::Read, Self::Write]; +} + +/// Whether the crate for `format` can locate (`Read`) or write (`Write`) `carrier` as a raw payload. +/// +/// This is the **raw** surface — the crate's own byte-level `metadata()` / `with_*` API, which every +/// format crate ships unconditionally. Whether it also exposes the facade's typed models is +/// [`typed_wiring`]. Each arm cites the `STATUS.md` row of the crate it describes. +#[must_use] +pub const fn supports(format: Format, carrier: Carrier, direction: Direction) -> bool { + let read = matches!(direction, Direction::Read); + match (format, carrier) { + // gamut-jpeg STATUS.md P7: APP1 EXIF + XMP and multi-segment APP2 ICC, read (`metadata()`) + // and write (`with_exif` / `with_xmp` / `with_icc_profile`). + (Format::Jpeg, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-jpeg STATUS.md "Not implemented": APP13 IPTC-IIM deferred; no APP11 (C2PA) carriage. + (Format::Jpeg, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-png STATUS.md P8 (eXIf / iCCP / iTXt-XMP setters) and D5 (raw eXIf / iCCP / XMP + // payloads on decode). + (Format::Png, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-png STATUS.md: no IPTC-IIM chunk exists in PNG; no C2PA (`caBX`) row today. + (Format::Png, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-webp STATUS.md M4: `ICCP` / `EXIF` / `XMP ` chunks embedded on encode and preserved + // on decode (`metadata` / `with_icc_profile` / `with_exif` / `with_xmp`). + (Format::WebP, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-webp STATUS.md: RIFF has no IPTC-IIM chunk; no C2PA (`C2PA` chunk) row today. + (Format::WebP, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-avif STATUS.md M4: Exif / XMP items with a `cdsc` reference and `colr` `prof` ICC, + // written by `AvifEncoder::with_exif` / `with_xmp` / `with_icc_profile` and read back. + (Format::Avif, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-avif STATUS.md: no IPTC-IIM item type; no C2PA `uuid` box row today. + (Format::Avif, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-heic STATUS.md B (Exif/XMP lens via `cdsc`, `colr` accessor) and S7 (C2PA manifest + // store located in a top-level `uuid` box). The crate is decode-only: nothing is written. + (Format::Heic, Carrier::Exif | Carrier::Xmp | Carrier::Icc | Carrier::C2pa) => read, + // gamut-heic STATUS.md: HEIF has no IPTC-IIM item type. + (Format::Heic, Carrier::IptcIim) => false, + + // gamut-jxl STATUS.md "Exif / XMP container boxes" (written by `with_exif` / `with_xmp`, + // read back by `JxlDecoder::metadata`) and "Colour signalling" (`ColorSpec::Icc` written, + // `embedded_icc_profile` read). + (Format::Jxl, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-jxl STATUS.md: no IPTC-IIM box; the `jumb` (C2PA) box is neither located nor + // written, and a Brotli-compressed `brob` metadata box is a typed `Unsupported`. + (Format::Jxl, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-tiff STATUS.md: "metadata tags (§12 beyond `PageNumber`)" deferred — the `XMP`, + // `ExifIFD` and `ICCProfile` tags are recognised by name only; no payload is located and + // there is no `with_exif`-style setter. + (Format::Tiff, _) => false, + + // gamut-dng STATUS.md P16: EXIF sub-IFD + XMP (700) / IPTC-IIM (33723) / ICC (34675), + // embedded and decoded (`DngMetadata`, `DngMetadata::blocks`). + (Format::Dng, Carrier::Exif | Carrier::Xmp | Carrier::Icc | Carrier::IptcIim) => true, + // gamut-dng STATUS.md "Out of scope": C2PA surfaces only as a typed `RawTag`, not as a + // located manifest store. + (Format::Dng, Carrier::C2pa) => false, + } +} + +/// Whether the crate for `format` exposes the facade's typed models directly — `blocks()` / +/// `metadata()` accessors on its decoded metadata and a `with_metadata` builder on its encoder — +/// behind that crate's `metadata` Cargo feature. +/// +/// `false` means the crate still hands its payloads over as raw bytes that a caller feeds to +/// [`Metadata::from_blocks`](crate::Metadata::from_blocks) by hand. +#[must_use] +pub const fn typed_wiring(format: Format) -> bool { + match format { + // gamut-dng STATUS.md P16 (#353): `DngMetadata::exif` is the facade's `Exif`, `blocks()` + // hands over the byte carriers. gamut-jpeg / gamut-jxl / gamut-heic: the `metadata` + // feature (issue #420). + Format::Dng | Format::Jpeg | Format::Jxl | Format::Heic => true, + // Raw payloads only, tracked by the #420 remainder. + Format::Png | Format::WebP | Format::Avif | Format::Tiff => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The complete set of `true` cells, transcribed from the per-crate `STATUS.md` rows the table + /// cites. Every other (format, carrier, direction) is `false`. + const SUPPORTED: &[(Format, Carrier, Direction)] = &[ + (Format::Jpeg, Carrier::Exif, Direction::Read), + (Format::Jpeg, Carrier::Exif, Direction::Write), + (Format::Jpeg, Carrier::Xmp, Direction::Read), + (Format::Jpeg, Carrier::Xmp, Direction::Write), + (Format::Jpeg, Carrier::Icc, Direction::Read), + (Format::Jpeg, Carrier::Icc, Direction::Write), + (Format::Png, Carrier::Exif, Direction::Read), + (Format::Png, Carrier::Exif, Direction::Write), + (Format::Png, Carrier::Xmp, Direction::Read), + (Format::Png, Carrier::Xmp, Direction::Write), + (Format::Png, Carrier::Icc, Direction::Read), + (Format::Png, Carrier::Icc, Direction::Write), + (Format::WebP, Carrier::Exif, Direction::Read), + (Format::WebP, Carrier::Exif, Direction::Write), + (Format::WebP, Carrier::Xmp, Direction::Read), + (Format::WebP, Carrier::Xmp, Direction::Write), + (Format::WebP, Carrier::Icc, Direction::Read), + (Format::WebP, Carrier::Icc, Direction::Write), + (Format::Avif, Carrier::Exif, Direction::Read), + (Format::Avif, Carrier::Exif, Direction::Write), + (Format::Avif, Carrier::Xmp, Direction::Read), + (Format::Avif, Carrier::Xmp, Direction::Write), + (Format::Avif, Carrier::Icc, Direction::Read), + (Format::Avif, Carrier::Icc, Direction::Write), + (Format::Heic, Carrier::Exif, Direction::Read), + (Format::Heic, Carrier::Xmp, Direction::Read), + (Format::Heic, Carrier::Icc, Direction::Read), + (Format::Heic, Carrier::C2pa, Direction::Read), + (Format::Jxl, Carrier::Exif, Direction::Read), + (Format::Jxl, Carrier::Exif, Direction::Write), + (Format::Jxl, Carrier::Xmp, Direction::Read), + (Format::Jxl, Carrier::Xmp, Direction::Write), + (Format::Jxl, Carrier::Icc, Direction::Read), + (Format::Jxl, Carrier::Icc, Direction::Write), + (Format::Dng, Carrier::Exif, Direction::Read), + (Format::Dng, Carrier::Exif, Direction::Write), + (Format::Dng, Carrier::Xmp, Direction::Read), + (Format::Dng, Carrier::Xmp, Direction::Write), + (Format::Dng, Carrier::Icc, Direction::Read), + (Format::Dng, Carrier::Icc, Direction::Write), + (Format::Dng, Carrier::IptcIim, Direction::Read), + (Format::Dng, Carrier::IptcIim, Direction::Write), + ]; + + #[test] + fn supports_equals_the_documented_matrix_in_every_cell() { + // Walks the full product so a flipped arm anywhere in `supports` is a named cell here. + for format in Format::ALL { + for carrier in Carrier::ALL { + for direction in Direction::ALL { + let expected = SUPPORTED.contains(&(format, carrier, direction)); + assert_eq!( + supports(format, carrier, direction), + expected, + "{format:?} / {carrier:?} / {direction:?}" + ); + } + } + } + } + + #[test] + fn typed_wiring_names_exactly_the_four_wired_crates() { + let wired: Vec = Format::ALL + .into_iter() + .filter(|&f| typed_wiring(f)) + .collect(); + assert_eq!( + wired, + vec![Format::Jpeg, Format::Heic, Format::Jxl, Format::Dng] + ); + } + + #[test] + fn crate_name_follows_the_workspace_naming() { + for format in Format::ALL { + let name = format.crate_name(); + assert!(name.starts_with("gamut-"), "{format:?}: {name}"); + assert_eq!( + name.trim_start_matches("gamut-"), + format!("{format:?}").to_ascii_lowercase(), + "{format:?}: {name}" + ); + } + } + + #[test] + fn discriminants_are_the_documented_append_only_values() { + // The `repr(u8)` values are a public contract (C ABI); pin them so a reorder is a failure. + assert_eq!( + Format::ALL.map(|f| f as u8), + core::array::from_fn::(|i| i as u8) + ); + assert_eq!( + Carrier::ALL.map(|c| c as u8), + core::array::from_fn::(|i| i as u8) + ); + assert_eq!(Direction::ALL.map(|d| d as u8), [0, 1]); + } +} diff --git a/crates/gamut-metadata/src/lib.rs b/crates/gamut-metadata/src/lib.rs index 1634349a..1a781c4e 100644 --- a/crates/gamut-metadata/src/lib.rs +++ b/crates/gamut-metadata/src/lib.rs @@ -156,6 +156,22 @@ //! # Ok::<(), gamut_metadata::MetadataError>(()) //! ``` //! +//! # Which formats carry what +//! +//! The facade never parses a container, so it cannot say whether a *file* has metadata — but it can +//! say whether gamut's crate for a format can locate or write a given carrier at all, before a +//! caller pulls that crate in. [`capability::supports`] answers per (format × carrier × direction), +//! and [`capability::typed_wiring`] says whether the format crate also exposes these typed models +//! directly (behind its `metadata` feature) rather than as raw bytes: +//! +//! ``` +//! use gamut_metadata::capability::{Carrier, Direction, Format, supports, typed_wiring}; +//! +//! assert!(supports(Format::Jpeg, Carrier::Exif, Direction::Write)); +//! assert!(!supports(Format::Heic, Carrier::Exif, Direction::Write)); // decode-only crate +//! assert!(typed_wiring(Format::Jpeg)); +//! ``` +//! //! # Quick start //! //! ``` @@ -178,6 +194,7 @@ //! ``` #![forbid(unsafe_code)] +pub mod capability; pub mod embed; pub mod error; pub mod extension; From d9e2db3d17688bbc9250bc8655647ed8d286263d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 04:20:38 -0400 Subject: [PATCH 02/15] feat(jpeg): wire the gamut-metadata facade behind a `metadata` feature Add an optional, normal dependency on gamut-metadata behind a new `metadata` Cargo feature (off by default), and the gamut-dng pattern on top of the raw APP-segment surface: - `JpegMetadata::blocks` hands the located payloads over as `MetadataBlock`s (EXIF = the TIFF stream without `Exif\0\0`, XMP = the xpacket, ICC = the reassembled profile) and `JpegMetadata::metadata` parses them into a unified `Metadata`; - `JpegEncoder::with_metadata(&Metadata)` embeds through the default `MetadataEmbedder` and `with_encoded_metadata(&EncodedMetadata)` accepts caller-chosen policies, routing each carrier to the existing raw setter. IPTC-IIM (APP13) and C2PA (APP11) blocks are typed `Unsupported`, never dropped; a manifest store is never copied forward. The typed extract -> embed -> extract equality is pinned through the stream. The exiv2 oracle has no JPEG reader, so the container-level differential cell is recorded as untested in STATUS.md. Refs #420 --- Cargo.lock | 1 + crates/gamut-jpeg/Cargo.toml | 11 ++ crates/gamut-jpeg/STATUS.md | 12 +- crates/gamut-jpeg/src/lib.rs | 13 ++ crates/gamut-jpeg/src/metadata.rs | 309 ++++++++++++++++++++++++++++++ 5 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 crates/gamut-jpeg/src/metadata.rs diff --git a/Cargo.lock b/Cargo.lock index fcdd8e15..8d3a03fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,6 +797,7 @@ dependencies = [ "gamut-color", "gamut-core", "gamut-dsp", + "gamut-metadata", "libjpeg-oracle", ] diff --git a/crates/gamut-jpeg/Cargo.toml b/crates/gamut-jpeg/Cargo.toml index 525f447b..8394ccad 100644 --- a/crates/gamut-jpeg/Cargo.toml +++ b/crates/gamut-jpeg/Cargo.toml @@ -24,6 +24,17 @@ gamut-codec-abi.workspace = true gamut-color.workspace = true # The §A.3.3 forward DCT kernel and the §A.3.4 round-to-nearest quantizer divide. gamut-dsp.workspace = true +# Typed metadata (issue #420): the unified `Metadata` model over the raw APP1/APP2 payloads this +# crate locates and writes. Optional and off by default — a plain JPEG consumer pays for none of +# the four metadata crates — and a *normal* dependency (never dev-only), so release ordering +# follows it (`mise run check-release-deps`). +gamut-metadata = { workspace = true, optional = true } + +[features] +default = [] +# Typed metadata wiring: `JpegMetadata::blocks` / `JpegMetadata::metadata` and +# `JpegEncoder::with_metadata` / `with_encoded_metadata` over the `gamut-metadata` facade. +metadata = ["dep:gamut-metadata"] [dev-dependencies] # Differential cross-check oracle: a vendored, statically-linked libjpeg-turbo 3.2.0 (built from the diff --git a/crates/gamut-jpeg/STATUS.md b/crates/gamut-jpeg/STATUS.md index 1afa4ad0..0f7ae36f 100644 --- a/crates/gamut-jpeg/STATUS.md +++ b/crates/gamut-jpeg/STATUS.md @@ -48,8 +48,15 @@ progressive-stream walker (scan script, per-scan DHTs, restart cadence, EOBn-run - Colour-space handling: JFIF (APP0) and Adobe (APP14) transform flags; CMYK / YCCK **decode**. - APP-segment metadata (P7): APP1 EXIF + XMP and multi-segment APP2 `ICC_PROFILE`, **read** (`metadata()`) and **write** (`with_exif`/`with_xmp`/`with_icc_profile`), raw-bytes payloads that - feed `gamut-metadata`'s `MetadataBlock` directly (proven by a dev-only interop test; the runtime - dependency edge stays jpeg ← core, color, dsp). + feed `gamut-metadata`'s `MetadataBlock` directly (the default dependency edge stays jpeg ← core, + color, dsp). +- Typed metadata (P14, issue #420) behind the opt-in **`metadata`** feature (a normal, optional + dependency on `gamut-metadata`): `JpegMetadata::blocks` (EXIF = the TIFF stream, `Exif\0\0` + stripped; XMP = the `xpacket`; ICC = the reassembled profile) and `JpegMetadata::metadata` + (`Metadata::from_blocks`); `JpegEncoder::with_metadata(&Metadata)` (default `MetadataEmbedder`) + and `with_encoded_metadata(&EncodedMetadata)` (caller-chosen policies), routing each carrier to + the raw setter above. IPTC-IIM and C2PA blocks are typed `Unsupported` on the encoder (no APP13 / + APP11 carriage — see below), and a C2PA store is never copied forward (facade policy). - Pluggable codestream backends (P8, issue #277): the `backend` module's `JpegStreamDecoder` / `JpegStreamEncoder` traits, `JpegDecoder::push_backend` / `JpegEncoder::push_backend`, and the `gamut-codec-abi` adapters in both directions. @@ -228,3 +235,4 @@ progressive-stream walker (scan script, per-scan DHTs, restart cadence, EOBn-run | P11 | T.81 §A.3.4, §B.2.4.1; issue #332 | **Caller-supplied quantization tables:** the public `QuantTables` pair — natural order, every entry `1..=255` **by construction** (`new` rejects zero, so the encoder never divides by zero and never emits a DQT its own decoder refuses) — used verbatim via `JpegEncoder::with_quant_tables`, with `annex_k()`/`scaled()` recovering the frozen IJG mapping over arbitrary bases. Quality becomes inert while set; the frozen quality contract still governs the default path; backends are vetoed (a `JpegEncodeRequest` cannot carry tables). Alternate built-in base tables deferred (citation obligation) | ✅ done | | P12 | T.81 §F.1.2, Annex K.5/K.6; issue #333 | **RD-optimized coefficient selection:** the `rd` module's per-block AC trellis — dynamic program over (position, {v, v−1}) nodes with the exact run/size + ZRL + EOB bit cost of the typical AC tables as rate proxy, step-normalized distortion (the quantization table as the perceptual weighting), tuned dimensionless λ (pinned; measured 8.7% battery-wide saving at ≤ 0.35 dB) — plus `TrellisAdaptive` per-block λ modulation `√(energy/Σstep²)` clamped `[¼, 4]`. Opt-in `with_rd_optimization`; DC plain (deferred: cross-block DP through the DC predictor/restarts); default byte-identical; backends vetoed; progressive carries identical coefficients by the shared `quantize_block_rd` seam + configuration-only rate model | ✅ done | | P13 | Adobe TN #5116; ISO/IEC 18181-1 (XYB, via the vendored CD + pinned libjxl 0.12.0 constants); issue #334 | **XYB colour mode:** `with_color_mode(JpegColorMode::Xyb)` — sRGB → linear (EOTF LUT) → opsin XYB → scaled-XYB u8 planes (X, Y, B−Y) coded 4:4:4 under SOI → APP14 `transform=0` → EXIF/XMP → APP2 `XYB_ICC_PROFILE` → DQT, SOF ids `R`,`G`,`B`, X/Y on the luminance table and B−Y on the chrominance table (placeholder pairing, ledgered above). The 768-byte profile (input class, D50 XYZ PCS, `desc`/`cprt`/`wtpt`/`chad`/`A2B0` mAB with a 2×2×2 CLUT + cube-root parametric M curves + the frozen 0.5·XYZ(D50)·OpsinInverse matrix/`B2A0` no-op) is vendored static and umbrella-pinned; the decoder needs zero changes (RGB passthrough via APP14/ids). Differential: libjpeg-turbo decodes + test-side XYB inversion ≥ 40 dB on the battery; lcms2 reproduces sRGB from real samples (worst 25 codes at the documented near-black X amplification, mean 2.46) | ✅ done | +| P14 | issue #420; `gamut-metadata` | **Typed metadata wiring** behind the opt-in `metadata` feature: `JpegMetadata::blocks` / `metadata`, `JpegEncoder::with_metadata` / `with_encoded_metadata`; IPTC-IIM / C2PA typed `Unsupported`; pinned by the typed extract → embed → extract equality through the stream. **Oracle cell not covered:** `tooling/exiv2-oracle` is block-level and in-memory (no JPEG reader), so "exiv2 reads the embedded APP1/APP2 payloads out of the JPEG" is untested; the located bytes are pinned byte-exact against libjpeg-turbo (ICC, `tests/oracle.rs`) and the leaf crates pin the payloads against exiv2 (#510) | ✅ done | diff --git a/crates/gamut-jpeg/src/lib.rs b/crates/gamut-jpeg/src/lib.rs index 98d2f79a..cb4fb3f2 100644 --- a/crates/gamut-jpeg/src/lib.rs +++ b/crates/gamut-jpeg/src/lib.rs @@ -50,6 +50,13 @@ //! embed them. The payloads are raw bytes in exactly the form the `gamut-metadata` facade's //! `MetadataBlock` borrows. //! +//! With the optional **`metadata`** Cargo feature the same payloads are wired to the facade's typed +//! models: [`JpegMetadata::blocks`] hands them over as `MetadataBlock`s and +//! [`JpegMetadata::metadata`] parses them into a unified `Metadata`, while +//! [`JpegEncoder::with_metadata`] / [`JpegEncoder::with_encoded_metadata`] embed one, routing each +//! carrier to the raw setter above. The feature is off by default so a plain JPEG consumer never +//! builds the metadata crates; the dependency direction stays `gamut-jpeg → gamut-metadata`. +//! //! # Pluggable backends //! //! Both directions are pluggable through the [`backend`] module: [`JpegStreamDecoder`] and @@ -102,6 +109,8 @@ mod decoder; mod encoder; mod huffman; mod marker; +#[cfg(feature = "metadata")] +mod metadata; mod progressive; mod quant; mod rd; @@ -115,6 +124,10 @@ pub use backend::{ is_backend_declined, }; pub use decoder::{JpegDecoder, JpegInfo, JpegMetadata, JpegProcess, info, metadata}; +// The facade types named in the `metadata`-feature signatures, so a caller can spell +// `JpegMetadata::metadata` / `JpegEncoder::with_metadata` without a direct dependency. +#[cfg(feature = "metadata")] +pub use gamut_metadata::{EncodedMetadata, Metadata, MetadataBlock}; pub use encoder::{ChromaSubsampling, JpegColorMode, JpegEncoder, RdOptimization, XYB_ICC_PROFILE}; pub use marker::DensityUnit; pub use quant::{CHROMINANCE, LUMINANCE, QuantTables}; diff --git a/crates/gamut-jpeg/src/metadata.rs b/crates/gamut-jpeg/src/metadata.rs new file mode 100644 index 00000000..cfb9693e --- /dev/null +++ b/crates/gamut-jpeg/src/metadata.rs @@ -0,0 +1,309 @@ +//! The `metadata` feature: typed [`gamut_metadata`] wiring over the raw APP-segment surface. +//! +//! [`crate::metadata`] locates the APP1 EXIF / APP1 XMP / APP2 `ICC_PROFILE` payloads as bytes; +//! this module hands them to the facade as [`MetadataBlock`]s and turns a facade [`Metadata`] back +//! into the encoder's raw setters. The dependency direction is `gamut-jpeg → gamut-metadata` +//! (the facade never learns about JPEG segments), and the module is compiled only with the +//! `metadata` Cargo feature so a plain JPEG consumer never pays for the metadata crates. +//! +//! The block boundaries are the ones the facade documents: the EXIF block is the **TIFF stream** +//! (`II`/`MM` first; the `Exif\0\0` APP1 signature is already stripped by [`crate::metadata`] and +//! re-added by [`JpegEncoder::with_exif`]), the XMP block is the `xpacket` with the namespace URI +//! stripped, and the ICC block is the profile **reassembled** from its APP2 chunks. + +use gamut_core::{Error, Result}; +use gamut_metadata::{EncodedMetadata, Metadata, MetadataBlock, MetadataEmbedder}; + +use crate::{JpegEncoder, JpegMetadata}; + +impl JpegMetadata { + /// The located payloads as [`MetadataBlock`]s, ready for [`Metadata::from_blocks`] or a + /// [`MetadataExtractor`](gamut_metadata::MetadataExtractor) with a chosen + /// [`ConflictPolicy`](gamut_metadata::ConflictPolicy): the EXIF TIFF stream, the XMP packet + /// and the reassembled ICC profile, each present only when the stream carried it. + /// + /// JPEG's legacy IPTC-IIM carrier (APP13) and the C2PA APP11 carriage are not located by this + /// crate (see `STATUS.md`), so no [`MetadataBlock::IptcIim`] / [`MetadataBlock::C2pa`] is ever + /// produced here. + #[must_use] + pub fn blocks(&self) -> Vec> { + let mut blocks = Vec::new(); + if let Some(exif) = &self.exif { + blocks.push(MetadataBlock::Exif(exif)); + } + if let Some(xmp) = &self.xmp { + blocks.push(MetadataBlock::Xmp(xmp)); + } + if let Some(icc) = &self.icc { + blocks.push(MetadataBlock::Icc(icc)); + } + blocks + } + + /// Parses the located payloads into the unified [`Metadata`] model — + /// [`Metadata::from_blocks`] over [`blocks`](Self::blocks). + /// + /// # Errors + /// + /// Returns the facade's [`MetadataError`](gamut_metadata::MetadataError) naming the carrier + /// whose parse failed. + /// + /// # Example + /// + /// ``` + /// use gamut_core::{Dimensions, EncodeImage, Gray8, ImageRef}; + /// use gamut_jpeg::JpegEncoder; + /// use gamut_metadata::exif::{ByteOrder, Exif, ExifTag, Value}; + /// use gamut_metadata::Metadata; + /// + /// # fn main() -> Result<(), Box> { + /// let mut exif = Exif::new(ByteOrder::LittleEndian); + /// exif.set_tag(ExifTag::PhotographicSensitivity, Value::Short(vec![400])); + /// let typed = Metadata::from_carriers(Some(exif), None, None); + /// + /// let pixels = vec![0u8; 64]; + /// let image = ImageRef::::new(&pixels, Dimensions::new(8, 8)?)?; + /// let jpeg = JpegEncoder::new().with_metadata(&typed)?.encode_to_vec(image)?; + /// + /// let read = gamut_jpeg::metadata(&jpeg)?.metadata()?; + /// assert_eq!(read, typed); + /// # Ok(()) + /// # } + /// ``` + pub fn metadata(&self) -> gamut_metadata::Result { + Metadata::from_blocks(&self.blocks()) + } +} + +impl JpegEncoder { + /// Embeds a unified [`Metadata`] model: serializes it with the default + /// [`MetadataEmbedder`] and routes each carrier to the matching raw setter + /// ([`with_exif`](Self::with_exif), [`with_xmp`](Self::with_xmp), + /// [`with_icc_profile`](Self::with_icc_profile)). Returns the updated encoder for chaining. + /// + /// The default embedder emits no legacy IPTC-IIM block (IPTC lives inside the XMP packet) and + /// **drops** a C2PA manifest store — a store is signed over the file it came from and can never + /// be copied into a re-encoded one; see [`gamut_metadata::C2paPolicy`]. A caller that must be + /// told about either configures the embedder itself and calls + /// [`with_encoded_metadata`](Self::with_encoded_metadata). Carriers absent from the model leave + /// any earlier raw setting untouched. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] when the model does not serialize (the facade's message is + /// carried as [`Error::detail`]), and whatever + /// [`with_encoded_metadata`](Self::with_encoded_metadata) returns. + pub fn with_metadata(self, meta: &Metadata) -> Result { + let encoded = MetadataEmbedder::new().embed(meta).map_err(|e| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JPEG: metadata does not serialize", + ) + .with_detail(e.to_string()) + })?; + self.with_encoded_metadata(&encoded) + } + + /// Embeds already-serialized facade blocks — the output of a [`MetadataEmbedder`] configured by + /// the caller — routing each present carrier to the matching raw setter. Returns the updated + /// encoder for chaining. + /// + /// Only the carriers JPEG can write are accepted: EXIF (APP1), XMP (APP1) and ICC (APP2). The + /// size caps of those segments are still checked at encode time, exactly as for the raw + /// setters. Fields that are `None` leave any earlier raw setting untouched. + /// + /// # Errors + /// + /// Returns [`Error::Unsupported`] when the blocks carry a legacy IPTC-IIM stream (JPEG's APP13 + /// carriage is not implemented — see `STATUS.md`) or a C2PA manifest store (APP11 carriage is + /// not implemented, and a store must not be copied forward in any case). Nothing is applied + /// when an error is returned. + pub fn with_encoded_metadata(mut self, encoded: &EncodedMetadata) -> Result { + if encoded.iptc_iim.is_some() { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JPEG: IPTC-IIM (APP13) embedding is not supported", + )); + } + if encoded.c2pa.is_some() { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JPEG: C2PA manifest store (APP11) embedding is not supported", + )); + } + if let Some(exif) = &encoded.exif { + self = self.with_exif(exif); + } + if let Some(xmp) = &encoded.xmp { + self = self.with_xmp(xmp); + } + if let Some(icc) = &encoded.icc { + self = self.with_icc_profile(icc); + } + Ok(self) + } +} + +#[cfg(test)] +mod tests { + use gamut_core::{Dimensions, EncodeImage, ErrorKind, Gray8, ImageRef}; + use gamut_metadata::exif::{ByteOrder, Exif, ExifTag, Value}; + use gamut_metadata::icc::{ + ColorSpace, DeviceClass, IccProfile, ProfileHeader, Signature, TagData, + }; + use gamut_metadata::xmp::{WellKnownNs, XmpMeta}; + + use super::*; + + /// A typed model with all three JPEG-writable carriers populated, normalised through one + /// embed → extract pass so it is an *extracted* model: the facade's keystone equality is + /// extract → embed → extract, and a hand-built model differs from its parsed form in fields the + /// serializer stamps (the ICC header's `size`). + fn typed() -> Metadata { + let mut exif = Exif::new(ByteOrder::LittleEndian); + exif.set_tag(ExifTag::Make, Value::Ascii("gamut".to_owned())); + let mut xmp = XmpMeta::new(); + xmp.set_text(WellKnownNs::Xmp.uri(), "CreatorTool", "gamut"); + let icc = IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: Vec::new(), + }; + let encoded = Metadata::from_carriers(Some(exif), Some(xmp), Some(icc)) + .encode() + .unwrap(); + Metadata::from_blocks(&[ + MetadataBlock::Exif(encoded.exif.as_deref().unwrap()), + MetadataBlock::Xmp(encoded.xmp.as_deref().unwrap()), + MetadataBlock::Icc(encoded.icc.as_deref().unwrap()), + ]) + .unwrap() + } + + /// Encodes an 8×8 grayscale image with `encoder`. + fn encode(encoder: JpegEncoder) -> Vec { + let pixels = vec![128u8; 64]; + let image = ImageRef::::new(&pixels, Dimensions::new(8, 8).unwrap()).unwrap(); + encoder.encode_to_vec(image).unwrap() + } + + #[test] + fn blocks_expose_each_located_payload_in_facade_form() { + let meta = JpegMetadata { + exif: Some(b"II*\0".to_vec()), + xmp: Some(b"".to_vec()), + icc: Some(vec![7u8; 4]), + }; + assert_eq!( + meta.blocks(), + vec![ + MetadataBlock::Exif(b"II*\0"), + MetadataBlock::Xmp(b""), + MetadataBlock::Icc(&[7u8; 4]), + ] + ); + assert!(JpegMetadata::default().blocks().is_empty()); + } + + #[test] + fn typed_metadata_round_trips_through_the_stream() { + // The facade's keystone equality, extended through the APP segments: every carrier the + // model holds comes back as the same typed model. + let typed = typed(); + let jpeg = encode(JpegEncoder::new().with_metadata(&typed).unwrap()); + let read = crate::metadata(&jpeg).unwrap(); + assert!(read.exif.is_some() && read.xmp.is_some() && read.icc.is_some()); + assert_eq!(read.metadata().unwrap(), typed); + } + + #[test] + fn an_empty_model_embeds_nothing() { + let jpeg = encode(JpegEncoder::new().with_metadata(&Metadata::default()).unwrap()); + assert_eq!(crate::metadata(&jpeg).unwrap(), JpegMetadata::default()); + } + + #[test] + fn a_manifest_store_is_never_copied_forward() { + // The facade's policy, observed at this crate's boundary: the model's C2PA store produces + // no segment, and the other carriers are unaffected. + let mut typed = typed(); + typed.c2pa = Some(b"\0\0\0\x14jumbc2pa".to_vec()); + let jpeg = encode(JpegEncoder::new().with_metadata(&typed).unwrap()); + let read = crate::metadata(&jpeg).unwrap().metadata().unwrap(); + assert_eq!(read.c2pa, None); + typed.c2pa = None; + assert_eq!(read, typed); + } + + #[test] + fn unwritable_carriers_are_typed_unsupported_errors() { + let mut iim = EncodedMetadata::default(); + iim.iptc_iim = Some(vec![0x1c, 0x02, 0x05]); + let err = JpegEncoder::new().with_encoded_metadata(&iim).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JPEG: IPTC-IIM (APP13) embedding is not supported") + ); + + let mut c2pa = EncodedMetadata::default(); + c2pa.c2pa = Some(vec![0u8; 4]); + let err = JpegEncoder::new().with_encoded_metadata(&c2pa).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JPEG: C2PA manifest store (APP11) embedding is not supported") + ); + } + + #[test] + fn encoded_blocks_route_to_the_raw_setters() { + // Each present field lands in its APP segment; an absent one leaves an earlier setting. + let encoded = typed().encode().unwrap(); + let jpeg = encode( + JpegEncoder::new() + .with_encoded_metadata(&encoded) + .unwrap(), + ); + let read = crate::metadata(&jpeg).unwrap(); + // `EncodedMetadata::exif` carries the `Exif\0\0` signature; the stream stores the TIFF. + assert_eq!( + read.exif.as_deref(), + encoded.exif.as_deref().and_then(|e| e.strip_prefix(b"Exif\0\0")) + ); + assert_eq!(read.xmp, encoded.xmp); + assert_eq!(read.icc, encoded.icc); + + let mut only_icc = EncodedMetadata::default(); + only_icc.icc = encoded.icc.clone(); + let jpeg = encode( + JpegEncoder::new() + .with_xmp(b"") + .with_encoded_metadata(&only_icc) + .unwrap(), + ); + let read = crate::metadata(&jpeg).unwrap(); + assert_eq!(read.xmp.as_deref(), Some(&b""[..])); + assert_eq!(read.icc, encoded.icc); + assert_eq!(read.exif, None); + } + + #[test] + fn a_model_that_does_not_serialize_is_invalid_input_with_the_facade_detail() { + // The facade's own serialization failure: an ICC model with a duplicate tag signature is + // rejected by `gamut-icc`'s writer (ICC.1:2022 §7.3), and the encoder reports it as this + // crate's error with the facade's message carried as detail. + let duplicate = (Signature(*b"wtpt"), TagData::Xyz(Vec::new())); + let bad_icc = IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: vec![duplicate.clone(), duplicate], + }; + let model = Metadata::from_carriers(None, None, Some(bad_icc)); + let err = JpegEncoder::new().with_metadata(&model).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("JPEG: metadata does not serialize") + ); + assert_eq!(err.detail(), Some("ICC: icc: duplicate tag signature")); + } +} From 637d47e7023bcca5dde2b8563573ec95ce5ca14b Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 04:28:13 -0400 Subject: [PATCH 03/15] feat(jxl): read metadata boxes back and wire the gamut-metadata facade Add `JxlDecoder::metadata` -> `JxlMetadata { exif, xmp, icc }`, reading a stream's `Exif` / `xml ` container boxes and its codestream ICC profile without decoding pixels. jxl-rs consumes auxiliary boxes without exposing them (jxl-rs #674) and its box-header parser is `pub(super)`, so the crate walks the top-level box sequence itself: the 32-bit, `size == 0` and 64-bit `largesize` forms, the `Exif` payload's tiff-header offset applied (ISO/IEC 23008-12 A.2.1, reused by the JXL container), first box of a kind wins, a `brob`-wrapped `Exif`/`xml ` box is a typed `Unsupported`, and every overrun is `InvalidInput`. Behind a new optional `metadata` feature (a normal dependency on gamut-metadata), add the gamut-dng pattern: `JxlMetadata::blocks` / `metadata`, and `JxlEncoder::with_metadata(&Metadata)` / `with_encoded_metadata(&EncodedMetadata)` routing EXIF (`Exif\0\0` stripped) and XMP to the boxes and the ICC profile to `ColorSpec::Icc`. IPTC-IIM and C2PA blocks are typed `Unsupported`; a manifest store is never copied forward. The read-back is pinned against what the encoder writes, the walk's size forms and hostile-input refusals are unit-tested beside it, and the facade's typed extract -> embed -> extract equality holds through the container. The exiv2 oracle has no JPEG XL reader, so that cell is recorded as untested in STATUS.md. Refs #420 --- Cargo.lock | 1 + crates/gamut-jxl/Cargo.toml | 9 + crates/gamut-jxl/STATUS.md | 33 +- crates/gamut-jxl/src/decoder.rs | 437 ++++++++++++++++++++++ crates/gamut-jxl/src/encoder.rs | 92 +++++ crates/gamut-jxl/src/lib.rs | 19 +- crates/gamut-jxl/tests/metadata.rs | 41 ++ crates/gamut-jxl/tests/metadata_facade.rs | 151 ++++++++ 8 files changed, 774 insertions(+), 9 deletions(-) create mode 100644 crates/gamut-jxl/tests/metadata_facade.rs diff --git a/Cargo.lock b/Cargo.lock index 8d3a03fc..e0b7f30f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,6 +808,7 @@ dependencies = [ "gamut-codec-abi", "gamut-core", "gamut-jxl-sys", + "gamut-metadata", "jxl", ] diff --git a/crates/gamut-jxl/Cargo.toml b/crates/gamut-jxl/Cargo.toml index 4357ba3c..5b813ff0 100644 --- a/crates/gamut-jxl/Cargo.toml +++ b/crates/gamut-jxl/Cargo.toml @@ -28,6 +28,11 @@ gamut-core.workspace = true # build. jxl = { workspace = true, optional = true } +# Typed metadata (issue #420): the unified `Metadata` model over the `Exif` / `xml ` boxes and the +# codestream ICC profile this crate writes and reads. Optional and off by default, and a *normal* +# dependency (never dev-only) so release ordering follows it (`mise run check-release-deps`). +gamut-metadata = { workspace = true, optional = true } + # The libjxl-backed encoder. Target-conditional optional dep (the `getrandom` pattern), present on # every encoder-capable target: everything except wasm32 *without* emscripten. On # `wasm32-unknown-emscripten` gamut-jxl-sys builds libjxl with the emsdk toolchain, so the full @@ -52,3 +57,7 @@ encode = ["dep:gamut-jxl-sys"] # The pure-Rust JPEG XL decoder wrapping the `jxl` crate (jxl-rs). Pure safe Rust with no native # build, so — unlike `encode` — it is available on every target, wasm32 included. decode = ["dep:jxl"] +# Typed metadata wiring: `JxlMetadata::blocks` / `JxlMetadata::metadata` and +# `JxlEncoder::with_metadata` / `with_encoded_metadata` over the `gamut-metadata` facade. Pure Rust, +# target-independent; reading the boxes back (`JxlDecoder::metadata`) needs `decode`. +metadata = ["dep:gamut-metadata"] diff --git a/crates/gamut-jxl/STATUS.md b/crates/gamut-jxl/STATUS.md index 096f9435..6c4c6e8e 100644 --- a/crates/gamut-jxl/STATUS.md +++ b/crates/gamut-jxl/STATUS.md @@ -62,7 +62,21 @@ on a hand-written golden bitstream. (samples stay in coded order, decoders apply the transform). - **Exif / XMP container boxes.** `with_exif` (raw EXIF; the 4-byte tiff-offset prefix is added automatically) and `with_xmp`, written as uncompressed `Exif` / `xml ` boxes; requires - `Container::IsoBmff` (a typed error otherwise). + `Container::IsoBmff` (a typed error otherwise). **Read back** by `JxlDecoder::metadata` → + `JxlMetadata { exif, xmp, icc }` (issue #420): the crate walks the container's top-level box + sequence itself (jxl-rs does not expose auxiliary boxes — ledger below), applies the `Exif` + payload's tiff-header offset, takes the first box of a kind, refuses a Brotli-compressed + (`brob`) `Exif`/`xml ` box as `Unsupported`, and reports the codestream ICC profile alongside. +- **Typed metadata** (issue #420) behind the opt-in **`metadata`** feature (a normal, optional + dependency on `gamut-metadata`): `JxlMetadata::blocks` / `metadata` (the facade's + `MetadataBlock`s / `Metadata`), `JxlEncoder::with_metadata(&Metadata)` (default + `MetadataEmbedder`) and `with_encoded_metadata(&EncodedMetadata)` (caller-chosen policies), + routing EXIF (`Exif\0\0` stripped) and XMP to the boxes and the ICC profile to `ColorSpec::Icc`. + IPTC-IIM and C2PA blocks are typed `Unsupported`; a C2PA store is never copied forward (facade + policy). **Oracle cell not covered:** `tooling/exiv2-oracle` is block-level and in-memory (no + JPEG XL reader), so "exiv2 reads the boxes out of the `.jxl`" is untested; the box payloads are + pinned byte-exact by the raw box scan of libjxl's output (below), and the leaf crates pin the + payloads against exiv2 (#510). - **Full pixel decode (jxl-rs).** Decodes the entire ISO/IEC 18181-1 pixel surface jxl-rs covers — VarDCT and Modular (RCT/palette/squeeze), XYB, splines/patches/noise/spot colours, progressive-encoded streams, and both `jxlc`/`jxlp` container framings — reshaping to the @@ -117,9 +131,10 @@ unlocks it. - **JPEG reconstruction on decode.** gamut writes `jbrd` streams whose original JPEG the *libjxl* decoder reconstructs bit-for-bit; a pure-Rust reconstruction API is blocked on jxl-rs shipping its `jpeg-reconstruction` feature (ledger below). -- **Reading Exif / XMP boxes back on decode.** gamut writes the boxes; surfacing them from incoming - streams is blocked on jxl-rs exposing box contents (ledger below) and ties into the - `gamut-metadata` facade (issue #34) for typed parsing. +- **Brotli-compressed metadata boxes (`brob`).** `JxlDecoder::metadata` locates uncompressed + `Exif` / `xml ` boxes (delivered, #420); a `brob`-wrapped one is a typed `Unsupported` because + decompressing it needs a Brotli dependency this crate does not carry. Unlocks with that + dependency decision (#510). - **Premultiplied (associated) alpha decode.** Rejected today; unlocks with an un-premultiply step in `convert` (deliberately deferred: an integer un-premultiply is an approximate inverse — alpha = 0 is unrecoverable — so it belongs behind an explicit opt-in, not a silent default). @@ -200,8 +215,9 @@ The documented gaps, with upstream links (verified against the tracker 2026-07-1 short prefixes, every single-bit flip over the first 256 bytes) driven through the partial path in `tests/robustness.rs`; no panic has been observed. The default `DecodeImage` path never flushes. - **Container Exif / XMP metadata not exposed.** jxl-rs does not surface the `Exif`/`xml ` box bytes - ([#674](https://github.com/libjxl/jxl-rs/issues/674)) — the upstream half of gamut's deferred - *read-back* support (gamut's encoder writes the boxes today). + ([#674](https://github.com/libjxl/jxl-rs/issues/674)), and its box-header parser is + `pub(super)`. gamut-jxl therefore locates the boxes with its own top-level box walk + (`JxlDecoder::metadata`, #420); when jxl-rs exposes box events the walk can be replaced. - **CMYK.** Parsed but not presentable (see Out of scope). ## Oracle & test regime @@ -257,7 +273,10 @@ The documented gaps, with upstream links (verified against the tracker 2026-07-1 pinned by hand-reversal; explicit Identity is byte-identical to the default stream. - **Metadata boxes.** Exact `Exif` (tiff-offset prefix included) and `xml ` box payloads pinned by a raw box scan; pixels stay bit-exact with boxes present; Codestream+metadata and empty payloads - are typed errors. + are typed errors. Read-back: `JxlDecoder::metadata` returns what `with_exif` / `with_xmp` / + `ColorSpec::Icc` wrote (`tests/metadata.rs`), the box walk's size forms and hostile-input + refusals are unit-tested beside it, and with the `metadata` feature the facade's typed + extract → embed → extract equality holds through the container (`tests/metadata_facade.rs`). - **Signatures / conversions.** Codestream vs. container signature bytes and the gray→RGB / RGBA→RGB / RGB→gray-rejection conversion contracts are pinned. - **Mutants:** zero unjustified survivors; the only `exclude_re` entries carry strong justifications diff --git a/crates/gamut-jxl/src/decoder.rs b/crates/gamut-jxl/src/decoder.rs index 437e8cd5..758c1258 100644 --- a/crates/gamut-jxl/src/decoder.rs +++ b/crates/gamut-jxl/src/decoder.rs @@ -35,6 +35,197 @@ fn wrong_backend_layout() -> Error { ) } +/// Embedded metadata located in a JPEG XL stream by [`JxlDecoder::metadata`]: the container's +/// `Exif` / `xml ` boxes and the codestream's ICC profile. +/// +/// Each payload is stored in the form the dedicated metadata crates parse (and the +/// `gamut-metadata` facade's `MetadataBlock` borrows) directly; with the `metadata` feature, +/// [`JxlMetadata::blocks`] / [`JxlMetadata::metadata`] do that hand-over. Marked +/// `#[non_exhaustive]` so a later carrier (the `jumb` C2PA box) can be added without a breaking +/// change. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct JxlMetadata { + /// The EXIF TIFF stream (starts `II`/`MM`): the `Exif` box payload with its leading 4-byte + /// big-endian `exif_tiff_header_offset` applied. The JPEG XL container reuses HEIF's + /// `ExifDataBlock` (ISO/IEC 23008-12 §A.2.1; `references/jxl/format_overview.md`), which is + /// also what [`JxlEncoder::with_exif`](crate::JxlEncoder::with_exif) writes (offset `0`). + pub exif: Option>, + /// The XMP packet: the `xml ` box payload, verbatim. + pub xmp: Option>, + /// The ICC profile embedded in the codestream's colour encoding — exactly what + /// [`JxlDecoder::embedded_icc_profile`] reports — or `None` for a structured encoding. + pub icc: Option>, +} + +/// Locates the `Exif` and `xml ` boxes of an ISO BMFF `.jxl` container: the TIFF stream behind +/// the `Exif` box's tiff-header offset, and the `xml ` payload verbatim. For a duplicated box the +/// first wins (the workspace's JPEG convention). +/// +/// jxl-rs consumes auxiliary boxes without exposing them, so the walk is this crate's own: the +/// plain top-level box sequence of ISO/IEC 14496-12 §4.2 (32-bit size, `size == 1` with a 64-bit +/// `largesize`, `size == 0` meaning "to the end of the file"). Box *contents* are never +/// interpreted beyond the two metadata types, so a codestream (`jxlc`/`jxlp`), `jbrd`, or unknown +/// box is stepped over by length. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] for a truncated or malformed box header, a box overrunning the +/// stream, or an `Exif` payload shorter than its offset field or with the offset past its end; +/// [`Error::Unsupported`] for a Brotli-compressed (`brob`) `Exif`/`xml ` box, which this crate +/// cannot decompress. Any other `brob` box is skipped. +#[cfg(feature = "decode")] +fn container_metadata_boxes(data: &[u8]) -> Result { + let mut exif = None; + let mut xmp = None; + let mut pos = 0; + while pos < data.len() { + let (box_type, body, next) = read_box(data, pos)?; + match &box_type { + b"brob" => { + let Some(inner) = body.get(..4) else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: truncated brob box", + )); + }; + if inner == b"Exif" || inner == b"xml " { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JXL: Brotli-compressed metadata box (brob) is not supported", + )); + } + } + b"Exif" if exif.is_none() => exif = Some(exif_box_tiff_stream(body)?.to_vec()), + b"xml " if xmp.is_none() => xmp = Some(body.to_vec()), + _ => {} + } + pos = next; + } + Ok((exif, xmp)) +} + +/// The located `Exif` TIFF stream and `xml ` payload of a container, each `None` when absent. +#[cfg(feature = "decode")] +type MetadataBoxes = (Option>, Option>); + +/// Reads the box at `pos`: its type, its payload, and the offset just past it. +#[cfg(feature = "decode")] +fn read_box(data: &[u8], pos: usize) -> Result<([u8; 4], &[u8], usize)> { + let rest = &data[pos..]; + let [s0, s1, s2, s3, t0, t1, t2, t3, tail @ ..] = rest else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: truncated box header", + )); + }; + let box_type = [*t0, *t1, *t2, *t3]; + let (header_len, box_len) = match u32::from_be_bytes([*s0, *s1, *s2, *s3]) { + // `size == 0`: the box extends to the end of the file. + 0 => (8, rest.len()), + // `size == 1`: a 64-bit `largesize` follows the type. + 1 => { + let [l0, l1, l2, l3, l4, l5, l6, l7, ..] = tail else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: truncated box header", + )); + }; + let large = u64::from_be_bytes([*l0, *l1, *l2, *l3, *l4, *l5, *l6, *l7]); + match usize::try_from(large) { + Ok(len) if len >= 16 => (16, len), + Ok(_) => { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: malformed box size", + )); + } + Err(_) => { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: box overruns the stream", + )); + } + } + } + size if size < 8 => { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: malformed box size", + )); + } + size => (8, size as usize), + }; + if box_len > rest.len() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: box overruns the stream", + )); + } + Ok((box_type, &rest[header_len..box_len], pos + box_len)) +} + +/// The TIFF stream of an `Exif` box payload: skips the 4-byte big-endian `exif_tiff_header_offset` +/// and then `offset` further bytes (ISO/IEC 23008-12 §A.2.1). +#[cfg(feature = "decode")] +fn exif_box_tiff_stream(payload: &[u8]) -> Result<&[u8]> { + let [o0, o1, o2, o3, rest @ ..] = payload else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: truncated Exif box", + )); + }; + usize::try_from(u32::from_be_bytes([*o0, *o1, *o2, *o3])) + .ok() + .and_then(|offset| rest.get(offset..)) + .ok_or_else(|| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: Exif box tiff-header offset out of range", + ) + }) +} + +#[cfg(feature = "metadata")] +impl JxlMetadata { + /// The located payloads as [`MetadataBlock`](gamut_metadata::MetadataBlock)s, ready for + /// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) or a + /// [`MetadataExtractor`](gamut_metadata::MetadataExtractor) with a chosen + /// [`ConflictPolicy`](gamut_metadata::ConflictPolicy): the EXIF TIFF stream, the XMP packet + /// and the codestream ICC profile, each present only when the stream carried it. + /// + /// JPEG XL has no IPTC-IIM carrier, and the `jumb` (C2PA) box is not located by this crate + /// (see `STATUS.md`), so no `IptcIim` / `C2pa` block is ever produced here. + #[must_use] + pub fn blocks(&self) -> Vec> { + use gamut_metadata::MetadataBlock; + let mut blocks = Vec::new(); + if let Some(exif) = &self.exif { + blocks.push(MetadataBlock::Exif(exif)); + } + if let Some(xmp) = &self.xmp { + blocks.push(MetadataBlock::Xmp(xmp)); + } + if let Some(icc) = &self.icc { + blocks.push(MetadataBlock::Icc(icc)); + } + blocks + } + + /// Parses the located payloads into the unified + /// [`Metadata`](gamut_metadata::Metadata) model — + /// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) over + /// [`blocks`](Self::blocks). + /// + /// # Errors + /// + /// Returns the facade's [`MetadataError`](gamut_metadata::MetadataError) naming the carrier + /// whose parse failed. + pub fn metadata(&self) -> gamut_metadata::Result { + gamut_metadata::Metadata::from_blocks(&self.blocks()) + } +} + /// A JPEG XL decoder. /// /// Decodes both JPEG XL framings — a bare codestream and the ISO BMFF `.jxl` container — into any of @@ -177,6 +368,40 @@ impl JxlDecoder { crate::jxlrs::embedded_icc_profile(data) } + /// Reads the stream's embedded metadata without decoding any pixels: the container's `Exif` + /// and `xml ` boxes (what [`JxlEncoder::with_exif`](crate::JxlEncoder::with_exif) / + /// [`with_xmp`](crate::JxlEncoder::with_xmp) wrote) plus the codestream's ICC profile (as + /// [`embedded_icc_profile`](Self::embedded_icc_profile)). A bare codestream has no boxes, so + /// only the ICC field can be set for one. + /// + /// The boxes are located by this crate's own walk of the container's top-level box sequence — + /// the pure-Rust decode tail does not expose them — and a pushed backend is not consulted. The + /// `Exif` payload's 4-byte tiff-header offset is applied, so [`JxlMetadata::exif`] is the TIFF + /// stream itself; for a duplicated box the first wins. A Brotli-compressed (`brob`) `Exif` / + /// `xml ` box is refused rather than silently skipped. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if the data carries neither signature, is truncated before + /// the colour metadata, or has a malformed box sequence (a truncated header, a box overrunning + /// the stream, an `Exif` payload shorter than its offset field or with the offset past its + /// end); [`Error::Unsupported`] for a `brob`-wrapped `Exif` / `xml ` box. + #[cfg(feature = "decode")] + pub fn metadata(&self, data: &[u8]) -> Result { + let (exif, xmp) = match JxlFraming::detect(data) { + JxlFraming::IsoBmff => container_metadata_boxes(data)?, + JxlFraming::Codestream => (None, None), + JxlFraming::Unknown => { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: neither the codestream nor the container signature", + )); + } + }; + let icc = crate::jxlrs::embedded_icc_profile(data)?; + Ok(JxlMetadata { exif, xmp, icc }) + } + /// The stream's dimensions when the built-in header parser can determine them, else `None`. /// /// Used only to populate [`JxlStreamInfo::dimensions`](crate::JxlStreamInfo::dimensions), and @@ -746,3 +971,215 @@ mod tests { assert!(format!("{dec:?}").contains("backends: 1")); } } + +/// Unit tests for the container box walk behind [`JxlDecoder::metadata`]: the located payloads, +/// the three box-size forms, and the hostile-input refusals. `container_metadata_boxes` is +/// private, so these live beside it. +#[cfg(all(test, feature = "decode"))] +mod box_tests { + use gamut_core::ErrorKind; + + use super::*; + + /// The 12-byte container signature box. + const SIGNATURE: [u8; 12] = [ + 0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A, + ]; + /// A TIFF-shaped EXIF stream. + const TIFF: &[u8] = b"II\x2A\x00\x08\x00\x00\x00\x00\x00"; + + /// One box in the 32-bit size form. + fn bx(ty: &[u8; 4], body: &[u8]) -> Vec { + let mut out = (8 + body.len() as u32).to_be_bytes().to_vec(); + out.extend_from_slice(ty); + out.extend_from_slice(body); + out + } + + /// One box in the `size == 1` / 64-bit `largesize` form. + fn bx64(ty: &[u8; 4], body: &[u8]) -> Vec { + let mut out = 1u32.to_be_bytes().to_vec(); + out.extend_from_slice(ty); + out.extend_from_slice(&(16 + body.len() as u64).to_be_bytes()); + out.extend_from_slice(body); + out + } + + /// One box in the `size == 0` (to end of file) form. + fn bx0(ty: &[u8; 4], body: &[u8]) -> Vec { + let mut out = 0u32.to_be_bytes().to_vec(); + out.extend_from_slice(ty); + out.extend_from_slice(body); + out + } + + /// The signature followed by `boxes`. + fn container(boxes: &[Vec]) -> Vec { + let mut out = SIGNATURE.to_vec(); + for b in boxes { + out.extend_from_slice(b); + } + out + } + + /// An `Exif` box payload: the big-endian offset, `gap` filler bytes, then the TIFF stream. + fn exif_payload(offset: u32, gap: usize) -> Vec { + let mut out = offset.to_be_bytes().to_vec(); + out.extend(std::iter::repeat_n(0xEE, gap)); + out.extend_from_slice(TIFF); + out + } + + #[test] + fn exif_box_yields_the_tiff_stream_behind_the_offset() { + // Offset 0 (what the encoder writes) and a non-zero offset skipping filler bytes. + for (offset, gap) in [(0, 0), (6, 6)] { + let data = container(&[ + bx(b"ftyp", b"jxl "), + bx(b"Exif", &exif_payload(offset, gap)), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(exif.as_deref(), Some(TIFF), "offset {offset}"); + assert_eq!(xmp, None); + } + } + + #[test] + fn xml_box_is_verbatim_and_the_first_of_a_kind_wins() { + let data = container(&[ + bx(b"xml ", b"first"), + bx(b"jxlc", &[0xFF, 0x0A]), + bx(b"xml ", b"second"), + bx(b"Exif", &exif_payload(0, 0)), + bx(b"Exif", b"\0\0\0\0MM\0*"), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!( + xmp.as_deref(), + Some(&b"first"[..]) + ); + assert_eq!(exif.as_deref(), Some(TIFF)); + } + + #[test] + fn largesize_and_to_end_of_file_boxes_are_walked() { + let data = container(&[ + bx64(b"Exif", &exif_payload(0, 0)), + bx0(b"xml ", b""), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(exif.as_deref(), Some(TIFF)); + assert_eq!(xmp.as_deref(), Some(&b""[..])); + } + + #[test] + fn a_container_without_metadata_boxes_yields_nothing() { + let data = container(&[bx(b"ftyp", b"jxl "), bx(b"jxlc", &[0xFF, 0x0A])]); + assert_eq!(container_metadata_boxes(&data).unwrap(), (None, None)); + } + + #[test] + fn brob_wrapping_a_metadata_box_is_unsupported() { + for inner in [b"Exif", b"xml "] { + let mut body = inner.to_vec(); + body.extend_from_slice(b"\x0b\x02\x80compressed"); + let data = container(&[bx(b"brob", &body)]); + let err = container_metadata_boxes(&data).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JXL: Brotli-compressed metadata box (brob) is not supported") + ); + } + } + + #[test] + fn brob_wrapping_another_box_is_skipped() { + let data = container(&[ + bx(b"brob", b"jumb\x0b\x02\x80compressed"), + bx(b"xml ", b""), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(exif, None); + assert_eq!(xmp.as_deref(), Some(&b""[..])); + } + + #[test] + fn malformed_box_sequences_are_invalid_input_with_the_named_fault() { + let cases: [(Vec, &str); 7] = [ + // Seven bytes where a header should be. + ( + container(&[vec![0, 0, 0, 9, b'x', b'm', b'l']]), + "JXL: truncated box header", + ), + // `size == 1` but no `largesize`. + ( + container(&[vec![0, 0, 0, 1, b'E', b'x', b'i', b'f', 0, 0]]), + "JXL: truncated box header", + ), + // A 32-bit size below the header's own length. + ( + container(&[vec![0, 0, 0, 4, b'x', b'm', b'l', b' ']]), + "JXL: malformed box size", + ), + // A `largesize` below its header's own length. + ( + container(&[vec![ + 0, 0, 0, 1, b'x', b'm', b'l', b' ', 0, 0, 0, 0, 0, 0, 0, 8, + ]]), + "JXL: malformed box size", + ), + // A box claiming more bytes than remain. + ( + container(&[vec![ + 0, 0, 0, 20, b'x', b'm', b'l', b' ', b'<', b'x', b'/', b'>', + ]]), + "JXL: box overruns the stream", + ), + // A `largesize` no address space can hold. + ( + container(&[vec![ + 0, 0, 0, 1, b'x', b'm', b'l', b' ', 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, + ]]), + "JXL: box overruns the stream", + ), + // A `brob` box too short to name the box it wraps. + (container(&[bx(b"brob", b"xm")]), "JXL: truncated brob box"), + ]; + for (data, message) in cases { + let err = container_metadata_boxes(&data).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput, "{message}"); + assert_eq!(err.static_message(), Some(message)); + } + } + + #[test] + fn malformed_exif_payloads_are_invalid_input_with_the_named_fault() { + let cases: [(&[u8], &str); 2] = [ + (b"\0\0\0", "JXL: truncated Exif box"), + ( + b"\0\0\0\x0bII*\0", + "JXL: Exif box tiff-header offset out of range", + ), + ]; + for (payload, message) in cases { + let data = container(&[bx(b"Exif", payload)]); + let err = container_metadata_boxes(&data).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput, "{message}"); + assert_eq!(err.static_message(), Some(message)); + } + // An offset landing exactly at the end is an empty (not out-of-range) stream. + assert_eq!(exif_box_tiff_stream(b"\0\0\0\x02ab").unwrap(), b""); + } + + #[test] + fn metadata_of_a_stream_with_neither_signature_is_invalid_input() { + let err = JxlDecoder::new().metadata(b"not a jxl").unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("JXL: neither the codestream nor the container signature") + ); + } +} diff --git a/crates/gamut-jxl/src/encoder.rs b/crates/gamut-jxl/src/encoder.rs index 5d3ac5b5..0304c60e 100644 --- a/crates/gamut-jxl/src/encoder.rs +++ b/crates/gamut-jxl/src/encoder.rs @@ -240,6 +240,98 @@ impl JxlEncoder { self } + /// Embeds a unified [`Metadata`](gamut_metadata::Metadata) model: serializes it with the + /// default [`MetadataEmbedder`](gamut_metadata::MetadataEmbedder) and routes each carrier to + /// the matching setter — EXIF to [`with_exif`](Self::with_exif) (the facade's `Exif\0\0` + /// signature stripped, since the `Exif` box carries the TIFF stream), XMP to + /// [`with_xmp`](Self::with_xmp), and the ICC profile to [`with_color`](Self::with_color) as + /// [`ColorSpec::Icc`] — in JPEG XL the profile *is* the codestream's colour encoding, not a + /// container box, and it is validated against the image's colour family at encode time. Returns + /// the updated encoder for chaining. + /// + /// Encoding then requires [`Container::IsoBmff`](crate::Container::IsoBmff) whenever an EXIF or + /// XMP carrier was present, exactly as for the raw setters. The default embedder emits no legacy + /// IPTC-IIM block and **drops** a C2PA manifest store (a store is signed over the file it came + /// from; see [`gamut_metadata::C2paPolicy`]); a caller that must be told about either + /// configures the embedder itself and calls + /// [`with_encoded_metadata`](Self::with_encoded_metadata). Carriers absent from the model leave + /// any earlier setting untouched. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] when the model does not serialize (the facade's message is + /// carried as [`Error::detail`]), and whatever + /// [`with_encoded_metadata`](Self::with_encoded_metadata) returns. + #[cfg(feature = "metadata")] + pub fn with_metadata(self, meta: &gamut_metadata::Metadata) -> Result { + let encoded = gamut_metadata::MetadataEmbedder::new() + .embed(meta) + .map_err(|e| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: metadata does not serialize", + ) + .with_detail(e.to_string()) + })?; + self.with_encoded_metadata(&encoded) + } + + /// Embeds already-serialized facade blocks — the output of a + /// [`MetadataEmbedder`](gamut_metadata::MetadataEmbedder) configured by the caller — routing + /// each present carrier as [`with_metadata`](Self::with_metadata) does. Returns the updated + /// encoder for chaining. + /// + /// Only the carriers JPEG XL can write are accepted: EXIF (`Exif` box), XMP (`xml ` box) and + /// ICC (the codestream colour encoding). Fields that are `None` leave any earlier setting + /// untouched. + /// + /// # Errors + /// + /// Returns [`Error::Unsupported`] when the blocks carry a legacy IPTC-IIM stream (JPEG XL has + /// no carrier for it) or a C2PA manifest store (the `jumb` box is not written by this crate, + /// and a store must not be copied forward in any case), and [`Error::InvalidInput`] when the + /// XMP packet is not UTF-8 (the `xml ` box holds text). Nothing is applied when an error is + /// returned. + #[cfg(feature = "metadata")] + pub fn with_encoded_metadata( + mut self, + encoded: &gamut_metadata::EncodedMetadata, + ) -> Result { + if encoded.iptc_iim.is_some() { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JXL: IPTC-IIM has no container carrier", + )); + } + if encoded.c2pa.is_some() { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JXL: C2PA manifest store (jumb box) embedding is not supported", + )); + } + let xmp = encoded + .xmp + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: XMP packet is not UTF-8", + ) + })?; + if let Some(exif) = &encoded.exif { + self = self.with_exif(exif.strip_prefix(b"Exif\0\0").unwrap_or(exif)); + } + if let Some(xmp) = xmp { + self = self.with_xmp(xmp); + } + if let Some(icc) = &encoded.icc { + self = self.with_color(ColorSpec::Icc(icc.clone())); + } + Ok(self) + } + /// Declares the samples' **coded bit depth** N, making a 16-bit pixel buffer carry N-bit code /// values (`0 ..= 2^N - 1`) instead of full-range 16-bit. Returns the updated encoder for /// chaining. diff --git a/crates/gamut-jxl/src/lib.rs b/crates/gamut-jxl/src/lib.rs index 1f933317..23ddfb1c 100644 --- a/crates/gamut-jxl/src/lib.rs +++ b/crates/gamut-jxl/src/lib.rs @@ -85,7 +85,9 @@ //! [`JxlEncoder`] still exists and still encodes — through whatever backend was pushed. With //! neither, encoding returns [`Error::Unsupported`](gamut_core::Error::Unsupported). //! - `decode` (default) includes the jxl-rs decode tail, and additionally provides the header-only -//! accessors ([`JxlDecoder::info`], [`JxlDecoder::embedded_icc_profile`], [`JxlInfo`]) and the +//! accessors ([`JxlDecoder::info`], [`JxlDecoder::embedded_icc_profile`], [`JxlInfo`]), the +//! metadata read-back ([`JxlDecoder::metadata`] → [`JxlMetadata`]: the container's `Exif` / +//! `xml ` boxes, located by this crate's own box walk, plus the codestream ICC profile) and the //! best-effort [`DecodePartialImage`] surface, all of which are always answered by the built-in //! parser. Without it, [`JxlDecoder`] decodes through a pushed backend, or returns //! [`Error::Unsupported`](gamut_core::Error::Unsupported). @@ -93,6 +95,15 @@ //! This is why the encode direction works on `wasm32-unknown-unknown` despite libjxl being //! unbuildable there: push a backend and the tail's absence stops mattering. //! +//! ## The `metadata` feature +//! +//! Off by default. It adds the `gamut-metadata` facade's typed models over the raw surface above: +//! [`JxlMetadata::blocks`] hands the located payloads over as `MetadataBlock`s and +//! [`JxlMetadata::metadata`] parses them into a unified `Metadata`, while +//! [`JxlEncoder::with_metadata`] / [`JxlEncoder::with_encoded_metadata`] embed one, routing EXIF +//! and XMP to the container boxes and the ICC profile to [`ColorSpec::Icc`]. The dependency +//! direction stays `gamut-jxl → gamut-metadata`. +//! //! ## Deferred: container ownership //! //! Container-dependent features — ISO BMFF output, `Exif`/`xml ` boxes, and `jbrd` JPEG @@ -151,7 +162,11 @@ pub use backend::{ JxlImageRef, JxlOwnedSamples, JxlSamples, JxlStreamInfo, }; pub use config::{ColorSpec, Container, Distance, Effort, ModularMode, Orientation}; -pub use decoder::JxlDecoder; +pub use decoder::{JxlDecoder, JxlMetadata}; #[cfg(feature = "decode")] pub use decoder::{DecodePartialImage, JxlInfo, JxlPartialReport, JxlRender}; pub use encoder::JxlEncoder; +// The facade types named in the `metadata`-feature signatures, so a caller can spell +// `JxlMetadata::metadata` / `JxlEncoder::with_metadata` without a direct dependency. +#[cfg(feature = "metadata")] +pub use gamut_metadata::{EncodedMetadata, Metadata, MetadataBlock}; diff --git a/crates/gamut-jxl/tests/metadata.rs b/crates/gamut-jxl/tests/metadata.rs index 0c7dcd2d..8878d8e6 100644 --- a/crates/gamut-jxl/tests/metadata.rs +++ b/crates/gamut-jxl/tests/metadata.rs @@ -129,3 +129,44 @@ fn empty_metadata_payload_is_rejected() { assert_eq!(err.kind(), ErrorKind::InvalidInput, "{err:?}"); assert_eq!(err.static_message(), Some("JXL: empty metadata payload")); } + +#[test] +fn decoder_reads_the_exif_and_xmp_boxes_back() { + // The read-back half of the box contract: what `with_exif` / `with_xmp` wrote comes back as + // the TIFF stream (offset applied) and the packet, with no ICC for the default sRGB signal. + let jxl = encode_with(|enc| enc.with_exif(EXIF).with_xmp(XMP)); + let meta = JxlDecoder::new().metadata(&jxl).expect("metadata"); + assert_eq!(meta.exif.as_deref(), Some(EXIF)); + assert_eq!(meta.xmp.as_deref(), Some(XMP.as_bytes())); + assert_eq!(meta.icc, None); + + // A container with no boxes, and a bare codestream, both report nothing. + let empty = encode_with(|enc| enc); + assert_eq!( + JxlDecoder::new().metadata(&empty).expect("metadata"), + gamut_jxl::JxlMetadata::default() + ); + let dims = Dimensions::new(12, 9).unwrap(); + let samples = gen_u8(12, 9, 3); + let image = ImageRef::::new(&samples, dims).unwrap(); + let bare = JxlEncoder::lossless().encode_to_vec(image).unwrap(); + assert_eq!( + JxlDecoder::new().metadata(&bare).expect("metadata"), + gamut_jxl::JxlMetadata::default() + ); +} + +#[test] +fn metadata_reports_the_codestream_icc_profile() { + // The ICC field is the codestream's embedded profile (the libjxl-synthesized sRGB one, as the + // colour tests use), byte-for-byte, alongside the boxes. + let srgb = common::icc_profile(&encode_with(|enc| enc)).expect("oracle synthesizes sRGB"); + let jxl = encode_with(|enc| { + enc.with_color(gamut_jxl::ColorSpec::Icc(srgb.clone())) + .with_xmp(XMP) + }); + let meta = JxlDecoder::new().metadata(&jxl).expect("metadata"); + assert_eq!(meta.icc.as_deref(), Some(srgb.as_slice())); + assert_eq!(meta.xmp.as_deref(), Some(XMP.as_bytes())); + assert_eq!(meta.exif, None); +} diff --git a/crates/gamut-jxl/tests/metadata_facade.rs b/crates/gamut-jxl/tests/metadata_facade.rs new file mode 100644 index 00000000..6fe231e6 --- /dev/null +++ b/crates/gamut-jxl/tests/metadata_facade.rs @@ -0,0 +1,151 @@ +//! The `metadata` feature through the container: the facade's keystone equality +//! (extract → embed → extract over EXIF / XMP / ICC) extended through a real `.jxl` stream, the +//! routing of each `EncodedMetadata` carrier to its setter, and the typed refusals for carriers the +//! container cannot write. +//! +//! Needs both codec halves plus the facade; compiled only when all are available. +#![cfg(all( + feature = "encode", + feature = "decode", + feature = "metadata", + any(not(target_arch = "wasm32"), target_os = "emscripten") +))] + +mod common; + +use common::gen_u8; +use gamut_core::{Dimensions, EncodeImage, ErrorKind, ImageRef, Rgb8}; +use gamut_jxl::{Container, EncodedMetadata, JxlDecoder, JxlEncoder, Metadata, MetadataBlock}; +use gamut_metadata::exif::{ByteOrder, Exif, ExifTag, Value}; +use gamut_metadata::xmp::{WellKnownNs, XmpMeta}; + +/// Encodes the deterministic 12×9 RGB8 pattern losslessly with `encoder`. +fn encode(encoder: JxlEncoder) -> Vec { + let dims = Dimensions::new(12, 9).unwrap(); + let samples = gen_u8(12, 9, 3); + let image = ImageRef::::new(&samples, dims).unwrap(); + encoder.encode_to_vec(image).expect("encode failed") +} + +/// A container-framed lossless encoder. +fn container_encoder() -> JxlEncoder { + JxlEncoder::lossless().with_container(Container::IsoBmff) +} + +/// A typed model with EXIF, XMP and the libjxl-synthesized sRGB ICC profile, normalised through +/// one embed → extract pass so it is an *extracted* model (a hand-built model differs from its +/// parsed form in fields the serializer stamps). +fn typed() -> Metadata { + let mut exif = Exif::new(ByteOrder::LittleEndian); + exif.set_tag(ExifTag::Make, Value::Ascii("gamut".to_owned())); + let mut xmp = XmpMeta::new(); + xmp.set_text(WellKnownNs::Xmp.uri(), "CreatorTool", "gamut"); + let srgb = common::icc_profile(&encode(container_encoder())).expect("oracle synthesizes sRGB"); + let encoded = Metadata::from_carriers(Some(exif), Some(xmp), None) + .encode() + .unwrap(); + Metadata::from_blocks(&[ + MetadataBlock::Exif(encoded.exif.as_deref().unwrap()), + MetadataBlock::Xmp(encoded.xmp.as_deref().unwrap()), + MetadataBlock::Icc(&srgb), + ]) + .unwrap() +} + +#[test] +fn typed_metadata_round_trips_through_the_container() { + let typed = typed(); + let jxl = encode(container_encoder().with_metadata(&typed).unwrap()); + let read = JxlDecoder::new().metadata(&jxl).unwrap(); + assert!(read.exif.is_some() && read.xmp.is_some() && read.icc.is_some()); + assert_eq!(read.blocks().len(), 3); + assert_eq!(read.metadata().unwrap(), typed); +} + +#[test] +fn a_manifest_store_is_never_copied_forward() { + let mut typed = typed(); + typed.c2pa = Some(b"\0\0\0\x14jumbc2pa".to_vec()); + let jxl = encode(container_encoder().with_metadata(&typed).unwrap()); + let read = JxlDecoder::new().metadata(&jxl).unwrap().metadata().unwrap(); + assert_eq!(read.c2pa, None); + typed.c2pa = None; + assert_eq!(read, typed); +} + +#[test] +fn encoded_blocks_route_to_the_setters_with_the_exif_signature_stripped() { + let encoded = typed().encode().unwrap(); + let jxl = encode(container_encoder().with_encoded_metadata(&encoded).unwrap()); + let read = JxlDecoder::new().metadata(&jxl).unwrap(); + // `EncodedMetadata::exif` carries `Exif\0\0`; the `Exif` box carries the TIFF stream. + assert_eq!( + read.exif.as_deref(), + encoded + .exif + .as_deref() + .and_then(|e| e.strip_prefix(b"Exif\0\0")) + ); + assert_eq!(read.xmp, encoded.xmp); + assert_eq!(read.icc, encoded.icc); + + // An absent field leaves an earlier setting untouched. + let mut only_xmp = EncodedMetadata::default(); + only_xmp.xmp = encoded.xmp.clone(); + let jxl = encode( + container_encoder() + .with_exif(b"II\x2A\x00\x08\x00\x00\x00\x00\x00") + .with_encoded_metadata(&only_xmp) + .unwrap(), + ); + let read = JxlDecoder::new().metadata(&jxl).unwrap(); + assert_eq!(read.exif.as_deref(), Some(&b"II\x2A\x00\x08\x00\x00\x00\x00\x00"[..])); + assert_eq!(read.xmp, encoded.xmp); +} + +#[test] +fn unwritable_carriers_are_typed_errors() { + let mut iim = EncodedMetadata::default(); + iim.iptc_iim = Some(vec![0x1c, 0x02, 0x05]); + let err = container_encoder().with_encoded_metadata(&iim).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JXL: IPTC-IIM has no container carrier") + ); + + let mut c2pa = EncodedMetadata::default(); + c2pa.c2pa = Some(vec![0u8; 4]); + let err = container_encoder().with_encoded_metadata(&c2pa).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JXL: C2PA manifest store (jumb box) embedding is not supported") + ); + + let mut binary_xmp = EncodedMetadata::default(); + binary_xmp.xmp = Some(vec![0xFF, 0xFE, 0x00]); + let err = container_encoder() + .with_encoded_metadata(&binary_xmp) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!(err.static_message(), Some("JXL: XMP packet is not UTF-8")); +} + +#[test] +fn typed_metadata_still_needs_the_container_framing() { + // The raw setters' rule holds unchanged: boxes need the ISO BMFF container. + let dims = Dimensions::new(12, 9).unwrap(); + let samples = gen_u8(12, 9, 3); + let image = ImageRef::::new(&samples, dims).unwrap(); + let err = JxlEncoder::lossless() + .with_metadata(&typed()) + .unwrap() + .encode_to_vec(image) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("JXL: Exif/XMP metadata requires the ISO BMFF container") + ); +} From 41fc5dc19840f9f6105cc7375a2150ed0908cfed Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 11:09:12 -0400 Subject: [PATCH 04/15] style(jpeg): reformat the metadata wiring with the workspace rustfmt The `metadata` wiring landed without a nightly `cargo fmt --all` pass, so `fmt-check` has been failing on this branch. Formatting only; no behaviour changes. --- crates/gamut-jpeg/src/lib.rs | 2 +- crates/gamut-jpeg/src/metadata.rs | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/gamut-jpeg/src/lib.rs b/crates/gamut-jpeg/src/lib.rs index cb4fb3f2..11179de0 100644 --- a/crates/gamut-jpeg/src/lib.rs +++ b/crates/gamut-jpeg/src/lib.rs @@ -124,10 +124,10 @@ pub use backend::{ is_backend_declined, }; pub use decoder::{JpegDecoder, JpegInfo, JpegMetadata, JpegProcess, info, metadata}; +pub use encoder::{ChromaSubsampling, JpegColorMode, JpegEncoder, RdOptimization, XYB_ICC_PROFILE}; // The facade types named in the `metadata`-feature signatures, so a caller can spell // `JpegMetadata::metadata` / `JpegEncoder::with_metadata` without a direct dependency. #[cfg(feature = "metadata")] pub use gamut_metadata::{EncodedMetadata, Metadata, MetadataBlock}; -pub use encoder::{ChromaSubsampling, JpegColorMode, JpegEncoder, RdOptimization, XYB_ICC_PROFILE}; pub use marker::DensityUnit; pub use quant::{CHROMINANCE, LUMINANCE, QuantTables}; diff --git a/crates/gamut-jpeg/src/metadata.rs b/crates/gamut-jpeg/src/metadata.rs index cfb9693e..ad35ce3a 100644 --- a/crates/gamut-jpeg/src/metadata.rs +++ b/crates/gamut-jpeg/src/metadata.rs @@ -95,11 +95,8 @@ impl JpegEncoder { /// [`with_encoded_metadata`](Self::with_encoded_metadata) returns. pub fn with_metadata(self, meta: &Metadata) -> Result { let encoded = MetadataEmbedder::new().embed(meta).map_err(|e| { - Error::invalid_input( - env!("CARGO_PKG_NAME"), - "JPEG: metadata does not serialize", - ) - .with_detail(e.to_string()) + Error::invalid_input(env!("CARGO_PKG_NAME"), "JPEG: metadata does not serialize") + .with_detail(e.to_string()) })?; self.with_encoded_metadata(&encoded) } @@ -217,7 +214,11 @@ mod tests { #[test] fn an_empty_model_embeds_nothing() { - let jpeg = encode(JpegEncoder::new().with_metadata(&Metadata::default()).unwrap()); + let jpeg = encode( + JpegEncoder::new() + .with_metadata(&Metadata::default()) + .unwrap(), + ); assert_eq!(crate::metadata(&jpeg).unwrap(), JpegMetadata::default()); } @@ -259,16 +260,15 @@ mod tests { fn encoded_blocks_route_to_the_raw_setters() { // Each present field lands in its APP segment; an absent one leaves an earlier setting. let encoded = typed().encode().unwrap(); - let jpeg = encode( - JpegEncoder::new() - .with_encoded_metadata(&encoded) - .unwrap(), - ); + let jpeg = encode(JpegEncoder::new().with_encoded_metadata(&encoded).unwrap()); let read = crate::metadata(&jpeg).unwrap(); // `EncodedMetadata::exif` carries the `Exif\0\0` signature; the stream stores the TIFF. assert_eq!( read.exif.as_deref(), - encoded.exif.as_deref().and_then(|e| e.strip_prefix(b"Exif\0\0")) + encoded + .exif + .as_deref() + .and_then(|e| e.strip_prefix(b"Exif\0\0")) ); assert_eq!(read.xmp, encoded.xmp); assert_eq!(read.icc, encoded.icc); From 33322c31cbec94623c36fbf3469da2ef433527cb Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 11:09:16 -0400 Subject: [PATCH 05/15] style(jxl): reformat the metadata wiring with the workspace rustfmt The `metadata` wiring landed without a nightly `cargo fmt --all` pass, so `fmt-check` has been failing on this branch. Formatting only; no behaviour changes. --- crates/gamut-jxl/src/decoder.rs | 10 ++-------- crates/gamut-jxl/src/encoder.rs | 12 +++--------- crates/gamut-jxl/src/lib.rs | 2 +- crates/gamut-jxl/tests/metadata_facade.rs | 15 ++++++++++++--- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/crates/gamut-jxl/src/decoder.rs b/crates/gamut-jxl/src/decoder.rs index 758c1258..b1e55e38 100644 --- a/crates/gamut-jxl/src/decoder.rs +++ b/crates/gamut-jxl/src/decoder.rs @@ -1054,19 +1054,13 @@ mod box_tests { bx(b"Exif", b"\0\0\0\0MM\0*"), ]); let (exif, xmp) = container_metadata_boxes(&data).unwrap(); - assert_eq!( - xmp.as_deref(), - Some(&b"first"[..]) - ); + assert_eq!(xmp.as_deref(), Some(&b"first"[..])); assert_eq!(exif.as_deref(), Some(TIFF)); } #[test] fn largesize_and_to_end_of_file_boxes_are_walked() { - let data = container(&[ - bx64(b"Exif", &exif_payload(0, 0)), - bx0(b"xml ", b""), - ]); + let data = container(&[bx64(b"Exif", &exif_payload(0, 0)), bx0(b"xml ", b"")]); let (exif, xmp) = container_metadata_boxes(&data).unwrap(); assert_eq!(exif.as_deref(), Some(TIFF)); assert_eq!(xmp.as_deref(), Some(&b""[..])); diff --git a/crates/gamut-jxl/src/encoder.rs b/crates/gamut-jxl/src/encoder.rs index 0304c60e..f383d91f 100644 --- a/crates/gamut-jxl/src/encoder.rs +++ b/crates/gamut-jxl/src/encoder.rs @@ -267,11 +267,8 @@ impl JxlEncoder { let encoded = gamut_metadata::MetadataEmbedder::new() .embed(meta) .map_err(|e| { - Error::invalid_input( - env!("CARGO_PKG_NAME"), - "JXL: metadata does not serialize", - ) - .with_detail(e.to_string()) + Error::invalid_input(env!("CARGO_PKG_NAME"), "JXL: metadata does not serialize") + .with_detail(e.to_string()) })?; self.with_encoded_metadata(&encoded) } @@ -315,10 +312,7 @@ impl JxlEncoder { .map(std::str::from_utf8) .transpose() .map_err(|_| { - Error::invalid_input( - env!("CARGO_PKG_NAME"), - "JXL: XMP packet is not UTF-8", - ) + Error::invalid_input(env!("CARGO_PKG_NAME"), "JXL: XMP packet is not UTF-8") })?; if let Some(exif) = &encoded.exif { self = self.with_exif(exif.strip_prefix(b"Exif\0\0").unwrap_or(exif)); diff --git a/crates/gamut-jxl/src/lib.rs b/crates/gamut-jxl/src/lib.rs index 23ddfb1c..23f50d64 100644 --- a/crates/gamut-jxl/src/lib.rs +++ b/crates/gamut-jxl/src/lib.rs @@ -162,9 +162,9 @@ pub use backend::{ JxlImageRef, JxlOwnedSamples, JxlSamples, JxlStreamInfo, }; pub use config::{ColorSpec, Container, Distance, Effort, ModularMode, Orientation}; -pub use decoder::{JxlDecoder, JxlMetadata}; #[cfg(feature = "decode")] pub use decoder::{DecodePartialImage, JxlInfo, JxlPartialReport, JxlRender}; +pub use decoder::{JxlDecoder, JxlMetadata}; pub use encoder::JxlEncoder; // The facade types named in the `metadata`-feature signatures, so a caller can spell // `JxlMetadata::metadata` / `JxlEncoder::with_metadata` without a direct dependency. diff --git a/crates/gamut-jxl/tests/metadata_facade.rs b/crates/gamut-jxl/tests/metadata_facade.rs index 6fe231e6..422da6cb 100644 --- a/crates/gamut-jxl/tests/metadata_facade.rs +++ b/crates/gamut-jxl/tests/metadata_facade.rs @@ -67,7 +67,11 @@ fn a_manifest_store_is_never_copied_forward() { let mut typed = typed(); typed.c2pa = Some(b"\0\0\0\x14jumbc2pa".to_vec()); let jxl = encode(container_encoder().with_metadata(&typed).unwrap()); - let read = JxlDecoder::new().metadata(&jxl).unwrap().metadata().unwrap(); + let read = JxlDecoder::new() + .metadata(&jxl) + .unwrap() + .metadata() + .unwrap(); assert_eq!(read.c2pa, None); typed.c2pa = None; assert_eq!(read, typed); @@ -99,7 +103,10 @@ fn encoded_blocks_route_to_the_setters_with_the_exif_signature_stripped() { .unwrap(), ); let read = JxlDecoder::new().metadata(&jxl).unwrap(); - assert_eq!(read.exif.as_deref(), Some(&b"II\x2A\x00\x08\x00\x00\x00\x00\x00"[..])); + assert_eq!( + read.exif.as_deref(), + Some(&b"II\x2A\x00\x08\x00\x00\x00\x00\x00"[..]) + ); assert_eq!(read.xmp, encoded.xmp); } @@ -116,7 +123,9 @@ fn unwritable_carriers_are_typed_errors() { let mut c2pa = EncodedMetadata::default(); c2pa.c2pa = Some(vec![0u8; 4]); - let err = container_encoder().with_encoded_metadata(&c2pa).unwrap_err(); + let err = container_encoder() + .with_encoded_metadata(&c2pa) + .unwrap_err(); assert_eq!(err.kind(), ErrorKind::Unsupported); assert_eq!( err.static_message(), From 3e7bd3164bd2ea5375cc83debd30ab9a43359c23 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 11:10:37 -0400 Subject: [PATCH 06/15] refactor(jxl): bound the container box walk's step inside the walk `read_box` returned the offset just past the box it read, so the walk's progress lived in the value a callee returned: a `read_box` that reported a non-advancing offset left `container_metadata_boxes` looping forever. Under `cargo mutants --in-diff` that is seven return-value mutants of `read_box` that no test can kill because they hang instead of failing, and the incremental gate reports them as timeouts. Split the header parse from the walk. `parse_box_header` now only reports what the header claims -- type, header length, box length -- and slices nothing; the walk owns every bound: the box must fit in what remains, it must be at least the 8-byte minimum header, and the body must be a range inside it. The step is then at least 8 bytes per iteration whatever the parser reports, so the loop terminates for any return value a mutant can produce and the mutants become killable by an ordinary assertion. Behaviour is unchanged: every fault keeps the message the tests already pin -- the 64-bit form declaring a `largesize` below its own 16-byte header still ends as `malformed box size` (the body range is empty-to-negative), and a `largesize` no address space can hold saturates and is reported by the overrun check. --- crates/gamut-jxl/src/decoder.rs | 97 ++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/crates/gamut-jxl/src/decoder.rs b/crates/gamut-jxl/src/decoder.rs index b1e55e38..30f19bd3 100644 --- a/crates/gamut-jxl/src/decoder.rs +++ b/crates/gamut-jxl/src/decoder.rs @@ -78,9 +78,33 @@ pub struct JxlMetadata { fn container_metadata_boxes(data: &[u8]) -> Result { let mut exif = None; let mut xmp = None; - let mut pos = 0; - while pos < data.len() { - let (box_type, body, next) = read_box(data, pos)?; + let mut rest = data; + while !rest.is_empty() { + let (box_type, header_len, box_len) = parse_box_header(rest)?; + if box_len > rest.len() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: box overruns the stream", + )); + } + // §4.2: a box's `size` counts its own header, so it is never below `MIN_BOX_HEADER`. The + // walk enforces that here rather than trusting the header parser, which also makes the + // loop's progress local: every iteration consumes at least `MIN_BOX_HEADER` bytes of + // `rest`, so the walk terminates whatever the parser reports. + if box_len < MIN_BOX_HEADER { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: malformed box size", + )); + } + // `header_len > box_len` is the 64-bit form declaring a `largesize` smaller than the + // header it is part of; the body is then not a range at all. + let Some(body) = rest.get(header_len..box_len) else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: malformed box size", + )); + }; match &box_type { b"brob" => { let Some(inner) = body.get(..4) else { @@ -100,7 +124,7 @@ fn container_metadata_boxes(data: &[u8]) -> Result { b"xml " if xmp.is_none() => xmp = Some(body.to_vec()), _ => {} } - pos = next; + rest = &rest[box_len..]; } Ok((exif, xmp)) } @@ -109,20 +133,36 @@ fn container_metadata_boxes(data: &[u8]) -> Result { #[cfg(feature = "decode")] type MetadataBoxes = (Option>, Option>); -/// Reads the box at `pos`: its type, its payload, and the offset just past it. +/// The smallest ISO BMFF box header (§4.2): a 4-byte `size` and a 4-byte type. +#[cfg(feature = "decode")] +const MIN_BOX_HEADER: usize = 8; + +/// The 64-bit box header: [`MIN_BOX_HEADER`] plus the 8-byte `largesize` that follows the type. #[cfg(feature = "decode")] -fn read_box(data: &[u8], pos: usize) -> Result<([u8; 4], &[u8], usize)> { - let rest = &data[pos..]; - let [s0, s1, s2, s3, t0, t1, t2, t3, tail @ ..] = rest else { +const LARGE_BOX_HEADER: usize = 16; + +/// Parses the box header at the start of `data`, returning its type, the length of the header +/// itself, and the length of the whole box (header included) as the header declares it. +/// +/// Nothing here is validated against `data`'s length, and nothing is sliced: the returned lengths +/// are what the header *claims*, and the caller — which owns the walk's progress — is what checks +/// them (see [`container_metadata_boxes`]). +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] if `data` is too short to hold the header it announces. +#[cfg(feature = "decode")] +fn parse_box_header(data: &[u8]) -> Result<([u8; 4], usize, usize)> { + let [s0, s1, s2, s3, t0, t1, t2, t3, tail @ ..] = data else { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), "JXL: truncated box header", )); }; let box_type = [*t0, *t1, *t2, *t3]; - let (header_len, box_len) = match u32::from_be_bytes([*s0, *s1, *s2, *s3]) { + match u32::from_be_bytes([*s0, *s1, *s2, *s3]) { // `size == 0`: the box extends to the end of the file. - 0 => (8, rest.len()), + 0 => Ok((box_type, MIN_BOX_HEADER, data.len())), // `size == 1`: a 64-bit `largesize` follows the type. 1 => { let [l0, l1, l2, l3, l4, l5, l6, l7, ..] = tail else { @@ -132,37 +172,16 @@ fn read_box(data: &[u8], pos: usize) -> Result<([u8; 4], &[u8], usize)> { )); }; let large = u64::from_be_bytes([*l0, *l1, *l2, *l3, *l4, *l5, *l6, *l7]); - match usize::try_from(large) { - Ok(len) if len >= 16 => (16, len), - Ok(_) => { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "JXL: malformed box size", - )); - } - Err(_) => { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "JXL: box overruns the stream", - )); - } - } - } - size if size < 8 => { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "JXL: malformed box size", - )); + // A length no address space can hold cannot be a length within `data` either, so it + // saturates and the caller's overrun check reports it. + Ok(( + box_type, + LARGE_BOX_HEADER, + usize::try_from(large).unwrap_or(usize::MAX), + )) } - size => (8, size as usize), - }; - if box_len > rest.len() { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "JXL: box overruns the stream", - )); + size => Ok((box_type, MIN_BOX_HEADER, size as usize)), } - Ok((box_type, &rest[header_len..box_len], pos + box_len)) } /// The TIFF stream of an `Exif` box payload: skips the 4-byte big-endian `exif_tiff_header_offset` From 1470d1e49eb4a80521c318117a25e1f6cf43f74d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 11:10:45 -0400 Subject: [PATCH 07/15] test(jxl): pin that an empty container box is walked over, not rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk's minimum step is the 8-byte header, so a box whose `size` is exactly that header is legal §4.2 framing carrying no payload. Nothing asserted it, and `cargo mutants --in-diff` reported the boundary open: relaxing the rule to `box_len <= 8` -- which rejects the empty box -- survived the suite. Assert both halves of the boundary: an empty `free` box between the metadata boxes is stepped over and the walk keeps going, and an empty `xml ` box yields an empty payload rather than an absent one. --- crates/gamut-jxl/src/decoder.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/gamut-jxl/src/decoder.rs b/crates/gamut-jxl/src/decoder.rs index 30f19bd3..6b5f5cfb 100644 --- a/crates/gamut-jxl/src/decoder.rs +++ b/crates/gamut-jxl/src/decoder.rs @@ -1085,6 +1085,28 @@ mod box_tests { assert_eq!(xmp.as_deref(), Some(&b""[..])); } + #[test] + fn an_empty_box_is_walked_over_rather_than_rejected() { + // A box whose `size` is exactly the 8-byte header carries no payload and is legal §4.2 + // framing: the walk must step over it and keep going, so the minimum-size rule is + // `box_len < 8`, not `<= 8`. It is also the smallest step the walk can take, which is what + // makes the loop's progress local to it. + let data = container(&[ + bx(b"free", b""), + bx(b"Exif", &exif_payload(0, 0)), + bx(b"free", b""), + bx(b"xml ", b""), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(exif.as_deref(), Some(TIFF)); + assert_eq!(xmp.as_deref(), Some(&b""[..])); + + // An empty box of a kind the walk *does* read is an empty payload, not an absent one. + let data = container(&[bx(b"xml ", b"")]); + let (_, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(xmp.as_deref(), Some(&b""[..])); + } + #[test] fn a_container_without_metadata_boxes_yields_nothing() { let data = container(&[bx(b"ftyp", b"jxl "), bx(b"jxlc", &[0xFF, 0x0A])]); From f042320029855f329c14f8fe5ecf4b573d6a0184 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 11:12:41 -0400 Subject: [PATCH 08/15] feat(heic): wire the gamut-metadata facade behind a `metadata` feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crate located the Exif and XMP items and the `colr` property already, but handed every payload back opaque, so a caller wanting a typed model had to know the `ExifDataBlock` framing and the `colr` variants itself. Add the two lenses that framing needs, ungated: `HeifItem::exif_tiff_stream` applies the payload's 4-byte big-endian `exif_tiff_header_offset` and yields the TIFF stream `gamut-exif` parses (ISO/IEC 23008-12 §A.2.1), refusing a non-Exif item, a payload shorter than the offset field and an offset past the payload's end; `HeifItem::icc_profile` yields the `rICC`/`prof` bytes whichever order the item's `colr` properties are in, where `colour()` reports only the first. Over them, behind an optional `metadata` feature (off by default, a normal optional dependency so release ordering follows it), `HeifImage::blocks` hands the three located payloads to the facade as `MetadataBlock`s and `HeifImage::metadata` parses them into a unified `Metadata`. Both are fallible: a hostile Exif item can carry a truncated or out-of-range offset, and a facade parse failure is carried as `InvalidInput` with the facade's message, naming the carrier, as the error's detail. HEIF has no IPTC-IIM item type, and a C2PA manifest store lives in a top-level `uuid` box outside the item model, so neither block is produced here; `HeifContainer::c2pa` still locates the store and STATUS.md records that a caller appends it itself. --- Cargo.lock | 1 + crates/gamut-heic/Cargo.toml | 10 + crates/gamut-heic/STATUS.md | 5 +- crates/gamut-heic/src/image.rs | 223 +++++++++++++++++++++ crates/gamut-heic/src/lib.rs | 21 +- crates/gamut-heic/tests/metadata_facade.rs | 143 +++++++++++++ 6 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 crates/gamut-heic/tests/metadata_facade.rs diff --git a/Cargo.lock b/Cargo.lock index e0b7f30f..6958d2c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -739,6 +739,7 @@ dependencies = [ "gamut-color", "gamut-core", "gamut-isobmff", + "gamut-metadata", "libheif-oracle", ] diff --git a/crates/gamut-heic/Cargo.toml b/crates/gamut-heic/Cargo.toml index 922963a3..e03fed56 100644 --- a/crates/gamut-heic/Cargo.toml +++ b/crates/gamut-heic/Cargo.toml @@ -20,6 +20,16 @@ gamut-codec-abi.workspace = true gamut-core.workspace = true gamut-color.workspace = true gamut-isobmff.workspace = true +# Typed metadata (issue #420): the unified `Metadata` model over the Exif / XMP items and `colr` +# ICC profile this crate locates. Optional and off by default, and a *normal* dependency (never +# dev-only) so release ordering follows it (`mise run check-release-deps`). +gamut-metadata = { workspace = true, optional = true } + +[features] +default = [] +# Typed metadata wiring: `HeifImage::blocks` / `HeifImage::metadata` over the `gamut-metadata` +# facade (decode-only, like the crate). +metadata = ["dep:gamut-metadata"] [dev-dependencies] # libheif (+ libde265 decode, kvazaar encode) as the differential-conformance oracle: FFI to a diff --git a/crates/gamut-heic/STATUS.md b/crates/gamut-heic/STATUS.md index c6f0a828..cc7e4535 100644 --- a/crates/gamut-heic/STATUS.md +++ b/crates/gamut-heic/STATUS.md @@ -151,7 +151,7 @@ references (`dinf`/`dref`, `iloc` `construction_method` 2); mirroring the finali | Meta-level accounting: `meta`/`iprp` children not consumed by the model surfaced as `UnknownBox` (e.g. `dinf`/`dref`, `uuid`) | 14496-12 | ✅ | S1 | | C2PA manifest store located in a top-level `uuid` `ContentProvenanceBox`: opaque bytes + exact byte range, purposes `manifest`/`original`/`update` (`c2pa`, `c2pa_manifest_stores`) | C2PA 2.4 §A.5.1, §A.5.3, §8.4.2.3 (`references/c2pa` pending, #431) | ✅ | S7 | | Store bounding is `LBox`-only and content-dependent (`LBox` validity alone cannot separate a store bound from a plausible interior length). Two routes close it: assert the `jumb` `TBox` — traceable to §A.3.9/§15.12.3.2 but only as a JPEG XL aside, so it is a maintainer call because it narrows what is reported — or confirm the store by §11.1.4.2's JUMBF type UUID, which needs 19566-5's Description Box layout. A `c2pa-rs` oracle fixture would settle either empirically | C2PA 2.4 §A.3.9, §11.1.4.2, §A.5.3; ISO/IEC 19566-5 (not vendored) | ☐ | #239 oracle | -| C2PA store surfaced through the `gamut-metadata` facade as a `MetadataBlock` | C2PA 2.4 §A.5 | ☐ | later | +| C2PA store surfaced through the `gamut-metadata` facade as a `MetadataBlock` (the store lives in a top-level `uuid` box outside the item model `HeifImage::blocks` reads; a caller appends `MetadataBlock::C2pa(HeifContainer::c2pa().bytes)` itself) | C2PA 2.4 §A.5 | ☐ | later | | C2PA validation: JUMBF interior parse, `c2pa.hash.bmff.v3` hard binding, signature/trust verification | C2PA 2.4 §18.6, §A.5.6 | ☐ | user / #239 | | `ftyp` brands + `is_hevc_still` (`heic`/`heix`/`heim`/`heis`, or `mif1`+`hvcC` primary) | 23008-12; `references/heif` §7 | ✅ | S1 | | Sequence brands `msf1`/`hevc`/`hevx` (image sequences) | `references/heif` §7 | OOS | OOS | @@ -175,7 +175,8 @@ references (`dinf`/`dref`, `iloc` `construction_method` 2); mirroring the finali | Derived-image sources (`dimg`), `grid` payload + tile-count validation, `iovl` payload | 23008-12 §6.6.2; `references/heif` §4 | ✅ | S1 | | `iden` identity derived item recognised (kind); source via `dimg` | 23008-12 §6.6.2.1 | ✅ | S1 | | Entity groups + `altr` alternatives lens | 14496-12; MIAF | ✅ | S1 | -| Decoded Exif/XMP bytes → `gamut-exif`/`gamut-xmp` (payload exposed opaque here) | 23008-12 §A | ☐ | later | +| Exif `ExifDataBlock` lens: `HeifItem::exif_tiff_stream` applies the 4-byte `exif_tiff_header_offset` and yields the TIFF stream (`II`/`MM`); `HeifItem::icc_profile` yields the `rICC`/`prof` bytes regardless of `nclx` order | 23008-12 §A.2.1; `references/heif` §9 | ✅ | #420 | +| Decoded Exif/XMP/ICC bytes → the `gamut-metadata` facade: `HeifImage::blocks` (`MetadataBlock`s) and `HeifImage::metadata` (`Metadata`), behind the opt-in `metadata` feature (a normal, optional dependency). Pinned by typed extraction from an authored fixture at offsets 0 and 6. **Oracle cell not covered:** `tooling/exiv2-oracle` is block-level and in-memory (no HEIF reader), so "exiv2 reads the items out of the HEIC" is untested; the item bytes are pinned byte-exact against libheif (`tests/conformance.rs`) and the leaf crates pin the payloads against exiv2 (#510) | 23008-12 §A; issue #420 | ✅ | #420 | | Protected / `uri ` items; external data references | 23008-12 | OOS | OOS | ## C. HEVC configuration & NAL layer (14496-15 · H.265) diff --git a/crates/gamut-heic/src/image.rs b/crates/gamut-heic/src/image.rs index ff55de6a..115b7791 100644 --- a/crates/gamut-heic/src/image.rs +++ b/crates/gamut-heic/src/image.rs @@ -286,6 +286,62 @@ impl HeifImage { } } +#[cfg(feature = "metadata")] +impl HeifImage { + /// The primary item's located metadata payloads as + /// [`MetadataBlock`](gamut_metadata::MetadataBlock)s, ready for + /// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) or a + /// [`MetadataExtractor`](gamut_metadata::MetadataExtractor) with a chosen + /// [`ConflictPolicy`](gamut_metadata::ConflictPolicy): the Exif item's TIFF stream + /// ([`HeifItem::exif_tiff_stream`]), the XMP `mime` item's packet ([`xmp`](Self::xmp)) and the + /// primary item's `colr` ICC profile ([`HeifItem::icc_profile`]), each present only when the + /// file carries it. + /// + /// HEIF has no IPTC-IIM item type, so no `IptcIim` block is produced. A C2PA manifest store + /// lives in a top-level `uuid` box outside the item model, so it is not produced here either: + /// [`HeifContainer::c2pa`](crate::HeifContainer::c2pa) locates it, and a caller wanting it in + /// the same model appends a [`MetadataBlock::C2pa`](gamut_metadata::MetadataBlock::C2pa). + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if the Exif item's payload is malformed — shorter than its + /// 4-byte `exif_tiff_header_offset`, or with the offset past the payload's end. + pub fn blocks(&self) -> Result>> { + use gamut_metadata::MetadataBlock; + let mut blocks = Vec::new(); + if let Some(exif) = self.exif() { + blocks.push(MetadataBlock::Exif(exif.exif_tiff_stream()?)); + } + if let Some(xmp) = self.xmp() { + blocks.push(MetadataBlock::Xmp(&xmp.as_isobmff_item().payload)); + } + if let Some(icc) = self.primary_item().icc_profile() { + blocks.push(MetadataBlock::Icc(icc)); + } + Ok(blocks) + } + + /// Parses the primary item's located metadata into the unified + /// [`Metadata`](gamut_metadata::Metadata) model — + /// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) over + /// [`blocks`](Self::blocks). + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] as [`blocks`](Self::blocks) does, or when a located payload + /// does not parse — the facade's [`MetadataError`](gamut_metadata::MetadataError) message, + /// naming the carrier, is carried as [`Error::detail`]. + pub fn metadata(&self) -> Result { + gamut_metadata::Metadata::from_blocks(&self.blocks()?).map_err(|e| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "HEIF: embedded metadata does not parse", + ) + .with_detail(e.to_string()) + }) + } +} + /// A single HEIF item, viewed by role. A zero-cost borrow of the underlying [`gamut_isobmff::Item`]; /// [`as_isobmff_item`](Self::as_isobmff_item) exposes it. Per-item accessors read the item's type /// and properties; cross-item relationships live on [`HeifImage`]. @@ -301,6 +357,55 @@ impl<'a> HeifItem<'a> { self.inner } + /// For an `Exif` item, the TIFF stream (starting `II`/`MM`) behind the payload's 4-byte + /// big-endian `exif_tiff_header_offset` — the `ExifDataBlock` of ISO/IEC 23008-12 §A.2.1, + /// whose offset counts bytes from the end of the field to the TIFF header (`references/heif` + /// §9). This is the form `gamut-exif` parses; the raw payload, offset included, stays + /// available through [`as_isobmff_item`](Self::as_isobmff_item). + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if the item is not an `Exif` item, if the payload is shorter + /// than the offset field, or if the offset points past the payload's end. + pub fn exif_tiff_stream(&self) -> Result<&'a [u8]> { + if !matches!(self.kind(), ItemKind::Exif) { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "HEIF: item is not an Exif item", + )); + } + let [o0, o1, o2, o3, rest @ ..] = self.inner.payload.as_slice() else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "HEIF: Exif item payload is shorter than its tiff-header offset field", + )); + }; + usize::try_from(u32::from_be_bytes([*o0, *o1, *o2, *o3])) + .ok() + .and_then(|offset| rest.get(offset..)) + .ok_or_else(|| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "HEIF: Exif item tiff-header offset out of range", + ) + }) + } + + /// The ICC profile carried by the item's first `colr` property of ICC type (`rICC` or `prof`), + /// if any — the bytes `gamut-icc` parses. An item may carry both an `nclx` and an ICC `colr` + /// (MIAF allows the pair); [`colour`](Self::colour) returns whichever comes first, this lens + /// the profile regardless of order. + #[must_use] + pub fn icc_profile(&self) -> Option<&'a [u8]> { + self.inner.properties.iter().find_map(|p| match &p.kind { + PropertyKind::Colour( + ColourInformation::RestrictedIcc(profile) + | ColourInformation::UnrestrictedIcc(profile), + ) => Some(profile.as_slice()), + _ => None, + }) + } + /// The item's id. #[must_use] pub fn id(&self) -> u32 { @@ -698,3 +803,121 @@ fn is_alpha_urn(aux_type: &str) -> bool { fn is_depth_urn(aux_type: &str) -> bool { DEPTH_AUX_URNS.contains(&aux_type) } + +/// Unit tests for the two metadata lenses on [`HeifItem`]: the `exif_tiff_header_offset` +/// arithmetic of `exif_tiff_stream` and the property search of `icc_profile`. They read +/// `HeifImage::new`, which is `pub(crate)`, so they live here. +#[cfg(test)] +mod tests { + use gamut_core::ErrorKind; + use gamut_isobmff::{IsoBmffImage, NclxColr, Property}; + + use super::*; + + /// A one-item file whose primary is `item`. + fn image_of(item: Item) -> HeifImage { + HeifImage::new(IsoBmffImage { + major_brand: *b"heic", + minor_version: 0, + compatible_brands: vec![*b"heic", *b"mif1"], + primary_item_id: item.id, + items: vec![item], + groups: vec![], + }) + .unwrap() + } + + /// A bare item of the given type and payload. + fn item(item_type: [u8; 4], payload: Vec, properties: Vec) -> Item { + Item { + id: 1, + item_type, + name: String::new(), + content_type: None, + content_encoding: None, + hidden: false, + references: vec![], + properties, + payload, + } + } + + fn colr(info: ColourInformation) -> Property { + Property { + essential: false, + kind: PropertyKind::Colour(info), + } + } + + #[test] + fn exif_tiff_stream_skips_the_offset_field_and_the_offset() { + // Offset 0 (the usual case) and a non-zero offset skipping filler bytes. + for (payload, expected) in [ + (b"\0\0\0\0II*\0".to_vec(), &b"II*\0"[..]), + (b"\0\0\0\x02\xEE\xEEMM\0*".to_vec(), &b"MM\0*"[..]), + // An offset landing exactly at the end is an empty stream, not an error. + (b"\0\0\0\x01\xEE".to_vec(), &b""[..]), + ] { + let image = image_of(item(*b"Exif", payload.clone(), vec![])); + assert_eq!( + image.primary_item().exif_tiff_stream().unwrap(), + expected, + "{payload:?}" + ); + } + } + + #[test] + fn exif_tiff_stream_refuses_the_named_faults() { + let cases: [(Item, &str); 3] = [ + ( + item(*b"mime", b"\0\0\0\0".to_vec(), vec![]), + "HEIF: item is not an Exif item", + ), + ( + item(*b"Exif", b"\0\0\0".to_vec(), vec![]), + "HEIF: Exif item payload is shorter than its tiff-header offset field", + ), + ( + item(*b"Exif", b"\0\0\0\x05II*\0".to_vec(), vec![]), + "HEIF: Exif item tiff-header offset out of range", + ), + ]; + for (item, message) in cases { + let image = image_of(item); + let err = image.primary_item().exif_tiff_stream().unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput, "{message}"); + assert_eq!(err.static_message(), Some(message)); + } + } + + #[test] + fn icc_profile_finds_the_icc_colr_behind_an_nclx_one() { + let nclx = ColourInformation::Nclx(NclxColr { + colour_primaries: 1, + transfer_characteristics: 13, + matrix_coefficients: 6, + full_range: true, + }); + let hvc1 = |props: Vec| item(*b"hvc1", vec![0xAA], props); + + // nclx first, then `prof`: `colour()` reports the nclx, the lens the profile. + let image = image_of(hvc1(vec![ + colr(nclx.clone()), + colr(ColourInformation::UnrestrictedIcc(vec![1, 2, 3])), + ])); + let primary = image.primary_item(); + assert!(matches!(primary.colour(), Some(ColourInformation::Nclx(_)))); + assert_eq!(primary.icc_profile(), Some(&[1u8, 2, 3][..])); + + // `rICC` counts too. + let image = image_of(hvc1(vec![colr(ColourInformation::RestrictedIcc(vec![9]))])); + assert_eq!(image.primary_item().icc_profile(), Some(&[9u8][..])); + + // nclx alone, or no colr at all: no profile. + let image = image_of(hvc1(vec![colr(nclx)])); + assert_eq!(image.primary_item().icc_profile(), None); + let image = image_of(hvc1(vec![])); + assert_eq!(image.primary_item().icc_profile(), None); + } +} diff --git a/crates/gamut-heic/src/lib.rs b/crates/gamut-heic/src/lib.rs index 540b16d1..29fec200 100644 --- a/crates/gamut-heic/src/lib.rs +++ b/crates/gamut-heic/src/lib.rs @@ -77,11 +77,22 @@ //! [`HevcDecoder`] seam — over gamut-authored fixtures generated at test time //! (`tests/conformance.rs`, the dev-only `tooling/libheif-oracle`; see `references/heif` "Oracle"). //! +//! # Metadata +//! +//! [`HeifImage::exif`] / [`HeifImage::xmp`] locate the Exif and XMP items describing the primary +//! image, [`HeifItem::exif_tiff_stream`] applies the Exif item's `exif_tiff_header_offset` to +//! yield the TIFF stream `gamut-exif` parses, and [`HeifItem::icc_profile`] yields the `colr` ICC +//! bytes `gamut-icc` parses. With the optional **`metadata`** Cargo feature (off by default) the +//! same payloads are wired to the `gamut-metadata` facade's typed models: [`HeifImage::blocks`] +//! hands them over as `MetadataBlock`s and [`HeifImage::metadata`] parses them into a unified +//! `Metadata`. A C2PA manifest store lives outside the item model, in a top-level `uuid` box; +//! [`HeifContainer::c2pa`] locates it. The dependency direction stays +//! `gamut-heic → gamut-metadata`. +//! //! # Deferred to later slices //! -//! Wiring the decoded Exif/XMP bytes through `gamut-exif`/`gamut-xmp`. Image *sequences* -//! (`msf1`/`hevc`/`hevx` tracks) are permanently out of scope (gamut is image-first). See this -//! crate's `STATUS.md`. +//! Image *sequences* (`msf1`/`hevc`/`hevx` tracks) are permanently out of scope (gamut is +//! image-first). See this crate's `STATUS.md`. //! //! # Example //! @@ -160,6 +171,10 @@ pub use backend::{ pub use c2pa::{C2PA_UUID, C2paBoxPurpose, C2paManifestStore}; pub use container::{HeifContainer, Segment, SegmentKind, UnknownBox, UnknownBoxLocation}; pub use decode::{DecodedFrame, HevcDecoder}; +// The facade types named in the `metadata`-feature signatures, so a caller can spell +// `HeifImage::metadata` / `HeifImage::blocks` without a direct dependency. +#[cfg(feature = "metadata")] +pub use gamut_metadata::{Metadata, MetadataBlock}; pub use hvcc::{ChromaFormat, HevcConfig, NalArray}; pub use image::{ CleanAperture, ContentLightLevel, HeifImage, HeifItem, ItemKind, PixelAspectRatio, diff --git a/crates/gamut-heic/tests/metadata_facade.rs b/crates/gamut-heic/tests/metadata_facade.rs new file mode 100644 index 00000000..de9a2cf4 --- /dev/null +++ b/crates/gamut-heic/tests/metadata_facade.rs @@ -0,0 +1,143 @@ +//! The `metadata` feature over a HEIF fixture: `HeifImage::blocks` / `metadata` hand the Exif +//! item (offset applied), the XMP `mime` item and the `colr` ICC profile to the facade, and the +//! typed model equals the one the payloads were built from. Decode-only (the crate has no +//! encoder), so the fixture is authored through `gamut_isobmff::write`. +#![cfg(feature = "metadata")] + +mod common; + +use common::{clean_file, hvc1_item, iref, item}; +use gamut_core::ErrorKind; +use gamut_heic::{HeifContainer, Metadata, MetadataBlock}; +use gamut_isobmff::{ColourInformation, Item, Property, PropertyKind}; +use gamut_metadata::exif::{ByteOrder, Exif, ExifTag, Value}; +use gamut_metadata::icc::{ColorSpace, DeviceClass, IccProfile, ProfileHeader}; +use gamut_metadata::xmp::{WellKnownNs, XmpMeta}; + +/// The three carrier payloads as the leaf crates serialize them: an `Exif\0\0`-prefixed EXIF +/// blob, an XMP packet and an ICC profile. +fn payloads() -> (Vec, Vec, Vec) { + let mut exif = Exif::new(ByteOrder::LittleEndian); + exif.set_tag(ExifTag::Make, Value::Ascii("gamut".to_owned())); + let mut xmp = XmpMeta::new(); + xmp.set_text(WellKnownNs::Xmp.uri(), "CreatorTool", "gamut"); + let icc = IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: Vec::new(), + }; + let encoded = Metadata::from_carriers(Some(exif), Some(xmp), Some(icc)) + .encode() + .unwrap(); + ( + encoded.exif.unwrap(), + encoded.xmp.unwrap(), + encoded.icc.unwrap(), + ) +} + +/// A HEIF whose primary (1) carries a `prof` ICC `colr`, described by an Exif item (2) with the +/// given `ExifDataBlock` payload and an XMP `mime` item (3). +fn fixture(exif_payload: Vec, xmp: Vec, icc: Vec) -> Vec { + let mut primary = hvc1_item(1, vec![1, 2, 3, 4]); + primary.properties.push(Property { + essential: false, + kind: PropertyKind::Colour(ColourInformation::UnrestrictedIcc(icc)), + }); + let exif = Item { + references: vec![iref(b"cdsc", &[1])], + ..item(2, *b"Exif", exif_payload) + }; + let xmp = Item { + content_type: Some("application/rdf+xml".to_string()), + references: vec![iref(b"cdsc", &[1])], + ..item(3, *b"mime", xmp) + }; + clean_file(1, vec![primary, exif, xmp]) +} + +/// `offset` as a big-endian `exif_tiff_header_offset` followed by `rest`. +fn exif_data_block(offset: u32, rest: &[u8]) -> Vec { + let mut out = offset.to_be_bytes().to_vec(); + out.extend_from_slice(rest); + out +} + +#[test] +fn typed_metadata_is_extracted_from_the_items_and_the_colr() { + let (exif, xmp, icc) = payloads(); + // The model the payloads came from, as the facade itself extracts it (the ICC header's + // `size` is stamped by serialization, so the hand-built model is not the comparison point). + let expected = Metadata::from_blocks(&[ + MetadataBlock::Exif(&exif), + MetadataBlock::Xmp(&xmp), + MetadataBlock::Icc(&icc), + ]) + .unwrap(); + + // Offset 0 over the bare TIFF stream (the usual authoring), and offset 6 keeping the + // `Exif\0\0` signature in front of the TIFF header — both locate the same stream. + let tiff = exif.strip_prefix(b"Exif\0\0").unwrap(); + for exif_payload in [exif_data_block(0, tiff), exif_data_block(6, &exif)] { + let data = fixture(exif_payload, xmp.clone(), icc.clone()); + let container = HeifContainer::parse(&data).unwrap(); + let image = container.image(); + + let blocks = image.blocks().unwrap(); + assert_eq!( + blocks, + vec![ + MetadataBlock::Exif(tiff), + MetadataBlock::Xmp(&xmp), + MetadataBlock::Icc(&icc), + ] + ); + assert_eq!(image.metadata().unwrap(), expected); + } +} + +#[test] +fn a_file_without_metadata_yields_an_empty_model() { + let data = clean_file(1, vec![hvc1_item(1, vec![1, 2, 3, 4])]); + let container = HeifContainer::parse(&data).unwrap(); + assert!(container.image().blocks().unwrap().is_empty()); + assert_eq!(container.image().metadata().unwrap(), Metadata::default()); +} + +#[test] +fn a_malformed_exif_item_is_invalid_input_from_both_accessors() { + let (_, xmp, icc) = payloads(); + // Three bytes: shorter than the offset field itself. + let data = fixture(vec![0, 0, 0], xmp, icc); + let container = HeifContainer::parse(&data).unwrap(); + for err in [ + container.image().blocks().unwrap_err(), + container.image().metadata().unwrap_err(), + ] { + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("HEIF: Exif item payload is shorter than its tiff-header offset field") + ); + } +} + +#[test] +fn an_unparsable_payload_is_invalid_input_with_the_facade_detail() { + let (exif, xmp, _) = payloads(); + let tiff = exif.strip_prefix(b"Exif\0\0").unwrap(); + // A `prof` colr whose bytes are not an ICC profile: located fine, refused by the facade. + let data = fixture( + exif_data_block(0, tiff), + xmp, + b"not an icc profile".to_vec(), + ); + let container = HeifContainer::parse(&data).unwrap(); + assert_eq!(container.image().blocks().unwrap().len(), 3); + let err = container.image().metadata().unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("HEIF: embedded metadata does not parse") + ); + assert!(err.detail().is_some_and(|d| d.starts_with("ICC:")), "{err}"); +} From 327db14c5af00f4e6947d67260b61bf09728c1c0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 12:42:31 -0400 Subject: [PATCH 09/15] feat(gamut): forward the format crates' `metadata` features from the umbrella `gamut-jpeg`, `gamut-jxl` and `gamut-heic` each gained an optional `metadata` feature carrying the typed accessors (`blocks()`, `metadata()`, `with_metadata`). The umbrella's own `metadata` feature enabled only the metadata crates, so `gamut = { features = ["jpeg", "metadata"] }` compiled the facade and the codec but not the wiring between them: reaching `JpegMetadata::blocks` meant depending on `gamut-jpeg` directly, which is what the umbrella exists to avoid. Add the three weak forwards. Weak (`?/`) is what keeps both directions honest: `metadata` alone still pulls in no codec, and a format alone still pulls in no facade -- each forward fires only when that format's feature already brought the crate into the graph. Verified at the rustc invocation rather than by inspection: with `--features jpeg,jxl,heic,metadata` all three crates are compiled with `--cfg feature="metadata"` and five accessors named only through `gamut::` go from a compile error to a compile; with `--features jpeg,jxl,heic` the same three are compiled without it; with the formats alone no facade crate is in the dependency graph, and with `metadata` alone no codec crate is. No new feature name is introduced, so `gamut-ffi`'s mirrored table is unchanged, and no package is added, so the lockfile is unchanged. --- crates/gamut/Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/gamut/Cargo.toml b/crates/gamut/Cargo.toml index 6bbfa6f4..c83bb5ab 100644 --- a/crates/gamut/Cargo.toml +++ b/crates/gamut/Cargo.toml @@ -89,6 +89,13 @@ metadata = [ "dep:gamut-icc", "dep:gamut-iptc", "dep:gamut-ifd", + # The format crates' own `metadata` features, which turn on their typed accessors + # (`blocks()` / `metadata()` / `with_metadata`). Weak (`?/`), so each one is enabled only + # when that format's feature already pulled the crate in: `metadata` alone must not drag a + # codec into the build, and a format alone must not drag the facade in. + "gamut-jpeg?/metadata", + "gamut-jxl?/metadata", + "gamut-heic?/metadata", ] # The ICC colour management module (transform engine) over gamut-icc profiles. cmm = ["dep:gamut-cmm"] From 571c2fabbe52c17518e96ec31bec620c768a1f55 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:10:51 -0400 Subject: [PATCH 10/15] test(gamut): pin the umbrella's three metadata feature forwards The forwards added in the previous commit had nothing holding them: `mise run test` reported the same 3816 tests before and after them, because no test named anything the forwards switch on. A feature edge that nothing notices when it disappears is the defect this suite exists to catch, so it should not be the shape the fix itself ships in. Pin both directions, by different techniques, because only one of them is observable from a compiled build. `the_metadata_feature_reaches_each_format_ crates_accessors` names one accessor per crate -- the fewest it takes to observe the three edges -- and each exists only under that crate's `metadata` feature, so a dropped forward is a compile error; nothing is called and no fixture is built, so a fixture bug or a signature change cannot fail it. `every_format_metadata_forward_is_weak` reads the compiled-in manifest and asserts each entry carries the `?`, which is the whole of what stops `metadata` alone from pulling three codecs into a build that asked for none. It checks the non-weak form first so that dropping the `?` is diagnosed as dropping the `?` rather than as a missing forward. Both were falsified before landing: deleting a forward fails the resolution test at compile time naming that crate, and removing a `?` fails the weakness test with the message about weakness. `crates/gamut/tests/` is mutation-invisible and `AGENTS.md` forbids pinning anything there *by choice*. This is the linkage exception the same rule names: the edge under test is in the umbrella's own feature graph, no lower crate can observe who enabled its features, and the three format crates must not gain dev-dependency edges on one another. The module docs say so at the test. --- crates/gamut/tests/feature_forwarding.rs | 96 ++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 crates/gamut/tests/feature_forwarding.rs diff --git a/crates/gamut/tests/feature_forwarding.rs b/crates/gamut/tests/feature_forwarding.rs new file mode 100644 index 00000000..10602f7e --- /dev/null +++ b/crates/gamut/tests/feature_forwarding.rs @@ -0,0 +1,96 @@ +//! integration · drift guard — the umbrella's `metadata` feature forwards to the format crates. +//! +//! `gamut-jpeg`, `gamut-jxl` and `gamut-heic` each carry the typed metadata accessors behind their +//! own `metadata` Cargo feature. The umbrella's `metadata` feature forwards to all three weakly +//! (`gamut-jpeg?/metadata`), so that a consumer who asks the umbrella for a format *and* for +//! metadata gets the wiring between them, while neither half drags the other in on its own. +//! +//! **Why this cannot live in a lower crate.** `AGENTS.md` forbids pinning anything under +//! `crates/gamut/tests/` *by choice*, because `.cargo/mutants.toml` sets `test_workspace = false` +//! and excludes `crates/gamut/**`, so nothing here can kill a mutant. This file is not here by +//! choice: what it pins is an edge in the umbrella's own feature graph, and no lower crate can +//! observe that edge — `gamut-jpeg` cannot see who enabled its `metadata` feature, and the three +//! format crates must not gain dev-dependency edges on one another or on the umbrella +//! (`mise run check-release-deps`). That is the linkage exception the same rule names, and this +//! file is in its only legal home. +//! +//! **These tests are therefore mutation-invisible**, and nothing else holds the forwards: the +//! wiring they switch on is covered inside each format crate (`gamut-jpeg`'s, `gamut-jxl`'s and +//! `gamut-heic`'s own `metadata` suites, all mutation-visible), but the *forward* — the three +//! entries in `crates/gamut/Cargo.toml` — is pinned by this file alone. Delete it and a dropped +//! forward becomes silent again. +//! +//! The two directions are pinned by different techniques because only one of them is observable +//! from a compiled build: +//! +//! - **Forward fires.** With the umbrella's `metadata` feature and a format's feature both on, the +//! format crate's `metadata` feature is on too. Observed by *resolution*: each accessor named +//! below exists only under that feature, so a dropped forward is a compile error. +//! - **Forward is weak.** Enabling `metadata` alone must not pull a codec into a build that asked +//! for none. That is exactly what the `?` in `gamut-jpeg?/metadata` means, and it is a property +//! of the feature table rather than of any compiled artefact — a single build cannot see it — so +//! it is pinned as a drift guard over the manifest text. +//! +//! The complementary half of the negative direction — that a format feature alone pulls in no +//! facade crate — holds because each format crate's `metadata` feature is off by default, which is +//! that crate's property and is pinned in that crate. It is measured here only as evidence +//! (`cargo tree -p gamut --features "jpeg,jxl,heic"` lists no facade crate), not asserted. + +/// With `metadata` and all three format features on, every forwarded accessor resolves. +/// +/// Nothing is called and no fixture is built: a fixture bug, a signature change or a parser defect +/// must not be able to fail this test. One item per crate is named — the fewest it takes to +/// observe the three edges — so the only ways this can break are the forward being dropped and the +/// item being renamed, and a rename is a signal to update the pin rather than a false alarm. +#[cfg(all( + feature = "metadata", + feature = "jpeg", + feature = "jxl", + feature = "heic" +))] +#[test] +fn the_metadata_feature_reaches_each_format_crates_accessors() { + /// Accepts any item and does nothing: naming one as the argument is the whole assertion, and + /// the generic parameter keeps the pin independent of the item's signature. + fn resolves(_item: T) {} + + // Each is `#[cfg(feature = "metadata")]` inside its own crate, so the path resolves only if + // the umbrella's forward switched that crate's `metadata` feature on. + resolves(gamut::jpeg::JpegMetadata::blocks); + resolves(gamut::jxl::JxlMetadata::blocks); + resolves(gamut::heic::HeifImage::blocks); +} + +/// Every forward is weak, so `metadata` alone pulls no codec into the build. +/// +/// `gamut-jpeg?/metadata` enables that crate's feature only if something else already brought the +/// crate in; `gamut-jpeg/metadata` — the same line without the `?` — would enable the optional +/// dependency itself, so asking the umbrella for metadata would silently compile three codecs. +/// A single build cannot observe the difference, so the feature table is read directly. +/// +/// This one carries no `cfg`, so under a feature set that compiles the resolution test above out — +/// `--features metadata` with no format — it is also what notices a forward being deleted. +#[test] +fn every_format_metadata_forward_is_weak() { + // Compiled in, not read from disk: the pin travels with the crate, including in a package + // built for publication. + const MANIFEST: &str = include_str!("../Cargo.toml"); + + for crate_name in ["gamut-jpeg", "gamut-jxl", "gamut-heic"] { + let weak = format!("\"{crate_name}?/metadata\""); + let strong = format!("\"{crate_name}/metadata\""); + // The strong form is checked first so that dropping the `?` is diagnosed as dropping the + // `?`. Checking presence first would report a de-weakened forward as a missing one, which + // sends the next reader after the wrong fault. + assert!( + !MANIFEST.contains(&strong), + "the forward {strong} is not weak; enabling `metadata` alone would now pull \ + {crate_name} into builds that asked for no codec" + ); + assert!( + MANIFEST.contains(&weak), + "the umbrella's `metadata` feature no longer forwards {weak}; a consumer enabling \ + `metadata` with that format can no longer reach its typed accessors" + ); + } +} From d373c1ebdf3945aba7a079c73da4afaa7b312c9e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:55:03 -0400 Subject: [PATCH 11/15] test(gamut): scope the forward pin to the metadata feature's own entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weakness guard matched its three forwards over the whole manifest, so it could not see which feature list an entry belonged to. Moving `"gamut-jpeg?/metadata"` out of `metadata = [ … ]` and into `jpeg = [ … ]` left both assertions passing while making `gamut --features jpeg` resolve the format crate with its metadata wiring — and therefore the entire facade — which is the build the weak form exists to prevent. Slice the feature's own entry list out of the manifest and assert presence over that, plus a manifest-wide count of one so the entry cannot also be attached to a format feature. Both attacks now fail, each naming its own cause. The resolution half named one accessor per crate, so a dropped forward and a renamed accessor produced the same compile error. Add a feature witness per crate — the facade's `Metadata`, re-exported under each crate's own `metadata` cfg — which the forward breaks and a rename does not, so the two faults differ by which errors appear. --- crates/gamut/tests/feature_forwarding.rs | 79 +++++++++++++++++++----- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/gamut/tests/feature_forwarding.rs b/crates/gamut/tests/feature_forwarding.rs index 10602f7e..961de247 100644 --- a/crates/gamut/tests/feature_forwarding.rs +++ b/crates/gamut/tests/feature_forwarding.rs @@ -24,24 +24,24 @@ //! from a compiled build: //! //! - **Forward fires.** With the umbrella's `metadata` feature and a format's feature both on, the -//! format crate's `metadata` feature is on too. Observed by *resolution*: each accessor named -//! below exists only under that feature, so a dropped forward is a compile error. -//! - **Forward is weak.** Enabling `metadata` alone must not pull a codec into a build that asked -//! for none. That is exactly what the `?` in `gamut-jpeg?/metadata` means, and it is a property -//! of the feature table rather than of any compiled artefact — a single build cannot see it — so -//! it is pinned as a drift guard over the manifest text. +//! format crate's `metadata` feature is on too. Observed by *resolution*: each name below exists +//! only under that feature, so a dropped forward is a compile error. +//! - **Forward is weak, and belongs to `metadata`.** Enabling `metadata` alone must not pull a +//! codec into a build that asked for none, and enabling a format alone must not pull the facade +//! in. Both are properties of the feature *table* rather than of any compiled artefact — a +//! single build cannot see either — so they are pinned as a drift guard over the manifest text. //! //! The complementary half of the negative direction — that a format feature alone pulls in no //! facade crate — holds because each format crate's `metadata` feature is off by default, which is //! that crate's property and is pinned in that crate. It is measured here only as evidence //! (`cargo tree -p gamut --features "jpeg,jxl,heic"` lists no facade crate), not asserted. -/// With `metadata` and all three format features on, every forwarded accessor resolves. +/// With `metadata` and all three format features on, every forwarded name resolves. /// /// Nothing is called and no fixture is built: a fixture bug, a signature change or a parser defect -/// must not be able to fail this test. One item per crate is named — the fewest it takes to -/// observe the three edges — so the only ways this can break are the forward being dropped and the -/// item being renamed, and a rename is a signal to update the pin rather than a false alarm. +/// must not be able to fail this test. Two names per crate are enough to observe the edge *and* to +/// say which of the two possible faults broke it — a dropped forward, or a renamed accessor. They +/// fail in different combinations, so the compile error is a diagnosis rather than a puzzle. #[cfg(all( feature = "metadata", feature = "jpeg", @@ -54,28 +54,65 @@ fn the_metadata_feature_reaches_each_format_crates_accessors() { /// the generic parameter keeps the pin independent of the item's signature. fn resolves(_item: T) {} - // Each is `#[cfg(feature = "metadata")]` inside its own crate, so the path resolves only if - // the umbrella's forward switched that crate's `metadata` feature on. + /// Accepts any *type* and does nothing, so a feature-gated re-export can be named without + /// constructing a value of it. + fn type_resolves() {} + + // The feature witness. Each crate re-exports the facade's `Metadata` under its own + // `#[cfg(feature = "metadata")]`, so these three paths resolve exactly when the forward fired, + // and they name a type this branch does not own. A dropped forward breaks the witness *and* + // the accessor beneath it; renaming an accessor breaks only the accessor. Without the witness + // the two produce the same error, and the next reader cannot tell which happened. + type_resolves::(); + type_resolves::(); + type_resolves::(); + + // The accessors the forward exists to deliver. Each is `#[cfg(feature = "metadata")]` inside + // its own crate, so the path resolves only if the umbrella's forward switched that crate's + // `metadata` feature on. A rename here is a signal to update the pin, not a false alarm. resolves(gamut::jpeg::JpegMetadata::blocks); resolves(gamut::jxl::JxlMetadata::blocks); resolves(gamut::heic::HeifImage::blocks); } -/// Every forward is weak, so `metadata` alone pulls no codec into the build. +/// The entry list of the umbrella's own `metadata` feature, sliced out of `manifest`. +/// +/// `None` when the feature's opening line or its closing bracket is not where this file reads +/// them, which the caller reports rather than asserting over text it did not find. +fn metadata_feature_entries(manifest: &str) -> Option<&str> { + let after_open = manifest.split_once("\nmetadata = [")?.1; + Some(after_open.split_once("\n]")?.0) +} + +/// Every forward is weak and is listed under `metadata` alone. /// /// `gamut-jpeg?/metadata` enables that crate's feature only if something else already brought the /// crate in; `gamut-jpeg/metadata` — the same line without the `?` — would enable the optional /// dependency itself, so asking the umbrella for metadata would silently compile three codecs. /// A single build cannot observe the difference, so the feature table is read directly. /// +/// *Which* list an entry sits in matters as much as its form, and matching the whole file cannot +/// see that: moving `"gamut-jpeg?/metadata"` out of `metadata = [ … ]` and into `jpeg = [ … ]` +/// leaves the text present, while making `--features jpeg` alone resolve the format crate with its +/// metadata wiring — and therefore the entire facade — which is the build the weak form exists to +/// prevent. So presence is asserted over the feature's **own entry list**, and a manifest-wide +/// count pins that the entry is not *also* attached to a format feature. +/// /// This one carries no `cfg`, so under a feature set that compiles the resolution test above out — /// `--features metadata` with no format — it is also what notices a forward being deleted. #[test] -fn every_format_metadata_forward_is_weak() { +fn every_metadata_forward_is_weak_and_listed_under_metadata_alone() { // Compiled in, not read from disk: the pin travels with the crate, including in a package // built for publication. const MANIFEST: &str = include_str!("../Cargo.toml"); + let Some(entries) = metadata_feature_entries(MANIFEST) else { + panic!( + "the umbrella's `metadata = [ … ]` feature list is not where this pin reads it, so \ + none of the forwards below could be checked at all" + ) + }; + for crate_name in ["gamut-jpeg", "gamut-jxl", "gamut-heic"] { let weak = format!("\"{crate_name}?/metadata\""); let strong = format!("\"{crate_name}/metadata\""); @@ -88,9 +125,17 @@ fn every_format_metadata_forward_is_weak() { {crate_name} into builds that asked for no codec" ); assert!( - MANIFEST.contains(&weak), - "the umbrella's `metadata` feature no longer forwards {weak}; a consumer enabling \ - `metadata` with that format can no longer reach its typed accessors" + entries.contains(&weak), + "the umbrella's `metadata` feature no longer lists {weak}; a consumer enabling \ + `metadata` with that format can no longer reach its typed accessors — and if the \ + entry moved to that format's own feature, that format alone now drags in the whole \ + facade" + ); + assert_eq!( + MANIFEST.matches(weak.as_str()).count(), + 1, + "{weak} is listed more than once; a second copy under a format feature makes that \ + format alone drag in the whole facade" ); } } From ecad80636502def3f91b24ffbd704f7888a60956 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:58:45 -0400 Subject: [PATCH 12/15] docs(metadata): correct two overclaims about the capability table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table's prose asserted two things the crates disprove. `typed_wiring` was documented as answering for "that crate's `metadata` Cargo feature". `gamut-dng` has no such feature — it depends on `gamut-metadata` unconditionally, since its `DngMetadata` holds the facade's `Exif` by value — so a reader following the instruction reaches a hard cargo error. Three of the four wired crates gate the surface; name them, and name DNG as the one that does not. `supports` was documented as the surface "every format crate ships unconditionally". `gamut-jxl` gates its reader on `decode` and its encoder on `encode`, so a build with `default-features = false, features = ["encode"]` compiles no reader while the table answers `true` for `Read`. A `const fn` can see neither another crate's features nor the target, so say what is true: the table describes the surface a crate defines, not what a build compiled, and name the gated case. The cells themselves are unchanged and were verified correct. --- crates/gamut-metadata/README.md | 24 ++++++++++++----- crates/gamut-metadata/src/capability.rs | 36 ++++++++++++++++++++----- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/crates/gamut-metadata/README.md b/crates/gamut-metadata/README.md index f7f35fe6..2f4e7a1e 100644 --- a/crates/gamut-metadata/README.md +++ b/crates/gamut-metadata/README.md @@ -233,13 +233,23 @@ assert!(typed_wiring(Format::Jpeg)); // `blocks()` / `metadata()` / `with_meta assert!(!typed_wiring(Format::Png)); // raw bytes handed to `Metadata::from_blocks` by hand ``` -`supports` describes the crate's **raw** surface, which every format crate ships unconditionally; -`typed_wiring` says whether it also exposes this crate's models directly, behind that crate's -`metadata` Cargo feature. C2PA is read-only everywhere by construction — no embedder copies a -manifest store forward (see above). The enums are `#[repr(u8)]` with append-only discriminants and -carry `ALL` constants for enumeration, since `Format` and `Carrier` are `#[non_exhaustive]`. The -audio/video half of the same question is outside an image-first workspace and stays with issue -#216. +`supports` describes the crate's **raw** surface — its byte-level `metadata()` / `with_*` API; +`typed_wiring` says whether it also exposes this crate's models directly. C2PA is read-only +everywhere by construction — no embedder copies a manifest store forward (see above). + +Both describe the surface a crate **defines**, not the surface a particular build compiled: a +`const` table sees neither another crate's Cargo features nor the target. Two places where that +gap is real, and both are in the table's own docs. `gamut-jxl` gates its reader on its `decode` +feature and its encoder on `encode`, so a build with `default-features = false, features = +["encode"]` has no reader while the table still says `r`. And typed wiring reaches three of the +four wired crates through an optional `metadata` feature of their own (`gamut-jpeg`, `gamut-jxl`, +`gamut-heic`, each off by default and each forwarded weakly by the `gamut` umbrella) — but +**`gamut-dng` has no such feature**: it depends on this crate unconditionally, so `gamut-dng/metadata` +is not something a manifest can ask for. + +The enums are `#[repr(u8)]` with append-only discriminants and carry `ALL` constants for +enumeration, since `Format` and `Carrier` are `#[non_exhaustive]`. The audio/video half of the same +question is outside an image-first workspace and stays with issue #216. ## Consumer integration diff --git a/crates/gamut-metadata/src/capability.rs b/crates/gamut-metadata/src/capability.rs index 1e9bc2bc..580f2951 100644 --- a/crates/gamut-metadata/src/capability.rs +++ b/crates/gamut-metadata/src/capability.rs @@ -10,10 +10,14 @@ //! //! - [`supports`] — can the format crate **locate** ([`Direction::Read`]) or **write** //! ([`Direction::Write`]) the carrier as a raw payload? This is the crate's own surface -//! (`metadata()` / `with_exif`-style setters), independent of any feature. +//! (`metadata()` / `with_exif`-style setters). //! - [`typed_wiring`] — does the format crate also expose the facade's typed models directly //! (`blocks()` / `metadata()` accessors and a `with_metadata` encoder builder), behind that -//! crate's `metadata` Cargo feature? +//! crate's optional `metadata` Cargo feature where it has one? +//! +//! Both answer for the surface a crate **defines**, not for what a particular build compiled: a +//! `const fn` sees neither the Cargo features another crate was built with nor the target. Each +//! function's own docs name the cases where that gap is real. //! //! The table is a transcription of each crate's `STATUS.md` **as of this facade version**; every //! arm below cites the row that justifies it, and the cell changes in the pull request that changes @@ -141,9 +145,19 @@ impl Direction { /// Whether the crate for `format` can locate (`Read`) or write (`Write`) `carrier` as a raw payload. /// -/// This is the **raw** surface — the crate's own byte-level `metadata()` / `with_*` API, which every -/// format crate ships unconditionally. Whether it also exposes the facade's typed models is -/// [`typed_wiring`]. Each arm cites the `STATUS.md` row of the crate it describes. +/// This is the **raw** surface — the crate's own byte-level `metadata()` / `with_*` API — as +/// against the facade's typed models, which are [`typed_wiring`]. Each arm cites the `STATUS.md` +/// row of the crate it describes. +/// +/// # What a `const` table cannot see +/// +/// It answers for the surface the crate **defines**, not for the surface a given build compiled: +/// a `const fn` observes neither another crate's Cargo features nor the target. Most format crates +/// ship their raw surface unconditionally, but not all — `gamut-jxl` gates its reader +/// (`JxlDecoder::metadata`, `JxlDecoder::embedded_icc_profile`) on its `decode` feature and its +/// encoder on `encode`, so under `default-features = false, features = ["encode"]` no reader +/// exists while this table still answers `true` for [`Direction::Read`]. A caller who switches a +/// format crate's own default features off owns that intersection. #[must_use] pub const fn supports(format: Format, carrier: Carrier, direction: Direction) -> bool { let read = matches!(direction, Direction::Read); @@ -201,11 +215,19 @@ pub const fn supports(format: Format, carrier: Carrier, direction: Direction) -> } /// Whether the crate for `format` exposes the facade's typed models directly — `blocks()` / -/// `metadata()` accessors on its decoded metadata and a `with_metadata` builder on its encoder — -/// behind that crate's `metadata` Cargo feature. +/// `metadata()` accessors on its decoded metadata and a `with_metadata` builder on its encoder. /// /// `false` means the crate still hands its payloads over as raw bytes that a caller feeds to /// [`Metadata::from_blocks`](crate::Metadata::from_blocks) by hand. +/// +/// # How a `true` cell is switched on +/// +/// Three of the four wired crates put that surface behind an optional `metadata` Cargo feature of +/// their own — `gamut-jpeg`, `gamut-jxl` and `gamut-heic`, each off by default, and each forwarded +/// weakly by the `gamut` umbrella's `metadata` feature. `gamut-dng` has **no such feature**: it +/// depends on this crate unconditionally, because its `DngMetadata` holds the facade's `Exif` by +/// value, so `gamut-dng/metadata` is not a feature that can be asked for. As with [`supports`], +/// the answer describes what the crate defines, not what a given build compiled. #[must_use] pub const fn typed_wiring(format: Format) -> bool { match format { From 64cca8e80925170126d0bb1742e18225b84188e0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:59:00 -0400 Subject: [PATCH 13/15] refactor(metadata): make the capability ALL constants slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Format::ALL` and `Carrier::ALL` were fixed-length arrays on `#[non_exhaustive]` enums, so appending a variant would change each constant's *type* and break every caller who had named one — the exact breakage `#[non_exhaustive]` exists to prevent, and it would have shipped baked into new API. A `&'static [Self]` absorbs the append. `Direction` is exhaustive and cannot gain a variant, so it keeps its array; the docs now say why the two differ. The discriminant pin collects instead of mapping over an array, which keeps the length inside what it compares. --- crates/gamut-metadata/README.md | 8 ++++--- crates/gamut-metadata/src/capability.rs | 31 +++++++++++++++---------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/gamut-metadata/README.md b/crates/gamut-metadata/README.md index 2f4e7a1e..3bd958bc 100644 --- a/crates/gamut-metadata/README.md +++ b/crates/gamut-metadata/README.md @@ -247,9 +247,11 @@ four wired crates through an optional `metadata` feature of their own (`gamut-jp **`gamut-dng` has no such feature**: it depends on this crate unconditionally, so `gamut-dng/metadata` is not something a manifest can ask for. -The enums are `#[repr(u8)]` with append-only discriminants and carry `ALL` constants for -enumeration, since `Format` and `Carrier` are `#[non_exhaustive]`. The audio/video half of the same -question is outside an image-first workspace and stays with issue #216. +The enums are `#[repr(u8)]` with append-only discriminants. `Format` and `Carrier` are +`#[non_exhaustive]` and carry their `ALL` constants as **slices** rather than fixed-length arrays, +so appending a variant does not change a constant's type under a caller who named it; `Direction` +is exhaustive and keeps an array. The audio/video half of the same question is outside an +image-first workspace and stays with issue #216. ## Consumer integration diff --git a/crates/gamut-metadata/src/capability.rs b/crates/gamut-metadata/src/capability.rs index 580f2951..de2566f7 100644 --- a/crates/gamut-metadata/src/capability.rs +++ b/crates/gamut-metadata/src/capability.rs @@ -74,7 +74,11 @@ pub enum Format { impl Format { /// Every format, in discriminant order — the way to enumerate a `#[non_exhaustive]` enum. - pub const ALL: [Self; 8] = [ + /// + /// A slice, not a fixed-length array, precisely because the enum is `#[non_exhaustive]`: + /// appending a format would change an array constant's *type*, breaking every caller who + /// named it, which is the opposite of what `#[non_exhaustive]` promises. + pub const ALL: &'static [Self] = &[ Self::Jpeg, Self::Png, Self::WebP, @@ -124,8 +128,8 @@ pub enum Carrier { } impl Carrier { - /// Every carrier, in discriminant order. - pub const ALL: [Self; 5] = [Self::Exif, Self::Xmp, Self::Icc, Self::IptcIim, Self::C2pa]; + /// Every carrier, in discriminant order. A slice for the same reason as [`Format::ALL`]. + pub const ALL: &'static [Self] = &[Self::Exif, Self::Xmp, Self::Icc, Self::IptcIim, Self::C2pa]; } /// Which way the metadata moves. @@ -139,7 +143,7 @@ pub enum Direction { } impl Direction { - /// Both directions. + /// Both directions. An array, since this enum is exhaustive and cannot gain a variant. pub const ALL: [Self; 2] = [Self::Read, Self::Write]; } @@ -294,8 +298,8 @@ mod tests { #[test] fn supports_equals_the_documented_matrix_in_every_cell() { // Walks the full product so a flipped arm anywhere in `supports` is a named cell here. - for format in Format::ALL { - for carrier in Carrier::ALL { + for &format in Format::ALL { + for &carrier in Carrier::ALL { for direction in Direction::ALL { let expected = SUPPORTED.contains(&(format, carrier, direction)); assert_eq!( @@ -311,7 +315,8 @@ mod tests { #[test] fn typed_wiring_names_exactly_the_four_wired_crates() { let wired: Vec = Format::ALL - .into_iter() + .iter() + .copied() .filter(|&f| typed_wiring(f)) .collect(); assert_eq!( @@ -322,7 +327,7 @@ mod tests { #[test] fn crate_name_follows_the_workspace_naming() { - for format in Format::ALL { + for &format in Format::ALL { let name = format.crate_name(); assert!(name.starts_with("gamut-"), "{format:?}: {name}"); assert_eq!( @@ -336,13 +341,15 @@ mod tests { #[test] fn discriminants_are_the_documented_append_only_values() { // The `repr(u8)` values are a public contract (C ABI); pin them so a reorder is a failure. + // Collected rather than `map`ped over an array: `ALL` is a slice, so its length is part of + // what these compare, and an appended variant without its discriminant fails here. assert_eq!( - Format::ALL.map(|f| f as u8), - core::array::from_fn::(|i| i as u8) + Format::ALL.iter().map(|&f| f as u8).collect::>(), + (0..8).collect::>() ); assert_eq!( - Carrier::ALL.map(|c| c as u8), - core::array::from_fn::(|i| i as u8) + Carrier::ALL.iter().map(|&c| c as u8).collect::>(), + (0..5).collect::>() ); assert_eq!(Direction::ALL.map(|d| d as u8), [0, 1]); } From 9875ac51ccaed4e34a524f5261e40ca972f91115 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:59:14 -0400 Subject: [PATCH 14/15] docs(jxl): document that a model's ICC replaces the encoder's colour spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `with_metadata` routes a present ICC profile to `with_color(ColorSpec::Icc(..))`, so it overwrites a colour encoding the caller chose through a different builder call, not merely an earlier profile — JPEG XL is the one wired format where the profile *is* the codestream's colour encoding rather than a container payload. The docs said only that absent carriers leave earlier settings untouched, which left the present case for a caller to discover. State the precedence and the ordering it implies. Whether last-write-wins is the right rule here, or the conflict should be refused, is issue #626; this records today's behaviour rather than settling it. --- crates/gamut-jxl/src/encoder.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/gamut-jxl/src/encoder.rs b/crates/gamut-jxl/src/encoder.rs index f383d91f..7912b05b 100644 --- a/crates/gamut-jxl/src/encoder.rs +++ b/crates/gamut-jxl/src/encoder.rs @@ -254,8 +254,17 @@ impl JxlEncoder { /// IPTC-IIM block and **drops** a C2PA manifest store (a store is signed over the file it came /// from; see [`gamut_metadata::C2paPolicy`]); a caller that must be told about either /// configures the embedder itself and calls - /// [`with_encoded_metadata`](Self::with_encoded_metadata). Carriers absent from the model leave - /// any earlier setting untouched. + /// [`with_encoded_metadata`](Self::with_encoded_metadata). + /// + /// # Precedence + /// + /// A carrier **absent** from the model leaves any earlier setting untouched. A carrier + /// **present** in it overwrites one, because each is routed to the raw setter — and for ICC + /// that setter is [`with_color`](Self::with_color), so a profile in the model replaces a + /// [`ColorSpec`] chosen before this call, not merely an earlier profile. Call this before + /// [`with_color`](Self::with_color) when the explicit colour choice is meant to win. Whether + /// last-write-wins is the right rule for a colour encoding, or the conflict should be refused, + /// is open (issue #626); this documents what it does today rather than settling it. /// /// # Errors /// @@ -280,7 +289,9 @@ impl JxlEncoder { /// /// Only the carriers JPEG XL can write are accepted: EXIF (`Exif` box), XMP (`xml ` box) and /// ICC (the codestream colour encoding). Fields that are `None` leave any earlier setting - /// untouched. + /// untouched; a field that is `Some` overwrites one, and for ICC that means replacing the + /// encoder's [`ColorSpec`] — see the precedence note on + /// [`with_metadata`](Self::with_metadata). /// /// # Errors /// From 79373174eeccadbfe8c30b9f92b094012e74b055 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:59:14 -0400 Subject: [PATCH 15/15] docs(metadata): record the embedding precedence a present carrier has The consumer-integration section described what `with_metadata` embeds but not what it displaces. A carrier absent from the model leaves an earlier setting untouched and a present one overwrites it, which matters most for ICC in JPEG XL, where the profile is the codestream's colour encoding rather than a container box. Say so where a caller reads about the seam, and point at #626 for the open question of whether that rule is the right one. --- crates/gamut-metadata/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/gamut-metadata/README.md b/crates/gamut-metadata/README.md index 3bd958bc..5b6e83f9 100644 --- a/crates/gamut-metadata/README.md +++ b/crates/gamut-metadata/README.md @@ -266,6 +266,15 @@ setter — plus `with_encoded_metadata(&EncodedMetadata)` for a caller who chose policies. A carrier the container cannot write is a typed `Unsupported` error there, never a silent drop, and a manifest store is never copied forward. +**Precedence, as it behaves today.** A carrier absent from the model leaves whatever the caller set +earlier untouched; a carrier *present* in the model overwrites it, because the routing is the raw +setter. That is unremarkable for a container box, and consequential for ICC in JPEG XL, where the +profile is not a box but the codestream's colour encoding: `JxlEncoder::with_metadata` routes a +present profile to `with_color(ColorSpec::Icc(…))`, replacing a `ColorSpec` the caller chose before +the call. Order the two accordingly. Whether last-write-wins is the right rule for a colour +encoding — as against refusing the conflict — is an open question, filed as #626 rather than +decided here. + Wired today: `gamut-dng` (its `DngMetadata` holds the facade's `Exif` by value), `gamut-jpeg`, `gamut-jxl` and `gamut-heic` (decode-only). The remaining format crates hand their payloads over as raw bytes — see the capability table above.