From 032342a93b9d07f12032525e2a17c356987a8193 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:11:33 -0400 Subject: [PATCH 01/43] feat(tiff): carry ICC, XMP, IPTC-IIM and an Exif sub-IFD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gamut-tiff` had no metadata read or write surface at all: `tags.rs` named XMP (700), IPTC/NAA (33723), ICC (34675) and the Exif/GPS/Interop pointers only so `deconstruct` would not flag them unknown, and a caller wanting any of them had to drop to the re-exported `gamut_ifd` read/write spine. Add the seam: `TiffMetadata` is a `#[non_exhaustive]` struct of optional payloads that `TiffEncoder::with_metadata` writes into IFD 0 — on the strip, tile and multi-page paths alike — and `TiffDecoder::metadata` reads back. XMP, IPTC-IIM and ICC are opaque bytes carried verbatim in both directions, the raw blocks the workspace's metadata facade consumes; the `ExifIFD` is handed over as `gamut_ifd::Ifd`, because it *is* a directory this crate has already parsed and a byte blob would force every caller to re-parse it. Nothing is validated or completed, so what the caller supplies is what the file gets. The blocks are out-of-line values, so they displace the pixel data; libtiff reads a gamut TIFF carrying all four back pixel-exact. --- crates/gamut-tiff/src/decoder.rs | 30 +++ crates/gamut-tiff/src/encoder.rs | 23 +- crates/gamut-tiff/src/lib.rs | 2 + crates/gamut-tiff/src/metadata.rs | 279 +++++++++++++++++++++ crates/gamut-tiff/tests/metadata.rs | 128 ++++++++++ crates/gamut-tiff/tests/oracle_metadata.rs | 50 ++++ 6 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 crates/gamut-tiff/src/metadata.rs create mode 100644 crates/gamut-tiff/tests/metadata.rs create mode 100644 crates/gamut-tiff/tests/oracle_metadata.rs diff --git a/crates/gamut-tiff/src/decoder.rs b/crates/gamut-tiff/src/decoder.rs index d867a98d..fab2a6f1 100644 --- a/crates/gamut-tiff/src/decoder.rs +++ b/crates/gamut-tiff/src/decoder.rs @@ -16,6 +16,7 @@ use gamut_ifd::{ByteOrder, Ifd, read}; use crate::compression::{Compression, ccitt, deflate, lzw, packbits, predictor}; use crate::ifd::{PhotometricInterpretation, Predictor, SampleFormat}; use crate::info::{self, TiffInfo}; +use crate::metadata::{self, TiffMetadata}; use crate::palette::Palette8; use crate::tags; @@ -185,6 +186,35 @@ impl TiffDecoder { info::page_info(ifd, file.order) } + /// Reads the metadata a TIFF carries, without decoding pixels. + /// + /// IFD 0 supplies the XMP, IPTC-IIM and ICC payloads and the `ExifIFD` sub-IFD. Every + /// byte-carried payload comes back **verbatim** — this crate parses none of them — so a block + /// written by [`TiffEncoder::with_metadata`](crate::TiffEncoder::with_metadata) reads back + /// identical. + /// + /// ``` + /// use gamut_core::{Dimensions, EncodeImage, Gray8, ImageRef}; + /// use gamut_tiff::{TiffDecoder, TiffEncoder, TiffMetadata}; + /// + /// let dims = Dimensions { width: 2, height: 1 }; + /// let tiff = TiffEncoder::new() + /// .with_metadata(TiffMetadata::new().with_xmp(b"".to_vec())) + /// .encode_to_vec(ImageRef::::new(&[7, 9], dims)?)?; + /// + /// let meta = TiffDecoder::new().metadata(&tiff)?; + /// assert_eq!(meta.xmp.as_deref(), Some(&b""[..])); + /// # Ok::<(), gamut_core::Error>(()) + /// ``` + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] for a malformed header or IFD chain, or a sub-IFD pointer + /// graph that is not a tree. + pub fn metadata(&self, data: &[u8]) -> Result { + metadata::read_metadata(data) + } + /// Selects which lossy conversions a typed decode may perform. /// /// Defaults to [`ConvertPolicy::lossless`], under which a layout that cannot hold the page diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 5519f99a..c6a2bfc2 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -8,6 +8,7 @@ use gamut_ifd::{ByteOrder, Ifd, Value, Variant}; use crate::compression::{Compression, ccitt, deflate, lzw, packbits, predictor}; use crate::ifd::{PhotometricInterpretation, Predictor}; +use crate::metadata::TiffMetadata; use crate::palette::Palette8; use crate::{tags, writer}; @@ -33,6 +34,7 @@ pub struct TiffEncoder { predictor: Predictor, tiling: Option<(u32, u32)>, big_tiff: bool, + metadata: TiffMetadata, } impl Default for TiffEncoder { @@ -43,6 +45,7 @@ impl Default for TiffEncoder { predictor: Predictor::None, tiling: None, big_tiff: false, + metadata: TiffMetadata::new(), } } } @@ -103,6 +106,18 @@ impl TiffEncoder { self } + /// Returns a copy of this encoder that embeds `metadata` — an Exif sub-IFD plus opaque + /// XMP / IPTC-IIM / ICC blocks. + /// + /// The blocks and the Exif sub-IFD go in **IFD 0**, which for + /// [`encode_pages_rgb8`](Self::encode_pages_rgb8) is the first page: they describe the + /// document, not one of its pages. + #[must_use] + pub fn with_metadata(mut self, metadata: TiffMetadata) -> Self { + self.metadata = metadata; + self + } + /// The container variant this encoder writes (BigTIFF when [`Self::with_big_tiff`] is set). fn variant(&self) -> Variant { if self.big_tiff { @@ -218,7 +233,8 @@ impl TiffEncoder { if let Some((tw, tl)) = self.tiling { return self.encode_tiled(packed, dims, layout, extra_fields, tw, tl, out); } - let (ifd, strips) = self.build_strip_image(packed, dims, layout, extra_fields)?; + let (mut ifd, strips) = self.build_strip_image(packed, dims, layout, extra_fields)?; + self.metadata.apply(&mut ifd); let bytes = writer::write_image(self.order, self.variant(), &ifd, &strips)?; out.extend_from_slice(&bytes); Ok(bytes.len()) @@ -342,6 +358,10 @@ impl TiffEncoder { &extra, )?); } + // The blocks describe the document, not one of its pages, so they go in IFD 0 alone. + if let Some((ifd0, _)) = images.first_mut() { + self.metadata.apply(ifd0); + } let bytes = writer::write_multipage(self.order, self.variant(), &images)?; out.extend_from_slice(&bytes); Ok(bytes.len()) @@ -495,6 +515,7 @@ impl TiffEncoder { for (tag, value) in extra_fields { ifd.set(*tag, value.clone()); } + self.metadata.apply(&mut ifd); let bytes = writer::write_image_tiled(self.order, self.variant(), &ifd, &tiles)?; out.extend_from_slice(&bytes); diff --git a/crates/gamut-tiff/src/lib.rs b/crates/gamut-tiff/src/lib.rs index 614c2a1c..fb19e51b 100644 --- a/crates/gamut-tiff/src/lib.rs +++ b/crates/gamut-tiff/src/lib.rs @@ -74,6 +74,7 @@ mod deconstruct; mod encoder; mod ifd; mod info; +mod metadata; mod palette; mod writer; @@ -94,5 +95,6 @@ pub use gamut_ifd::{ }; pub use ifd::{PhotometricInterpretation, Predictor, SampleFormat}; pub use info::TiffInfo; +pub use metadata::TiffMetadata; pub use palette::Palette8; pub use writer::{write_image, write_image_tiled, write_multipage}; diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs new file mode 100644 index 00000000..31d23415 --- /dev/null +++ b/crates/gamut-tiff/src/metadata.rs @@ -0,0 +1,279 @@ +//! Optional metadata embedded in a TIFF: an Exif sub-IFD plus XMP / IPTC / ICC blocks. +//! +//! TIFF stores metadata the way it stores everything else — as IFD entries — so the seam is thin +//! by construction: [`TiffMetadata`] is a plain struct of optional payloads that +//! [`TiffEncoder::with_metadata`](crate::TiffEncoder::with_metadata) writes into IFD 0 and +//! [`TiffDecoder::metadata`](crate::TiffDecoder::metadata) reads back. +//! +//! Everything except EXIF is a **single opaque payload** in the file — XMP (700), IPTC-IIM +//! (33723) and ICC (34675) — so this crate carries the bytes verbatim in both directions and +//! parses none of them. That is deliberate: they are the raw blocks the workspace's metadata +//! facade consumes (the same shape `gamut-png` and `gamut-webp` hand over), and keeping them +//! opaque here is what lets a caller choose its own conflict policy instead of inheriting one +//! from the container. +//! +//! EXIF is the exception, and only because TIFF makes it one: an `ExifIFD` (34665) *is* an IFD, +//! which this crate has already parsed by the time a caller sees it. Handing it back as +//! [`gamut_ifd::Ifd`] rather than as bytes saves every caller from re-parsing a directory the +//! decoder already walked. Its fields are neither validated nor completed — what the caller +//! supplies is what the file gets, and what the file holds is what the caller gets. + +use gamut_core::Result; +use gamut_ifd::{Ifd, Value, read_tree}; + +use crate::tags; + +/// Metadata to embed in a TIFF, or read back from one: an Exif sub-IFD and/or opaque +/// XMP / IPTC-IIM / ICC payloads. +/// +/// `#[non_exhaustive]`, so a later carrier is an additive change: build one from +/// [`TiffMetadata::new`] and the `with_*` builders, or assign the public fields of a value you +/// already hold. +/// +/// ``` +/// use gamut_tiff::TiffMetadata; +/// +/// let meta = TiffMetadata::new() +/// .with_xmp(b"".to_vec()) +/// .with_icc(vec![0, 0, 2, 32]); +/// assert!(!meta.is_empty()); +/// assert_eq!(meta.xmp.as_deref(), Some(&b""[..])); +/// ``` +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct TiffMetadata { + /// The Exif private sub-IFD (`ExifIFD`, 34665), as the shared directory model. + /// + /// Carried **verbatim**: every entry the caller supplies is written, and every entry the + /// file holds is returned. This crate adds no mandatory Exif field (not even `ExifVersion`) + /// and drops none, because a TIFF's `ExifIFD` is the caller's directory — completing it + /// would silently change what a round-trip returns. + pub exif: Option, + /// An XMP packet (UTF-8 RDF/XML), stored in the `XMP` tag (700) as `BYTE`, verbatim. + pub xmp: Option>, + /// A legacy IPTC-IIM dataset stream, stored in the `IPTC/NAA` tag (33723) as `BYTE`, + /// verbatim. + /// + /// Kept as its own carrier rather than folded into [`xmp`](Self::xmp): IIM is a genuinely + /// separate serialization that real TIFFs hold, and reconciling it into an XMP graph is a + /// policy decision that belongs to the caller. + pub iptc: Option>, + /// An ICC profile, stored in the `ICCProfile` tag (34675) as `UNDEFINED`, verbatim. + pub icc: Option>, +} + +impl TiffMetadata { + /// Creates an empty metadata set. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns a copy carrying `exif` as the file's `ExifIFD` sub-IFD. + #[must_use] + pub fn with_exif(mut self, exif: Ifd) -> Self { + self.exif = Some(exif); + self + } + + /// Returns a copy carrying `packet` as the file's XMP. + #[must_use] + pub fn with_xmp(mut self, packet: Vec) -> Self { + self.xmp = Some(packet); + self + } + + /// Returns a copy carrying `iim` as the file's IPTC-IIM block. + #[must_use] + pub fn with_iptc(mut self, iim: Vec) -> Self { + self.iptc = Some(iim); + self + } + + /// Returns a copy carrying `profile` as the file's embedded ICC profile. + #[must_use] + pub fn with_icc(mut self, profile: Vec) -> Self { + self.icc = Some(profile); + self + } + + /// Whether there is nothing to embed: no payload set, and no Exif sub-IFD with fields in it. + /// + /// An `exif` directory with no entries counts as empty — writing it would add an `ExifIFD` + /// pointer to a directory with nothing in it. + #[must_use] + pub fn is_empty(&self) -> bool { + self.exif_ifd().is_none() && self.xmp.is_none() && self.iptc.is_none() && self.icc.is_none() + } + + /// The Exif sub-IFD to write, or `None` when there is no Exif content worth a directory. + fn exif_ifd(&self) -> Option<&Ifd> { + self.exif.as_ref().filter(|ifd| !ifd.fields().is_empty()) + } + + /// Writes the XMP / IPTC / ICC blocks and the Exif sub-IFD into `ifd0`. + pub(crate) fn apply(&self, ifd0: &mut Ifd) { + if let Some(xmp) = &self.xmp { + ifd0.set(tags::XMP, Value::Byte(xmp.clone())); + } + if let Some(iptc) = &self.iptc { + ifd0.set(tags::IPTC_NAA, Value::Byte(iptc.clone())); + } + if let Some(icc) = &self.icc { + ifd0.set(tags::ICC_PROFILE, Value::Undefined(icc.clone())); + } + if let Some(exif) = self.exif_ifd() { + ifd0.set_sub_ifd(tags::EXIF_IFD, vec![exif.clone()]); + } + } +} + +/// A raw `BYTE`/`UNDEFINED` payload, copied out of a directory entry. +fn bytes_value(value: Option<&Value>) -> Option> { + value.and_then(Value::as_bytes).map(<[u8]>::to_vec) +} + +/// Reads the metadata a TIFF carries: IFD 0's blocks and its Exif sub-IFD. +pub(crate) fn read_metadata(data: &[u8]) -> Result { + let file = read_tree(data, &[tags::EXIF_IFD])?; + // A file with no IFD at all carries no metadata. + let Some(ifd0) = file.ifds.first() else { + return Ok(TiffMetadata::new()); + }; + Ok(TiffMetadata { + exif: ifd0 + .sub_ifds() + .iter() + .find(|group| group.tag == tags::EXIF_IFD) + .and_then(|group| group.ifds.first()) + .cloned(), + xmp: bytes_value(ifd0.get(tags::XMP)), + iptc: bytes_value(ifd0.get(tags::IPTC_NAA)), + icc: bytes_value(ifd0.get(tags::ICC_PROFILE)), + }) +} + +#[cfg(test)] +mod tests { + use gamut_ifd::{ByteOrder, TiffFile, Variant, write}; + + use super::*; + + /// A directory holding one recognisable field. + fn exif_ifd() -> Ifd { + let mut ifd = Ifd::new(); + ifd.set(33434, Value::Rational(vec![(1, 250)])); // ExposureTime + ifd + } + + /// A minimal one-page file carrying `ifd0`, so `read_metadata` has a chain to walk. + fn file_with(ifd0: Ifd) -> Vec { + write(&TiffFile { + order: ByteOrder::LittleEndian, + variant: Variant::Classic, + ifds: vec![ifd0], + }) + .expect("write") + } + + #[test] + fn empty_metadata_writes_nothing() { + let mut ifd = Ifd::new(); + let meta = TiffMetadata::new(); + assert!(meta.is_empty()); + meta.apply(&mut ifd); + assert!(ifd.fields().is_empty()); + assert!(ifd.sub_ifds().is_empty()); + } + + #[test] + fn an_exif_directory_with_no_fields_is_empty() { + // An empty directory must not become an `ExifIFD` pointer to nothing. + let meta = TiffMetadata::new().with_exif(Ifd::new()); + assert!(meta.is_empty()); + let mut ifd = Ifd::new(); + meta.apply(&mut ifd); + assert!(ifd.sub_ifds().is_empty()); + } + + #[test] + fn each_carrier_alone_makes_the_set_non_empty() { + // One assertion per carrier, so a builder that assigned the wrong field, or an + // `is_empty` that stopped consulting one, fails here rather than in a whole-file test. + let singles = [ + TiffMetadata::new().with_exif(exif_ifd()), + TiffMetadata::new().with_xmp(vec![1]), + TiffMetadata::new().with_iptc(vec![1]), + TiffMetadata::new().with_icc(vec![1]), + ]; + for (i, meta) in singles.iter().enumerate() { + assert!(!meta.is_empty(), "carrier {i} alone must be non-empty"); + } + } + + #[test] + fn the_builders_set_the_field_they_name() { + let meta = TiffMetadata::new() + .with_exif(exif_ifd()) + .with_xmp(b"".to_vec()) + .with_iptc(vec![0x1c, 0x02, 0x05]) + .with_icc(vec![7; 4]); + assert_eq!(meta.exif, Some(exif_ifd())); + assert_eq!(meta.xmp.as_deref(), Some(&b""[..])); + assert_eq!(meta.iptc.as_deref(), Some(&[0x1c, 0x02, 0x05][..])); + assert_eq!(meta.icc.as_deref(), Some(&[7, 7, 7, 7][..])); + } + + #[test] + fn apply_writes_each_block_under_its_own_tag_and_type() { + // The tag *and* the field type are the on-disk contract: XMP and IPTC are BYTE, ICC is + // UNDEFINED. Distinct payloads, so a block written under the wrong tag is visible. + let meta = TiffMetadata::new() + .with_xmp(b"".to_vec()) + .with_iptc(vec![0x1c, 0x02, 0x05]) + .with_icc(vec![7; 4]) + .with_exif(exif_ifd()); + let mut ifd = Ifd::new(); + meta.apply(&mut ifd); + assert_eq!( + ifd.get(tags::XMP), + Some(&Value::Byte(b"".to_vec())) + ); + assert_eq!( + ifd.get(tags::IPTC_NAA), + Some(&Value::Byte(vec![0x1c, 0x02, 0x05])) + ); + assert_eq!( + ifd.get(tags::ICC_PROFILE), + Some(&Value::Undefined(vec![7; 4])) + ); + let group = &ifd.sub_ifds()[0]; + assert_eq!(group.tag, tags::EXIF_IFD); + assert_eq!(group.ifds, vec![exif_ifd()]); + } + + #[test] + fn read_metadata_returns_each_payload_verbatim() { + let mut ifd0 = Ifd::new(); + TiffMetadata::new() + .with_xmp(b"".to_vec()) + .with_iptc(vec![0x1c, 0x02, 0x05]) + .with_icc(vec![7; 4]) + .with_exif(exif_ifd()) + .apply(&mut ifd0); + let read = read_metadata(&file_with(ifd0)).expect("read"); + assert_eq!(read.xmp.as_deref(), Some(&b""[..])); + assert_eq!(read.iptc.as_deref(), Some(&[0x1c, 0x02, 0x05][..])); + assert_eq!(read.icc.as_deref(), Some(&[7, 7, 7, 7][..])); + assert_eq!(read.exif, Some(exif_ifd())); + } + + #[test] + fn a_file_with_no_metadata_reads_back_empty() { + let mut ifd0 = Ifd::new(); + ifd0.set(tags::IMAGE_WIDTH, Value::Short(vec![1])); + let read = read_metadata(&file_with(ifd0)).expect("read"); + assert!(read.is_empty()); + assert_eq!(read, TiffMetadata::new()); + } +} diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs new file mode 100644 index 00000000..575d87b1 --- /dev/null +++ b/crates/gamut-tiff/tests/metadata.rs @@ -0,0 +1,128 @@ +//! The metadata seam end to end: what `TiffEncoder::with_metadata` puts in a file, on every +//! layout the encoder writes, and what `TiffDecoder::metadata` gets back out of it. +//! +//! Each test pins one encode path's use of the seam, so a path that stopped embedding metadata +//! fails on its own rather than hiding behind another. + +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +use gamut_tiff::{Ifd, TiffDecoder, TiffEncoder, TiffMetadata, Value, read, tags}; + +/// Distinct payloads per carrier, so a block written under the wrong tag is visible. +const XMP: &[u8] = b""; +const IPTC: &[u8] = &[0x1c, 0x02, 0x05, 0x00, 0x04, b't', b'e', b's', b't']; +const ICC: &[u8] = &[0, 0, 0, 12, b'a', b'c', b's', b'p', 1, 2, 3, 4]; + +/// An Exif sub-IFD with one recognisable field (`ExposureTime`, 33434). +fn exif() -> Ifd { + let mut ifd = Ifd::new(); + ifd.set(33434, Value::Rational(vec![(1, 250)])); + ifd +} + +fn metadata() -> TiffMetadata { + TiffMetadata::new() + .with_xmp(XMP.to_vec()) + .with_iptc(IPTC.to_vec()) + .with_icc(ICC.to_vec()) + .with_exif(exif()) +} + +fn rgb(w: u32, h: u32) -> Vec { + (0..w * h * 3).map(|i| (i % 251) as u8).collect() +} + +fn image(pixels: &[u8], w: u32, h: u32) -> ImageRef<'_, Rgb8> { + ImageRef::::new( + pixels, + Dimensions { + width: w, + height: h, + }, + ) + .expect("image") +} + +/// Asserts that `ifd` carries every block of [`metadata`] under its own tag and field type. +fn assert_carries_every_block(ifd: &Ifd) { + assert_eq!(ifd.get(tags::XMP), Some(&Value::Byte(XMP.to_vec()))); + assert_eq!(ifd.get(tags::IPTC_NAA), Some(&Value::Byte(IPTC.to_vec()))); + assert_eq!( + ifd.get(tags::ICC_PROFILE), + Some(&Value::Undefined(ICC.to_vec())) + ); + // The Exif sub-IFD is written as a pointer field, so a plain `read` sees the offset rather + // than the directory; that it is present at all is this assertion's claim. + assert!(ifd.get(tags::EXIF_IFD).is_some()); +} + +#[test] +fn the_strip_path_embeds_the_metadata_in_ifd_0() { + let pixels = rgb(8, 4); + let bytes = TiffEncoder::new() + .with_metadata(metadata()) + .encode_to_vec(image(&pixels, 8, 4)) + .expect("encode"); + assert_carries_every_block(&read(&bytes).expect("read").ifds[0]); +} + +#[test] +fn the_tile_path_embeds_the_metadata_in_ifd_0() { + // Tiling builds its own directory rather than going through `build_strip_image`, so it needs + // its own claim: a tiled encode that forgot the seam would pass the strip test above. + let pixels = rgb(32, 32); + let bytes = TiffEncoder::new() + .with_tiling(16, 16) + .with_metadata(metadata()) + .encode_to_vec(image(&pixels, 32, 32)) + .expect("encode"); + let file = read(&bytes).expect("read"); + assert!(file.ifds[0].get(tags::TILE_WIDTH).is_some(), "tiled"); + assert_carries_every_block(&file.ifds[0]); +} + +#[test] +fn a_multipage_document_carries_the_metadata_on_page_0_only() { + // The blocks describe the document, so exactly one page holds them — duplicating an ICC + // profile onto every page would inflate the file and contradict that. + let pixels = rgb(4, 4); + let page = image(&pixels, 4, 4); + let mut bytes = Vec::new(); + TiffEncoder::new() + .with_metadata(metadata()) + .encode_pages_rgb8(&[page, page], &mut bytes) + .expect("encode"); + let file = read(&bytes).expect("read"); + assert_eq!(file.ifds.len(), 2); + assert_carries_every_block(&file.ifds[0]); + for tag in [tags::XMP, tags::IPTC_NAA, tags::ICC_PROFILE, tags::EXIF_IFD] { + assert_eq!(file.ifds[1].get(tag), None, "tag {tag} on page 1"); + } +} + +#[test] +fn the_decoder_returns_every_block_verbatim() { + let pixels = rgb(8, 4); + let bytes = TiffEncoder::new() + .with_metadata(metadata()) + .encode_to_vec(image(&pixels, 8, 4)) + .expect("encode"); + let read_back = TiffDecoder::new().metadata(&bytes).expect("metadata"); + assert_eq!(read_back.xmp.as_deref(), Some(XMP)); + assert_eq!(read_back.iptc.as_deref(), Some(IPTC)); + assert_eq!(read_back.icc.as_deref(), Some(ICC)); + assert_eq!(read_back.exif, Some(exif())); +} + +#[test] +fn a_file_without_metadata_decodes_to_an_empty_set() { + let pixels = rgb(8, 4); + let bytes = TiffEncoder::new() + .encode_to_vec(image(&pixels, 8, 4)) + .expect("encode"); + assert!( + TiffDecoder::new() + .metadata(&bytes) + .expect("metadata") + .is_empty() + ); +} diff --git a/crates/gamut-tiff/tests/oracle_metadata.rs b/crates/gamut-tiff/tests/oracle_metadata.rs new file mode 100644 index 00000000..4dc06ba3 --- /dev/null +++ b/crates/gamut-tiff/tests/oracle_metadata.rs @@ -0,0 +1,50 @@ +//! Differential cross-check: libtiff still reads a gamut TIFF that carries embedded metadata. +//! +//! The seam adds XMP (700), IPTC/NAA (33723), ICC (34675) and an `ExifIFD` (34665) to IFD 0. +//! Those are out-of-line values, so they move the pixel data's offsets — the risk this file +//! exists for is a directory whose metadata is well-formed but whose strip offsets no longer +//! point where the pixels are. libtiff, not gamut's own reader, is the judge. + +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +use gamut_tiff::{Ifd, TiffEncoder, TiffMetadata, Value}; + +mod common; + +use common::rgb_pattern; + +const SIZES: &[(u32, u32)] = &[(1, 1), (17, 13), (64, 100)]; + +/// A metadata set whose blocks are large enough to be stored out of line (past the 4-byte inline +/// threshold), which is what makes them displace the pixel data. +fn metadata() -> TiffMetadata { + let mut exif = Ifd::new(); + exif.set(33434, Value::Rational(vec![(1, 250)])); // ExposureTime + TiffMetadata::new() + .with_xmp(b"".to_vec()) + .with_iptc(vec![0x1c, 0x02, 0x05, 0x00, 0x04, b't', b'e', b's', b't']) + .with_icc(vec![0, 0, 0, 12, b'a', b'c', b's', b'p', 1, 2, 3, 4]) + .with_exif(exif) +} + +#[test] +fn libtiff_decodes_a_gamut_rgb_image_carrying_metadata() { + for &(w, h) in SIZES { + let src = rgb_pattern(w, h); + let tiff = TiffEncoder::new() + .with_metadata(metadata()) + .encode_to_vec( + ImageRef::::new( + &src, + Dimensions { + width: w, + height: h, + }, + ) + .expect("image"), + ) + .expect("gamut encode"); + let dec = libtiff_oracle::decode_tiff(&tiff).expect("libtiff decode"); + assert_eq!((dec.width, dec.height, dec.samples_per_pixel), (w, h, 3)); + assert_eq!(dec.pixels, src, "RGB mismatch at {w}x{h} with metadata"); + } +} From e5d31d0f4f1b7d7f8d39a50b8b7c137bfb7935f3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:14:36 -0400 Subject: [PATCH 02/43] feat(tiff): carry the C2PA manifest store over the shared placement rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest store joins the seam as a fifth carrier, but it is the one with a placement rule of its own: C2PA 2.4 §A.3.6 puts its entry in the last IFD of the main chain and its bytes at the end of the file, and §18.5.5 makes a signer exclude two disjoint ranges — the store, and the `count` field of its entry — from the `c2pa.hash.data` binding that §18.7.3.3 leaves as a TIFF asset's only hard binding. None of that is restated here. `gamut_ifd::c2pa` owns the tag, the placement, the reserve-then-append relocation and the locator, and `gamut-dng` already calls it; this crate wires the same helper to its three encode paths, so the two formats cannot drift. `TiffEncoder::with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in place, `encode_with_report` returns the exclusion ranges, and `c2pa_exclusions` recovers them from any TIFF's bytes — including one written through `encode_palette8` or `encode_pages_rgb8`, which the object-safe `EncodeImage` seam cannot report through. The store is opaque and never byte-swapped: the header's `ByteOrder` does not govern it (§A.3.6), which the tests pin with an asymmetric payload in a big-endian file. Tag 52545 joins `is_known_tag`, so the strict deconstruct claims the store as its entry's value span rather than flagging a private tag and an unaccounted trailer, and libtiff still decodes such a file pixel-exact. --- crates/gamut-tiff/src/decoder.rs | 9 +- crates/gamut-tiff/src/encoder.rs | 225 +++++++++++++++++++-- crates/gamut-tiff/src/lib.rs | 8 +- crates/gamut-tiff/src/metadata.rs | 142 +++++++++++-- crates/gamut-tiff/src/tags.rs | 8 + crates/gamut-tiff/tests/c2pa.rs | 193 ++++++++++++++++++ crates/gamut-tiff/tests/oracle_metadata.rs | 36 +++- 7 files changed, 583 insertions(+), 38 deletions(-) create mode 100644 crates/gamut-tiff/tests/c2pa.rs diff --git a/crates/gamut-tiff/src/decoder.rs b/crates/gamut-tiff/src/decoder.rs index fab2a6f1..e7c9d700 100644 --- a/crates/gamut-tiff/src/decoder.rs +++ b/crates/gamut-tiff/src/decoder.rs @@ -188,10 +188,11 @@ impl TiffDecoder { /// Reads the metadata a TIFF carries, without decoding pixels. /// - /// IFD 0 supplies the XMP, IPTC-IIM and ICC payloads and the `ExifIFD` sub-IFD. Every - /// byte-carried payload comes back **verbatim** — this crate parses none of them — so a block - /// written by [`TiffEncoder::with_metadata`](crate::TiffEncoder::with_metadata) reads back - /// identical. + /// IFD 0 supplies the XMP, IPTC-IIM and ICC payloads and the `ExifIFD` sub-IFD; the last IFD + /// of the main chain supplies the C2PA manifest store (C2PA 2.4 §A.3.6). Every byte-carried + /// payload comes back **verbatim** — this crate parses none of them — so a block written by + /// [`TiffEncoder::with_metadata`](crate::TiffEncoder::with_metadata) reads back identical. + /// Use [`c2pa_exclusions`](crate::c2pa_exclusions) for *where* the store sits. /// /// ``` /// use gamut_core::{Dimensions, EncodeImage, Gray8, ImageRef}; diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index c6a2bfc2..04c90160 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -1,14 +1,17 @@ //! The TIFF encoder. +use std::borrow::Cow; + use gamut_core::{ - Bilevel, Cmyk8, Dimensions, EncodeImage, Error, Gray8, Gray16, ImageRef, Indexed8, Result, - Rgb8, Rgb16, Rgba8, Rgba16, + Bilevel, Cmyk8, Dimensions, EncodeImage, Error, Gray8, Gray16, ImageRef, Indexed8, Pixel, + Result, Rgb8, Rgb16, Rgba8, Rgba16, }; +use gamut_ifd::c2pa::{self, C2paExclusions}; use gamut_ifd::{ByteOrder, Ifd, Value, Variant}; use crate::compression::{Compression, ccitt, deflate, lzw, packbits, predictor}; use crate::ifd::{PhotometricInterpretation, Predictor}; -use crate::metadata::TiffMetadata; +use crate::metadata::{TiffMetadata, c2pa_exclusions}; use crate::palette::Palette8; use crate::{tags, writer}; @@ -35,6 +38,26 @@ pub struct TiffEncoder { tiling: Option<(u32, u32)>, big_tiff: bool, metadata: TiffMetadata, + c2pa_reserve: Option, +} + +/// What [`TiffEncoder::encode_with_report`] produced: the byte count, and — when the file carries +/// a C2PA manifest store or a reservation for one — the two byte ranges an external signer +/// excludes from its `c2pa.hash.data` hard binding (C2PA 2.4 §18.5.5). +/// +/// `#[non_exhaustive]`: later encoder features may report more without a breaking change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct TiffEncodeReport { + /// The number of bytes appended to the output — the whole TIFF. + pub len: usize, + /// The C2PA exclusion ranges, as offsets from the first byte of this TIFF (not of the output + /// buffer it was appended to). `None` when no store or reservation was requested. + /// + /// `store` is the last range of the file — the store is placed after everything else + /// (§A.3.6), so a signer overwriting a reservation in place, or replacing the store with one + /// of a different size, moves no other offset. + pub c2pa: Option, } impl Default for TiffEncoder { @@ -46,6 +69,7 @@ impl Default for TiffEncoder { tiling: None, big_tiff: false, metadata: TiffMetadata::new(), + c2pa_reserve: None, } } } @@ -107,17 +131,112 @@ impl TiffEncoder { } /// Returns a copy of this encoder that embeds `metadata` — an Exif sub-IFD plus opaque - /// XMP / IPTC-IIM / ICC blocks. + /// XMP / IPTC-IIM / ICC blocks, and a caller-computed C2PA manifest store (see + /// [`TiffMetadata::c2pa`]). /// - /// The blocks and the Exif sub-IFD go in **IFD 0**, which for - /// [`encode_pages_rgb8`](Self::encode_pages_rgb8) is the first page: they describe the - /// document, not one of its pages. + /// The blocks and the Exif sub-IFD go in **IFD 0**; the C2PA store's entry goes in the last + /// IFD of the main chain and its bytes at the end of the file, as C2PA 2.4 §A.3.6 requires. + /// For a single-image encode those are the same directory; for + /// [`encode_pages_rgb8`](Self::encode_pages_rgb8) they are the first and last page. #[must_use] pub fn with_metadata(mut self, metadata: TiffMetadata) -> Self { self.metadata = metadata; self } + /// Returns a copy of this encoder that reserves `len` zero bytes for a C2PA manifest store an + /// external signer will fill in afterwards. + /// + /// The reservation is written exactly where a store goes — the `C2PA` tag (52545) of the last + /// main-chain IFD, its value last in the file (C2PA 2.4 §A.3.6) — and + /// [`encode_with_report`](Self::encode_with_report) (or [`c2pa_exclusions`] over the produced + /// bytes) reports its two exclusion ranges (§18.5.5). A signer hashes the file around those + /// ranges and overwrites the reservation in place; nothing else in the file moves. `len` must + /// be at least [`gamut_ifd::c2pa::MIN_STORE_LEN`] (a JUMBF box header), and a reservation + /// cannot be combined with a store supplied through [`with_metadata`](Self::with_metadata) — + /// either is a typed error at encode time. + #[must_use] + pub fn with_c2pa_reserved(mut self, len: usize) -> Self { + self.c2pa_reserve = Some(len); + self + } + + /// The C2PA manifest store to write, if any: the caller's, or a zero-filled reservation. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if both were requested, or if the store is too short to be + /// a JUMBF box at all ([`c2pa::MIN_STORE_LEN`]) — caught here, before any pixel work. + fn c2pa_store(&self) -> Result>> { + let store = match (&self.metadata.c2pa, self.c2pa_reserve) { + (Some(_), Some(_)) => { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: supply either a C2PA manifest store or a reservation, not both", + )); + } + (Some(store), None) => Cow::Borrowed(store.as_slice()), + (None, Some(len)) => Cow::Owned(vec![0; len]), + (None, None) => return Ok(None), + }; + if store.len() < c2pa::MIN_STORE_LEN { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: a C2PA manifest store is at least a JUMBF box header (8 bytes)", + )); + } + Ok(Some(store)) + } + + /// Places `store` (if any) at the end of the finished file and appends the result to `out`, + /// returning the number of bytes written. + /// + /// The store lands after everything else, and the reserved entry is re-pointed at it, by + /// [`gamut_ifd::c2pa::append_store`] — so a store of a different size moves no other offset. + fn emit( + &self, + mut bytes: Vec, + store: Option>, + out: &mut Vec, + ) -> Result { + if let Some(store) = store { + c2pa::append_store(&mut bytes, &store)?; + } + out.extend_from_slice(&bytes); + Ok(bytes.len()) + } + + /// Encodes `image` as [`encode_image`](EncodeImage::encode_image) does, also reporting where + /// the C2PA manifest store (or its reservation, + /// [`with_c2pa_reserved`](Self::with_c2pa_reserved)) landed. + /// + /// The report's ranges are offsets from the first byte of the TIFF, so a caller appending to + /// a non-empty `out` rebases them by `out.len()` before the call. They are read back out of + /// the bytes just written by [`c2pa_exclusions`], the same locator a verifier uses — which is + /// also how the entry points this method cannot reach (`encode_palette8`, + /// `encode_pages_rgb8`) report their store. + /// + /// # Errors + /// + /// As [`encode_image`](EncodeImage::encode_image); additionally [`Error::InvalidInput`] if + /// both a store and a reservation were configured, or the store is shorter than + /// [`gamut_ifd::c2pa::MIN_STORE_LEN`]. + pub fn encode_with_report( + &self, + image: ImageRef<'_, P>, + out: &mut Vec, + ) -> Result + where + Self: EncodeImage

, + { + let base = out.len(); + let len = self.encode_image(image, out)?; + Ok(TiffEncodeReport { + len, + c2pa: c2pa_exclusions(&out[base..])?, + }) + } + /// The container variant this encoder writes (BigTIFF when [`Self::with_big_tiff`] is set). fn variant(&self) -> Variant { if self.big_tiff { @@ -230,14 +349,18 @@ impl TiffEncoder { extra_fields: &[(u16, Value)], out: &mut Vec, ) -> Result { + // Validated before any pixel work, so a contradictory C2PA configuration fails fast. + let store = self.c2pa_store()?; if let Some((tw, tl)) = self.tiling { - return self.encode_tiled(packed, dims, layout, extra_fields, tw, tl, out); + return self.encode_tiled(packed, dims, layout, extra_fields, tw, tl, store, out); } let (mut ifd, strips) = self.build_strip_image(packed, dims, layout, extra_fields)?; self.metadata.apply(&mut ifd); + if store.is_some() { + c2pa::reserve_entry(&mut ifd); + } let bytes = writer::write_image(self.order, self.variant(), &ifd, &strips)?; - out.extend_from_slice(&bytes); - Ok(bytes.len()) + self.emit(bytes, store, out) } /// Builds one strip image's directory (without `StripOffsets`/`StripByteCounts`) and its @@ -338,6 +461,7 @@ impl TiffEncoder { "TIFF: no pages to encode", )); } + let store = self.c2pa_store()?; let total = pages.len() as u16; let mut images: Vec<(Ifd, Vec>)> = Vec::with_capacity(pages.len()); for (i, page) in pages.iter().enumerate() { @@ -358,13 +482,17 @@ impl TiffEncoder { &extra, )?); } - // The blocks describe the document, not one of its pages, so they go in IFD 0 alone. + // The blocks describe the document, so they go in IFD 0; the manifest store's entry must + // sit in the *last* IFD of the main chain (C2PA 2.4 §A.3.6), which for a multi-page TIFF + // is the last page rather than the first. if let Some((ifd0, _)) = images.first_mut() { self.metadata.apply(ifd0); } + if let (Some((last, _)), true) = (images.last_mut(), store.is_some()) { + c2pa::reserve_entry(last); + } let bytes = writer::write_multipage(self.order, self.variant(), &images)?; - out.extend_from_slice(&bytes); - Ok(bytes.len()) + self.emit(bytes, store, out) } /// Applies the selected compression to one strip's already-packed bytes. @@ -421,7 +549,7 @@ impl TiffEncoder { /// Lays out an 8-bit image as a grid of `tile_w × tile_h` tiles (edge tiles zero-padded). #[allow(clippy::too_many_arguments)] - fn encode_tiled( + fn encode_tiled<'a>( &self, packed: &[u8], dims: Dimensions, @@ -429,6 +557,7 @@ impl TiffEncoder { extra_fields: &[(u16, Value)], tile_w: u32, tile_h: u32, + store: Option>, out: &mut Vec, ) -> Result { if !matches!(layout.bits_per_sample, 8 | 16) { @@ -516,10 +645,12 @@ impl TiffEncoder { ifd.set(*tag, value.clone()); } self.metadata.apply(&mut ifd); + if store.is_some() { + c2pa::reserve_entry(&mut ifd); + } let bytes = writer::write_image_tiled(self.order, self.variant(), &ifd, &tiles)?; - out.extend_from_slice(&bytes); - Ok(bytes.len()) + self.emit(bytes, store, out) } } @@ -679,6 +810,68 @@ mod tests { assert!(matches!(dim_value(u32::from(u16::MAX) + 1), Value::Long(_))); } + #[test] + fn a_store_and_a_reservation_cannot_both_be_configured() { + // A reservation exists to be overwritten by a signer who has not computed a store yet; + // supplying both says two different things about the same bytes, so it is refused rather + // than silently resolved one way. + let err = TiffEncoder::new() + .with_metadata(TiffMetadata::new().with_c2pa(vec![0; 16])) + .with_c2pa_reserved(16) + .c2pa_store() + .expect_err("contradictory configuration"); + assert!(err.to_string().contains("not both"), "{err}"); + } + + #[test] + fn a_store_shorter_than_a_jumbf_box_header_is_refused() { + // A manifest store is a JUMBF superbox, so it is at least an 8-byte LBox + TBox; seven + // bytes could never be filled with a valid one. The boundary is the claim, so it is + // asserted at the two lengths that straddle it, for a supplied store and a reservation + // alike. + for encoder in [ + TiffEncoder::new().with_metadata(TiffMetadata::new().with_c2pa(vec![0; 7])), + TiffEncoder::new().with_c2pa_reserved(7), + ] { + let err = encoder.c2pa_store().expect_err("too short"); + assert!(err.to_string().contains("JUMBF box header"), "{err}"); + } + assert!( + TiffEncoder::new() + .with_c2pa_reserved(8) + .c2pa_store() + .expect("8 bytes is a box header") + .is_some() + ); + } + + #[test] + fn the_store_is_the_callers_bytes_or_a_zero_filled_reservation() { + let supplied = b"\0\0\0\x14jumbc2pa".to_vec(); + assert_eq!( + TiffEncoder::new() + .with_metadata(TiffMetadata::new().with_c2pa(supplied.clone())) + .c2pa_store() + .expect("a store") + .as_deref(), + Some(&supplied[..]) + ); + assert_eq!( + TiffEncoder::new() + .with_c2pa_reserved(12) + .c2pa_store() + .expect("a reservation") + .as_deref(), + Some(&[0u8; 12][..]) + ); + assert!( + TiffEncoder::new() + .c2pa_store() + .expect("no C2PA configured") + .is_none() + ); + } + #[test] fn image_ref_rejects_mismatched_buffer() { // Validation now lives at the ImageRef boundary, so a wrong-length or zero-sized buffer diff --git a/crates/gamut-tiff/src/lib.rs b/crates/gamut-tiff/src/lib.rs index fb19e51b..a4015a6a 100644 --- a/crates/gamut-tiff/src/lib.rs +++ b/crates/gamut-tiff/src/lib.rs @@ -83,7 +83,11 @@ pub use decoder::TiffDecoder; pub use deconstruct::{ Anomaly, DeconstructReport, Severity, UnknownFieldType, UnknownTag, deconstruct, }; -pub use encoder::TiffEncoder; +pub use encoder::{TiffEncodeReport, TiffEncoder}; +// The C2PA exclusion set is `gamut_ifd::c2pa`'s — §A.3.6 and §18.5.5 are stated once, for this +// crate and `gamut-dng` alike — but it is reachable from `TiffEncodeReport` and +// [`c2pa_exclusions`], so it is re-exported here too and needs no direct gamut-ifd dependency. +pub use gamut_ifd::c2pa::C2paExclusions; // The structural IFD core lives in gamut-ifd; re-export the types a gamut-tiff user can touch — // the read/write spine plus every type reachable from this crate's own public items // (`DeconstructReport` exposes `SegmentReport`, which exposes `Segment`/`SpanKind`/`DataLabel`/ @@ -95,6 +99,6 @@ pub use gamut_ifd::{ }; pub use ifd::{PhotometricInterpretation, Predictor, SampleFormat}; pub use info::TiffInfo; -pub use metadata::TiffMetadata; +pub use metadata::{TiffMetadata, c2pa_exclusions}; pub use palette::Palette8; pub use writer::{write_image, write_image_tiled, write_multipage}; diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 31d23415..39e2080c 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -1,4 +1,4 @@ -//! Optional metadata embedded in a TIFF: an Exif sub-IFD plus XMP / IPTC / ICC blocks. +//! Optional metadata embedded in a TIFF: an Exif sub-IFD plus XMP / IPTC / ICC / C2PA blocks. //! //! TIFF stores metadata the way it stores everything else — as IFD entries — so the seam is thin //! by construction: [`TiffMetadata`] is a plain struct of optional payloads that @@ -6,25 +6,36 @@ //! [`TiffDecoder::metadata`](crate::TiffDecoder::metadata) reads back. //! //! Everything except EXIF is a **single opaque payload** in the file — XMP (700), IPTC-IIM -//! (33723) and ICC (34675) — so this crate carries the bytes verbatim in both directions and -//! parses none of them. That is deliberate: they are the raw blocks the workspace's metadata -//! facade consumes (the same shape `gamut-png` and `gamut-webp` hand over), and keeping them -//! opaque here is what lets a caller choose its own conflict policy instead of inheriting one -//! from the container. +//! (33723), ICC (34675) and the C2PA manifest store (52545) — so this crate carries the bytes +//! verbatim in both directions and parses none of them. That is deliberate: they are the raw +//! blocks the workspace's metadata facade consumes (the same shape `gamut-png` and `gamut-webp` +//! hand over), and keeping them opaque here is what lets a caller choose its own conflict policy +//! instead of inheriting one from the container. //! //! EXIF is the exception, and only because TIFF makes it one: an `ExifIFD` (34665) *is* an IFD, //! which this crate has already parsed by the time a caller sees it. Handing it back as //! [`gamut_ifd::Ifd`] rather than as bytes saves every caller from re-parsing a directory the //! decoder already walked. Its fields are neither validated nor completed — what the caller //! supplies is what the file gets, and what the file holds is what the caller gets. +//! +//! # The C2PA manifest store +//! +//! One carrier has a placement rule of its own: C2PA 2.4 §A.3.6 puts the manifest store's entry +//! in the **last IFD of the main chain** and its bytes at the **end of the file**, and §18.5.5 +//! makes an external signer exclude two disjoint ranges from its hard binding. All of that is +//! [`gamut_ifd::c2pa`]'s — the one place the workspace states §A.3.6, shared with `gamut-dng` +//! rather than re-derived here. This module only wires it to the encoder +//! ([`TiffEncoder::with_c2pa_reserved`](crate::TiffEncoder::with_c2pa_reserved)) and exposes the +//! read-side locator as [`c2pa_exclusions`]. use gamut_core::Result; +use gamut_ifd::c2pa::{self, C2paExclusions}; use gamut_ifd::{Ifd, Value, read_tree}; use crate::tags; /// Metadata to embed in a TIFF, or read back from one: an Exif sub-IFD and/or opaque -/// XMP / IPTC-IIM / ICC payloads. +/// XMP / IPTC-IIM / ICC / C2PA payloads. /// /// `#[non_exhaustive]`, so a later carrier is an additive change: build one from /// [`TiffMetadata::new`] and the `with_*` builders, or assign the public fields of a value you @@ -60,6 +71,17 @@ pub struct TiffMetadata { pub iptc: Option>, /// An ICC profile, stored in the `ICCProfile` tag (34675) as `UNDEFINED`, verbatim. pub icc: Option>, + /// A C2PA manifest store, stored in the `C2PA` tag + /// ([`gamut_ifd::c2pa::C2PA_MANIFEST_STORE`], 52545, type `UNDEFINED`), verbatim. + /// + /// **Opaque, and bound to one exact file.** A manifest store is signed over the bytes + /// *around* it (C2PA 2.4 §18.5), so the only store valid here is one an external signer + /// computed over this encoder's own output — through + /// [`TiffEncoder::with_c2pa_reserved`](crate::TiffEncoder::with_c2pa_reserved) and the + /// exclusion ranges [`c2pa_exclusions`] reports. A store copied out of another file is + /// invalid by construction. The bytes are written exactly as given: the TIFF header's + /// `ByteOrder` does not govern them (§A.3.6). + pub c2pa: Option>, } impl TiffMetadata { @@ -97,13 +119,25 @@ impl TiffMetadata { self } + /// Returns a copy carrying `store` as the file's C2PA manifest store — see + /// [`c2pa`](Self::c2pa) for what makes a store valid. + #[must_use] + pub fn with_c2pa(mut self, store: Vec) -> Self { + self.c2pa = Some(store); + self + } + /// Whether there is nothing to embed: no payload set, and no Exif sub-IFD with fields in it. /// /// An `exif` directory with no entries counts as empty — writing it would add an `ExifIFD` /// pointer to a directory with nothing in it. #[must_use] pub fn is_empty(&self) -> bool { - self.exif_ifd().is_none() && self.xmp.is_none() && self.iptc.is_none() && self.icc.is_none() + self.exif_ifd().is_none() + && self.xmp.is_none() + && self.iptc.is_none() + && self.icc.is_none() + && self.c2pa.is_none() } /// The Exif sub-IFD to write, or `None` when there is no Exif content worth a directory. @@ -112,6 +146,10 @@ impl TiffMetadata { } /// Writes the XMP / IPTC / ICC blocks and the Exif sub-IFD into `ifd0`. + /// + /// The C2PA store is deliberately **not** written here: its bytes must land at the end of + /// the file (C2PA 2.4 §A.3.6), after the image data, which only the encoder can arrange once + /// the rest of the file exists ([`gamut_ifd::c2pa::append_store`]). pub(crate) fn apply(&self, ifd0: &mut Ifd) { if let Some(xmp) = &self.xmp { ifd0.set(tags::XMP, Value::Byte(xmp.clone())); @@ -133,13 +171,24 @@ fn bytes_value(value: Option<&Value>) -> Option> { value.and_then(Value::as_bytes).map(<[u8]>::to_vec) } -/// Reads the metadata a TIFF carries: IFD 0's blocks and its Exif sub-IFD. +/// Reads the metadata a TIFF carries: IFD 0's blocks and Exif sub-IFD, plus the C2PA manifest +/// store from the last IFD of the main chain (C2PA 2.4 §A.3.6). +/// +/// The store is taken only as the `UNDEFINED` bytes §A.3.6 mandates — a tag-52545 entry of any +/// other type is not a manifest store and is reported as absence, the same test +/// [`gamut_ifd::c2pa::locate`] applies. pub(crate) fn read_metadata(data: &[u8]) -> Result { let file = read_tree(data, &[tags::EXIF_IFD])?; - // A file with no IFD at all carries no metadata. - let Some(ifd0) = file.ifds.first() else { + // §A.3.6: one store for the whole asset, in the last IFD of the main chain. `ifds` is that + // chain, so its last element is where the entry belongs — and a single-page file makes the + // two the same directory. A file with no IFD at all carries no metadata. + let (Some(ifd0), Some(store_ifd)) = (file.ifds.first(), file.ifds.last()) else { return Ok(TiffMetadata::new()); }; + let c2pa = match store_ifd.get(tags::C2PA_MANIFEST_STORE) { + Some(Value::Undefined(store)) => Some(store.clone()), + _ => None, + }; Ok(TiffMetadata { exif: ifd0 .sub_ifds() @@ -150,9 +199,31 @@ pub(crate) fn read_metadata(data: &[u8]) -> Result { xmp: bytes_value(ifd0.get(tags::XMP)), iptc: bytes_value(ifd0.get(tags::IPTC_NAA)), icc: bytes_value(ifd0.get(tags::ICC_PROFILE)), + c2pa, }) } +/// The byte ranges an external signer excludes from a `c2pa.hash.data` hard binding over `file` +/// (C2PA 2.4 §18.5.5): the manifest store's own bytes, and the `count` field of its IFD entry. +/// +/// Returns `Ok(None)` when `file` carries no manifest store — including when a tag-52545 entry +/// sits somewhere other than the last IFD of the main chain, which §A.3.6 does not allow. The +/// two ranges are always disjoint, and both are offsets from the first byte of `file`. +/// +/// This is the read side of [`TiffEncoder::with_c2pa_reserved`](crate::TiffEncoder::with_c2pa_reserved): +/// it reports where a store *is*, whether this crate wrote the file or not, so a caller that +/// encoded through a path without a report (a palette or multi-page image) recovers the ranges +/// from the bytes. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if the container is +/// unreadable (bad header, looping or runaway IFD chain, no IFD) or the store's declared extent +/// lies outside `file`. +pub fn c2pa_exclusions(file: &[u8]) -> Result> { + c2pa::locate(file) +} + #[cfg(test)] mod tests { use gamut_ifd::{ByteOrder, TiffFile, Variant, write}; @@ -205,6 +276,7 @@ mod tests { TiffMetadata::new().with_xmp(vec![1]), TiffMetadata::new().with_iptc(vec![1]), TiffMetadata::new().with_icc(vec![1]), + TiffMetadata::new().with_c2pa(vec![1]), ]; for (i, meta) in singles.iter().enumerate() { assert!(!meta.is_empty(), "carrier {i} alone must be non-empty"); @@ -217,11 +289,13 @@ mod tests { .with_exif(exif_ifd()) .with_xmp(b"".to_vec()) .with_iptc(vec![0x1c, 0x02, 0x05]) - .with_icc(vec![7; 4]); + .with_icc(vec![7; 4]) + .with_c2pa(b"\0\0\0\x14jumbc2pa".to_vec()); assert_eq!(meta.exif, Some(exif_ifd())); assert_eq!(meta.xmp.as_deref(), Some(&b""[..])); assert_eq!(meta.iptc.as_deref(), Some(&[0x1c, 0x02, 0x05][..])); assert_eq!(meta.icc.as_deref(), Some(&[7, 7, 7, 7][..])); + assert_eq!(meta.c2pa.as_deref(), Some(&b"\0\0\0\x14jumbc2pa"[..])); } #[test] @@ -252,6 +326,16 @@ mod tests { assert_eq!(group.ifds, vec![exif_ifd()]); } + #[test] + fn apply_never_writes_the_c2pa_store() { + // The store is the encoder's to place at the end of the file (§A.3.6), so `apply` must + // leave the directory without it even when one is configured. + let mut ifd = Ifd::new(); + TiffMetadata::new().with_c2pa(vec![0; 16]).apply(&mut ifd); + assert!(ifd.get(tags::C2PA_MANIFEST_STORE).is_none()); + assert!(ifd.fields().is_empty()); + } + #[test] fn read_metadata_returns_each_payload_verbatim() { let mut ifd0 = Ifd::new(); @@ -261,11 +345,23 @@ mod tests { .with_icc(vec![7; 4]) .with_exif(exif_ifd()) .apply(&mut ifd0); + ifd0.set(tags::C2PA_MANIFEST_STORE, Value::Undefined(vec![0x10; 12])); let read = read_metadata(&file_with(ifd0)).expect("read"); assert_eq!(read.xmp.as_deref(), Some(&b""[..])); assert_eq!(read.iptc.as_deref(), Some(&[0x1c, 0x02, 0x05][..])); assert_eq!(read.icc.as_deref(), Some(&[7, 7, 7, 7][..])); assert_eq!(read.exif, Some(exif_ifd())); + assert_eq!(read.c2pa.as_deref(), Some(&[0x10; 12][..])); + } + + #[test] + fn a_c2pa_tag_of_the_wrong_type_is_not_a_store() { + // §A.3.6 fixes the type at 7 (UNDEFINED). A BYTE entry under the same tag is some other + // writer's field, and reporting it as a manifest store would be a lie. + let mut ifd0 = Ifd::new(); + ifd0.set(tags::C2PA_MANIFEST_STORE, Value::Byte(vec![0x10; 12])); + let read = read_metadata(&file_with(ifd0)).expect("read"); + assert_eq!(read.c2pa, None); } #[test] @@ -276,4 +372,26 @@ mod tests { assert!(read.is_empty()); assert_eq!(read, TiffMetadata::new()); } + + #[test] + fn read_metadata_takes_the_store_from_the_last_ifd_of_the_chain() { + // §A.3.6 puts the one store in the last main-chain IFD; a tag-52545 entry in an earlier + // page is not it. Distinct payloads pin which directory was consulted. + let mut first = Ifd::new(); + first.set(tags::C2PA_MANIFEST_STORE, Value::Undefined(vec![0xAA; 12])); + first.set(tags::XMP, Value::Byte(b"first".to_vec())); + let mut last = Ifd::new(); + last.set(tags::C2PA_MANIFEST_STORE, Value::Undefined(vec![0xBB; 12])); + last.set(tags::XMP, Value::Byte(b"last".to_vec())); + let bytes = write(&TiffFile { + order: ByteOrder::LittleEndian, + variant: Variant::Classic, + ifds: vec![first, last], + }) + .expect("write"); + let read = read_metadata(&bytes).expect("read"); + assert_eq!(read.c2pa.as_deref(), Some(&[0xBB; 12][..])); + // The other blocks stay IFD 0's, so the two directories are not confused for each other. + assert_eq!(read.xmp.as_deref(), Some(&b"first"[..])); + } } diff --git a/crates/gamut-tiff/src/tags.rs b/crates/gamut-tiff/src/tags.rs index 67069c7f..3d0a29b9 100644 --- a/crates/gamut-tiff/src/tags.rs +++ b/crates/gamut-tiff/src/tags.rs @@ -68,6 +68,12 @@ pub const ICC_PROFILE: u16 = 34675; pub const GPS_INFO: u16 = gamut_ifd::tags::GPS_INFO; /// `InteroperabilityIFD` (40965) — the offset of the Exif Interoperability sub-IFD. pub const INTEROPERABILITY_IFD: u16 = gamut_ifd::tags::INTEROPERABILITY_IFD; +/// `C2PA` (52545, `0xCD41`) — the C2PA manifest store, type `UNDEFINED` (C2PA 2.4 §A.3.6). +/// +/// A private tag in TIFF 6.0 §7 terms, but one this crate now reads and writes, so +/// [`is_known_tag`] recognises it. Its placement and exclusion rules are [`gamut_ifd::c2pa`]'s; +/// the payload is carried verbatim ([`TiffMetadata::c2pa`](crate::TiffMetadata::c2pa)). +pub const C2PA_MANIFEST_STORE: u16 = gamut_ifd::c2pa::C2PA_MANIFEST_STORE; /// Whether `tag` is one this crate recognises as part of TIFF 6.0 — the baseline reference (§8) /// plus the Part 2 still-image extension tags — as opposed to a private or unknown tag a strict @@ -113,6 +119,7 @@ pub fn is_known_tag(tag: u16) -> bool { | ICC_PROFILE | GPS_INFO | INTEROPERABILITY_IFD + | C2PA_MANIFEST_STORE // Other TIFF 6.0 baseline (§8) and Part 2 still-image extension tags a valid file may // carry but the codec does not act on. Kept as numeric literals — no codec constant // is needed for tags the pixel path never reads. @@ -205,6 +212,7 @@ mod tests { ICC_PROFILE, GPS_INFO, INTEROPERABILITY_IFD, + C2PA_MANIFEST_STORE, ] { assert!(is_known_tag(tag), "tag {tag} should be known"); } diff --git a/crates/gamut-tiff/tests/c2pa.rs b/crates/gamut-tiff/tests/c2pa.rs new file mode 100644 index 00000000..57fcdb09 --- /dev/null +++ b/crates/gamut-tiff/tests/c2pa.rs @@ -0,0 +1,193 @@ +//! The C2PA manifest store in a TIFF: where the encoder puts it, what it reports, and what a +//! reader gets back (C2PA 2.4 §A.3.6, §18.5.5). +//! +//! Placement and exclusion are `gamut_ifd::c2pa`'s and are pinned there; what this file pins is +//! this crate's use of them — that a store survives an encode of a real image, in the right +//! directory, at the end of the file, verbatim. + +use gamut_core::{Dimensions, ImageRef, Rgb8}; +use gamut_tiff::{ + ByteOrder, SpanKind, TiffDecoder, TiffEncoder, TiffMetadata, c2pa_exclusions, deconstruct, + read, tags, +}; + +/// A store whose bytes are neither a palindrome nor a repetition, so a byte-swapped copy of it +/// cannot equal it. §A.3.6 says the TIFF header's `ByteOrder` does **not** govern the store, and +/// only an asymmetric payload can catch a writer that swapped it anyway — which is also why +/// every test here writes big-endian (`MM`), the order where a swap would be invisible under a +/// little-endian default. +const STORE: &[u8] = &[ + 0x00, 0x00, 0x00, 0x16, b'j', b'u', b'm', b'b', 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, +]; + +fn rgb(w: u32, h: u32) -> Vec { + (0..w * h * 3).map(|i| (i % 251) as u8).collect() +} + +fn image(pixels: &[u8], w: u32, h: u32) -> ImageRef<'_, Rgb8> { + ImageRef::::new( + pixels, + Dimensions { + width: w, + height: h, + }, + ) + .expect("image") +} + +/// A big-endian TIFF carrying `STORE`, plus the encoder's report of where it landed. +fn encoded_with_store() -> (Vec, gamut_tiff::TiffEncodeReport) { + let pixels = rgb(17, 13); + let mut bytes = Vec::new(); + let report = TiffEncoder::new() + .with_byte_order(ByteOrder::BigEndian) + .with_metadata(TiffMetadata::new().with_c2pa(STORE.to_vec())) + .encode_with_report(image(&pixels, 17, 13), &mut bytes) + .expect("encode"); + (bytes, report) +} + +#[test] +fn the_store_is_written_verbatim_into_a_big_endian_file() { + let (bytes, report) = encoded_with_store(); + let range = report.c2pa.expect("a store was written").store; + assert_eq!( + &bytes[range.start as usize..range.end() as usize], + STORE, + "the store's bytes are not governed by the file's byte order" + ); + assert_eq!( + TiffDecoder::new() + .metadata(&bytes) + .expect("metadata") + .c2pa + .as_deref(), + Some(STORE) + ); +} + +#[test] +fn the_store_is_the_last_thing_in_the_file() { + // §A.3.6: the store goes at the end so resizing it moves no other offset. If anything were + // written after it, a signer replacing the store would invalidate those bytes' offsets. + let (bytes, report) = encoded_with_store(); + let range = report.c2pa.expect("a store was written").store; + assert_eq!(range.end(), bytes.len() as u64); + assert_eq!(range.len, STORE.len() as u64); +} + +#[test] +fn the_two_exclusion_ranges_are_disjoint() { + // §18.5.5 excludes the store *and*, separately, the count field of its IFD entry — two + // ranges, never one: the count field lives in a directory body and the store outside it. + let (_, report) = encoded_with_store(); + let excl = report.c2pa.expect("a store was written"); + assert!( + excl.count_field.end() <= excl.store.start || excl.store.end() <= excl.count_field.start, + "overlapping exclusion ranges: {excl:?}" + ); + assert_eq!(excl.count_field.len, 4, "classic TIFF count field"); +} + +#[test] +fn a_reservation_is_zero_filled_at_the_offset_the_report_gives() { + // The reserve-then-sign flow: a signer hashes around these ranges and overwrites the + // reservation in place, so the reservation must be exactly `len` bytes where the report says. + let pixels = rgb(17, 13); + let mut bytes = Vec::new(); + let report = TiffEncoder::new() + .with_byte_order(ByteOrder::BigEndian) + .with_c2pa_reserved(64) + .encode_with_report(image(&pixels, 17, 13), &mut bytes) + .expect("encode"); + let range = report.c2pa.expect("a reservation was written").store; + assert_eq!(range.len, 64); + assert_eq!(&bytes[range.start as usize..range.end() as usize], &[0; 64]); +} + +#[test] +fn the_tile_path_places_the_store_too() { + // Tiling builds its own directory, so it needs its own claim: a tiled encode that forgot to + // reserve the entry would still pass every strip-path test. + let pixels = rgb(32, 32); + let mut bytes = Vec::new(); + let report = TiffEncoder::new() + .with_byte_order(ByteOrder::BigEndian) + .with_tiling(16, 16) + .with_metadata(TiffMetadata::new().with_c2pa(STORE.to_vec())) + .encode_with_report(image(&pixels, 32, 32), &mut bytes) + .expect("encode"); + let range = report.c2pa.expect("a store was written").store; + assert_eq!(&bytes[range.start as usize..range.end() as usize], STORE); +} + +#[test] +fn a_multipage_document_puts_the_entry_in_its_last_page() { + // §A.3.6: one store for the whole asset, in the *last* IFD of the main chain — not page 0, + // where this crate's other metadata goes. + let pixels = rgb(4, 4); + let page = image(&pixels, 4, 4); + let mut bytes = Vec::new(); + TiffEncoder::new() + .with_byte_order(ByteOrder::BigEndian) + .with_metadata(TiffMetadata::new().with_c2pa(STORE.to_vec())) + .encode_pages_rgb8(&[page, page], &mut bytes) + .expect("encode"); + let file = read(&bytes).expect("read"); + assert_eq!(file.ifds.len(), 2); + assert_eq!(file.ifds[0].get(tags::C2PA_MANIFEST_STORE), None); + assert!(file.ifds[1].get(tags::C2PA_MANIFEST_STORE).is_some()); + let range = c2pa_exclusions(&bytes) + .expect("locate") + .expect("a store") + .store; + assert_eq!(&bytes[range.start as usize..range.end() as usize], STORE); +} + +#[test] +fn a_file_without_a_store_has_no_exclusion_ranges() { + let pixels = rgb(8, 4); + let mut bytes = Vec::new(); + let report = TiffEncoder::new() + .encode_with_report(image(&pixels, 8, 4), &mut bytes) + .expect("encode"); + assert_eq!(report.c2pa, None); + assert_eq!(report.len, bytes.len()); + assert_eq!(c2pa_exclusions(&bytes).expect("locate"), None); +} + +#[test] +fn the_deconstruct_accounts_the_store_and_recognises_its_tag() { + // The crate's v1 guarantee is zero-tolerance byte accounting. A store appended after the + // pixel data must come back as its entry's typed value span — not as an unclassified run, + // not as a trailer — and its private tag must not be reported as unknown. + let (bytes, report) = encoded_with_store(); + let range = report.c2pa.expect("a store was written").store; + let deconstructed = deconstruct(&bytes).expect("deconstruct"); + assert!( + deconstructed.segments.is_fully_classified(), + "unclassified: {:?}", + deconstructed.segments.unclassified + ); + assert!( + deconstructed.segments.segments.iter().any(|segment| { + segment.range == range + && matches!(segment.kind, SpanKind::Value { tag, .. } if tag == tags::C2PA_MANIFEST_STORE) + }), + "the store is not claimed as its entry's value: {:?}", + deconstructed.segments.segments + ); + assert!( + !deconstructed + .segments + .segments + .iter() + .any(|segment| segment.kind == SpanKind::Trailer), + "the store must be claimed, not left as a trailer" + ); + assert!( + deconstructed.unknown_tags.is_empty(), + "unknown tags: {:?}", + deconstructed.unknown_tags + ); +} diff --git a/crates/gamut-tiff/tests/oracle_metadata.rs b/crates/gamut-tiff/tests/oracle_metadata.rs index 4dc06ba3..9cb6f17a 100644 --- a/crates/gamut-tiff/tests/oracle_metadata.rs +++ b/crates/gamut-tiff/tests/oracle_metadata.rs @@ -1,9 +1,11 @@ //! Differential cross-check: libtiff still reads a gamut TIFF that carries embedded metadata. //! -//! The seam adds XMP (700), IPTC/NAA (33723), ICC (34675) and an `ExifIFD` (34665) to IFD 0. -//! Those are out-of-line values, so they move the pixel data's offsets — the risk this file -//! exists for is a directory whose metadata is well-formed but whose strip offsets no longer -//! point where the pixels are. libtiff, not gamut's own reader, is the judge. +//! The seam adds XMP (700), IPTC/NAA (33723), ICC (34675) and an `ExifIFD` (34665) to IFD 0, and +//! the C2PA manifest store (52545) after the pixel data. Those are out-of-line values, so they +//! move the pixel data's offsets — the risk this file exists for is a directory whose metadata is +//! well-formed but whose strip offsets no longer point where the pixels are, or a private tag +//! that turns a valid TIFF into one an established reader refuses. libtiff, not gamut's own +//! reader, is the judge. use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; use gamut_tiff::{Ifd, TiffEncoder, TiffMetadata, Value}; @@ -48,3 +50,29 @@ fn libtiff_decodes_a_gamut_rgb_image_carrying_metadata() { assert_eq!(dec.pixels, src, "RGB mismatch at {w}x{h} with metadata"); } } + +#[test] +fn libtiff_decodes_a_gamut_image_carrying_a_c2pa_manifest_store() { + // The store is a private tag (52545) whose value sits *after* the pixel data, at the end of + // the file (C2PA 2.4 §A.3.6). An established reader must be indifferent to both facts. + let store = b"\0\0\0\x16jumb\x01\x02\x03\x04\x05\x06".to_vec(); + for &(w, h) in SIZES { + let src = rgb_pattern(w, h); + let tiff = TiffEncoder::new() + .with_metadata(metadata().with_c2pa(store.clone())) + .encode_to_vec( + ImageRef::::new( + &src, + Dimensions { + width: w, + height: h, + }, + ) + .expect("image"), + ) + .expect("gamut encode"); + let dec = libtiff_oracle::decode_tiff(&tiff).expect("libtiff decode"); + assert_eq!((dec.width, dec.height, dec.samples_per_pixel), (w, h, 3)); + assert_eq!(dec.pixels, src, "RGB mismatch at {w}x{h} with a C2PA store"); + } +} From cacea4b111e8f750b9937be4d635d98b884559e0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:15:41 -0400 Subject: [PATCH 03/43] docs(tiff): record the metadata seam and the C2PA manifest store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STATUS.md gains the semver-minor ledger entry for both halves — what the seam carries, why every payload but the Exif sub-IFD stays opaque bytes, and which clauses of C2PA 2.4 are `gamut_ifd::c2pa`'s rather than this crate's — plus the new public items under the v1 surface's freeze list and a deferred row for the typed `gamut-metadata` wiring this crate deliberately does not do. README.md gains the matching Status bullet. --- crates/gamut-tiff/README.md | 12 +++++++++++- crates/gamut-tiff/STATUS.md | 38 ++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index bc49213e..df562554 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -67,13 +67,23 @@ compression schemes land additively on this frozen surface (see Status). - **Compression** — uncompressed, PackBits, LZW (+ strip predictor), and Adobe Deflate (+ horizontal differencing on strips or tiles), plus the bilevel CCITT schemes Modified Huffman (Group 3 1-D) and Group 4 (T.6). +- **Metadata** — `TiffEncoder::with_metadata` / `TiffDecoder::metadata` carry an Exif sub-IFD + (`ExifIFD`, 34665, as a `gamut_ifd::Ifd`) plus opaque XMP (700), IPTC-IIM (33723), ICC (34675) + and C2PA (52545) payloads, verbatim in both directions — the raw blocks the workspace's + metadata facade consumes. The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared + `gamut_ifd::c2pa` helper it and `gamut-dng` both call: the entry in the last IFD of the main + chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by + `TiffEncoder::encode_with_report` or recovered from any file by `gamut_tiff::c2pa_exclusions`. + `with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in + place. - The decoder is hardened against hostile input (`#![forbid(unsafe_code)]`, a size cap, and a byte-flip fuzz corpus). **Deferred — planned, additive** (see the [STATUS.md](STATUS.md) scope ledger): YCbCr (§21), CIE L\*a\*b\* / RGB colorimetry (§20, §23), new-style JPEG-in-TIFF (§22, `Compression = 7`), and smaller items (CCITT Group 3 2-D, planar config, IEEE-float and 32-bit samples, 4-bit grayscale, -halftone hints). +halftone hints). The metadata payloads are carried as raw bytes rather than parsed here; wiring +them to the typed [`gamut-metadata`](../gamut-metadata) facade is tracked separately. **Permanently out of scope:** old-style JPEG (§22, `Compression = 6`), deprecated and unimplementable-as-specified per TIFF Technical Note 2. diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 167beac8..f013120b 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -68,6 +68,33 @@ Cross-depth requests resolve rather than fail: 8-bit widens to 16-bit by `×257` narrows to 8-bit by truncation (lossy, documented). Evidence: `tests/high_bit_depth.rs`, pixel-exact against libtiff in both directions. +**Added since v1.0 (semver-minor) — the metadata seam and the C2PA manifest store (issue #446).** +Until now the crate had no metadata surface at all: `tags.rs` named XMP (700), IPTC/NAA (33723), +ICC (34675) and the Exif/GPS/Interop pointers only so `deconstruct` would not flag them unknown, +and a caller wanting any of them dropped to the re-exported `gamut-ifd` spine. `TiffMetadata` +(`#[non_exhaustive]`, built through `new` + `with_*`) is now written by +`TiffEncoder::with_metadata` on the strip, tile and multi-page paths alike and read back by +`TiffDecoder::metadata`. XMP, IPTC-IIM, ICC and C2PA are **opaque bytes carried verbatim** — the +raw blocks the workspace's metadata facade consumes, as `gamut-png` and `gamut-webp` hand them +over — so this crate parses, validates and reconciles none of them; the `ExifIFD` is handed over +as a `gamut_ifd::Ifd`, because it *is* a directory the decoder has already walked. + +The C2PA manifest store is the one carrier with a placement rule of its own, and that rule is not +restated here: `gamut_ifd::c2pa` owns C2PA 2.4 §A.3.6 (tag 52545 / `0xCD41`, type `UNDEFINED`, one +store per asset, its entry in the **last IFD of the main chain**, its bytes at the **end of the +file**) and §18.5.5 (the two disjoint exclusion ranges — the store, and the `count` field of its +entry — that a `c2pa.hash.data` binding excludes; §18.7.3.3 leaves that the only binding a TIFF +asset has), and `gamut-dng` calls the same helper, so the two formats cannot drift. +`with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in +place; `encode_with_report` reports the ranges, and `c2pa_exclusions` recovers them from any +TIFF's bytes — including files written through `encode_palette8` or `encode_pages_rgb8`, which the +object-safe `EncodeImage` seam cannot report through. The store's bytes are never byte-swapped: +the header's `ByteOrder` does not govern them (§A.3.6). Tag 52545 joins `is_known_tag`, so the +v1 zero-tolerance byte accounting claims the store as its entry's typed value span rather than +reporting an unknown private tag and an unaccounted trailer. Evidence: `tests/c2pa.rs`, +`tests/metadata.rs`, and libtiff decoding a store-carrying file pixel-exact +(`tests/oracle_metadata.rs`). + **Deferred (planned, additive).** Each plugs into the existing strip/tile pipeline and libtiff oracle the way every codec above did: @@ -84,7 +111,9 @@ oracle the way every codec above did: 4-bit grayscale; 16-bit palette (`ColorMap` indices stay 8-bit); `Cmyk16`/`GrayAlpha16` presentation (no such `gamut-core` pixel type — a 16-bit CMYK page decodes through `Cmyk8` by narrowing, or `Rgb16` with the fourth sample dropped); halftone hints (§17); document-storage - metadata tags (§12 beyond `PageNumber`). + metadata tags (§12 beyond `PageNumber`); **typed metadata** — the seam above carries raw + payloads only, and wiring them to the `gamut-metadata` facade's models is deliberately left out + (adding that dependency edge is the metadata epic's job, not this crate's). **Additivity guarantee:** each deferred row lands semver-minor — a new variant on a `#[non_exhaustive]` enum (`Compression`, `PhotometricInterpretation`, `Predictor`), a new builder @@ -127,6 +156,13 @@ The API was frozen after a full-surface review; the additions and breaks: `Rgba16`. All new items; nothing existing was reshaped. The one behavioural change is that a 16-bit page requested as an 8-bit pixel type now returns `Ok` (narrowed) where it previously returned `Err(Unsupported)`. +- **Additions since the freeze (#446)** — `TiffMetadata`, `TiffEncodeReport`, `c2pa_exclusions`, + `tags::C2PA_MANIFEST_STORE`, `TiffEncoder::{with_metadata, with_c2pa_reserved, + encode_with_report}`, `TiffDecoder::metadata`, and the `C2paExclusions` re-export that keeps the + closure complete. All new items; nothing existing was reshaped. `TiffMetadata` is + `#[non_exhaustive]` from the start — a sixth carrier must not cost a major, which is exactly + what an exhaustive struct cost `gamut-dng`. The one behavioural change is that tag 52545 is no + longer reported as an unknown tag by `deconstruct`, since the crate now reads and writes it. - **Documented freeze rationales** — `UnknownTag.field_type` stays a raw `u16` (unrecognised on-disk type codes must be representable); `Anomaly`'s `detail` strings are human-readable diagnostics whose wording is not contractual. From d4e55f60f2457743aa9286d56ace4a4e9a29086a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:18:57 -0400 Subject: [PATCH 04/43] style(tiff): read the multi-page store reservation as a let-chain The tuple pattern `if let (Some((last, _)), true) = (images.last_mut(), store.is_some())` said the right thing awkwardly; a let-chain puts the condition first and the binding second, in reading order. No behaviour change. --- crates/gamut-tiff/src/encoder.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 04c90160..45a85a5b 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -488,7 +488,9 @@ impl TiffEncoder { if let Some((ifd0, _)) = images.first_mut() { self.metadata.apply(ifd0); } - if let (Some((last, _)), true) = (images.last_mut(), store.is_some()) { + if store.is_some() + && let Some((last, _)) = images.last_mut() + { c2pa::reserve_entry(last); } let bytes = writer::write_multipage(self.order, self.variant(), &images)?; From d933c9560e3c8f8c6afeb816bff5e04fc37fb978 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:19:54 -0400 Subject: [PATCH 05/43] test(tiff): fuzz the metadata entry points and pin a BigTIFF store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TiffDecoder::metadata` follows the `ExifIFD` pointer into a second directory and `c2pa_exclusions` walks the IFD chain to its end to read an offset/count pair — offset-driven reads of untrusted bytes on paths `decode_page` never takes, so the existing byte-flip corpus could not reach them. `byte_flip_fuzz` now takes the entry point under test as a closure, and both new entry points get the truncation sweep and the 5000-mutation corpus. Separately, BigTIFF is the one place the container variant changes what §18.5.5 names: the entry's count field widens from 4 bytes to 8. Pinned, together with the store still landing verbatim at the end of the file. --- crates/gamut-tiff/tests/c2pa.rs | 22 +++++++++++ crates/gamut-tiff/tests/robustness.rs | 55 ++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/gamut-tiff/tests/c2pa.rs b/crates/gamut-tiff/tests/c2pa.rs index 57fcdb09..697d236f 100644 --- a/crates/gamut-tiff/tests/c2pa.rs +++ b/crates/gamut-tiff/tests/c2pa.rs @@ -121,6 +121,28 @@ fn the_tile_path_places_the_store_too() { assert_eq!(&bytes[range.start as usize..range.end() as usize], STORE); } +#[test] +fn a_bigtiff_carries_the_store_with_its_wider_count_field() { + // BigTIFF widens the entry's count and value words to 8 bytes, so the count field the signer + // excludes is 8 bytes rather than 4 — the one place the container variant changes what + // §18.5.5 names. + let pixels = rgb(17, 13); + let mut bytes = Vec::new(); + let report = TiffEncoder::new() + .with_byte_order(ByteOrder::BigEndian) + .with_big_tiff(true) + .with_metadata(TiffMetadata::new().with_c2pa(STORE.to_vec())) + .encode_with_report(image(&pixels, 17, 13), &mut bytes) + .expect("encode"); + let excl = report.c2pa.expect("a store was written"); + assert_eq!(excl.count_field.len, 8); + assert_eq!( + &bytes[excl.store.start as usize..excl.store.end() as usize], + STORE + ); + assert_eq!(excl.store.end(), bytes.len() as u64); +} + #[test] fn a_multipage_document_puts_the_entry_in_its_last_page() { // §A.3.6: one store for the whole asset, in the *last* IFD of the main chain — not page 0, diff --git a/crates/gamut-tiff/tests/robustness.rs b/crates/gamut-tiff/tests/robustness.rs index 4872eda0..78b22766 100644 --- a/crates/gamut-tiff/tests/robustness.rs +++ b/crates/gamut-tiff/tests/robustness.rs @@ -2,7 +2,7 @@ //! panic, never allocate unboundedly — on hostile data (P19). use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgb16}; -use gamut_tiff::{Compression, Predictor, TiffDecoder, TiffEncoder}; +use gamut_tiff::{Compression, Ifd, Predictor, TiffDecoder, TiffEncoder, TiffMetadata, Value}; fn valid_lzw_tiff() -> Vec { let dims = Dimensions { @@ -34,6 +34,47 @@ fn valid_rgb16_tiff() -> Vec { .expect("encode") } +/// A file carrying every metadata block, an `ExifIFD` sub-IFD and a C2PA manifest store: the +/// structures `TiffDecoder::metadata` and `c2pa_exclusions` walk, and the only ones in this crate +/// reached by following a pointer tag out of IFD 0 or an offset/count pair out of the last IFD. +fn valid_metadata_tiff() -> Vec { + let dims = Dimensions { + width: 12, + height: 9, + }; + let rgb: Vec = (0..12 * 9 * 3).map(|i| (i * 7) as u8).collect(); + let mut exif = Ifd::new(); + exif.set(33434, Value::Rational(vec![(1, 250)])); + TiffEncoder::new() + .with_metadata( + TiffMetadata::new() + .with_exif(exif) + .with_xmp(b"".to_vec()) + .with_iptc(vec![0x1c, 0x02, 0x05, 0x00, 0x04, b't', b'e', b's', b't']) + .with_icc(vec![0, 0, 0, 12, b'a', b'c', b's', b'p', 1, 2, 3, 4]) + .with_c2pa(b"\0\0\0\x16jumb\x01\x02\x03\x04\x05\x06".to_vec()), + ) + .encode_to_vec(ImageRef::::new(&rgb, dims).unwrap()) + .expect("encode") +} + +#[test] +fn hostile_input_to_the_metadata_entry_points_does_not_panic() { + // `metadata` follows the `ExifIFD` pointer into a second directory and `c2pa_exclusions` + // walks the chain to its end to read an offset/count pair — offset-driven reads of untrusted + // bytes on paths `decode_page` never takes, so the fuzz corpus above cannot reach them. + let dec = TiffDecoder::new(); + let valid = valid_metadata_tiff(); + for len in 0..=valid.len() { + let _ = dec.metadata(&valid[..len]); + let _ = gamut_tiff::c2pa_exclusions(&valid[..len]); + } + byte_flip_fuzz(&valid, |data| { + let _ = dec.metadata(data); + let _ = gamut_tiff::c2pa_exclusions(data); + }); +} + #[test] fn specific_malformed_inputs_error_without_panic() { let dec = TiffDecoder::new(); @@ -66,13 +107,17 @@ fn truncations_do_not_panic() { #[test] fn byte_flip_fuzz_does_not_panic() { + let dec = TiffDecoder::new(); for valid in [valid_lzw_tiff(), valid_rgb16_tiff()] { - byte_flip_fuzz(&valid); + byte_flip_fuzz(&valid, |data| { + let _ = dec.decode_page(data, 0); + }); } } -fn byte_flip_fuzz(valid: &[u8]) { - let dec = TiffDecoder::new(); +/// Feeds `consume` 5000 deterministically mutated copies of `valid`; the caller names which entry +/// point is under test. +fn byte_flip_fuzz(valid: &[u8], mut consume: impl FnMut(&[u8])) { // Deterministic LCG (no RNG dependency) drives the mutations. let mut state: u64 = 0x1234_5678_9abc_def0; let mut next = || { @@ -88,6 +133,6 @@ fn byte_flip_fuzz(valid: &[u8]) { let pos = next() as usize % data.len(); data[pos] ^= (next() & 0xff) as u8; } - let _ = dec.decode_page(&data, 0); + consume(&data); } } From c26c8510b25500f65e766db3e7ac51af6758ac8c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:06:40 -0400 Subject: [PATCH 06/43] fix(tiff): make the metadata seam agree with its own contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects found in review, each one the seam contradicting something it already claimed. A decoded Exif sub-IFD carried a dangling pointer. `read_metadata` asked `read_tree` for `ExifIFD` alone, and `read_tree` re-parses only the tags it is given, so an `InteroperabilityIFD` (40965) *inside* the Exif directory — near-universal in camera EXIF — came back as a raw `Long` holding the source file's absolute offset, which `apply` then wrote verbatim into a file laid out at different offsets. Decoding and re-encoding produced a file the crate's own judge rejects: not fully classified, with a `severity: Error` "sub-IFD could not be parsed", breaking gamut-tiff's v1 zero-tolerance byte accounting on a file gamut-tiff itself wrote. Read with `gamut_ifd::tags::STANDARD_POINTER_TAGS` instead — the list the repository already provides and `gamut-dng`'s rewrite path already uses — so a nested pointer directory becomes a child the writer re-points. `metadata()` and `c2pa_exclusions()` disagreed about what a manifest store is, while the docstring claimed they agreed. The reader accepted any `UNDEFINED` value under 52545; `locate` additionally rejects one below `MIN_STORE_LEN` or a duplicated entry. A four-byte entry therefore decoded to `Some(...)` that the encoder then refused — exactly the decode → encode trap `locate` reports absence to avoid. The reader now gates on the locator, as `gamut-dng` does. `with_c2pa_reserved`'s documented minimum was wrong for BigTIFF. `append_store` refuses a store that fits inline, so the true minimum is the variant's inline threshold plus one: 8 in classic TIFF but 9 in BigTIFF. A BigTIFF reservation of 8 compressed the whole image before failing from `gamut-ifd`, despite the `# Errors` promise to catch length "before any pixel work". `c2pa_store` now gates on `min_store_len()` and both doc claims are corrected. Also states three contracts that were decided but unwritten: the Exif directory's entries are carried unchanged while its *ordering* is normalised (ascending tag, duplicates collapsed, a child's next-IFD pointer ignored) rather than being byte-identical; the blocks live in IFD 0 only, so a reader decoding one page of a multi-page document must look there for them; and `metadata()` can fail on a file `decode_image` decodes happily, because reporting "no EXIF" for a directory the file declares is silent loss a caller cannot detect. --- crates/gamut-tiff/src/decoder.rs | 12 +++- crates/gamut-tiff/src/encoder.rs | 79 +++++++++++++++++-------- crates/gamut-tiff/src/metadata.rs | 90 ++++++++++++++++++++++++----- crates/gamut-tiff/tests/metadata.rs | 62 +++++++++++++++++++- 4 files changed, 201 insertions(+), 42 deletions(-) diff --git a/crates/gamut-tiff/src/decoder.rs b/crates/gamut-tiff/src/decoder.rs index e7c9d700..c613f5bc 100644 --- a/crates/gamut-tiff/src/decoder.rs +++ b/crates/gamut-tiff/src/decoder.rs @@ -211,7 +211,17 @@ impl TiffDecoder { /// # Errors /// /// Returns [`Error::InvalidInput`] for a malformed header or IFD chain, or a sub-IFD pointer - /// graph that is not a tree. + /// graph that is not a tree (a cycle, a repeated child offset, an out-of-bounds or + /// unparseable pointer target, or nesting deeper than 16 levels). + /// + /// **This can fail on a file [`decode_image`](DecodeImage::decode_image) decodes happily**, + /// and that is deliberate. Pixel decoding never follows a metadata pointer, so a broken + /// `ExifIFD` offset cannot stop it; this method does follow one, and the alternative to + /// failing is reporting `exif: None` for a directory the file plainly declares — silent loss + /// a caller cannot tell apart from "there is no EXIF here". A caller that wants a partial + /// answer can walk the re-exported [`read`](crate::read) / [`gamut_ifd::read_tree`] spine + /// itself and decide per pointer. (`gamut-dng` degrades instead of failing, because there the + /// metadata is incidental to a raw *image* decode that must still succeed.) pub fn metadata(&self, data: &[u8]) -> Result { metadata::read_metadata(data) } diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 45a85a5b..51ee65b9 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -152,21 +152,37 @@ impl TiffEncoder { /// [`encode_with_report`](Self::encode_with_report) (or [`c2pa_exclusions`] over the produced /// bytes) reports its two exclusion ranges (§18.5.5). A signer hashes the file around those /// ranges and overwrites the reservation in place; nothing else in the file moves. `len` must - /// be at least [`gamut_ifd::c2pa::MIN_STORE_LEN`] (a JUMBF box header), and a reservation - /// cannot be combined with a store supplied through [`with_metadata`](Self::with_metadata) — - /// either is a typed error at encode time. + /// be at least [`gamut_ifd::c2pa::MIN_STORE_LEN`] (a JUMBF box header, 8 bytes) **and longer + /// than the container's inline threshold**, so BigTIFF's true minimum is 9 — a value of 8 or + /// less would be packed into the entry's own value word rather than placed out of line at the + /// end of the file. A reservation cannot be combined with a store supplied through + /// [`with_metadata`](Self::with_metadata). Either is a typed error raised before any pixel + /// work, not after the image has been compressed. #[must_use] pub fn with_c2pa_reserved(mut self, len: usize) -> Self { self.c2pa_reserve = Some(len); self } + /// The shortest manifest store this encoder can place, for the container variant it writes. + /// + /// Two lower bounds apply and the larger wins. [`c2pa::MIN_STORE_LEN`] (8) is the format's: a + /// manifest store is a JUMBF superbox, so nothing shorter than an `LBox` + `TBox` could be + /// one. The container's is the variant's **inline threshold** — 4 bytes in classic TIFF, 8 in + /// BigTIFF — because a value that fits inline is packed into the entry's value word instead of + /// being placed out of line, which is not where §A.3.6 puts a store and would make the two + /// exclusion ranges overlap. So classic TIFF's minimum is 8 and BigTIFF's is **9**. + fn min_store_len(&self) -> usize { + c2pa::MIN_STORE_LEN.max(self.variant().inline_threshold() + 1) + } + /// The C2PA manifest store to write, if any: the caller's, or a zero-filled reservation. /// /// # Errors /// - /// Returns [`Error::InvalidInput`] if both were requested, or if the store is too short to be - /// a JUMBF box at all ([`c2pa::MIN_STORE_LEN`]) — caught here, before any pixel work. + /// Returns [`Error::InvalidInput`] if both were requested, or if the store is shorter than + /// [`min_store_len`](Self::min_store_len) — caught here, before any pixel work, rather than + /// after a whole image has been compressed. fn c2pa_store(&self) -> Result>> { let store = match (&self.metadata.c2pa, self.c2pa_reserve) { (Some(_), Some(_)) => { @@ -179,10 +195,11 @@ impl TiffEncoder { (None, Some(len)) => Cow::Owned(vec![0; len]), (None, None) => return Ok(None), }; - if store.len() < c2pa::MIN_STORE_LEN { + if store.len() < self.min_store_len() { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), - "TIFF: a C2PA manifest store is at least a JUMBF box header (8 bytes)", + "TIFF: a C2PA manifest store must be a JUMBF box header (8 bytes) and longer \ + than the container's inline threshold (9 bytes in BigTIFF)", )); } Ok(Some(store)) @@ -826,25 +843,39 @@ mod tests { } #[test] - fn a_store_shorter_than_a_jumbf_box_header_is_refused() { - // A manifest store is a JUMBF superbox, so it is at least an 8-byte LBox + TBox; seven - // bytes could never be filled with a valid one. The boundary is the claim, so it is - // asserted at the two lengths that straddle it, for a supplied store and a reservation - // alike. - for encoder in [ - TiffEncoder::new().with_metadata(TiffMetadata::new().with_c2pa(vec![0; 7])), - TiffEncoder::new().with_c2pa_reserved(7), - ] { - let err = encoder.c2pa_store().expect_err("too short"); + fn the_shortest_placeable_store_differs_between_classic_tiff_and_bigtiff() { + // Two lower bounds, larger wins: the JUMBF box header (8) and the variant's inline + // threshold + 1, since a value that fits inline is packed into the entry's value word + // instead of being placed at the end of the file where §A.3.6 wants it. Classic TIFF's + // minimum is therefore 8 and BigTIFF's is 9. The boundary is the whole claim, so each + // variant is asserted at the two lengths that straddle its own — and 8 is the length that + // separates them, accepted as classic and refused as BigTIFF. + for (big_tiff, minimum) in [(false, 8), (true, 9)] { + let at = |len: usize| { + TiffEncoder::new() + .with_big_tiff(big_tiff) + .with_c2pa_reserved(len) + }; + assert_eq!(at(0).min_store_len(), minimum, "big_tiff={big_tiff}"); + let err = at(minimum - 1).c2pa_store().expect_err("too short"); assert!(err.to_string().contains("JUMBF box header"), "{err}"); + assert!( + at(minimum) + .c2pa_store() + .expect("the minimum is placeable") + .is_some(), + "big_tiff={big_tiff}" + ); + // A supplied store is held to the same bound as a reservation. + assert!( + TiffEncoder::new() + .with_big_tiff(big_tiff) + .with_metadata(TiffMetadata::new().with_c2pa(vec![0; minimum - 1])) + .c2pa_store() + .is_err(), + "big_tiff={big_tiff}" + ); } - assert!( - TiffEncoder::new() - .with_c2pa_reserved(8) - .c2pa_store() - .expect("8 bytes is a box header") - .is_some() - ); } #[test] diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 39e2080c..a93f34d5 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -16,7 +16,18 @@ //! which this crate has already parsed by the time a caller sees it. Handing it back as //! [`gamut_ifd::Ifd`] rather than as bytes saves every caller from re-parsing a directory the //! decoder already walked. Its fields are neither validated nor completed — what the caller -//! supplies is what the file gets, and what the file holds is what the caller gets. +//! supplies is what the file gets, and what the file holds is what the caller gets — subject to +//! the three normalisations a directory model implies, named on [`TiffMetadata::exif`]. +//! +//! # Where the blocks live, and what that costs a page-at-a-time reader +//! +//! Every block goes in **IFD 0** and only there, including for a multi-page document: they +//! describe the document, and an N-page file carrying N copies of an ICC profile is the worse +//! outcome. IFD 0 is also where a reader conventionally looks. The cost is real and worth +//! stating: a reader that decodes page 3 on its own sees no ICC profile, no XMP and no EXIF, and +//! must consult IFD 0 for them. The C2PA manifest store is the deliberate exception — §A.3.6 +//! puts its entry in the **last** IFD of the main chain, so for a multi-page file that is the +//! last page rather than the first. //! //! # The C2PA manifest store //! @@ -55,10 +66,25 @@ use crate::tags; pub struct TiffMetadata { /// The Exif private sub-IFD (`ExifIFD`, 34665), as the shared directory model. /// - /// Carried **verbatim**: every entry the caller supplies is written, and every entry the - /// file holds is returned. This crate adds no mandatory Exif field (not even `ExifVersion`) - /// and drops none, because a TIFF's `ExifIFD` is the caller's directory — completing it - /// would silently change what a round-trip returns. + /// **Entries are carried unchanged; ordering is normalised.** This crate adds no mandatory + /// Exif field — not even `ExifVersion` — and drops none, because a TIFF's `ExifIFD` is the + /// caller's directory and completing it would silently change what a round trip returns. What + /// it does not promise is byte-identity, because [`gamut_ifd::Ifd`] is a directory model + /// rather than a byte range, and three normalisations are inherent to it: + /// + /// 1. fields are kept **sorted by ascending tag**, as TIFF 6.0 §2 requires on disk, so a + /// source directory written out of order comes back in order; + /// 2. a **duplicated tag collapses** to its last occurrence; + /// 3. a child directory's **next-IFD pointer is ignored** — a sub-IFD is a directory, not a + /// chain. + /// + /// A conforming source directory is unaffected by all three. A non-conforming one is + /// silently repaired, which is worth knowing before using a re-encode to prove a file + /// unmodified. + /// + /// Pointer tags *inside* this directory (`InteroperabilityIFD`, 40965) come back as parsed + /// [`sub_ifds`](gamut_ifd::Ifd::sub_ifds) groups, never as raw offsets into the file they were + /// read from — the writer gives them fresh offsets when this directory is embedded again. pub exif: Option, /// An XMP packet (UTF-8 RDF/XML), stored in the `XMP` tag (700) as `BYTE`, verbatim. pub xmp: Option>, @@ -174,19 +200,30 @@ fn bytes_value(value: Option<&Value>) -> Option> { /// Reads the metadata a TIFF carries: IFD 0's blocks and Exif sub-IFD, plus the C2PA manifest /// store from the last IFD of the main chain (C2PA 2.4 §A.3.6). /// -/// The store is taken only as the `UNDEFINED` bytes §A.3.6 mandates — a tag-52545 entry of any -/// other type is not a manifest store and is reported as absence, the same test -/// [`gamut_ifd::c2pa::locate`] applies. +/// Whether a tag-52545 entry *is* a manifest store is [`gamut_ifd::c2pa::locate`]'s decision, not +/// a second opinion held here: this asks the locator first and reports the store only when it +/// agrees. That matters because `locate` reports absence for an entry a caller could not use — +/// one of the wrong type, one too short to be a JUMBF box, a duplicated one — and reporting such +/// an entry as a store would hand the caller a [`TiffMetadata`] that +/// [`TiffEncoder`](crate::TiffEncoder) then refuses to encode. `gamut-dng` gates its own decode +/// on the same locator for the same reason. pub(crate) fn read_metadata(data: &[u8]) -> Result { - let file = read_tree(data, &[tags::EXIF_IFD])?; + // Every standard pointer tag, not just `ExifIFD`: an `InteroperabilityIFD` (40965) inside the + // Exif directory — near-universal in camera EXIF — is itself a pointer, and a pointer left + // unparsed comes back as the *source* file's absolute offset. Handing that to + // [`TiffMetadata::apply`] would write a dangling offset into a file laid out differently, so + // a directory this reader returns must have every pointer under it resolved into a child the + // writer can re-point. `gamut-dng`'s rewrite path reads with the same list. + let file = read_tree(data, gamut_ifd::tags::STANDARD_POINTER_TAGS)?; // §A.3.6: one store for the whole asset, in the last IFD of the main chain. `ifds` is that // chain, so its last element is where the entry belongs — and a single-page file makes the // two the same directory. A file with no IFD at all carries no metadata. let (Some(ifd0), Some(store_ifd)) = (file.ifds.first(), file.ifds.last()) else { return Ok(TiffMetadata::new()); }; + let located = c2pa::locate(data)?.is_some(); let c2pa = match store_ifd.get(tags::C2PA_MANIFEST_STORE) { - Some(Value::Undefined(store)) => Some(store.clone()), + Some(Value::Undefined(store)) if located => Some(store.clone()), _ => None, }; Ok(TiffMetadata { @@ -355,13 +392,34 @@ mod tests { } #[test] - fn a_c2pa_tag_of_the_wrong_type_is_not_a_store() { - // §A.3.6 fixes the type at 7 (UNDEFINED). A BYTE entry under the same tag is some other - // writer's field, and reporting it as a manifest store would be a lie. + fn the_reader_and_the_locator_agree_on_what_a_store_is() { + // The two surfaces must never disagree: a `TiffMetadata` reporting a store that + // `c2pa_exclusions` cannot find is one the encoder would refuse, turning a decode→encode + // round trip into a hard error. Each case below is an entry `locate` reports absent for a + // *different* reason — wrong type (§A.3.6 fixes it at 7), and too short to be a JUMBF box + // (below `MIN_STORE_LEN`) — so a fix that only handled one of them still fails here. + for value in [ + Value::Byte(vec![0x10; 12]), + Value::Undefined(vec![9; c2pa::MIN_STORE_LEN - 1]), + ] { + let mut ifd0 = Ifd::new(); + ifd0.set(tags::C2PA_MANIFEST_STORE, value.clone()); + let bytes = file_with(ifd0); + assert_eq!(read_metadata(&bytes).expect("read").c2pa, None, "{value:?}"); + assert_eq!(c2pa_exclusions(&bytes).expect("locate"), None, "{value:?}"); + } + } + + #[test] + fn a_store_the_locator_accepts_is_returned_verbatim() { + // The other side of the agreement: the reader must not be so strict that it drops a store + // the locator does find, which would make the two disagree in the opposite direction. + let store = b"\0\0\0\x16jumb\x01\x02\x03".to_vec(); let mut ifd0 = Ifd::new(); - ifd0.set(tags::C2PA_MANIFEST_STORE, Value::Byte(vec![0x10; 12])); - let read = read_metadata(&file_with(ifd0)).expect("read"); - assert_eq!(read.c2pa, None); + ifd0.set(tags::C2PA_MANIFEST_STORE, Value::Undefined(store.clone())); + let bytes = file_with(ifd0); + assert_eq!(read_metadata(&bytes).expect("read").c2pa, Some(store)); + assert!(c2pa_exclusions(&bytes).expect("locate").is_some()); } #[test] diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs index 575d87b1..d78a07db 100644 --- a/crates/gamut-tiff/tests/metadata.rs +++ b/crates/gamut-tiff/tests/metadata.rs @@ -5,7 +5,9 @@ //! fails on its own rather than hiding behind another. use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; -use gamut_tiff::{Ifd, TiffDecoder, TiffEncoder, TiffMetadata, Value, read, tags}; +use gamut_tiff::{ + Anomaly, Ifd, Severity, TiffDecoder, TiffEncoder, TiffMetadata, Value, deconstruct, read, tags, +}; /// Distinct payloads per carrier, so a block written under the wrong tag is visible. const XMP: &[u8] = b""; @@ -113,6 +115,64 @@ fn the_decoder_returns_every_block_verbatim() { assert_eq!(read_back.exif, Some(exif())); } +#[test] +fn a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file() { + // An `InteroperabilityIFD` (40965) *inside* the Exif directory is near-universal in camera + // EXIF, and it is a pointer: its value is an absolute file offset. A decoder that returned it + // as a raw `Long` rather than as a parsed child directory would hand the caller the *source* + // file's offset, and re-encoding would write it verbatim into a file laid out differently — + // a dangling pointer. The crate's own judge is the test: gamut-tiff's v1 guarantee is that + // every file it writes is fully classified by `deconstruct`. + let mut interop = Ifd::new(); + interop.set(1, Value::Ascii("R98".into())); // InteroperabilityIndex + let mut exif = exif(); + exif.set(37500, Value::Undefined(vec![0xAB; 6])); // MakerNote, so the directory is not tiny + exif.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![interop.clone()]); + + let pixels = rgb(8, 4); + let first = TiffEncoder::new() + .with_metadata(TiffMetadata::new().with_exif(exif)) + .encode_to_vec(image(&pixels, 8, 4)) + .expect("encode"); + + // Decode the metadata back and feed it straight into a new encode — the round trip a caller + // makes when rewriting a file. + let decoded = TiffDecoder::new().metadata(&first).expect("metadata"); + let exif_back = decoded.exif.clone().expect("an Exif sub-IFD"); + assert_eq!( + exif_back + .sub_ifds() + .iter() + .find(|group| group.tag == tags::INTEROPERABILITY_IFD) + .map(|group| group.ifds.as_slice()), + Some(&[interop][..]), + "the Interop directory must come back parsed, not as a raw offset" + ); + assert_eq!( + exif_back.get(tags::INTEROPERABILITY_IFD), + None, + "a parsed pointer is consumed into the sub-IFD group, not left as a stale offset" + ); + + let second = TiffEncoder::new() + .with_metadata(decoded) + .encode_to_vec(image(&pixels, 8, 4)) + .expect("re-encode"); + let report = deconstruct(&second).expect("deconstruct"); + assert!( + report.segments.is_fully_classified(), + "unclassified after a metadata round trip: {:?}", + report.segments.unclassified + ); + assert!( + !report.anomalies.iter().any( + |a| matches!(a, Anomaly::Structure { severity, .. } if *severity == Severity::Error) + ), + "structural errors after a metadata round trip: {:?}", + report.anomalies + ); +} + #[test] fn a_file_without_metadata_decodes_to_an_empty_set() { let pixels = rgb(8, 4); From 98c734a17de4c95eb27984aab94443107a7e44c0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:06:51 -0400 Subject: [PATCH 07/43] test(tiff): pin the palette path's C2PA manifest store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STATUS, README and the encoder docs all say a palette file carries a store and reports it through `c2pa_exclusions`. Nothing called `encode_palette8` with `with_metadata`, so that claim held by luck: it works today, and nothing would have failed if it stopped. `encode_palette8` is an inherent method rather than an `EncodeImage` impl — it needs a separate colour table — so `encode_with_report` cannot reach it, which is precisely why the locator is the documented route and why it needed its own test. --- crates/gamut-tiff/tests/c2pa.rs | 49 +++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/gamut-tiff/tests/c2pa.rs b/crates/gamut-tiff/tests/c2pa.rs index 697d236f..1e06608b 100644 --- a/crates/gamut-tiff/tests/c2pa.rs +++ b/crates/gamut-tiff/tests/c2pa.rs @@ -5,10 +5,10 @@ //! this crate's use of them — that a store survives an encode of a real image, in the right //! directory, at the end of the file, verbatim. -use gamut_core::{Dimensions, ImageRef, Rgb8}; +use gamut_core::{Dimensions, ImageRef, Indexed8, Rgb8}; use gamut_tiff::{ - ByteOrder, SpanKind, TiffDecoder, TiffEncoder, TiffMetadata, c2pa_exclusions, deconstruct, - read, tags, + ByteOrder, Palette8, SpanKind, TiffDecoder, TiffEncoder, TiffMetadata, c2pa_exclusions, + deconstruct, read, tags, }; /// A store whose bytes are neither a palindrome nor a repetition, so a byte-swapped copy of it @@ -143,6 +143,49 @@ fn a_bigtiff_carries_the_store_with_its_wider_count_field() { assert_eq!(excl.store.end(), bytes.len() as u64); } +#[test] +fn the_palette_path_places_the_store_and_reports_it_through_the_locator() { + // `encode_palette8` needs a separate colour table, so it is an inherent method rather than an + // `EncodeImage` impl and `encode_with_report` cannot reach it. STATUS, README and the docs all + // say such a file still carries a store and still reports it through `c2pa_exclusions`; this + // is what makes that true rather than merely claimed. + let indices: Vec = (0..16 * 16).map(|i| (i % 251) as u8).collect(); + let palette = + Palette8::from_rgb_triples(&(0..768).map(|i| (i % 251) as u8).collect::>()) + .expect("palette"); + let mut bytes = Vec::new(); + TiffEncoder::new() + .with_byte_order(ByteOrder::BigEndian) + .with_metadata(TiffMetadata::new().with_c2pa(STORE.to_vec())) + .encode_palette8( + ImageRef::::new( + &indices, + Dimensions { + width: 16, + height: 16, + }, + ) + .expect("indices"), + &palette, + &mut bytes, + ) + .expect("encode"); + let range = c2pa_exclusions(&bytes) + .expect("locate") + .expect("a store") + .store; + assert_eq!(&bytes[range.start as usize..range.end() as usize], STORE); + assert_eq!(range.end(), bytes.len() as u64); + assert_eq!( + TiffDecoder::new() + .metadata(&bytes) + .expect("metadata") + .c2pa + .as_deref(), + Some(STORE) + ); +} + #[test] fn a_multipage_document_puts_the_entry_in_its_last_page() { // §A.3.6: one store for the whole asset, in the *last* IFD of the main chain — not page 0, From 20268b8b574370d9ec10a87073cf363d285954cf Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:06:51 -0400 Subject: [PATCH 08/43] docs(tiff): record the seam's normalisations and its IFD-0 placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STATUS.md and README.md said the metadata was carried "verbatim in both directions". That is exact for the byte payloads and approximate for the Exif sub-IFD, which is a directory model rather than a byte range: `gamut_ifd::Ifd` sorts fields by ascending tag, collapses a duplicated tag to its last occurrence, and ignores a child directory's next-IFD pointer. Say "entries carried unchanged, ordering normalised" and name the three, so nobody uses a re-encode to argue a file is unmodified. Also record what the reader now resolves and where the blocks live: every standard pointer tag rather than `ExifIFD` alone, and IFD 0 only — with the cost of that choice stated, since a reader decoding page 3 of a multi-page document alone sees none of the blocks. --- crates/gamut-tiff/README.md | 10 +++++++--- crates/gamut-tiff/STATUS.md | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index df562554..61e302b5 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -68,9 +68,13 @@ compression schemes land additively on this frozen surface (see Status). (+ horizontal differencing on strips or tiles), plus the bilevel CCITT schemes Modified Huffman (Group 3 1-D) and Group 4 (T.6). - **Metadata** — `TiffEncoder::with_metadata` / `TiffDecoder::metadata` carry an Exif sub-IFD - (`ExifIFD`, 34665, as a `gamut_ifd::Ifd`) plus opaque XMP (700), IPTC-IIM (33723), ICC (34675) - and C2PA (52545) payloads, verbatim in both directions — the raw blocks the workspace's - metadata facade consumes. The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared + (`ExifIFD`, 34665, as a `gamut_ifd::Ifd`, its own `InteroperabilityIFD` resolved into a child + directory rather than a stale offset) plus opaque XMP (700), IPTC-IIM (33723), ICC (34675) and + C2PA (52545) payloads — the raw blocks the workspace's metadata facade consumes. Byte payloads + are verbatim; the Exif directory's *entries* are carried unchanged but its ordering is + normalised (ascending tag, duplicate tags collapsed, a child's next-IFD pointer ignored). The + blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone must + look at IFD 0 for them. The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared `gamut_ifd::c2pa` helper it and `gamut-dng` both call: the entry in the last IFD of the main chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by `TiffEncoder::encode_with_report` or recovered from any file by `gamut_tiff::c2pa_exclusions`. diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index f013120b..9ec8dd0f 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -79,6 +79,18 @@ raw blocks the workspace's metadata facade consumes, as `gamut-png` and `gamut-w over — so this crate parses, validates and reconciles none of them; the `ExifIFD` is handed over as a `gamut_ifd::Ifd`, because it *is* a directory the decoder has already walked. +Three consequences are contractual rather than incidental, and are documented where they are made. +**(a)** The Exif directory's *entries* are carried unchanged but its **ordering is normalised** — +ascending tag (TIFF 6.0 §2 requires it on disk), duplicate tags collapsed to the last, a child's +next-IFD pointer ignored — so "verbatim" is claimed for byte payloads, not for a directory model. +**(b)** The reader resolves **every standard pointer tag** (`gamut_ifd::tags::STANDARD_POINTER_TAGS`, +the list `gamut-dng`'s rewrite path uses), not just `ExifIFD`: an `InteroperabilityIFD` inside the +Exif directory is itself an absolute file offset, and returning it unparsed would let a caller +re-encode a dangling pointer into a file laid out differently — which the crate's own +`deconstruct` would then reject. **(c)** The blocks live in **IFD 0 only**, so a reader decoding +page 3 of a multi-page document alone sees none of them; duplicating an ICC profile onto every +page is the worse outcome, and IFD 0 is where a reader conventionally looks. + The C2PA manifest store is the one carrier with a placement rule of its own, and that rule is not restated here: `gamut_ifd::c2pa` owns C2PA 2.4 §A.3.6 (tag 52545 / `0xCD41`, type `UNDEFINED`, one store per asset, its entry in the **last IFD of the main chain**, its bytes at the **end of the From 80f40853b67d391cc272b1996512734293e3b7bc Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:50:29 -0400 Subject: [PATCH 09/43] fix(tiff): stop a pointer the metadata never returns from failing the read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving every standard pointer tag fixed a dangling `InteroperabilityIFD` but over-reached: `read_tree` takes one flat list and applies it at every node, so `SubIFDs` (330) and `GPSInfo` (34853) were followed at IFD 0 too. Neither feeds any field of `TiffMetadata` and neither is re-encoded by `apply`, which writes into a directory the encoder builds fresh — so following them could only add failure modes, and did. On a well-formed single-strip RGB file carrying XMP, a dangling `SubIFDs` offset (and likewise a dangling `GPSInfo`) made `metadata()` return `Err(TIFF: read out of bounds)` while `decode_image` succeeded, putting XMP, ICC, IPTC and C2PA all out of reach because of a thumbnail pointer nobody asked for. From the same code, two pages of a multi-page document whose `SubIFDs` share one thumbnail directory failed with "sub-IFD pointer loop", because `visited` spans the whole chain. Scope the list to what the struct actually returns: `ExifIFD`, because that directory is handed to the caller and may be written back, and `InteroperabilityIFD`, the one standard pointer that occurs inside it. A broken Exif pointer is still an error — its content *is* returned, so silence there would be data loss — and both regressed cases are pinned. --- crates/gamut-tiff/src/metadata.rs | 57 ++++++++++++++++++++++++----- crates/gamut-tiff/tests/metadata.rs | 52 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index a93f34d5..5d974219 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -82,9 +82,17 @@ pub struct TiffMetadata { /// silently repaired, which is worth knowing before using a re-encode to prove a file /// unmodified. /// - /// Pointer tags *inside* this directory (`InteroperabilityIFD`, 40965) come back as parsed - /// [`sub_ifds`](gamut_ifd::Ifd::sub_ifds) groups, never as raw offsets into the file they were - /// read from — the writer gives them fresh offsets when this directory is embedded again. + /// The **standard** pointer tag that occurs inside this directory — `InteroperabilityIFD` + /// (40965) — comes back as a parsed [`sub_ifds`](gamut_ifd::Ifd::sub_ifds) group rather than a + /// raw offset, so the writer gives it a fresh offset when the directory is embedded again. + /// + /// **Only the standard pointer tags are recognised as pointers.** A *private* tag whose value + /// happens to be a `LONG` file offset — some vendors point at their own sub-directories this + /// way — is indistinguishable from an ordinary integer field here, so it is carried through + /// unchanged and re-encoded verbatim, still holding an offset into the file it was read from. + /// Neither this crate nor [`deconstruct`](crate::deconstruct) can grade that, because neither + /// knows the tag is a pointer. A caller rewriting a file with vendor metadata must not treat a + /// round trip through this field as proof the result is pointer-safe. pub exif: Option, /// An XMP packet (UTF-8 RDF/XML), stored in the `XMP` tag (700) as `BYTE`, verbatim. pub xmp: Option>, @@ -192,6 +200,30 @@ impl TiffMetadata { } } +/// The pointer tags [`read_metadata`] follows, scoped to what [`TiffMetadata`] actually returns. +/// +/// Two tags, and the pair is a deliberate lower bound rather than a subset of convenience. +/// +/// `ExifIFD` is followed because that directory **is** a field of [`TiffMetadata`]: it is handed to +/// the caller and may be written back, so a pointer under it that stayed a raw offset would be +/// re-encoded into a file laid out differently. `InteroperabilityIFD` is the one standard pointer +/// that occurs *inside* an Exif directory (EXIF 2.3 §4.6.3), and it is near-universal in camera +/// EXIF — leaving it unresolved is exactly the dangling-pointer defect this list exists to prevent. +/// +/// The other two members of [`gamut_ifd::tags::STANDARD_POINTER_TAGS`] are deliberately **not** +/// here. `SubIFDs` (330) locates thumbnails and reduced-resolution subfiles and `GPSInfo` (34853) +/// locates a GPS directory; neither feeds any field of [`TiffMetadata`], and neither is re-encoded +/// by [`TiffMetadata::apply`], which writes into a directory the encoder builds fresh. Following +/// them could therefore only *add* failure modes, and it did: a single dangling `SubIFDs` offset +/// made XMP, IPTC, ICC and C2PA all unreachable on a file whose pixels decode perfectly, and two +/// pages sharing one thumbnail directory tripped the reader's cross-chain loop guard. A pointer +/// whose target this reader throws away must not be able to fail the whole call. +/// +/// One over-reach remains and is harmless: `InteroperabilityIFD` is also followed if it appears at +/// IFD 0, where it does not belong. A TIFF whose IFD 0 carries tag 40965 is already out of spec, +/// and `read_tree` takes one flat list for the whole tree. +const POINTER_TAGS: &[u16] = &[tags::EXIF_IFD, tags::INTEROPERABILITY_IFD]; + /// A raw `BYTE`/`UNDEFINED` payload, copied out of a directory entry. fn bytes_value(value: Option<&Value>) -> Option> { value.and_then(Value::as_bytes).map(<[u8]>::to_vec) @@ -207,14 +239,19 @@ fn bytes_value(value: Option<&Value>) -> Option> { /// an entry as a store would hand the caller a [`TiffMetadata`] that /// [`TiffEncoder`](crate::TiffEncoder) then refuses to encode. `gamut-dng` gates its own decode /// on the same locator for the same reason. +/// +/// Reader and locator agreeing is not the same as every readable store being writable, and this +/// does not promise the latter — it cannot, because a reader does not know which container the +/// caller will write. One case exists: `locate` accepts a store of exactly +/// [`MIN_STORE_LEN`](gamut_ifd::c2pa::MIN_STORE_LEN) (8) bytes, while writing **BigTIFF** needs 9, +/// since 8 bytes would pack into the entry's own value word instead of being placed out of line +/// at the end of the file. So an 8-byte store read from any file cannot be written back to a +/// BigTIFF. The affected input is degenerate — 8 bytes is a JUMBF box header with no content, so +/// there is no manifest in it — but the refusal is real and comes from +/// [`TiffEncoder::with_c2pa_reserved`](crate::TiffEncoder::with_c2pa_reserved)'s placement rule, +/// not from disagreement here. pub(crate) fn read_metadata(data: &[u8]) -> Result { - // Every standard pointer tag, not just `ExifIFD`: an `InteroperabilityIFD` (40965) inside the - // Exif directory — near-universal in camera EXIF — is itself a pointer, and a pointer left - // unparsed comes back as the *source* file's absolute offset. Handing that to - // [`TiffMetadata::apply`] would write a dangling offset into a file laid out differently, so - // a directory this reader returns must have every pointer under it resolved into a child the - // writer can re-point. `gamut-dng`'s rewrite path reads with the same list. - let file = read_tree(data, gamut_ifd::tags::STANDARD_POINTER_TAGS)?; + let file = read_tree(data, POINTER_TAGS)?; // §A.3.6: one store for the whole asset, in the last IFD of the main chain. `ifds` is that // chain, so its last element is where the entry belongs — and a single-page file makes the // two the same directory. A file with no IFD at all carries no metadata. diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs index d78a07db..88b78f27 100644 --- a/crates/gamut-tiff/tests/metadata.rs +++ b/crates/gamut-tiff/tests/metadata.rs @@ -173,6 +173,58 @@ fn a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file() { ); } +/// A well-formed single-strip RGB file carrying XMP, plus one extra IFD-0 field. +/// +/// Used to hand `metadata()` a file whose *pixels* are perfectly readable but whose IFD 0 carries +/// a pointer tag feeding no field of [`TiffMetadata`]. +fn file_with_extra_ifd0_field(tag: u16, value: Value) -> Vec { + let mut ifd = Ifd::new(); + ifd.set(tags::IMAGE_WIDTH, Value::Short(vec![2])); + ifd.set(tags::IMAGE_LENGTH, Value::Short(vec![2])); + ifd.set(tags::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(tags::COMPRESSION, Value::Short(vec![1])); + ifd.set(tags::PHOTOMETRIC_INTERPRETATION, Value::Short(vec![2])); + ifd.set(tags::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(tags::ROWS_PER_STRIP, Value::Short(vec![2])); + ifd.set(tags::XMP, Value::Byte(XMP.to_vec())); + ifd.set(tag, value); + gamut_tiff::write_image( + gamut_tiff::ByteOrder::LittleEndian, + gamut_tiff::Variant::Classic, + &ifd, + &[vec![0u8; 2 * 2 * 3]], + ) + .expect("write") +} + +#[test] +fn a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks() { + // `TiffMetadata` has five fields, and an IFD-0 `SubIFDs` (330) or `GPSInfo` (34853) group + // feeds none of them — a thumbnail directory and a GPS directory are not XMP, IPTC, ICC, C2PA + // or the Exif sub-IFD. So following them can only add failure modes: a dangling one would make + // every block unreachable because of a pointer nobody asked for. The pixels of these files + // decode fine, which is exactly what makes losing the metadata indefensible. + for tag in [tags::SUB_IFDS, tags::GPS_INFO] { + let dangling = Value::Long(vec![0xFFFF_FF00]); + let bytes = file_with_extra_ifd0_field(tag, dangling); + let meta = TiffDecoder::new() + .metadata(&bytes) + .unwrap_or_else(|e| panic!("tag {tag}: metadata must survive a dangling pointer: {e}")); + assert_eq!(meta.xmp.as_deref(), Some(XMP), "tag {tag}"); + // The same property keeps a multi-page file whose pages share one thumbnail directory + // readable: an offset that is never followed cannot trip the cross-chain loop guard. + } +} + +#[test] +fn a_broken_exif_pointer_is_still_an_error() { + // The other half of the scoping rule. The Exif sub-IFD's content *is* returned, so reporting + // `exif: None` for a directory the file declares would be silent loss — this is the one + // pointer whose failure the caller must hear about. + let bytes = file_with_extra_ifd0_field(tags::EXIF_IFD, Value::Long(vec![0xFFFF_FF00])); + assert!(TiffDecoder::new().metadata(&bytes).is_err()); +} + #[test] fn a_file_without_metadata_decodes_to_an_empty_set() { let pixels = rgb(8, 4); From 65c48cb4539eade57e40ed67ce0701379522f818 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:50:42 -0400 Subject: [PATCH 10/43] fix(tiff): refuse a bad C2PA configuration before every pixel path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `with_c2pa_reserved` promises the refusal is raised "before any pixel work", and `c2pa_store()?` sits in `encode_packed`, the chokepoint every layout funnels through. Two paths reach it late: `encode_16bit` first allocates and fills a byte-order-corrected copy of the samples, and `EncodeImage` first runs a whole bit-packing pass. Both are O(width x height), so the documented claim was false wherever it mattered most. Add `check_c2pa` at the head of those two paths. The claim is now true on every path, and a test asserts it per path shape — a mutant deleting either call leaves every output byte-identical, so nothing else would have caught it. --- crates/gamut-tiff/src/encoder.rs | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 51ee65b9..82670a86 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -176,6 +176,18 @@ impl TiffEncoder { c2pa::MIN_STORE_LEN.max(self.variant().inline_threshold() + 1) } + /// Rejects a contradictory or unplaceable C2PA configuration, discarding the store itself. + /// + /// [`encode_packed`](Self::encode_packed) is the chokepoint every layout funnels through, but + /// two paths do real work *before* reaching it — [`encode_16bit`](Self::encode_16bit) + /// allocates and fills a byte-order-corrected copy of the samples, and the [`Bilevel`] impl + /// runs a whole bit-packing pass — so each calls this first. That is what makes + /// [`with_c2pa_reserved`](Self::with_c2pa_reserved)'s promise to fail before any pixel work + /// true on every path rather than on most of them. + fn check_c2pa(&self) -> Result<()> { + self.c2pa_store().map(|_| ()) + } + /// The C2PA manifest store to write, if any: the caller's, or a zero-filled reservation. /// /// # Errors @@ -332,6 +344,8 @@ impl TiffEncoder { extra_fields: &[(u16, Value)], out: &mut Vec, ) -> Result { + // Before the serialisation buffer below, so a bad C2PA configuration costs no allocation. + self.check_c2pa()?; // As in `encode_8bit`, the caller hands us an ImageRef-validated buffer. let row_bytes = dims.width as usize * spp * 2; debug_assert_eq!(samples.len() * 2, row_bytes * dims.height as usize); @@ -777,6 +791,8 @@ impl EncodeImage for TiffEncoder { impl EncodeImage for TiffEncoder { /// Packs one byte per pixel (`0` = black, non-zero = white) MSB-first into bits, `BlackIsZero`. fn encode_image(&self, image: ImageRef<'_, Bilevel>, out: &mut Vec) -> Result { + // Before the bit-packing pass below, so a bad C2PA configuration costs no pixel work. + self.check_c2pa()?; let (w, h) = (image.width() as usize, image.height() as usize); let pixels = image.as_samples(); let stored_row_bytes = w.div_ceil(8); @@ -905,6 +921,48 @@ mod tests { ); } + #[test] + fn every_pixel_type_refuses_a_bad_c2pa_configuration_before_touching_pixels() { + // `with_c2pa_reserved` promises the refusal comes before any pixel work. `encode_packed` + // is the common chokepoint, but `encode_16bit` allocates a byte-order-corrected copy of + // the samples first and the `Bilevel` impl runs a whole bit-packing pass first, so those + // two paths needed their own check — and a mutant deleting either would leave the doc + // claim false while every output stayed byte-identical. One case per path shape. + let dims = Dimensions { + width: 2, + height: 2, + }; + let bad = TiffEncoder::new() + .with_metadata(TiffMetadata::new().with_c2pa(vec![0; 4])) + .with_c2pa_reserved(4); + let mut out = Vec::new(); + assert!( + bad.encode_image( + ImageRef::::new(&[0u16; 12], dims).expect("16-bit image"), + &mut out + ) + .is_err(), + "the 16-bit path must refuse before packing" + ); + assert!( + bad.encode_image( + ImageRef::::new(&[0u8; 4], dims).expect("bilevel image"), + &mut out + ) + .is_err(), + "the bilevel path must refuse before packing" + ); + assert!( + bad.encode_image( + ImageRef::::new(&[0u8; 12], dims).expect("8-bit image"), + &mut out + ) + .is_err(), + "the 8-bit path must refuse" + ); + assert!(out.is_empty(), "a refused encode writes nothing"); + } + #[test] fn image_ref_rejects_mismatched_buffer() { // Validation now lives at the ImageRef boundary, so a wrong-length or zero-sized buffer From f55d10df6c36ba47adb7c1d962f98b14b214bbff Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:50:43 -0400 Subject: [PATCH 11/43] docs(tiff): stop claiming more than the seam delivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two overclaims, both found by reading the docs against the code. The C2PA reader's rationale said agreeing with `locate` means a store read can be written back. It does not, and a reader cannot promise that: it does not know which container the caller will write. `locate` accepts a store of exactly `MIN_STORE_LEN`, while writing BigTIFF needs 9 bytes, since 8 would pack into the entry's value word instead of being placed out of line. Name the case, and say the input is degenerate — 8 bytes is a JUMBF header with no content — so the break's real scope is visible. `TiffMetadata::exif` said pointer tags inside the directory come back parsed "never as raw offsets". Only the standard pointer tags are recognised as pointers, so a vendor private tag holding a `LONG` offset is carried through and re-encoded verbatim — the same defect as a stale Interop pointer, for a narrower input class, and one `deconstruct` cannot grade because it does not know the tag is a pointer either. STATUS.md was already precise; make the API doc a caller actually reads equally precise, and say plainly that a round trip through this field does not prove a rewrite pointer-safe. --- crates/gamut-tiff/README.md | 4 +++- crates/gamut-tiff/STATUS.md | 15 ++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index 61e302b5..d83cc135 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -69,7 +69,9 @@ compression schemes land additively on this frozen surface (see Status). (Group 3 1-D) and Group 4 (T.6). - **Metadata** — `TiffEncoder::with_metadata` / `TiffDecoder::metadata` carry an Exif sub-IFD (`ExifIFD`, 34665, as a `gamut_ifd::Ifd`, its own `InteroperabilityIFD` resolved into a child - directory rather than a stale offset) plus opaque XMP (700), IPTC-IIM (33723), ICC (34675) and + directory rather than a stale offset; other pointer tags, whose targets the seam does not + return, are left alone so a broken one cannot hide the blocks) plus opaque XMP (700), + IPTC-IIM (33723), ICC (34675) and C2PA (52545) payloads — the raw blocks the workspace's metadata facade consumes. Byte payloads are verbatim; the Exif directory's *entries* are carried unchanged but its ordering is normalised (ascending tag, duplicate tags collapsed, a child's next-IFD pointer ignored). The diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 9ec8dd0f..1594270b 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -83,11 +83,16 @@ Three consequences are contractual rather than incidental, and are documented wh **(a)** The Exif directory's *entries* are carried unchanged but its **ordering is normalised** — ascending tag (TIFF 6.0 §2 requires it on disk), duplicate tags collapsed to the last, a child's next-IFD pointer ignored — so "verbatim" is claimed for byte payloads, not for a directory model. -**(b)** The reader resolves **every standard pointer tag** (`gamut_ifd::tags::STANDARD_POINTER_TAGS`, -the list `gamut-dng`'s rewrite path uses), not just `ExifIFD`: an `InteroperabilityIFD` inside the -Exif directory is itself an absolute file offset, and returning it unparsed would let a caller -re-encode a dangling pointer into a file laid out differently — which the crate's own -`deconstruct` would then reject. **(c)** The blocks live in **IFD 0 only**, so a reader decoding +**(b)** The reader resolves `ExifIFD` **and** `InteroperabilityIFD`, and deliberately no other +pointer tag. Interop is in the list because it sits *inside* the Exif directory, which is returned +to the caller and may be written back: returning it as a raw offset would let a caller re-encode a +dangling pointer into a file laid out differently, which the crate's own `deconstruct` then +rejects. `SubIFDs` and `GPSInfo` are *out* of the list because their targets feed no field of +`TiffMetadata` and are never re-encoded, so following them could only add failure modes — and did: +a single dangling `SubIFDs` offset made XMP, IPTC, ICC and C2PA unreachable on a file whose pixels +decode perfectly, and two pages sharing one thumbnail directory tripped the reader's cross-chain +loop guard. Only *standard* pointer tags are recognised; a vendor private tag holding an offset is +carried through unchanged, and nothing in this crate can grade that. **(c)** The blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone sees none of them; duplicating an ICC profile onto every page is the worse outcome, and IFD 0 is where a reader conventionally looks. From 01668a816d54070e5bab7c608a7f64e4a6f3d14d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 23:31:24 -0400 Subject: [PATCH 12/43] refactor(tiff): make the C2PA check load-bearing instead of discarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-pixel C2PA check was a call whose value was thrown away, so removing its body changed no output on any input: encode_packed resolved the store again and refused with the same message. Nothing could tell whether it ran. encode_packed now takes the already-resolved store as a parameter, the shape encode_pages_rgb8 and encode_tiled already use. Every entry point must therefore resolve it — and take its refusal — before the pass that produces the packed bytes, so the ordering is held by the signature rather than by a check that could be deleted unnoticed, and a reservation is no longer built twice. The path test now reads the refusal's message under a tile size the layout stage also rejects, which is what separates 'resolved before the layout stage' from 'resolved inside it'; asserting is_err cannot. --- crates/gamut-tiff/src/encoder.rs | 114 ++++++++++++++++++------------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 82670a86..459ef115 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -176,18 +176,6 @@ impl TiffEncoder { c2pa::MIN_STORE_LEN.max(self.variant().inline_threshold() + 1) } - /// Rejects a contradictory or unplaceable C2PA configuration, discarding the store itself. - /// - /// [`encode_packed`](Self::encode_packed) is the chokepoint every layout funnels through, but - /// two paths do real work *before* reaching it — [`encode_16bit`](Self::encode_16bit) - /// allocates and fills a byte-order-corrected copy of the samples, and the [`Bilevel`] impl - /// runs a whole bit-packing pass — so each calls this first. That is what makes - /// [`with_c2pa_reserved`](Self::with_c2pa_reserved)'s promise to fail before any pixel work - /// true on every path rather than on most of them. - fn check_c2pa(&self) -> Result<()> { - self.c2pa_store().map(|_| ()) - } - /// The C2PA manifest store to write, if any: the caller's, or a zero-filled reservation. /// /// # Errors @@ -288,6 +276,7 @@ impl TiffEncoder { palette: &Palette8, out: &mut Vec, ) -> Result { + let store = self.c2pa_store()?; let w = indices.width() as usize; let colormap = palette.to_tiff_colormap(); self.encode_packed( @@ -300,6 +289,7 @@ impl TiffEncoder { photometric: PhotometricInterpretation::Palette, }, &[(tags::COLOR_MAP, Value::Short(colormap))], + store, out, ) } @@ -314,6 +304,7 @@ impl TiffEncoder { ) -> Result { // The caller is an EncodeImage impl handing us an ImageRef-validated buffer, so // pixels.len() == width * height * spp holds and the product cannot overflow. + let store = self.c2pa_store()?; let row_bytes = dims.width as usize * spp; debug_assert_eq!(pixels.len(), row_bytes * dims.height as usize); self.encode_packed( @@ -326,6 +317,7 @@ impl TiffEncoder { photometric, }, &[], + store, out, ) } @@ -345,7 +337,7 @@ impl TiffEncoder { out: &mut Vec, ) -> Result { // Before the serialisation buffer below, so a bad C2PA configuration costs no allocation. - self.check_c2pa()?; + let store = self.c2pa_store()?; // As in `encode_8bit`, the caller hands us an ImageRef-validated buffer. let row_bytes = dims.width as usize * spp * 2; debug_assert_eq!(samples.len() * 2, row_bytes * dims.height as usize); @@ -366,22 +358,32 @@ impl TiffEncoder { photometric, }, extra_fields, + store, out, ) } /// Lays out an image from already-packed sample bytes (`height * stored_row_bytes`), applying /// the strip codec and building the directory. + /// + /// `store` is the already-resolved C2PA manifest store (or reservation) to place at the end of + /// the file. It is a parameter rather than something resolved here so that every entry point + /// has to call [`c2pa_store`](Self::c2pa_store) — and so take its refusal — *before* whatever + /// pixel work it does to produce `packed`, which for + /// [`encode_16bit`](Self::encode_16bit) is a byte-order-corrected copy of the samples and for + /// the [`Bilevel`] impl a whole bit-packing pass. That is what makes + /// [`with_c2pa_reserved`](Self::with_c2pa_reserved)'s promise to fail before any pixel work + /// true on every path rather than on most of them, and it is the same shape + /// [`encode_pages_rgb8`](Self::encode_pages_rgb8) already uses. fn encode_packed( &self, packed: &[u8], dims: Dimensions, layout: &SampleLayout, extra_fields: &[(u16, Value)], + store: Option>, out: &mut Vec, ) -> Result { - // Validated before any pixel work, so a contradictory C2PA configuration fails fast. - let store = self.c2pa_store()?; if let Some((tw, tl)) = self.tiling { return self.encode_tiled(packed, dims, layout, extra_fields, tw, tl, store, out); } @@ -727,6 +729,7 @@ impl EncodeImage for TiffEncoder { impl EncodeImage for TiffEncoder { /// Stores the fourth sample as *unassociated* alpha (`ExtraSamples = 2`, not premultiplied). fn encode_image(&self, image: ImageRef<'_, Rgba8>, out: &mut Vec) -> Result { + let store = self.c2pa_store()?; let row_bytes = image.width() as usize * 4; self.encode_packed( image.as_samples(), @@ -738,6 +741,7 @@ impl EncodeImage for TiffEncoder { photometric: PhotometricInterpretation::Rgb, }, &[(tags::EXTRA_SAMPLES, Value::Short(vec![2]))], + store, out, ) } @@ -792,7 +796,7 @@ impl EncodeImage for TiffEncoder { /// Packs one byte per pixel (`0` = black, non-zero = white) MSB-first into bits, `BlackIsZero`. fn encode_image(&self, image: ImageRef<'_, Bilevel>, out: &mut Vec) -> Result { // Before the bit-packing pass below, so a bad C2PA configuration costs no pixel work. - self.check_c2pa()?; + let store = self.c2pa_store()?; let (w, h) = (image.width() as usize, image.height() as usize); let pixels = image.as_samples(); let stored_row_bytes = w.div_ceil(8); @@ -816,6 +820,7 @@ impl EncodeImage for TiffEncoder { photometric: PhotometricInterpretation::BlackIsZero, }, &[], + store, out, ) } @@ -922,44 +927,59 @@ mod tests { } #[test] - fn every_pixel_type_refuses_a_bad_c2pa_configuration_before_touching_pixels() { - // `with_c2pa_reserved` promises the refusal comes before any pixel work. `encode_packed` - // is the common chokepoint, but `encode_16bit` allocates a byte-order-corrected copy of - // the samples first and the `Bilevel` impl runs a whole bit-packing pass first, so those - // two paths needed their own check — and a mutant deleting either would leave the doc - // claim false while every output stayed byte-identical. One case per path shape. + fn every_encode_path_resolves_the_c2pa_store_before_it_lays_out_pixels() { + // `with_c2pa_reserved` promises a contradictory configuration is refused before any pixel + // work. `encode_packed` takes the already-resolved store as a parameter, so every entry + // point has to resolve it before whatever pass produces the packed bytes — `encode_16bit` + // a byte-order-corrected copy of the samples, the `Bilevel` impl a whole bit-packing pass, + // nothing at all for the 8-bit impls. + // + // Ordering leaves no trace in a successful encode, so it is read off the *message* of the + // refusal instead: each case is also given a tile size that is not a multiple of 16, which + // the layout stage rejects with its own error. Coming back with the C2PA message rather + // than the tiling one is what says the store was resolved before the layout stage ran — + // asserting only `is_err` cannot tell the two orders apart, since both refuse. The + // remaining step, resolving it before the pixel pass *within* an entry point, changes no + // output at all and so is held by `encode_packed`'s signature rather than by a test. let dims = Dimensions { width: 2, height: 2, }; let bad = TiffEncoder::new() .with_metadata(TiffMetadata::new().with_c2pa(vec![0; 4])) - .with_c2pa_reserved(4); + .with_c2pa_reserved(4) + .with_tiling(17, 17); let mut out = Vec::new(); - assert!( - bad.encode_image( - ImageRef::::new(&[0u16; 12], dims).expect("16-bit image"), - &mut out - ) - .is_err(), - "the 16-bit path must refuse before packing" - ); - assert!( - bad.encode_image( - ImageRef::::new(&[0u8; 4], dims).expect("bilevel image"), - &mut out - ) - .is_err(), - "the bilevel path must refuse before packing" - ); - assert!( - bad.encode_image( - ImageRef::::new(&[0u8; 12], dims).expect("8-bit image"), - &mut out - ) - .is_err(), - "the 8-bit path must refuse" - ); + let refusals = [ + ( + "the 16-bit path", + bad.encode_image( + ImageRef::::new(&[0u16; 12], dims).expect("16-bit image"), + &mut out, + ), + ), + ( + "the bilevel path", + bad.encode_image( + ImageRef::::new(&[0u8; 4], dims).expect("bilevel image"), + &mut out, + ), + ), + ( + "the 8-bit path", + bad.encode_image( + ImageRef::::new(&[0u8; 12], dims).expect("8-bit image"), + &mut out, + ), + ), + ]; + for (path, result) in refusals { + let err = result.expect_err(path); + assert!( + err.to_string().contains("not both"), + "{path} refused for the wrong reason: {err}" + ); + } assert!(out.is_empty(), "a refused encode writes nothing"); } From 31028571fba2c3331de2ea67548aa58144bb604c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:50:33 -0400 Subject: [PATCH 13/43] fix(tiff): stop a pointer on a discarded page from failing the read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `read_metadata` handed one flat `POINTER_TAGS` list to `gamut_ifd::read_tree`, which resolves it at every node of every page, while the blocks and the Exif sub-IFD come from IFD 0 alone. So a pointer on a page the reader throws away could still fail the whole call — the very thing `POINTER_TAGS` exists to prevent, moved from a tag this reader ignores to a page it ignores. Two files showed it, both of which `decode_image` reads happily. A two-page classic TIFF whose page 1 carried `ExifIFD = Long(0xFFFF_FF00)` answered `metadata()` with "TIFF: read out of bounds"; two pages whose `ExifIFD` entries named one directory answered "TIFF: sub-IFD pointer loop", because `read_tree` walks a whole file with a single `visited` set and a second page naming a directory the first named looks exactly like a cycle. Both are now read, and both are pinned. The chain is read with `gamut_ifd::read`, which follows no pointer at all, and the list is then resolved over IFD 0's subtree by hand through `read_ifd_at` — the per-pointer control `read_tree`'s own documentation points at. It stays one flat list applied at every node of that subtree, so IFD 0's behaviour is unchanged, including the one over-reach `POINTER_TAGS` already documented: `InteroperabilityIFD` is followed at IFD 0 too, where a conformant file never puts it. Narrowing that further would take a per-node list, which is a `gamut-ifd` surface rather than a scoping decision this crate makes. The C2PA manifest store is unaffected and is asserted so: §A.3.6 puts its entry in the last IFD of the main chain, and that entry carries the store's bytes rather than an offset, so the store is reached on a page whose pointers are never resolved. The new two-page test reads its store from the same page that carries the dangling pointer. Also asserts the message rather than `is_err` where a dangling `ExifIFD` must still be an error, so a refusal that named some other pointer could not pass for it. --- crates/gamut-tiff/STATUS.md | 8 +- crates/gamut-tiff/src/decoder.rs | 10 +- crates/gamut-tiff/src/metadata.rs | 138 +++++++++++++++++++++++----- crates/gamut-tiff/tests/metadata.rs | 121 ++++++++++++++++++++++-- 4 files changed, 243 insertions(+), 34 deletions(-) diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 1594270b..68b78fab 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -92,7 +92,13 @@ rejects. `SubIFDs` and `GPSInfo` are *out* of the list because their targets fee a single dangling `SubIFDs` offset made XMP, IPTC, ICC and C2PA unreachable on a file whose pixels decode perfectly, and two pages sharing one thumbnail directory tripped the reader's cross-chain loop guard. Only *standard* pointer tags are recognised; a vendor private tag holding an offset is -carried through unchanged, and nothing in this crate can grade that. **(c)** The blocks live in **IFD 0 only**, so a reader decoding +carried through unchanged, and nothing in this crate can grade that. The same rule scopes *where* +the pair is resolved: **IFD 0's subtree and no other page's**. Every later page of a multi-page +document feeds one field — the C2PA manifest store, whose entry holds the store's bytes rather +than an offset — so a *pointer* there is a discarded target too, and a dangling `ExifIFD` on page +1, or two pages naming one Exif directory, used to fail the whole read. Within that subtree it is +still one flat list at every node, which leaves one harmless over-reach: `InteroperabilityIFD` is +followed at IFD 0 as well, where a spec-conformant file never puts it. **(c)** The blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone sees none of them; duplicating an ICC profile onto every page is the worse outcome, and IFD 0 is where a reader conventionally looks. diff --git a/crates/gamut-tiff/src/decoder.rs b/crates/gamut-tiff/src/decoder.rs index c613f5bc..9961024f 100644 --- a/crates/gamut-tiff/src/decoder.rs +++ b/crates/gamut-tiff/src/decoder.rs @@ -210,9 +210,13 @@ impl TiffDecoder { /// /// # Errors /// - /// Returns [`Error::InvalidInput`] for a malformed header or IFD chain, or a sub-IFD pointer - /// graph that is not a tree (a cycle, a repeated child offset, an out-of-bounds or - /// unparseable pointer target, or nesting deeper than 16 levels). + /// Returns [`Error::InvalidInput`] for a malformed header or IFD chain, or for a pointer + /// **inside IFD 0's subtree** that does not resolve into a tree: an out-of-bounds or + /// unparseable target, two pointers naming one directory, or nesting deeper than 16 levels. + /// Only `ExifIFD` (34665) and `InteroperabilityIFD` (40965) are followed, and only from + /// IFD 0 downwards — a pointer on any later page of a multi-page document is never resolved, + /// so however broken it is it cannot fail this call, not even by naming a directory IFD 0's + /// own subtree also names. /// /// **This can fail on a file [`decode_image`](DecodeImage::decode_image) decodes happily**, /// and that is deliberate. Pixel decoding never follows a metadata pointer, so a broken diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 5d974219..e168b90e 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -39,9 +39,9 @@ //! ([`TiffEncoder::with_c2pa_reserved`](crate::TiffEncoder::with_c2pa_reserved)) and exposes the //! read-side locator as [`c2pa_exclusions`]. -use gamut_core::Result; +use gamut_core::{Error, Result}; use gamut_ifd::c2pa::{self, C2paExclusions}; -use gamut_ifd::{Ifd, Value, read_tree}; +use gamut_ifd::{ByteOrder, Ifd, Value, Variant, read, read_header, read_ifd_at}; use crate::tags; @@ -200,7 +200,8 @@ impl TiffMetadata { } } -/// The pointer tags [`read_metadata`] follows, scoped to what [`TiffMetadata`] actually returns. +/// The pointer tags [`read_metadata`] follows in IFD 0's subtree, scoped to what +/// [`TiffMetadata`] actually returns. /// /// Two tags, and the pair is a deliberate lower bound rather than a subset of convenience. /// @@ -219,11 +220,93 @@ impl TiffMetadata { /// pages sharing one thumbnail directory tripped the reader's cross-chain loop guard. A pointer /// whose target this reader throws away must not be able to fail the whole call. /// -/// One over-reach remains and is harmless: `InteroperabilityIFD` is also followed if it appears at -/// IFD 0, where it does not belong. A TIFF whose IFD 0 carries tag 40965 is already out of spec, -/// and `read_tree` takes one flat list for the whole tree. +/// The same rule scopes *where* the list is resolved, not only what is in it: it is applied to +/// **IFD 0's subtree and nowhere else** ([`resolve_pointers`]). Every page after IFD 0 feeds one +/// field of [`TiffMetadata`] — the C2PA manifest store, whose entry holds the store's bytes +/// directly rather than a pointer — so a *pointer* on such a page is a thrown-away target too, +/// and one on page 1 of a two-page document used to fail the whole call. `read_tree` cannot be +/// scoped that way: it resolves the list it is given at every node of every page. +/// +/// Within that subtree it is still **one flat list at every node**, exactly as +/// [`gamut_ifd::read_tree`] applies one to a whole file, and that is where the one remaining +/// over-reach comes from: `InteroperabilityIFD` is also followed if it appears at IFD 0, where it +/// does not belong. It is harmless — a TIFF whose IFD 0 carries tag 40965 is already out of spec, +/// and the resolved group feeds no field either way — and narrowing it further would take a +/// per-node list, which is a `gamut-ifd` surface rather than a scoping decision this crate makes. const POINTER_TAGS: &[u16] = &[tags::EXIF_IFD, tags::INTEROPERABILITY_IFD]; +/// An upper bound on the sub-IFD nesting [`resolve_pointers`] follows, bounding a hostile pointer +/// graph. It is [`gamut_ifd::read_tree`]'s own bound, so the two walks agree on what is too deep; +/// the deepest legitimate tree reachable through [`POINTER_TAGS`] is Exif → Interop, two levels. +const MAX_POINTER_DEPTH: usize = 16; + +/// The file offsets a sub-IFD pointer value carries: a `LONG` array (TIFF 6.0 §2), the typed +/// `IFD` (13) form of TIFF Technical Note 1, or BigTIFF's 64-bit `LONG8`/`IFD8` forms. Any other +/// type is not a pointer, and its field is left in place — the rule +/// [`gamut_ifd::read_tree`] applies, restated here so the two walks cannot disagree about what a +/// pointer is. +fn pointer_offsets(value: &Value) -> Option> { + match value { + Value::Long(v) | Value::Ifd(v) => Some(v.iter().map(|&x| u64::from(x)).collect()), + Value::Long8(v) | Value::Ifd8(v) => Some(v.clone()), + _ => None, + } +} + +/// Resolves `tags` over `ifd` and, recursively, over the children it reaches, replacing each +/// pointer field with a [`sub_ifds`](Ifd::sub_ifds) group — what [`gamut_ifd::read_tree`] does +/// for a whole file, applied to **one** directory's subtree. +/// +/// The scoping is the whole reason this exists: `read_tree` resolves the flat list it is handed at +/// every node of every page, so a pointer on a page [`read_metadata`] discards can fail a call +/// whose answer that page never contributed to. Following a pointer by hand is +/// [`gamut_ifd::read_ifd_at`]'s documented purpose. `visited` spans the walk and `depth` bounds +/// it, so a cycle or two pointers claiming one directory fail here rather than loop — the guards +/// are restated because they guard *this* walk. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if a pointer target is +/// unreadable, if two pointers name one directory, or if the tree nests deeper than +/// [`MAX_POINTER_DEPTH`]. +fn resolve_pointers( + data: &[u8], + order: ByteOrder, + variant: Variant, + ifd: &mut Ifd, + tags: &[u16], + visited: &mut Vec, + depth: usize, +) -> Result<()> { + if depth > MAX_POINTER_DEPTH { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: sub-IFD tree too deep", + )); + } + for &tag in tags { + let Some(offsets) = ifd.get(tag).and_then(pointer_offsets) else { + continue; + }; + let mut children = Vec::with_capacity(offsets.len()); + for offset in offsets { + if visited.contains(&offset) { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: sub-IFD pointer loop", + )); + } + visited.push(offset); + let mut child = read_ifd_at(data, offset, order, variant)?; + resolve_pointers(data, order, variant, &mut child, tags, visited, depth + 1)?; + children.push(child); + } + ifd.remove(tag); + ifd.set_sub_ifd(tag, children); + } + Ok(()) +} + /// A raw `BYTE`/`UNDEFINED` payload, copied out of a directory entry. fn bytes_value(value: Option<&Value>) -> Option> { value.and_then(Value::as_bytes).map(<[u8]>::to_vec) @@ -251,28 +334,41 @@ fn bytes_value(value: Option<&Value>) -> Option> { /// [`TiffEncoder::with_c2pa_reserved`](crate::TiffEncoder::with_c2pa_reserved)'s placement rule, /// not from disagreement here. pub(crate) fn read_metadata(data: &[u8]) -> Result { - let file = read_tree(data, POINTER_TAGS)?; - // §A.3.6: one store for the whole asset, in the last IFD of the main chain. `ifds` is that - // chain, so its last element is where the entry belongs — and a single-page file makes the - // two the same directory. A file with no IFD at all carries no metadata. - let (Some(ifd0), Some(store_ifd)) = (file.ifds.first(), file.ifds.last()) else { + let (order, variant, _) = read_header(data)?; + // The chain alone: `read` follows no pointer at all. A file with no IFD carries no metadata. + let mut ifds = read(data)?.ifds; + let Some(ifd0) = ifds.first_mut() else { return Ok(TiffMetadata::new()); }; + // One flat list, resolved over IFD 0's subtree and no other page's — see [`POINTER_TAGS`]. + resolve_pointers(data, order, variant, ifd0, POINTER_TAGS, &mut Vec::new(), 0)?; + let exif = ifd0 + .sub_ifds() + .iter() + .find(|group| group.tag == tags::EXIF_IFD) + .and_then(|group| group.ifds.first()) + .cloned(); + let xmp = bytes_value(ifd0.get(tags::XMP)); + let iptc = bytes_value(ifd0.get(tags::IPTC_NAA)); + let icc = bytes_value(ifd0.get(tags::ICC_PROFILE)); + // §A.3.6: one store for the whole asset, in the last IFD of the main chain. `ifds` is that + // chain, so its last element is where the entry belongs — and a single-page file makes the + // two the same directory. The entry carries the store's bytes rather than an offset, so the + // last IFD is reached without following a pointer, on a page whose pointers were never + // resolved. let located = c2pa::locate(data)?.is_some(); - let c2pa = match store_ifd.get(tags::C2PA_MANIFEST_STORE) { + let c2pa = match ifds + .last() + .and_then(|ifd| ifd.get(tags::C2PA_MANIFEST_STORE)) + { Some(Value::Undefined(store)) if located => Some(store.clone()), _ => None, }; Ok(TiffMetadata { - exif: ifd0 - .sub_ifds() - .iter() - .find(|group| group.tag == tags::EXIF_IFD) - .and_then(|group| group.ifds.first()) - .cloned(), - xmp: bytes_value(ifd0.get(tags::XMP)), - iptc: bytes_value(ifd0.get(tags::IPTC_NAA)), - icc: bytes_value(ifd0.get(tags::ICC_PROFILE)), + exif, + xmp, + iptc, + icc, c2pa, }) } diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs index 88b78f27..3a430a5c 100644 --- a/crates/gamut-tiff/tests/metadata.rs +++ b/crates/gamut-tiff/tests/metadata.rs @@ -4,7 +4,7 @@ //! Each test pins one encode path's use of the seam, so a path that stopped embedding metadata //! fails on its own rather than hiding behind another. -use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +use gamut_core::{DecodeImage, Dimensions, EncodeImage, ImageBuf, ImageRef, Rgb8}; use gamut_tiff::{ Anomaly, Ifd, Severity, TiffDecoder, TiffEncoder, TiffMetadata, Value, deconstruct, read, tags, }; @@ -13,6 +13,9 @@ use gamut_tiff::{ const XMP: &[u8] = b""; const IPTC: &[u8] = &[0x1c, 0x02, 0x05, 0x00, 0x04, b't', b'e', b's', b't']; const ICC: &[u8] = &[0, 0, 0, 12, b'a', b'c', b's', b'p', 1, 2, 3, 4]; +/// A JUMBF-shaped C2PA manifest store: long enough for `gamut_ifd::c2pa::locate` to accept it +/// (`LBox` + `TBox`, 8 bytes) and out of line in a classic TIFF entry. +const STORE: &[u8] = &[0, 0, 0, 0x16, b'j', b'u', b'm', b'b', 1, 2, 3, 4]; /// An Exif sub-IFD with one recognisable field (`ExposureTime`, 33434). fn exif() -> Ifd { @@ -173,11 +176,9 @@ fn a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file() { ); } -/// A well-formed single-strip RGB file carrying XMP, plus one extra IFD-0 field. -/// -/// Used to hand `metadata()` a file whose *pixels* are perfectly readable but whose IFD 0 carries -/// a pointer tag feeding no field of [`TiffMetadata`]. -fn file_with_extra_ifd0_field(tag: u16, value: Value) -> Vec { +/// The uncompressed 2×2 RGB directory every hand-built page below starts from: enough fields for +/// the pixels to decode, and nothing that feeds [`TiffMetadata`]. +fn page_ifd() -> Ifd { let mut ifd = Ifd::new(); ifd.set(tags::IMAGE_WIDTH, Value::Short(vec![2])); ifd.set(tags::IMAGE_LENGTH, Value::Short(vec![2])); @@ -186,13 +187,41 @@ fn file_with_extra_ifd0_field(tag: u16, value: Value) -> Vec { ifd.set(tags::PHOTOMETRIC_INTERPRETATION, Value::Short(vec![2])); ifd.set(tags::SAMPLES_PER_PIXEL, Value::Short(vec![3])); ifd.set(tags::ROWS_PER_STRIP, Value::Short(vec![2])); + ifd +} + +/// The one strip [`page_ifd`]'s directory describes. +fn page_strips() -> Vec> { + vec![vec![0u8; 2 * 2 * 3]] +} + +/// A well-formed single-strip RGB file carrying XMP, plus one extra IFD-0 field. +/// +/// Used to hand `metadata()` a file whose *pixels* are perfectly readable but whose IFD 0 carries +/// a pointer tag feeding no field of [`TiffMetadata`]. +fn file_with_extra_ifd0_field(tag: u16, value: Value) -> Vec { + let mut ifd = page_ifd(); ifd.set(tags::XMP, Value::Byte(XMP.to_vec())); ifd.set(tag, value); gamut_tiff::write_image( gamut_tiff::ByteOrder::LittleEndian, gamut_tiff::Variant::Classic, &ifd, - &[vec![0u8; 2 * 2 * 3]], + &page_strips(), + ) + .expect("write") +} + +/// Serialises `pages` as a multi-page classic TIFF, every page carrying [`page_strips`]. +fn multipage(pages: &[Ifd]) -> Vec { + let pages: Vec<(Ifd, Vec>)> = pages + .iter() + .map(|ifd| (ifd.clone(), page_strips())) + .collect(); + gamut_tiff::write_multipage( + gamut_tiff::ByteOrder::LittleEndian, + gamut_tiff::Variant::Classic, + &pages, ) .expect("write") } @@ -220,9 +249,83 @@ fn a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks() { fn a_broken_exif_pointer_is_still_an_error() { // The other half of the scoping rule. The Exif sub-IFD's content *is* returned, so reporting // `exif: None` for a directory the file declares would be silent loss — this is the one - // pointer whose failure the caller must hear about. + // pointer whose failure the caller must hear about. The *message* is the claim, not merely + // `is_err`: the file also carries a dangling offset the reader must not have followed for any + // other reason, so a refusal naming something else would mean the wrong pointer failed. let bytes = file_with_extra_ifd0_field(tags::EXIF_IFD, Value::Long(vec![0xFFFF_FF00])); - assert!(TiffDecoder::new().metadata(&bytes).is_err()); + let err = TiffDecoder::new() + .metadata(&bytes) + .expect_err("a dangling ExifIFD is the caller's business"); + assert!(err.to_string().contains("read out of bounds"), "{err}"); +} + +#[test] +fn a_broken_pointer_on_a_page_the_metadata_discards_does_not_fail_the_read() { + // The same rule that keeps `SubIFDs` and `GPSInfo` out of `POINTER_TAGS`, applied to whole + // *pages*: the blocks come from IFD 0 and the C2PA store from the last IFD, so a pointer + // anywhere else feeds nothing this returns and following it can only add failure modes. A + // dangling `ExifIFD` on page 1 of a two-page document used to fail the whole call. + // + // The store on that same last page is the other half of the claim: its entry carries the + // store's bytes rather than an offset, so the directory whose pointers are never resolved + // still delivers it. + let mut page0 = page_ifd(); + page0.set(tags::XMP, Value::Byte(XMP.to_vec())); + let mut page1 = page_ifd(); + page1.set(tags::EXIF_IFD, Value::Long(vec![0xFFFF_FF00])); + page1.set(tags::C2PA_MANIFEST_STORE, Value::Undefined(STORE.to_vec())); + let bytes = multipage(&[page0, page1]); + + let meta = TiffDecoder::new() + .metadata(&bytes) + .expect("a pointer on a discarded page must not fail the read"); + assert_eq!(meta.xmp.as_deref(), Some(XMP), "IFD 0's blocks"); + assert_eq!(meta.c2pa.as_deref(), Some(STORE), "the last IFD's store"); + // And the file is sound, which is what makes losing its metadata indefensible. + let decoded: ImageBuf = TiffDecoder::new().decode_image(&bytes).expect("decode"); + assert_eq!(decoded.dimensions().width, 2); +} + +/// A two-page file whose pages point their `ExifIFD` at **one** directory. +/// +/// Built in two passes: the offset the writer gives page 0's Exif directory is not known until it +/// has laid the file out, so pass 1 carries a same-sized `LONG` placeholder on page 1 and pass 2 +/// substitutes the real offset into a byte-identical layout. +fn two_pages_sharing_one_exif_directory() -> Vec { + let build = |offset: u32| { + let mut page0 = page_ifd(); + page0.set_sub_ifd(tags::EXIF_IFD, vec![exif()]); + let mut page1 = page_ifd(); + page1.set(tags::EXIF_IFD, Value::Long(vec![offset])); + multipage(&[page0, page1]) + }; + let laid_out = read(&build(0)).expect("read").ifds[0] + .get_u32(tags::EXIF_IFD) + .expect("page 0's Exif pointer"); + let bytes = build(laid_out); + let pages = read(&bytes).expect("read").ifds; + assert_eq!( + ( + pages[0].get_u32(tags::EXIF_IFD), + pages[1].get_u32(tags::EXIF_IFD) + ), + (Some(laid_out), Some(laid_out)), + "the two pages must end up sharing one directory for this to be the file under test" + ); + bytes +} + +#[test] +fn two_pages_naming_one_exif_directory_do_not_trip_the_loop_guard() { + // A cross-page pointer graph the reader walked with a single `visited` set: page 1's + // `ExifIFD` looked like a second pointer claiming page 0's directory and failed the call — + // for a page whose Exif is discarded. Page 0's Exif is the one that is returned, so it is + // what the assertion reads. + let bytes = two_pages_sharing_one_exif_directory(); + let meta = TiffDecoder::new() + .metadata(&bytes) + .expect("a shared directory on a discarded page must not fail the read"); + assert_eq!(meta.exif, Some(exif())); } #[test] From 568c195adad0a96274abd299ada13784795b9690 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:51:54 -0400 Subject: [PATCH 14/43] test(tiff): pin the store on the pixel paths nothing else encoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `encode_packed` takes the resolved C2PA store as a parameter, so no entry point can forget to resolve one — but that is an obligation to pass *a* store, not the one `c2pa_store()` returned. Replacing the argument with `None` at the bilevel call site left the suite at 196 passed: `tests/c2pa.rs` covered the strip, tile, palette, multi-page and BigTIFF paths and never encoded 16-bit, RGBA or bilevel, and a dropped store is silent — the file is well formed and only `c2pa_exclusions` disagrees. Those three are pinned now, and they are the three that do pixel work of their own before the call: a byte-order-corrected copy, an extra sample, a whole bit-packing pass. Dropping the store at any of the three call sites now fails. The ordering test grows the two entry points it was missing — RGBA and the palette path — so every entry point that resolves a store of its own is named there, each asserting the refusal is the C2PA one rather than the tiling one. Only `encode_pages_rgb8` stays out, and not by choice: it builds strip images directly, so there is no second refusal for the C2PA one to be told apart from. --- crates/gamut-tiff/src/encoder.rs | 21 +++++++++++++ crates/gamut-tiff/tests/c2pa.rs | 52 +++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 459ef115..8cc06db7 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -941,10 +941,16 @@ mod tests { // asserting only `is_err` cannot tell the two orders apart, since both refuse. The // remaining step, resolving it before the pixel pass *within* an entry point, changes no // output at all and so is held by `encode_packed`'s signature rather than by a test. + // + // Every entry point that resolves a store of its own is here — the four `encode_packed` + // callers plus `encode_palette8`, which reaches it through the same layout stage. Only + // `encode_pages_rgb8` is absent, and not by choice: it builds strip images directly, so + // there is no second refusal for the C2PA one to be told apart from. let dims = Dimensions { width: 2, height: 2, }; + let palette = Palette8::from_rgb_triples(&[0u8; 768]).expect("palette"); let bad = TiffEncoder::new() .with_metadata(TiffMetadata::new().with_c2pa(vec![0; 4])) .with_c2pa_reserved(4) @@ -972,6 +978,21 @@ mod tests { &mut out, ), ), + ( + "the RGBA path", + bad.encode_image( + ImageRef::::new(&[0u8; 16], dims).expect("RGBA image"), + &mut out, + ), + ), + ( + "the palette path", + bad.encode_palette8( + ImageRef::::new(&[0u8; 4], dims).expect("palette image"), + &palette, + &mut out, + ), + ), ]; for (path, result) in refusals { let err = result.expect_err(path); diff --git a/crates/gamut-tiff/tests/c2pa.rs b/crates/gamut-tiff/tests/c2pa.rs index 1e06608b..5239c09d 100644 --- a/crates/gamut-tiff/tests/c2pa.rs +++ b/crates/gamut-tiff/tests/c2pa.rs @@ -5,7 +5,7 @@ //! this crate's use of them — that a store survives an encode of a real image, in the right //! directory, at the end of the file, verbatim. -use gamut_core::{Dimensions, ImageRef, Indexed8, Rgb8}; +use gamut_core::{Bilevel, Dimensions, EncodeImage, ImageRef, Indexed8, Rgb8, Rgb16, Rgba8}; use gamut_tiff::{ ByteOrder, Palette8, SpanKind, TiffDecoder, TiffEncoder, TiffMetadata, c2pa_exclusions, deconstruct, read, tags, @@ -209,6 +209,56 @@ fn a_multipage_document_puts_the_entry_in_its_last_page() { assert_eq!(&bytes[range.start as usize..range.end() as usize], STORE); } +#[test] +fn the_pixel_paths_that_pack_their_own_buffer_place_the_store_too() { + // Every entry point resolves the store before it lays out pixels and hands it to + // `encode_packed` as a parameter — but a path can still hand on `None`, and that is silent: + // the file is well formed and only `c2pa_exclusions` disagrees. The strip, tile, palette and + // multi-page paths are pinned above. These are the three the rest of this file never encodes, + // and they are the three that do pixel work of their own first — a byte-order-corrected copy + // for 16-bit, an extra sample for RGBA, a whole bit-packing pass for bilevel — which is + // exactly where a store gets dropped on the floor. + let dims = Dimensions { + width: 4, + height: 4, + }; + let encoder = TiffEncoder::new() + .with_byte_order(ByteOrder::BigEndian) + .with_metadata(TiffMetadata::new().with_c2pa(STORE.to_vec())); + let files = [ + ( + "the 16-bit path", + encoder + .encode_to_vec(ImageRef::::new(&[0u16; 48], dims).expect("16-bit image")) + .expect("encode"), + ), + ( + "the RGBA path", + encoder + .encode_to_vec(ImageRef::::new(&[0u8; 64], dims).expect("RGBA image")) + .expect("encode"), + ), + ( + "the bilevel path", + encoder + .encode_to_vec(ImageRef::::new(&[0u8; 16], dims).expect("bilevel image")) + .expect("encode"), + ), + ]; + for (path, bytes) in files { + let range = c2pa_exclusions(&bytes) + .expect("locate") + .unwrap_or_else(|| panic!("{path} wrote no store")) + .store; + assert_eq!( + &bytes[range.start as usize..range.end() as usize], + STORE, + "{path}" + ); + assert_eq!(range.end(), bytes.len() as u64, "{path}"); + } +} + #[test] fn a_file_without_a_store_has_no_exclusion_ranges() { let pixels = rgb(8, 4); From 1126489979a5960928ccb4d84df48be4b88465cd Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:53:46 -0400 Subject: [PATCH 15/43] fix(tiff): refuse a C2PA reservation no buffer could hold `with_c2pa_reserved` returns `Self`, so it cannot refuse anything itself, and the length it stores went straight into `vec![0; len]` at the top of every encode. Past `isize::MAX` that panics with a capacity overflow rather than returning, and the length is a caller's number: a panic out of a library path is this crate's defect. `usize::MAX` was the shortest way to reach it. The bound is now checked with the existing minimum, before the reservation is materialised, so an unusable length costs neither the allocation nor the panic. It is the smaller of what a buffer holds and what the container describes: classic TIFF counts an `UNDEFINED` value with a 32-bit `LONG` and addresses it with a 32-bit offset, so nothing beyond `u32::MAX` could be pointed at, while BigTIFF's words are 64-bit and leave only the buffer. A supplied store is held to the same bound as a reservation, as it already was to the minimum. What stays outside this crate's reach is the allocator's: a reservation the machine has no memory for aborts, as any oversized allocation does. The same decision was taken for `AvifEncoder::with_c2pa_reserved`, so the two container crates of the C2PA epic now agree about it. --- crates/gamut-tiff/README.md | 3 +- crates/gamut-tiff/STATUS.md | 9 +++- crates/gamut-tiff/src/encoder.rs | 91 ++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index d83cc135..7ba18d22 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -81,7 +81,8 @@ compression schemes land additively on this frozen surface (see Status). chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by `TiffEncoder::encode_with_report` or recovered from any file by `gamut_tiff::c2pa_exclusions`. `with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in - place. + place; because it is an infallible builder, a length no buffer or container could hold is + refused by the encode that follows, before the reservation is allocated. - The decoder is hardened against hostile input (`#![forbid(unsafe_code)]`, a size cap, and a byte-flip fuzz corpus). diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 68b78fab..93ff11b5 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -109,7 +109,14 @@ file**) and §18.5.5 (the two disjoint exclusion ranges — the store, and the ` entry — that a `c2pa.hash.data` binding excludes; §18.7.3.3 leaves that the only binding a TIFF asset has), and `gamut-dng` calls the same helper, so the two formats cannot drift. `with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in -place; `encode_with_report` reports the ranges, and `c2pa_exclusions` recovers them from any +place. It is an infallible builder, so every bound on `len` is enforced by the **encode** that +follows, as `Error::InvalidInput`, on every entry point: below the store's minimum (8 bytes, 9 in +BigTIFF — the JUMBF box header, and one more than the variant's inline threshold, since a value +that packs inline is not the run at the end of the file §A.3.6 wants), and above the smaller of +what a buffer holds (`isize::MAX`, past which zero-filling it panicked instead of returning) and +what the container's count and offset words describe (`u32::MAX` in classic TIFF; BigTIFF's are +64-bit). The length is settled before the reservation is allocated, so an unusable one costs +neither the allocation nor the panic. `encode_with_report` reports the ranges, and `c2pa_exclusions` recovers them from any TIFF's bytes — including files written through `encode_palette8` or `encode_pages_rgb8`, which the object-safe `EncodeImage` seam cannot report through. The store's bytes are never byte-swapped: the header's `ByteOrder` does not govern them (§A.3.6). Tag 52545 joins `is_known_tag`, so the diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 8cc06db7..8c4cabca 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -155,9 +155,14 @@ impl TiffEncoder { /// be at least [`gamut_ifd::c2pa::MIN_STORE_LEN`] (a JUMBF box header, 8 bytes) **and longer /// than the container's inline threshold**, so BigTIFF's true minimum is 9 — a value of 8 or /// less would be packed into the entry's own value word rather than placed out of line at the - /// end of the file. A reservation cannot be combined with a store supplied through - /// [`with_metadata`](Self::with_metadata). Either is a typed error raised before any pixel - /// work, not after the image has been compressed. + /// end of the file. It must also be no *longer* than the smaller of what a buffer holds + /// (`isize::MAX`, past which zero-filling it would panic rather than return) and what the + /// container's count and offset words describe (`u32::MAX` in classic TIFF; BigTIFF's are + /// 64-bit). A reservation cannot be combined with a store supplied through + /// [`with_metadata`](Self::with_metadata). Each of those is a typed error raised before any + /// pixel work — and before the reservation is allocated — not after the image has been + /// compressed. What is left outside this crate's reach is the allocator's: a reservation the + /// machine has no memory for aborts, as any oversized allocation in Rust does. #[must_use] pub fn with_c2pa_reserved(mut self, len: usize) -> Self { self.c2pa_reserve = Some(len); @@ -176,33 +181,67 @@ impl TiffEncoder { c2pa::MIN_STORE_LEN.max(self.variant().inline_threshold() + 1) } + /// The longest manifest store this encoder can place, for the container variant it writes. + /// + /// Two upper bounds apply and the smaller wins. The buffer's is `isize::MAX`, all a `Vec` + /// can hold: past it `vec![0; len]` panics with a capacity overflow instead of returning, and + /// a length is something a caller passes, not something this crate controls. The container's + /// is the width of the words that describe the store — classic TIFF counts an `UNDEFINED` + /// value with a 32-bit `LONG` and addresses it with a 32-bit offset, so nothing beyond + /// `u32::MAX` could be described or pointed at; BigTIFF's are 64-bit, which on any target this + /// crate builds for is no bound at all beside the buffer's. + /// + /// What remains outside this crate's reach is the allocator's: a reservation the machine has + /// no memory for aborts, as any oversized allocation in Rust does. + fn max_store_len(&self) -> usize { + // `usize::MAX / 2` is `isize::MAX`, spelled without a sign-losing cast. + const BUFFER_MAX: usize = usize::MAX / 2; + match self.variant() { + Variant::Classic => BUFFER_MAX.min(u32::MAX as usize), + Variant::Big => BUFFER_MAX, + } + } + /// The C2PA manifest store to write, if any: the caller's, or a zero-filled reservation. /// /// # Errors /// /// Returns [`Error::InvalidInput`] if both were requested, or if the store is shorter than - /// [`min_store_len`](Self::min_store_len) — caught here, before any pixel work, rather than - /// after a whole image has been compressed. + /// [`min_store_len`](Self::min_store_len) or longer than + /// [`max_store_len`](Self::max_store_len) — all caught here, before any pixel work, rather + /// than after a whole image has been compressed. fn c2pa_store(&self) -> Result>> { - let store = match (&self.metadata.c2pa, self.c2pa_reserve) { + // The *length* is settled before a reservation is materialised, so an unusable one costs + // neither the allocation nor the panic `vec![0; len]` raises past `isize::MAX`. + let len = match (&self.metadata.c2pa, self.c2pa_reserve) { (Some(_), Some(_)) => { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), "TIFF: supply either a C2PA manifest store or a reservation, not both", )); } - (Some(store), None) => Cow::Borrowed(store.as_slice()), - (None, Some(len)) => Cow::Owned(vec![0; len]), + (Some(store), None) => store.len(), + (None, Some(len)) => len, (None, None) => return Ok(None), }; - if store.len() < self.min_store_len() { + if len < self.min_store_len() { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), "TIFF: a C2PA manifest store must be a JUMBF box header (8 bytes) and longer \ than the container's inline threshold (9 bytes in BigTIFF)", )); } - Ok(Some(store)) + if len > self.max_store_len() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: a C2PA manifest store must fit both a buffer and the container's 32-bit \ + count and offset words (BigTIFF's are 64-bit)", + )); + } + Ok(Some(match &self.metadata.c2pa { + Some(store) => Cow::Borrowed(store.as_slice()), + None => Cow::Owned(vec![0; len]), + })) } /// Places `store` (if any) at the end of the finished file and appends the result to `out`, @@ -899,6 +938,38 @@ mod tests { } } + #[test] + fn a_reservation_no_buffer_or_container_could_hold_is_refused() { + // `with_c2pa_reserved` returns `Self`, so an unusable length arrives at the encode. + // Unchecked it reached `vec![0; len]`, which past `isize::MAX` panics with a capacity + // overflow instead of returning — and a length is a caller's number, so a panic is this + // crate's defect, not theirs. Classic TIFF's own bound is the smaller of the two: an + // `UNDEFINED` value is counted with a 32-bit `LONG` and addressed with a 32-bit offset, + // so nothing beyond `u32::MAX` could be described or pointed at. + // + // The accepting side of these boundaries is not asserted — it would mean allocating + // gigabytes — so each variant is asserted one past its own bound and again at + // `usize::MAX`, which is where the missing check panicked. + for (big_tiff, container_max) in [(false, u64::from(u32::MAX)), (true, u64::MAX)] { + let at = |len: usize| { + TiffEncoder::new() + .with_big_tiff(big_tiff) + .with_c2pa_reserved(len) + }; + let expected = container_max.min(usize::MAX as u64 / 2) as usize; + assert_eq!(at(0).max_store_len(), expected, "big_tiff={big_tiff}"); + let mut lengths = vec![usize::MAX]; + lengths.extend(expected.checked_add(1)); + for len in lengths { + let err = at(len).c2pa_store().expect_err("longer than the bound"); + assert!( + err.to_string().contains("must fit both a buffer"), + "big_tiff={big_tiff}, len={len}: {err}" + ); + } + } + } + #[test] fn the_store_is_the_callers_bytes_or_a_zero_filled_reservation() { let supplied = b"\0\0\0\x14jumbc2pa".to_vec(); From 175b0ff585f2f027e77d2e17dfc04525d2c26a46 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:07:06 -0400 Subject: [PATCH 16/43] fix(tiff): kill the diff mutants the round-4 repairs left alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five survivors, four of them the new code's own and each a real gap. The reservation's upper bound was a numeric comparison whose accepting side no test could assert — `>` and `>=` differ only at exactly `u32::MAX` or `isize::MAX` bytes, which is not a reservation a test can allocate. The bound is dropped for the thing it was standing in for: the reservation is now taken with `try_reserve_exact`, so a length no `Vec` can hold is a typed error at the same point, with no comparison to get wrong and no container arm to keep in step. It also retires the second bound, on the container's 32-bit count and offset words, which refused only lengths the writer already cannot lay out. `resolve_pointers` kept `read_tree`'s depth bound of sixteen, which neither side of could be asserted: a generic reader needs sixteen because it is handed arbitrary tags, and this walk follows two. The deepest tree `ExifIFD` and `InteroperabilityIFD` can legitimately reach is two levels (EXIF 2.3 §4.6.3), so the bound is two and both sides of it are pinned — the Exif → Interop round trip already in tests/metadata.rs, and an Interop directory inside an Interop directory, refused here. That also kills `depth + 1` becoming `depth * 1`, which had no observable effect while the bound was unreachable. `pointer_offsets`' 64-bit arm had no BigTIFF reader test behind it: a resolver that knew only `LONG` would leave every BigTIFF's `ExifIFD` in place as an integer and report `exif: None`. Pinned on the type as well as on the result, so the fixture cannot stop exercising the arm without saying so. --- crates/gamut-tiff/README.md | 4 +- crates/gamut-tiff/STATUS.md | 11 +-- crates/gamut-tiff/src/encoder.rs | 115 ++++++++++++------------------ crates/gamut-tiff/src/metadata.rs | 51 ++++++++++++- 4 files changed, 102 insertions(+), 79 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index 7ba18d22..04cd09db 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -81,8 +81,8 @@ compression schemes land additively on this frozen surface (see Status). chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by `TiffEncoder::encode_with_report` or recovered from any file by `gamut_tiff::c2pa_exclusions`. `with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in - place; because it is an infallible builder, a length no buffer or container could hold is - refused by the encode that follows, before the reservation is allocated. + place; because it is an infallible builder, a length no buffer could hold is refused by the + encode that follows rather than panicking. - The decoder is hardened against hostile input (`#![forbid(unsafe_code)]`, a size cap, and a byte-flip fuzz corpus). diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 93ff11b5..4fb22bae 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -112,11 +112,12 @@ asset has), and `gamut-dng` calls the same helper, so the two formats cannot dri place. It is an infallible builder, so every bound on `len` is enforced by the **encode** that follows, as `Error::InvalidInput`, on every entry point: below the store's minimum (8 bytes, 9 in BigTIFF — the JUMBF box header, and one more than the variant's inline threshold, since a value -that packs inline is not the run at the end of the file §A.3.6 wants), and above the smaller of -what a buffer holds (`isize::MAX`, past which zero-filling it panicked instead of returning) and -what the container's count and offset words describe (`u32::MAX` in classic TIFF; BigTIFF's are -64-bit). The length is settled before the reservation is allocated, so an unusable one costs -neither the allocation nor the panic. `encode_with_report` reports the ranges, and `c2pa_exclusions` recovers them from any +that packs inline is not the run at the end of the file §A.3.6 wants), and above what a buffer can +hold, since past `isize::MAX` a `Vec` cannot exist and `vec![0; len]` said so by panicking with +a capacity overflow. The reservation is taken fallibly instead, so a caller's number cannot panic a +library path. What stays outside this crate's reach is the allocator's: a reservation the machine +has no memory for aborts, as any oversized allocation in Rust does. `encode_with_report` reports +the ranges, and `c2pa_exclusions` recovers them from any TIFF's bytes — including files written through `encode_palette8` or `encode_pages_rgb8`, which the object-safe `EncodeImage` seam cannot report through. The store's bytes are never byte-swapped: the header's `ByteOrder` does not govern them (§A.3.6). Tag 52545 joins `is_known_tag`, so the diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 8c4cabca..9514ace5 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -155,14 +155,13 @@ impl TiffEncoder { /// be at least [`gamut_ifd::c2pa::MIN_STORE_LEN`] (a JUMBF box header, 8 bytes) **and longer /// than the container's inline threshold**, so BigTIFF's true minimum is 9 — a value of 8 or /// less would be packed into the entry's own value word rather than placed out of line at the - /// end of the file. It must also be no *longer* than the smaller of what a buffer holds - /// (`isize::MAX`, past which zero-filling it would panic rather than return) and what the - /// container's count and offset words describe (`u32::MAX` in classic TIFF; BigTIFF's are - /// 64-bit). A reservation cannot be combined with a store supplied through + /// end of the file. It must also be a length a buffer can hold: past `isize::MAX` a `Vec` + /// cannot exist, so the reservation is taken fallibly and such a `len` is refused rather than + /// panicking. A reservation cannot be combined with a store supplied through /// [`with_metadata`](Self::with_metadata). Each of those is a typed error raised before any - /// pixel work — and before the reservation is allocated — not after the image has been - /// compressed. What is left outside this crate's reach is the allocator's: a reservation the - /// machine has no memory for aborts, as any oversized allocation in Rust does. + /// pixel work, not after the image has been compressed. What is left outside this crate's + /// reach is the allocator's: a reservation the machine has no memory for aborts, as any + /// oversized allocation in Rust does. #[must_use] pub fn with_c2pa_reserved(mut self, len: usize) -> Self { self.c2pa_reserve = Some(len); @@ -181,35 +180,14 @@ impl TiffEncoder { c2pa::MIN_STORE_LEN.max(self.variant().inline_threshold() + 1) } - /// The longest manifest store this encoder can place, for the container variant it writes. - /// - /// Two upper bounds apply and the smaller wins. The buffer's is `isize::MAX`, all a `Vec` - /// can hold: past it `vec![0; len]` panics with a capacity overflow instead of returning, and - /// a length is something a caller passes, not something this crate controls. The container's - /// is the width of the words that describe the store — classic TIFF counts an `UNDEFINED` - /// value with a 32-bit `LONG` and addresses it with a 32-bit offset, so nothing beyond - /// `u32::MAX` could be described or pointed at; BigTIFF's are 64-bit, which on any target this - /// crate builds for is no bound at all beside the buffer's. - /// - /// What remains outside this crate's reach is the allocator's: a reservation the machine has - /// no memory for aborts, as any oversized allocation in Rust does. - fn max_store_len(&self) -> usize { - // `usize::MAX / 2` is `isize::MAX`, spelled without a sign-losing cast. - const BUFFER_MAX: usize = usize::MAX / 2; - match self.variant() { - Variant::Classic => BUFFER_MAX.min(u32::MAX as usize), - Variant::Big => BUFFER_MAX, - } - } - /// The C2PA manifest store to write, if any: the caller's, or a zero-filled reservation. /// /// # Errors /// - /// Returns [`Error::InvalidInput`] if both were requested, or if the store is shorter than - /// [`min_store_len`](Self::min_store_len) or longer than - /// [`max_store_len`](Self::max_store_len) — all caught here, before any pixel work, rather - /// than after a whole image has been compressed. + /// Returns [`Error::InvalidInput`] if both were requested, if the store is shorter than + /// [`min_store_len`](Self::min_store_len), or if a reservation is longer than a buffer can + /// hold ([`zeroed`]) — all caught here, before any pixel work, rather than after a whole image + /// has been compressed. fn c2pa_store(&self) -> Result>> { // The *length* is settled before a reservation is materialised, so an unusable one costs // neither the allocation nor the panic `vec![0; len]` raises past `isize::MAX`. @@ -231,16 +209,9 @@ impl TiffEncoder { than the container's inline threshold (9 bytes in BigTIFF)", )); } - if len > self.max_store_len() { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "TIFF: a C2PA manifest store must fit both a buffer and the container's 32-bit \ - count and offset words (BigTIFF's are 64-bit)", - )); - } Ok(Some(match &self.metadata.c2pa { Some(store) => Cow::Borrowed(store.as_slice()), - None => Cow::Owned(vec![0; len]), + None => Cow::Owned(zeroed(len)?), })) } @@ -865,6 +836,29 @@ impl EncodeImage for TiffEncoder { } } +/// A `len`-byte zero-filled C2PA reservation, or a typed error where `vec![0; len]` would panic. +/// +/// [`TiffEncoder::with_c2pa_reserved`] returns `Self`, so it cannot refuse anything itself and the +/// length it stores is a caller's number that reaches this untouched. Past `isize::MAX` a `Vec` +/// cannot exist at all, and `vec![0; len]` says so by panicking with a capacity overflow — which a +/// library path must not do. Reserving fallibly turns that into [`Error::InvalidInput`], raised +/// before any pixel work. +/// +/// What remains outside this crate's reach is the allocator's: a reservation the machine has no +/// memory for aborts, as any oversized allocation in Rust does, since a request the kernel +/// overcommits succeeds here and fails only when the bytes are written. +fn zeroed(len: usize) -> Result> { + let mut store = Vec::new(); + store.try_reserve_exact(len).map_err(|_| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: a C2PA manifest store reservation this long cannot be allocated", + ) + })?; + store.resize(len, 0); + Ok(store) +} + /// Stores a dimension/count as `SHORT` when it fits, else `LONG` (both are valid per §2). fn dim_value(n: u32) -> Value { if n <= u32::from(u16::MAX) { @@ -939,35 +933,18 @@ mod tests { } #[test] - fn a_reservation_no_buffer_or_container_could_hold_is_refused() { - // `with_c2pa_reserved` returns `Self`, so an unusable length arrives at the encode. - // Unchecked it reached `vec![0; len]`, which past `isize::MAX` panics with a capacity - // overflow instead of returning — and a length is a caller's number, so a panic is this - // crate's defect, not theirs. Classic TIFF's own bound is the smaller of the two: an - // `UNDEFINED` value is counted with a 32-bit `LONG` and addressed with a 32-bit offset, - // so nothing beyond `u32::MAX` could be described or pointed at. - // - // The accepting side of these boundaries is not asserted — it would mean allocating - // gigabytes — so each variant is asserted one past its own bound and again at - // `usize::MAX`, which is where the missing check panicked. - for (big_tiff, container_max) in [(false, u64::from(u32::MAX)), (true, u64::MAX)] { - let at = |len: usize| { - TiffEncoder::new() - .with_big_tiff(big_tiff) - .with_c2pa_reserved(len) - }; - let expected = container_max.min(usize::MAX as u64 / 2) as usize; - assert_eq!(at(0).max_store_len(), expected, "big_tiff={big_tiff}"); - let mut lengths = vec![usize::MAX]; - lengths.extend(expected.checked_add(1)); - for len in lengths { - let err = at(len).c2pa_store().expect_err("longer than the bound"); - assert!( - err.to_string().contains("must fit both a buffer"), - "big_tiff={big_tiff}, len={len}: {err}" - ); - } - } + fn a_reservation_no_buffer_could_hold_is_refused_instead_of_panicking() { + // `with_c2pa_reserved` returns `Self`, so an unusable length arrives at the encode, and it + // went straight into `vec![0; len]`. Past `isize::MAX` a `Vec` cannot exist and that + // expression says so by panicking with a capacity overflow — a panic out of a library path + // for a number the caller chose. `usize::MAX` is the shortest way to reach it; the + // accepting side of the boundary is not asserted, since it would mean allocating + // `isize::MAX` bytes. + let err = TiffEncoder::new() + .with_c2pa_reserved(usize::MAX) + .c2pa_store() + .expect_err("no buffer holds usize::MAX bytes"); + assert!(err.to_string().contains("cannot be allocated"), "{err}"); } #[test] diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index e168b90e..ca1beafe 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -236,9 +236,15 @@ impl TiffMetadata { const POINTER_TAGS: &[u16] = &[tags::EXIF_IFD, tags::INTEROPERABILITY_IFD]; /// An upper bound on the sub-IFD nesting [`resolve_pointers`] follows, bounding a hostile pointer -/// graph. It is [`gamut_ifd::read_tree`]'s own bound, so the two walks agree on what is too deep; -/// the deepest legitimate tree reachable through [`POINTER_TAGS`] is Exif → Interop, two levels. -const MAX_POINTER_DEPTH: usize = 16; +/// graph: a directory a hundred levels down is still a directory, and a file of a few kilobytes +/// holds enough of them to exhaust the stack. +/// +/// It is **two**, not [`gamut_ifd::read_tree`]'s sixteen, because this walk follows two tags and +/// the deepest tree they can legitimately reach is IFD 0 → `ExifIFD` → `InteroperabilityIFD` +/// (EXIF 2.3 §4.6.3). Nothing conformant puts an Exif or an Interop directory *inside* an Interop +/// directory, so a third level is already out of spec — a generic reader needs sixteen because it +/// is handed arbitrary tags, and this one is not. +const MAX_POINTER_DEPTH: usize = 2; /// The file offsets a sub-IFD pointer value carries: a `LONG` array (TIFF 6.0 §2), the typed /// `IFD` (13) form of TIFF Technical Note 1, or BigTIFF's 64-bit `LONG8`/`IFD8` forms. Any other @@ -506,6 +512,45 @@ mod tests { assert!(ifd.fields().is_empty()); } + #[test] + fn a_bigtiff_exif_pointer_is_followed_through_its_64_bit_form() { + // BigTIFF writes a sub-IFD pointer as `LONG8`, not `LONG`. A resolver that knew only the + // 32-bit forms would leave the field in place as a plain integer and report `exif: None` — + // silent loss on every BigTIFF, the one file shape where the type differs. + let mut ifd0 = Ifd::new(); + ifd0.set_sub_ifd(tags::EXIF_IFD, vec![exif_ifd()]); + let bytes = write(&TiffFile { + order: ByteOrder::LittleEndian, + variant: Variant::Big, + ifds: vec![ifd0], + }) + .expect("write"); + assert!(matches!( + read(&bytes).expect("read").ifds[0].get(tags::EXIF_IFD), + Some(Value::Long8(_)) + )); + assert_eq!(read_metadata(&bytes).expect("read").exif, Some(exif_ifd())); + } + + #[test] + fn a_directory_below_the_exif_interop_pair_is_too_deep() { + // The bound is two because the pair can legitimately reach two levels and no more, so + // both sides of it are asserted: `a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file` + // (tests/metadata.rs) reads an Exif → Interop tree back, and a third level — an Interop + // directory inside an Interop directory, which no conformant file writes — is refused + // here rather than walked. + let mut third = Ifd::new(); + third.set(1, Value::Ascii("R98".into())); // InteroperabilityIndex + let mut interop = Ifd::new(); + interop.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![third]); + let mut exif = exif_ifd(); + exif.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![interop]); + let mut ifd0 = Ifd::new(); + ifd0.set_sub_ifd(tags::EXIF_IFD, vec![exif]); + let err = read_metadata(&file_with(ifd0)).expect_err("three levels is out of spec"); + assert!(err.to_string().contains("sub-IFD tree too deep"), "{err}"); + } + #[test] fn read_metadata_returns_each_payload_verbatim() { let mut ifd0 = Ifd::new(); From d84f1799cf70f9f7e418f805e4299aa8a5edea22 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 04:38:44 -0400 Subject: [PATCH 17/43] fix(tiff): refuse an Exif tree deeper than the reader walks back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `with_metadata` took any nesting a caller built while `metadata()` refuses a directory more than two levels under IFD 0, so this crate wrote a well-formed four-level Exif tree and then rejected its own file as too deep — the same shape as the round-1 finding on this branch, and a direct contradiction of the seam's contract that what the file holds is what the caller gets. The bound now applies to both sides. `TiffMetadata::check` refuses an Exif sub-IFD nesting below the `ExifIFD` -> `InteroperabilityIFD` pair EXIF 2.3 §4.6.3 allows, as a typed error taken at one chokepoint with the C2PA store's refusals, before any pixel work on every entry point. It is measured against `MAX_POINTER_DEPTH` itself rather than against a second constant, so the two cannot drift. The reader's bound was narrowed from sixteen to two in the previous commit for a reason that is not sufficient on its own: that neither side of sixteen could be asserted, so a mutant lived. What justifies two is the spec — `ExifIFD` and `InteroperabilityIFD` reach two levels and a third is out of spec — and that is what the narrowing rests on now. A mutation-assertability argument never licenses narrowing a contract; it can only ask whether the contract was stated at the right width. The error documentation said "deeper than 16 levels" a round after the bound became two, and now states the shipped one. --- crates/gamut-tiff/README.md | 5 +- crates/gamut-tiff/STATUS.md | 8 +++- crates/gamut-tiff/src/decoder.rs | 5 +- crates/gamut-tiff/src/encoder.rs | 36 +++++++++++--- crates/gamut-tiff/src/metadata.rs | 73 +++++++++++++++++++++++++++++ crates/gamut-tiff/tests/metadata.rs | 24 ++++++++++ 6 files changed, 142 insertions(+), 9 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index 04cd09db..66ede182 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -76,7 +76,10 @@ compression schemes land additively on this frozen surface (see Status). are verbatim; the Exif directory's *entries* are carried unchanged but its ordering is normalised (ascending tag, duplicate tags collapsed, a child's next-IFD pointer ignored). The blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone must - look at IFD 0 for them. The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared + look at IFD 0 for them. What the encoder writes the decoder reads back: the Exif directory may + nest the one further directory `InteroperabilityIFD` (EXIF 2.3 §4.6.3), which is as deep as the + reader walks, and a caller's directory nested deeper is refused by the encode rather than + written into a file this crate could not read. The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared `gamut_ifd::c2pa` helper it and `gamut-dng` both call: the entry in the last IFD of the main chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by `TiffEncoder::encode_with_report` or recovered from any file by `gamut_tiff::c2pa_exclusions`. diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 4fb22bae..00839317 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -100,7 +100,13 @@ than an offset — so a *pointer* there is a discarded target too, and a danglin still one flat list at every node, which leaves one harmless over-reach: `InteroperabilityIFD` is followed at IFD 0 as well, where a spec-conformant file never puts it. **(c)** The blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone sees none of them; duplicating an ICC profile onto every -page is the worse outcome, and IFD 0 is where a reader conventionally looks. +page is the worse outcome, and IFD 0 is where a reader conventionally looks. **(d)** The **writer +is bounded by what the reader accepts**: the walk above stops two levels under IFD 0 — the deepest +tree `ExifIFD` and `InteroperabilityIFD` legitimately reach (EXIF 2.3 §4.6.3) — and an Exif +directory a caller nested deeper is refused by `with_metadata`'s encode, as `Error::InvalidInput` +before any pixel work, rather than written into a well-formed file this crate cannot read back. +The bound is the spec's; that a narrower one is also easier to assert is not on its own a reason +to narrow a contract. The C2PA manifest store is the one carrier with a placement rule of its own, and that rule is not restated here: `gamut_ifd::c2pa` owns C2PA 2.4 §A.3.6 (tag 52545 / `0xCD41`, type `UNDEFINED`, one diff --git a/crates/gamut-tiff/src/decoder.rs b/crates/gamut-tiff/src/decoder.rs index 9961024f..7581b166 100644 --- a/crates/gamut-tiff/src/decoder.rs +++ b/crates/gamut-tiff/src/decoder.rs @@ -212,7 +212,10 @@ impl TiffDecoder { /// /// Returns [`Error::InvalidInput`] for a malformed header or IFD chain, or for a pointer /// **inside IFD 0's subtree** that does not resolve into a tree: an out-of-bounds or - /// unparseable target, two pointers naming one directory, or nesting deeper than 16 levels. + /// unparseable target, two pointers naming one directory, or nesting below the + /// `ExifIFD` → `InteroperabilityIFD` pair — two levels under IFD 0, the deepest tree those + /// two tags legitimately reach (EXIF 2.3 §4.6.3), and the same bound + /// [`TiffEncoder::with_metadata`](crate::TiffEncoder::with_metadata) writes within. /// Only `ExifIFD` (34665) and `InteroperabilityIFD` (40965) are followed, and only from /// IFD 0 downwards — a pointer on any later page of a multi-page document is never resolved, /// so however broken it is it cannot fail this call, not even by naming a directory IFD 0's diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 9514ace5..c4db5b0f 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -138,6 +138,13 @@ impl TiffEncoder { /// IFD of the main chain and its bytes at the end of the file, as C2PA 2.4 §A.3.6 requires. /// For a single-image encode those are the same directory; for /// [`encode_pages_rgb8`](Self::encode_pages_rgb8) they are the first and last page. + /// + /// **What this encoder writes, [`TiffDecoder::metadata`](crate::TiffDecoder::metadata) reads + /// back.** The one thing that could break the agreement is nesting: an Exif sub-IFD may carry + /// sub-IFD groups of its own, the reader follows the `ExifIFD` → `InteroperabilityIFD` pair + /// (EXIF 2.3 §4.6.3) and no deeper, so a directory nested below that pair is refused here — + /// a typed [`Error::InvalidInput`] raised before any pixel work, not a well-formed file this + /// crate's own reader then rejects. #[must_use] pub fn with_metadata(mut self, metadata: TiffMetadata) -> Self { self.metadata = metadata; @@ -215,6 +222,23 @@ impl TiffEncoder { })) } + /// Every refusal a configuration can earn, taken together before any pixel work: metadata + /// this crate would write but could not read back ([`TiffMetadata::check`]), and the C2PA + /// manifest store ([`c2pa_store`](Self::c2pa_store)), whose bytes come back for the layout + /// stage to place. + /// + /// The two are resolved at one call rather than at each entry point's own convenience, + /// because "before any pixel work" is a promise every entry point has to keep and a second + /// place to forget it is a defect waiting to be written. + /// + /// # Errors + /// + /// As [`TiffMetadata::check`] and [`c2pa_store`](Self::c2pa_store). + fn checked_store(&self) -> Result>> { + self.metadata.check()?; + self.c2pa_store() + } + /// Places `store` (if any) at the end of the finished file and appends the result to `out`, /// returning the number of bytes written. /// @@ -286,7 +310,7 @@ impl TiffEncoder { palette: &Palette8, out: &mut Vec, ) -> Result { - let store = self.c2pa_store()?; + let store = self.checked_store()?; let w = indices.width() as usize; let colormap = palette.to_tiff_colormap(); self.encode_packed( @@ -314,7 +338,7 @@ impl TiffEncoder { ) -> Result { // The caller is an EncodeImage impl handing us an ImageRef-validated buffer, so // pixels.len() == width * height * spp holds and the product cannot overflow. - let store = self.c2pa_store()?; + let store = self.checked_store()?; let row_bytes = dims.width as usize * spp; debug_assert_eq!(pixels.len(), row_bytes * dims.height as usize); self.encode_packed( @@ -347,7 +371,7 @@ impl TiffEncoder { out: &mut Vec, ) -> Result { // Before the serialisation buffer below, so a bad C2PA configuration costs no allocation. - let store = self.c2pa_store()?; + let store = self.checked_store()?; // As in `encode_8bit`, the caller hands us an ImageRef-validated buffer. let row_bytes = dims.width as usize * spp * 2; debug_assert_eq!(samples.len() * 2, row_bytes * dims.height as usize); @@ -504,7 +528,7 @@ impl TiffEncoder { "TIFF: no pages to encode", )); } - let store = self.c2pa_store()?; + let store = self.checked_store()?; let total = pages.len() as u16; let mut images: Vec<(Ifd, Vec>)> = Vec::with_capacity(pages.len()); for (i, page) in pages.iter().enumerate() { @@ -739,7 +763,7 @@ impl EncodeImage for TiffEncoder { impl EncodeImage for TiffEncoder { /// Stores the fourth sample as *unassociated* alpha (`ExtraSamples = 2`, not premultiplied). fn encode_image(&self, image: ImageRef<'_, Rgba8>, out: &mut Vec) -> Result { - let store = self.c2pa_store()?; + let store = self.checked_store()?; let row_bytes = image.width() as usize * 4; self.encode_packed( image.as_samples(), @@ -806,7 +830,7 @@ impl EncodeImage for TiffEncoder { /// Packs one byte per pixel (`0` = black, non-zero = white) MSB-first into bits, `BlackIsZero`. fn encode_image(&self, image: ImageRef<'_, Bilevel>, out: &mut Vec) -> Result { // Before the bit-packing pass below, so a bad C2PA configuration costs no pixel work. - let store = self.c2pa_store()?; + let store = self.checked_store()?; let (w, h) = (image.width() as usize, image.height() as usize); let pixels = image.as_samples(); let stored_row_bytes = w.div_ceil(8); diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index ca1beafe..11ba7710 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -179,6 +179,40 @@ impl TiffMetadata { self.exif.as_ref().filter(|ifd| !ifd.fields().is_empty()) } + /// Refuses a set this crate would write into a file its own [`read_metadata`] then rejects. + /// + /// One thing can break that: the Exif sub-IFD is a caller's directory and may carry sub-IFD + /// groups of its own, and nothing about a directory in memory stops it nesting a hundred + /// levels down. The reader follows [`MAX_POINTER_DEPTH`] levels below IFD 0 and refuses what + /// is deeper, so the writer refuses the same tree rather than emitting a well-formed file + /// whose metadata this crate cannot read back. The Exif directory occupies the first of those + /// levels, so its own nesting may use the rest — one further directory, + /// `InteroperabilityIFD` (EXIF 2.3 §4.6.3), which is exactly the tree a decoded camera EXIF + /// comes back as. + /// + /// It is a *conservative* restatement in one respect: the reader only refuses a tree too deep + /// under a tag it follows, while this counts every sub-IFD group. A group under a tag the + /// reader does not follow is a directory this crate could not return either — it comes back + /// as the raw offset it was written to — so refusing it too keeps the writer inside what the + /// reader delivers rather than outside it. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if the Exif sub-IFD nests + /// deeper than the reader walks back. + pub(crate) fn check(&self) -> Result<()> { + if let Some(exif) = self.exif_ifd() + && !within_depth(exif, MAX_POINTER_DEPTH - 1) + { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: an Exif sub-IFD may nest one further directory \ + (ExifIFD -> InteroperabilityIFD, EXIF 2.3 §4.6.3) and this one nests deeper", + )); + } + Ok(()) + } + /// Writes the XMP / IPTC / ICC blocks and the Exif sub-IFD into `ifd0`. /// /// The C2PA store is deliberately **not** written here: its bytes must land at the end of @@ -246,6 +280,20 @@ const POINTER_TAGS: &[u16] = &[tags::EXIF_IFD, tags::INTEROPERABILITY_IFD]; /// is handed arbitrary tags, and this one is not. const MAX_POINTER_DEPTH: usize = 2; +/// Whether `ifd`'s own sub-IFD nesting stays within `depth` further levels — the writer's side of +/// [`MAX_POINTER_DEPTH`], used by [`TiffMetadata::check`]. +/// +/// Stops at the bound instead of measuring the whole tree, so a directory a caller nested a +/// hundred levels deep costs a hundred levels of neither recursion nor time. +fn within_depth(ifd: &Ifd, depth: usize) -> bool { + ifd.sub_ifds().iter().all(|group| { + group.ifds.iter().all(|child| match depth.checked_sub(1) { + Some(left) => within_depth(child, left), + None => false, + }) + }) +} + /// The file offsets a sub-IFD pointer value carries: a `LONG` array (TIFF 6.0 §2), the typed /// `IFD` (13) form of TIFF Technical Note 1, or BigTIFF's 64-bit `LONG8`/`IFD8` forms. Any other /// type is not a pointer, and its field is left in place — the rule @@ -532,6 +580,31 @@ mod tests { assert_eq!(read_metadata(&bytes).expect("read").exif, Some(exif_ifd())); } + #[test] + fn the_writer_refuses_the_exif_nesting_the_reader_refuses() { + // `metadata()` walks two levels below IFD 0 and refuses a third, so a set the encoder + // accepted at three levels became a well-formed file this crate could not read back — + // the encoder emitting what its own reader rejects. The bound is the whole claim, so + // both sides of it are asserted: the `ExifIFD` → `InteroperabilityIFD` pair (EXIF 2.3 + // §4.6.3) is accepted, and one directory below it is not. + let mut interop = Ifd::new(); + interop.set(1, Value::Ascii("R98".into())); // InteroperabilityIndex + let mut pair = exif_ifd(); + pair.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![interop]); + TiffMetadata::new() + .with_exif(pair.clone()) + .check() + .expect("the Exif -> Interop pair is what a decoded camera EXIF is"); + + let mut deeper = exif_ifd(); + deeper.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![pair]); + let err = TiffMetadata::new() + .with_exif(deeper) + .check() + .expect_err("a directory below the pair is one this crate could not read back"); + assert!(err.to_string().contains("nests deeper"), "{err}"); + } + #[test] fn a_directory_below_the_exif_interop_pair_is_too_deep() { // The bound is two because the pair can legitimately reach two levels and no more, so diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs index 3a430a5c..ec62f871 100644 --- a/crates/gamut-tiff/tests/metadata.rs +++ b/crates/gamut-tiff/tests/metadata.rs @@ -176,6 +176,30 @@ fn a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file() { ); } +#[test] +fn the_encoder_refuses_an_exif_tree_its_own_decoder_could_not_read_back() { + // The encoder used to write any nesting a caller built and `metadata()` refused a third level + // of it, so this crate emitted a well-formed file it could not itself read — the one shape a + // seam whose contract is "what the file holds is what the caller gets" must not have. The + // refusal is the encoder's, before any pixel work; the reader's side of the same bound is + // `a_directory_below_the_exif_interop_pair_is_too_deep` (src/metadata.rs), and the depth this + // pair *does* reach round-trips in + // `a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file` above. + let mut interop = Ifd::new(); + interop.set(1, Value::Ascii("R98".into())); // InteroperabilityIndex + let mut inner = exif(); + inner.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![interop]); + let mut deeper = exif(); + deeper.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![inner]); + + let pixels = rgb(8, 4); + let err = TiffEncoder::new() + .with_metadata(TiffMetadata::new().with_exif(deeper)) + .encode_to_vec(image(&pixels, 8, 4)) + .expect_err("a tree the decoder refuses must not be written"); + assert!(err.to_string().contains("nests deeper"), "{err}"); +} + /// The uncompressed 2×2 RGB directory every hand-built page below starts from: enough fields for /// the pixels to decode, and nothing that feeds [`TiffMetadata`]. fn page_ifd() -> Ifd { From 27a8beaf7934edb2ee855042afae39637e2ed164 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 04:38:59 -0400 Subject: [PATCH 18/43] perf(tiff): find a visited sub-IFD offset in log time, not linear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_pointers` remembered the offsets it had followed in a `Vec` and tested membership by scanning it. Nothing bounds how many offsets one pointer array holds — the count is a 32-bit field the file chooses — so the walk was quadratic in a number hostile input picks, on a surface the README calls hardened against hostile input. Measured in release on hand-built classic TIFFs whose single `ExifIFD` array names N distinct all-zero directories, every call returning `Ok`: 0.24 s at 0.59 MB, 0.98 s at 1.17 MB and 4.23 s at 2.34 MB — clean quadratic growth. With a `BTreeSet` the same three files answer in 5.2 ms, 13.6 ms and 26.9 ms. `insert` returning `false` *is* the loop guard, so the guard loses a line rather than gaining one. The shared walk this one restates has the same defect and is fixed separately (#578); what still has no bound at all is the breadth of a single pointer array (#579). --- crates/gamut-tiff/src/metadata.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 11ba7710..6247143d 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -39,6 +39,8 @@ //! ([`TiffEncoder::with_c2pa_reserved`](crate::TiffEncoder::with_c2pa_reserved)) and exposes the //! read-side locator as [`c2pa_exclusions`]. +use std::collections::BTreeSet; + use gamut_core::{Error, Result}; use gamut_ifd::c2pa::{self, C2paExclusions}; use gamut_ifd::{ByteOrder, Ifd, Value, Variant, read, read_header, read_ifd_at}; @@ -318,6 +320,14 @@ fn pointer_offsets(value: &Value) -> Option> { /// it, so a cycle or two pointers claiming one directory fail here rather than loop — the guards /// are restated because they guard *this* walk. /// +/// `visited` is a **set**, not a list, and that is a hardening decision rather than a style one: +/// nothing bounds how many offsets one pointer array holds, so a linear membership scan makes the +/// walk quadratic in a number a hostile file chooses. Measured in release on hand-built files +/// whose single `ExifIFD` array names N distinct empty directories, a scanned list took 0.24 s at +/// 0.6 MB and 4.2 s at 2.3 MB — clean quadratic growth, every call returning `Ok`. A set answers +/// the same files in 5 ms and 27 ms. What still has no bound is the *breadth* of one pointer +/// array; that is issue #579. +/// /// # Errors /// /// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if a pointer target is @@ -329,7 +339,7 @@ fn resolve_pointers( variant: Variant, ifd: &mut Ifd, tags: &[u16], - visited: &mut Vec, + visited: &mut BTreeSet, depth: usize, ) -> Result<()> { if depth > MAX_POINTER_DEPTH { @@ -344,13 +354,12 @@ fn resolve_pointers( }; let mut children = Vec::with_capacity(offsets.len()); for offset in offsets { - if visited.contains(&offset) { + if !visited.insert(offset) { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), "TIFF: sub-IFD pointer loop", )); } - visited.push(offset); let mut child = read_ifd_at(data, offset, order, variant)?; resolve_pointers(data, order, variant, &mut child, tags, visited, depth + 1)?; children.push(child); @@ -395,7 +404,15 @@ pub(crate) fn read_metadata(data: &[u8]) -> Result { return Ok(TiffMetadata::new()); }; // One flat list, resolved over IFD 0's subtree and no other page's — see [`POINTER_TAGS`]. - resolve_pointers(data, order, variant, ifd0, POINTER_TAGS, &mut Vec::new(), 0)?; + resolve_pointers( + data, + order, + variant, + ifd0, + POINTER_TAGS, + &mut BTreeSet::new(), + 0, + )?; let exif = ifd0 .sub_ifds() .iter() From 55739eec127e988ec073b46491629229d9060d0a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 04:39:14 -0400 Subject: [PATCH 19/43] fix(tiff): refuse a store classic TIFF's count word cannot describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit dropped `max_store_len` because its comparison had an accepting side no test could allocate. One of the two bounds it carried was standing in for something real: classic TIFF counts an `UNDEFINED` value with a 32-bit `LONG`, so a store past 4 GiB cannot be described whatever else holds it. Without the check a reservation that size is allocated, zero-filled and the whole image compressed before `gamut_ifd::c2pa::append_store` refuses it — with a message about the file's 4 GiB offset limit rather than about the length the caller passed. The documentation of a refusal that still happens went with it. The bound is back, expressed as `u32::try_from` over the variant rather than as a comparison, so there is no boundary whose accepting side needs 4 GiB to assert. It is deliberately *necessary*, not sufficient: whether the store's own offset fits depends on the size of the file it lands after, which only `append_store` knows, and that refusal stays there. The `isize::MAX` reservation test now asks for BigTIFF, since classic TIFF refuses that length one check earlier for a different reason. --- crates/gamut-tiff/README.md | 5 +-- crates/gamut-tiff/STATUS.md | 10 ++++-- crates/gamut-tiff/src/encoder.rs | 59 ++++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index 66ede182..f8d3302b 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -84,8 +84,9 @@ compression schemes land additively on this frozen surface (see Status). chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by `TiffEncoder::encode_with_report` or recovered from any file by `gamut_tiff::c2pa_exclusions`. `with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in - place; because it is an infallible builder, a length no buffer could hold is refused by the - encode that follows rather than panicking. + place; because it is an infallible builder, a length no buffer could hold — or, in a classic + TIFF, none its 32-bit `count` word could describe — is refused by the encode that follows + rather than panicking or costing a whole image's compression first. - The decoder is hardened against hostile input (`#![forbid(unsafe_code)]`, a size cap, and a byte-flip fuzz corpus). diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 00839317..2ba9dce9 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -118,10 +118,14 @@ asset has), and `gamut-dng` calls the same helper, so the two formats cannot dri place. It is an infallible builder, so every bound on `len` is enforced by the **encode** that follows, as `Error::InvalidInput`, on every entry point: below the store's minimum (8 bytes, 9 in BigTIFF — the JUMBF box header, and one more than the variant's inline threshold, since a value -that packs inline is not the run at the end of the file §A.3.6 wants), and above what a buffer can +that packs inline is not the run at the end of the file §A.3.6 wants), above what a buffer can hold, since past `isize::MAX` a `Vec` cannot exist and `vec![0; len]` said so by panicking with -a capacity overflow. The reservation is taken fallibly instead, so a caller's number cannot panic a -library path. What stays outside this crate's reach is the allocator's: a reservation the machine +a capacity overflow, and — in a classic TIFF — above the 4 GiB its entry's 32-bit `LONG` `count` +could describe, which the encode would otherwise discover only after compressing the image and +zero-filling the reservation (BigTIFF's count is 64-bit and has no such bound). The reservation is +taken fallibly instead, so a caller's number cannot panic a library path. A store whose *offset* +would pass classic TIFF's 4 GiB limit depends on the size of the file it lands after, so that one +stays `gamut_ifd::c2pa::append_store`'s, refused once the file exists. What stays outside this crate's reach is the allocator's: a reservation the machine has no memory for aborts, as any oversized allocation in Rust does. `encode_with_report` reports the ranges, and `c2pa_exclusions` recovers them from any TIFF's bytes — including files written through `encode_palette8` or `encode_pages_rgb8`, which the diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index c4db5b0f..a9604842 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -164,9 +164,15 @@ impl TiffEncoder { /// less would be packed into the entry's own value word rather than placed out of line at the /// end of the file. It must also be a length a buffer can hold: past `isize::MAX` a `Vec` /// cannot exist, so the reservation is taken fallibly and such a `len` is refused rather than - /// panicking. A reservation cannot be combined with a store supplied through - /// [`with_metadata`](Self::with_metadata). Each of those is a typed error raised before any - /// pixel work, not after the image has been compressed. What is left outside this crate's + /// panicking. In a **classic** TIFF it must further be countable by the 32-bit `LONG` the + /// entry's `count` field is — 4 GiB — since a longer store could not be described whatever + /// else held it; BigTIFF's count is 64-bit and has no such bound. A reservation cannot be + /// combined with a store supplied through [`with_metadata`](Self::with_metadata). Each of + /// those is a typed error raised before any pixel work, not after the image has been + /// compressed. What the length alone cannot settle stays with + /// [`gamut_ifd::c2pa::append_store`]: a store whose *offset* would pass classic TIFF's 4 GiB + /// limit depends on the size of the file it lands after, so it is refused there, once the + /// file exists. What is left outside this crate's /// reach is the allocator's: a reservation the machine has no memory for aborts, as any /// oversized allocation in Rust does. #[must_use] @@ -192,9 +198,9 @@ impl TiffEncoder { /// # Errors /// /// Returns [`Error::InvalidInput`] if both were requested, if the store is shorter than - /// [`min_store_len`](Self::min_store_len), or if a reservation is longer than a buffer can - /// hold ([`zeroed`]) — all caught here, before any pixel work, rather than after a whole image - /// has been compressed. + /// [`min_store_len`](Self::min_store_len), if classic TIFF's 32-bit `LONG` count cannot + /// describe it, or if a reservation is longer than a buffer can hold ([`zeroed`]) — all + /// caught here, before any pixel work, rather than after a whole image has been compressed. fn c2pa_store(&self) -> Result>> { // The *length* is settled before a reservation is materialised, so an unusable one costs // neither the allocation nor the panic `vec![0; len]` raises past `isize::MAX`. @@ -216,6 +222,25 @@ impl TiffEncoder { than the container's inline threshold (9 bytes in BigTIFF)", )); } + // Classic TIFF counts an `UNDEFINED` value with a 32-bit `LONG` and addresses it with a + // 32-bit offset; BigTIFF's words are 64-bit. A store no `LONG` can count is one + // `gamut_ifd::c2pa::append_store` refuses — but only once the whole image has been + // compressed and the reservation zero-filled, and with a message about the file's 4 GiB + // limit rather than about the length the caller passed. Comparing two lengths costs + // nothing, so the refusal is taken here as well. It is *necessary*, not sufficient: the + // store's own offset must also fit, and that depends on the size of the file it lands + // after, which only `append_store` knows. + let countable = match self.variant() { + Variant::Classic => u32::try_from(len).is_ok(), + Variant::Big => true, + }; + if !countable { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: a C2PA manifest store longer than 4 GiB cannot be counted by classic \ + TIFF's 32-bit LONG (BigTIFF's count is 64-bit)", + )); + } Ok(Some(match &self.metadata.c2pa { Some(store) => Cow::Borrowed(store.as_slice()), None => Cow::Owned(zeroed(len)?), @@ -963,14 +988,34 @@ mod tests { // expression says so by panicking with a capacity overflow — a panic out of a library path // for a number the caller chose. `usize::MAX` is the shortest way to reach it; the // accepting side of the boundary is not asserted, since it would mean allocating - // `isize::MAX` bytes. + // `isize::MAX` bytes. BigTIFF, because classic TIFF refuses this length one check earlier + // for a different reason — its `count` word could not describe it. let err = TiffEncoder::new() + .with_big_tiff(true) .with_c2pa_reserved(usize::MAX) .c2pa_store() .expect_err("no buffer holds usize::MAX bytes"); assert!(err.to_string().contains("cannot be allocated"), "{err}"); } + #[test] + #[cfg(target_pointer_width = "64")] + fn a_classic_tiff_store_its_count_word_cannot_describe_is_refused_before_the_pixels() { + // Classic TIFF counts an `UNDEFINED` value with a 32-bit `LONG`, so 4 GiB is the longest + // store the entry could describe. `append_store` does refuse it, but only after the image + // has been compressed and the reservation zero-filled — 4 GiB of it. Length alone settles + // this, so it is settled first. + // + // Neither the accepting side of the boundary nor BigTIFF's freedom from it is asserted: + // both would mean successfully allocating 4 GiB in a unit test. The test is 64-bit-only + // because on a 32-bit target no `usize` can exceed `u32::MAX`. + let err = TiffEncoder::new() + .with_c2pa_reserved(u32::MAX as usize + 1) + .c2pa_store() + .expect_err("longer than a classic TIFF LONG can count"); + assert!(err.to_string().contains("cannot be counted"), "{err}"); + } + #[test] fn the_store_is_the_callers_bytes_or_a_zero_filled_reservation() { let supplied = b"\0\0\0\x14jumbc2pa".to_vec(); From e3d81f107bbc4c20103358909385458363dd5eca Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 04:39:24 -0400 Subject: [PATCH 20/43] test(tiff): narrow the discarded-page test to the rule it names `a_broken_pointer_on_a_page_the_metadata_discards_does_not_fail_the_read` named one rule and asserted four things: that the read survives, that IFD 0's XMP comes back, that the last page's C2PA store comes back, and that the pixels decode. Three modules could fail it, and the store assertion killed nothing the inline `read_metadata_takes_the_store_from_the_last_ifd_of_the_chain` does not already kill. It now asserts the rule it is named for and nothing else: the call succeeds and still returns IFD 0's blocks. The fixture loses the store it no longer reads, which drops the file's last reach into the C2PA locator and the strip decoder. --- crates/gamut-tiff/tests/metadata.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs index ec62f871..c8346c40 100644 --- a/crates/gamut-tiff/tests/metadata.rs +++ b/crates/gamut-tiff/tests/metadata.rs @@ -4,7 +4,7 @@ //! Each test pins one encode path's use of the seam, so a path that stopped embedding metadata //! fails on its own rather than hiding behind another. -use gamut_core::{DecodeImage, Dimensions, EncodeImage, ImageBuf, ImageRef, Rgb8}; +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; use gamut_tiff::{ Anomaly, Ifd, Severity, TiffDecoder, TiffEncoder, TiffMetadata, Value, deconstruct, read, tags, }; @@ -13,9 +13,6 @@ use gamut_tiff::{ const XMP: &[u8] = b""; const IPTC: &[u8] = &[0x1c, 0x02, 0x05, 0x00, 0x04, b't', b'e', b's', b't']; const ICC: &[u8] = &[0, 0, 0, 12, b'a', b'c', b's', b'p', 1, 2, 3, 4]; -/// A JUMBF-shaped C2PA manifest store: long enough for `gamut_ifd::c2pa::locate` to accept it -/// (`LBox` + `TBox`, 8 bytes) and out of line in a classic TIFF entry. -const STORE: &[u8] = &[0, 0, 0, 0x16, b'j', b'u', b'm', b'b', 1, 2, 3, 4]; /// An Exif sub-IFD with one recognisable field (`ExposureTime`, 33434). fn exif() -> Ifd { @@ -290,24 +287,19 @@ fn a_broken_pointer_on_a_page_the_metadata_discards_does_not_fail_the_read() { // anywhere else feeds nothing this returns and following it can only add failure modes. A // dangling `ExifIFD` on page 1 of a two-page document used to fail the whole call. // - // The store on that same last page is the other half of the claim: its entry carries the - // store's bytes rather than an offset, so the directory whose pointers are never resolved - // still delivers it. + // Only that: which directory a store comes from is + // `read_metadata_takes_the_store_from_the_last_ifd_of_the_chain` (src/metadata.rs), and + // whether these pixels decode is the strip decoder's business, not this rule's. let mut page0 = page_ifd(); page0.set(tags::XMP, Value::Byte(XMP.to_vec())); let mut page1 = page_ifd(); page1.set(tags::EXIF_IFD, Value::Long(vec![0xFFFF_FF00])); - page1.set(tags::C2PA_MANIFEST_STORE, Value::Undefined(STORE.to_vec())); let bytes = multipage(&[page0, page1]); let meta = TiffDecoder::new() .metadata(&bytes) .expect("a pointer on a discarded page must not fail the read"); assert_eq!(meta.xmp.as_deref(), Some(XMP), "IFD 0's blocks"); - assert_eq!(meta.c2pa.as_deref(), Some(STORE), "the last IFD's store"); - // And the file is sound, which is what makes losing its metadata indefensible. - let decoded: ImageBuf = TiffDecoder::new().decode_image(&bytes).expect("decode"); - assert_eq!(decoded.dimensions().width, 2); } /// A two-page file whose pages point their `ExifIFD` at **one** directory. From 91d79117193d66a6c62eb68fb00a6c1320028fc5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 04:39:36 -0400 Subject: [PATCH 21/43] docs(tiff): say why the restated pointer match carries no feature guard `pointer_offsets` restates `gamut_ifd`'s rule for what a sub-IFD pointer value is, and the source guards its 64-bit arm with `#[cfg(feature = "bigtiff")]` while this copy does not. That reads as a dropped guard; it is not one, and it cannot be restored. `bigtiff` is `gamut-ifd`'s feature, enabled unconditionally by this crate's dependency on it and not re-exported, so the attribute here names a feature `gamut-tiff` does not have: `unexpected_cfgs` rejects it under the workspace's `-D warnings`, and were it accepted the arm would vanish and every BigTIFF's `ExifIFD` would read back as a plain integer. Verified by adding the attribute and building: one `unexpected cfg condition value: bigtiff` warning, which the lint gate takes as an error. --- crates/gamut-tiff/src/metadata.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 6247143d..61ea2903 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -301,6 +301,14 @@ fn within_depth(ifd: &Ifd, depth: usize) -> bool { /// type is not a pointer, and its field is left in place — the rule /// [`gamut_ifd::read_tree`] applies, restated here so the two walks cannot disagree about what a /// pointer is. +/// +/// The source guards its 64-bit arm with `#[cfg(feature = "bigtiff")]` and this restatement does +/// not, because it cannot: `bigtiff` is `gamut-ifd`'s feature, enabled unconditionally by this +/// crate's dependency on it and not re-exported, so the same attribute here would name a feature +/// `gamut-tiff` does not have. `unexpected_cfgs` rejects it under the workspace's `-D warnings`, +/// and were it accepted the arm would vanish and every BigTIFF's `ExifIFD` would read back as a +/// plain integer. The arm is therefore always live here, which is what a codec that always writes +/// and reads BigTIFF needs. fn pointer_offsets(value: &Value) -> Option> { match value { Value::Long(v) | Value::Ifd(v) => Some(v.iter().map(|&x| u64::from(x)).collect()), From b6dd0d2347e1ce986f7e53a1910f6c4a8025a7db Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 04:50:23 -0400 Subject: [PATCH 22/43] fix(tiff): keep the classic-count guard out of a mutant's reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's incremental mutation shard timed out on `delete !` in `c2pa_store` — the guard added one commit earlier, inverted into its own opposite. Deleting the `!` lets the test's oversized reservation reach `zeroed`, whose `try_reserve_exact` the runner's allocator granted, and the 4 GiB zero-fill that followed ran past the 60 s test timeout. The same mutant was CAUGHT locally, on a machine that zero-fills 4 GiB inside a minute: the mutant's fate depended on the hardware, which is not a gate. Two changes, neither of them a weakened check. The condition is spelled as the refusing one, `uncountable`, so there is no `!` to delete. And the test asks for `usize::MAX` rather than `u32::MAX + 1`, so a length that gets past any future guard meets a reservation no allocator can satisfy and fails instantly instead of allocating. Which of the two bounds answers first is now what separates this test from the BigTIFF one, since both ask for the same length. --- crates/gamut-tiff/src/encoder.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index a9604842..351073e9 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -230,11 +230,17 @@ impl TiffEncoder { // nothing, so the refusal is taken here as well. It is *necessary*, not sufficient: the // store's own offset must also fit, and that depends on the size of the file it lands // after, which only `append_store` knows. - let countable = match self.variant() { - Variant::Classic => u32::try_from(len).is_ok(), - Variant::Big => true, + // + // Spelled as the *refusing* condition, with no `!` in front of it, because deleting a `!` + // is a mutation cargo-mutants makes: over `!countable` it turns the guard into its own + // opposite, and the length that reaches `zeroed` from the test below is one whose + // reservation the machine may well satisfy — 4 GiB of zero-fill, which is a timed-out + // mutant rather than a caught one, and timed out only on machines slow enough to notice. + let uncountable = match self.variant() { + Variant::Classic => u32::try_from(len).is_err(), + Variant::Big => false, }; - if !countable { + if uncountable { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), "TIFF: a C2PA manifest store longer than 4 GiB cannot be counted by classic \ @@ -1009,8 +1015,16 @@ mod tests { // Neither the accepting side of the boundary nor BigTIFF's freedom from it is asserted: // both would mean successfully allocating 4 GiB in a unit test. The test is 64-bit-only // because on a 32-bit target no `usize` can exceed `u32::MAX`. + // + // `usize::MAX` rather than `u32::MAX + 1` for the same reason the guard avoids a `!`: + // should any mutant let this length past the guard, the next thing it meets is a + // reservation no allocator can satisfy, which fails instantly. A length the machine + // *might* satisfy would make the mutant's fate depend on how fast that machine zero-fills + // 4 GiB. Which of the two bounds fires first is what separates this from + // `a_reservation_no_buffer_could_hold_is_refused_instead_of_panicking`, which asks for the + // same length as BigTIFF and gets the other message. let err = TiffEncoder::new() - .with_c2pa_reserved(u32::MAX as usize + 1) + .with_c2pa_reserved(usize::MAX) .c2pa_store() .expect_err("longer than a classic TIFF LONG can count"); assert!(err.to_string().contains("cannot be counted"), "{err}"); From a31a47df94db20f75697ab2e3e597f322fcbd689 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:41:23 -0400 Subject: [PATCH 23/43] fix(tiff): resolve every standard pointer inside the Exif subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 followed `STANDARD_POINTER_TAGS` everywhere and round 3 narrowed the list to `ExifIFD` and `InteroperabilityIFD` so that a dangling `SubIFDs` offset on a page could not hide the blocks. The narrowing was right about the page and wrong about the directory: it also stopped resolving `SubIFDs` and `GPSInfo` *inside* the Exif directory, which is the one directory the seam hands back. Executed at the previous head, a `GPSInfo` group under `ExifIFD` encodes with no anomalies, comes back from `metadata()` as `Long([212])` — a raw absolute offset into the source file — and re-encoding that value makes this crate's own `deconstruct` report a `Severity::Error` structural anomaly on a file that is no longer fully classified. The two levels answer opposite questions, so the tag list is now per-level rather than one flat list. At IFD 0 a followed pointer can only add a failure mode, so only `ExifIFD` is followed there and round 3's finding stays closed. Below it an *un*followed pointer becomes a stale offset in a directory the caller is handed, so all four standard pointer tags are followed. The cost is stated where the list is: inside the subtree an unreadable target under any of the four now fails the read, which is the trade `ExifIFD` itself already made. The writer's bound follows the reader's, and splits into the two refusals it always conflated. A group under a tag outside the standard set is refused by its own message instead of one citing the Interop depth clause, which was the wrong clause for that mistake; a group nested past the reader's depth keeps the depth message. Neither counts a childless group, so the depth bound still counts children rather than groups. --- crates/gamut-tiff/src/metadata.rs | 315 ++++++++++++++++++++-------- crates/gamut-tiff/tests/metadata.rs | 64 ++++++ 2 files changed, 289 insertions(+), 90 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 61ea2903..06b75cf0 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -84,9 +84,19 @@ pub struct TiffMetadata { /// silently repaired, which is worth knowing before using a re-encode to prove a file /// unmodified. /// - /// The **standard** pointer tag that occurs inside this directory — `InteroperabilityIFD` - /// (40965) — comes back as a parsed [`sub_ifds`](gamut_ifd::Ifd::sub_ifds) group rather than a - /// raw offset, so the writer gives it a fresh offset when the directory is embedded again. + /// **Every standard pointer tag inside this directory is resolved.** All four members of + /// [`gamut_ifd::tags::STANDARD_POINTER_TAGS`] — `SubIFDs` (330), `ExifIFD` (34665), `GPSInfo` + /// (34853) and `InteroperabilityIFD` (40965) — come back as parsed + /// [`sub_ifds`](gamut_ifd::Ifd::sub_ifds) groups rather than as raw offsets, so the writer + /// gives each a fresh offset when the directory is embedded again. `InteroperabilityIFD` is + /// the one EXIF 2.3 §4.6.3 puts here and the one a camera writes; the other three are + /// resolved because a directory this crate *hands back* must not contain an offset into the + /// file it was read from, whichever tag carries it. + /// + /// This is the Exif subtree's rule and not IFD 0's: at IFD 0 only `ExifIFD` is followed, so a + /// `SubIFDs` or `GPSInfo` pointer sitting on the page — whose target feeds no field here — + /// cannot fail the call. Inside this directory the same pointer is followed, and an + /// unreadable target is an error, because this directory is the one that comes back. /// /// **Only the standard pointer tags are recognised as pointers.** A *private* tag whose value /// happens to be a `LONG` file offset — some vendors point at their own sub-directories this @@ -181,38 +191,33 @@ impl TiffMetadata { self.exif.as_ref().filter(|ifd| !ifd.fields().is_empty()) } - /// Refuses a set this crate would write into a file its own [`read_metadata`] then rejects. + /// Refuses a set this crate would write into a file its own [`read_metadata`] then rejects, + /// or reads back as something other than what was written. /// - /// One thing can break that: the Exif sub-IFD is a caller's directory and may carry sub-IFD - /// groups of its own, and nothing about a directory in memory stops it nesting a hundred - /// levels down. The reader follows [`MAX_POINTER_DEPTH`] levels below IFD 0 and refuses what - /// is deeper, so the writer refuses the same tree rather than emitting a well-formed file - /// whose metadata this crate cannot read back. The Exif directory occupies the first of those - /// levels, so its own nesting may use the rest — one further directory, - /// `InteroperabilityIFD` (EXIF 2.3 §4.6.3), which is exactly the tree a decoded camera EXIF - /// comes back as. + /// The Exif sub-IFD is a caller's directory and may carry sub-IFD groups of its own, and + /// nothing about a directory in memory stops it nesting a hundred levels down or hanging a + /// group off a tag no reader treats as a pointer. Two bounds therefore apply, and + /// [`check_exif_subtree`] reports them as **two distinct refusals** because they are two + /// distinct mistakes: /// - /// It is a *conservative* restatement in one respect: the reader only refuses a tree too deep - /// under a tag it follows, while this counts every sub-IFD group. A group under a tag the - /// reader does not follow is a directory this crate could not return either — it comes back - /// as the raw offset it was written to — so refusing it too keeps the writer inside what the - /// reader delivers rather than outside it. + /// 1. **the tag.** A group's tag must be one the reader resolves inside the Exif subtree + /// ([`EXIF_SUBTREE_POINTER_TAGS`]). Under any other tag the writer emits a pointer the + /// reader hands back as a raw offset into the file it came from, so the directory does not + /// survive a round trip. + /// 2. **the depth.** The reader follows [`MAX_POINTER_DEPTH`] levels below IFD 0 and refuses + /// what is deeper. The Exif directory occupies the first of those levels, so its own + /// nesting may use the rest — one further directory, which for a decoded camera EXIF is + /// `InteroperabilityIFD` (EXIF 2.3 §4.6.3). /// /// # Errors /// - /// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if the Exif sub-IFD nests - /// deeper than the reader walks back. + /// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if the Exif sub-IFD hangs + /// a group off a tag the reader does not resolve, or nests deeper than the reader walks back. pub(crate) fn check(&self) -> Result<()> { - if let Some(exif) = self.exif_ifd() - && !within_depth(exif, MAX_POINTER_DEPTH - 1) - { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "TIFF: an Exif sub-IFD may nest one further directory \ - (ExifIFD -> InteroperabilityIFD, EXIF 2.3 §4.6.3) and this one nests deeper", - )); + match self.exif_ifd() { + Some(exif) => check_exif_subtree(exif, MAX_POINTER_DEPTH - 1), + None => Ok(()), } - Ok(()) } /// Writes the XMP / IPTC / ICC blocks and the Exif sub-IFD into `ifd0`. @@ -236,64 +241,124 @@ impl TiffMetadata { } } -/// The pointer tags [`read_metadata`] follows in IFD 0's subtree, scoped to what -/// [`TiffMetadata`] actually returns. -/// -/// Two tags, and the pair is a deliberate lower bound rather than a subset of convenience. +/// The pointer tags [`read_metadata`] resolves **at IFD 0**, scoped to what [`TiffMetadata`] +/// actually returns. /// -/// `ExifIFD` is followed because that directory **is** a field of [`TiffMetadata`]: it is handed to -/// the caller and may be written back, so a pointer under it that stayed a raw offset would be -/// re-encoded into a file laid out differently. `InteroperabilityIFD` is the one standard pointer -/// that occurs *inside* an Exif directory (EXIF 2.3 §4.6.3), and it is near-universal in camera -/// EXIF — leaving it unresolved is exactly the dangling-pointer defect this list exists to prevent. +/// One tag, and it is a deliberate lower bound rather than a subset of convenience. `ExifIFD` is +/// followed because that directory **is** a field of [`TiffMetadata`]: it is handed to the caller +/// and may be written back, so a pointer under it that stayed a raw offset would be re-encoded +/// into a file laid out differently. /// -/// The other two members of [`gamut_ifd::tags::STANDARD_POINTER_TAGS`] are deliberately **not** -/// here. `SubIFDs` (330) locates thumbnails and reduced-resolution subfiles and `GPSInfo` (34853) -/// locates a GPS directory; neither feeds any field of [`TiffMetadata`], and neither is re-encoded -/// by [`TiffMetadata::apply`], which writes into a directory the encoder builds fresh. Following -/// them could therefore only *add* failure modes, and it did: a single dangling `SubIFDs` offset +/// The other three members of [`gamut_ifd::tags::STANDARD_POINTER_TAGS`] are deliberately **not** +/// here. `SubIFDs` (330) locates thumbnails and reduced-resolution subfiles, `GPSInfo` (34853) +/// locates a GPS directory, and `InteroperabilityIFD` (40965) does not belong at IFD 0 at all; +/// none feeds any field of [`TiffMetadata`] from this level, and none is re-encoded by +/// [`TiffMetadata::apply`], which writes into a directory the encoder builds fresh. Following them +/// here could therefore only *add* failure modes, and it did: a single dangling `SubIFDs` offset /// made XMP, IPTC, ICC and C2PA all unreachable on a file whose pixels decode perfectly, and two /// pages sharing one thumbnail directory tripped the reader's cross-chain loop guard. A pointer /// whose target this reader throws away must not be able to fail the whole call. /// -/// The same rule scopes *where* the list is resolved, not only what is in it: it is applied to +/// The same rule scopes *where* the walk runs, not only what it follows: it is applied to /// **IFD 0's subtree and nowhere else** ([`resolve_pointers`]). Every page after IFD 0 feeds one /// field of [`TiffMetadata`] — the C2PA manifest store, whose entry holds the store's bytes /// directly rather than a pointer — so a *pointer* on such a page is a thrown-away target too, -/// and one on page 1 of a two-page document used to fail the whole call. `read_tree` cannot be -/// scoped that way: it resolves the list it is given at every node of every page. +/// and one on page 1 of a two-page document used to fail the whole call. [`gamut_ifd::read_tree`] +/// cannot be scoped either way: it resolves the flat list it is given at every node of every page. +const IFD0_POINTER_TAGS: &[u16] = &[tags::EXIF_IFD]; + +/// The pointer tags [`read_metadata`] resolves **inside the Exif subtree** — every standard one. /// -/// Within that subtree it is still **one flat list at every node**, exactly as -/// [`gamut_ifd::read_tree`] applies one to a whole file, and that is where the one remaining -/// over-reach comes from: `InteroperabilityIFD` is also followed if it appears at IFD 0, where it -/// does not belong. It is harmless — a TIFF whose IFD 0 carries tag 40965 is already out of spec, -/// and the resolved group feeds no field either way — and narrowing it further would take a -/// per-node list, which is a `gamut-ifd` surface rather than a scoping decision this crate makes. -const POINTER_TAGS: &[u16] = &[tags::EXIF_IFD, tags::INTEROPERABILITY_IFD]; +/// The scoping rule that keeps three tags out of [`IFD0_POINTER_TAGS`] puts all four in here, and +/// it is the same rule, not an exception to it: a pointer is followed exactly when its target +/// belongs to a directory [`TiffMetadata`] hands back. At IFD 0 a `SubIFDs` or `GPSInfo` target is +/// thrown away, so following it can only add failure modes. Under `ExifIFD` the enclosing +/// directory *is* returned, so a pointer left unresolved there is handed to the caller as a raw +/// absolute offset into the source file, and re-encoding it writes that offset into a file laid +/// out differently — the dangling pointer this crate's own [`deconstruct`](crate::deconstruct) +/// grades `Severity::Error`. `InteroperabilityIFD` is the one EXIF 2.3 §4.6.3 puts here, but a +/// `GPSInfo` or `SubIFDs` group under `ExifIFD` re-encodes just as badly, and the reader cannot +/// tell a caller's hand-built directory from a camera's. +/// +/// The cost is stated rather than hidden: an unreadable target under *any* of these four fails +/// the whole [`read_metadata`] call, where at IFD 0 it would be ignored. That is the same trade +/// `ExifIFD` itself already makes — reporting `exif: None` for a directory the file declares +/// would be silent loss — extended to the pointers that directory contains. +/// +/// What is still **not** resolved is a *private* tag whose value happens to be an offset; see +/// [`TiffMetadata::exif`] for why that is undecidable here and what it costs a caller. +const EXIF_SUBTREE_POINTER_TAGS: &[u16] = gamut_ifd::tags::STANDARD_POINTER_TAGS; + +/// The pointer tags [`resolve_pointers`] follows at `depth`: [`IFD0_POINTER_TAGS`] at the page +/// itself, [`EXIF_SUBTREE_POINTER_TAGS`] at every level below it. +/// +/// A per-node list rather than [`gamut_ifd::read_tree`]'s one flat list, because the two levels +/// answer opposite questions: at IFD 0 a followed pointer can only add a failure mode, and below +/// it an *un*followed pointer becomes a stale offset in a directory the caller is handed. Depth 0 +/// is the only level whose directory is a page, and the walk never leaves IFD 0's subtree, so +/// "not depth 0" is exactly "inside the Exif subtree". +fn pointer_tags(depth: usize) -> &'static [u16] { + if depth == 0 { + IFD0_POINTER_TAGS + } else { + EXIF_SUBTREE_POINTER_TAGS + } +} /// An upper bound on the sub-IFD nesting [`resolve_pointers`] follows, bounding a hostile pointer /// graph: a directory a hundred levels down is still a directory, and a file of a few kilobytes /// holds enough of them to exhaust the stack. /// -/// It is **two**, not [`gamut_ifd::read_tree`]'s sixteen, because this walk follows two tags and -/// the deepest tree they can legitimately reach is IFD 0 → `ExifIFD` → `InteroperabilityIFD` -/// (EXIF 2.3 §4.6.3). Nothing conformant puts an Exif or an Interop directory *inside* an Interop -/// directory, so a third level is already out of spec — a generic reader needs sixteen because it -/// is handed arbitrary tags, and this one is not. +/// It is **two**, not [`gamut_ifd::read_tree`]'s sixteen, because the walk starts at a page and +/// the deepest tree the tags it follows can legitimately reach is IFD 0 → `ExifIFD` → one +/// directory the Exif spec puts inside it, `InteroperabilityIFD` (EXIF 2.3 §4.6.3). Nothing +/// conformant nests a further directory below that, so a third level is already out of spec — a +/// generic reader needs sixteen because it is handed arbitrary tags, and this one is not. const MAX_POINTER_DEPTH: usize = 2; -/// Whether `ifd`'s own sub-IFD nesting stays within `depth` further levels — the writer's side of -/// [`MAX_POINTER_DEPTH`], used by [`TiffMetadata::check`]. +/// The writer's side of what the reader delivers: refuses an Exif subtree whose groups this crate +/// could not hand back unchanged, `depth` further levels being all that is left below `ifd`. /// -/// Stops at the bound instead of measuring the whole tree, so a directory a caller nested a -/// hundred levels deep costs a hundred levels of neither recursion nor time. -fn within_depth(ifd: &Ifd, depth: usize) -> bool { - ifd.sub_ifds().iter().all(|group| { - group.ifds.iter().all(|child| match depth.checked_sub(1) { - Some(left) => within_depth(child, left), - None => false, - }) - }) +/// Two refusals, deliberately distinct, because they are two different mistakes and a caller +/// reading the message has to know which one it made: +/// +/// * a group under a tag outside [`EXIF_SUBTREE_POINTER_TAGS`] — the reader leaves that pointer +/// as a raw absolute offset, so what came back would not be what was written; +/// * a *child directory* nested past `depth` — the reader refuses to walk that far +/// ([`MAX_POINTER_DEPTH`]). A group with no children reaches no further level, so it is the +/// children and not the group that the bound counts. +/// +/// The tag is checked first: a group under an unfollowed tag is unreturnable whatever its depth, +/// and naming a depth clause for it is the message a reader cannot act on. Both stop at the first +/// offender and the depth bound stops at the bound rather than measuring the whole tree, so a +/// directory a caller nested a hundred levels deep costs a hundred levels of neither recursion nor +/// time. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) for either refusal. +fn check_exif_subtree(ifd: &Ifd, depth: usize) -> Result<()> { + for group in ifd.sub_ifds() { + if !EXIF_SUBTREE_POINTER_TAGS.contains(&group.tag) { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: an Exif sub-IFD may only nest a group under a standard pointer tag \ + (SubIFDs, ExifIFD, GPSInfo, InteroperabilityIFD) and this one uses another, \ + which would read back as a raw file offset", + )); + } + for child in &group.ifds { + let Some(left) = depth.checked_sub(1) else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: an Exif sub-IFD may nest one further directory \ + (ExifIFD -> InteroperabilityIFD, EXIF 2.3 §4.6.3) and this one nests deeper", + )); + }; + check_exif_subtree(child, left)?; + } + } + Ok(()) } /// The file offsets a sub-IFD pointer value carries: a `LONG` array (TIFF 6.0 §2), the typed @@ -317,16 +382,19 @@ fn pointer_offsets(value: &Value) -> Option> { } } -/// Resolves `tags` over `ifd` and, recursively, over the children it reaches, replacing each -/// pointer field with a [`sub_ifds`](Ifd::sub_ifds) group — what [`gamut_ifd::read_tree`] does -/// for a whole file, applied to **one** directory's subtree. +/// Resolves [`pointer_tags`]`(depth)` over `ifd` and, recursively, over the children it reaches, +/// replacing each pointer field with a [`sub_ifds`](Ifd::sub_ifds) group — what +/// [`gamut_ifd::read_tree`] does for a whole file, applied to **one** directory's subtree with a +/// **per-level** tag list. /// -/// The scoping is the whole reason this exists: `read_tree` resolves the flat list it is handed at -/// every node of every page, so a pointer on a page [`read_metadata`] discards can fail a call -/// whose answer that page never contributed to. Following a pointer by hand is -/// [`gamut_ifd::read_ifd_at`]'s documented purpose. `visited` spans the walk and `depth` bounds -/// it, so a cycle or two pointers claiming one directory fail here rather than loop — the guards -/// are restated because they guard *this* walk. +/// The scoping is the whole reason this exists, and it has two axes. `read_tree` resolves the flat +/// list it is handed at every node of every page, so a pointer on a page [`read_metadata`] discards +/// can fail a call whose answer that page never contributed to — hence one page. And it cannot +/// vary the list by level, so a list wide enough to keep the Exif directory pointer-free would +/// make an unrelated `SubIFDs` offset on the page itself able to fail the call — hence +/// [`pointer_tags`]. Following a pointer by hand is [`gamut_ifd::read_ifd_at`]'s documented +/// purpose. `visited` spans the walk and `depth` bounds it, so a cycle or two pointers claiming one +/// directory fail here rather than loop — the guards are restated because they guard *this* walk. /// /// `visited` is a **set**, not a list, and that is a hardening decision rather than a style one: /// nothing bounds how many offsets one pointer array holds, so a linear membership scan makes the @@ -346,7 +414,6 @@ fn resolve_pointers( order: ByteOrder, variant: Variant, ifd: &mut Ifd, - tags: &[u16], visited: &mut BTreeSet, depth: usize, ) -> Result<()> { @@ -356,7 +423,7 @@ fn resolve_pointers( "TIFF: sub-IFD tree too deep", )); } - for &tag in tags { + for &tag in pointer_tags(depth) { let Some(offsets) = ifd.get(tag).and_then(pointer_offsets) else { continue; }; @@ -369,7 +436,7 @@ fn resolve_pointers( )); } let mut child = read_ifd_at(data, offset, order, variant)?; - resolve_pointers(data, order, variant, &mut child, tags, visited, depth + 1)?; + resolve_pointers(data, order, variant, &mut child, visited, depth + 1)?; children.push(child); } ifd.remove(tag); @@ -411,16 +478,8 @@ pub(crate) fn read_metadata(data: &[u8]) -> Result { let Some(ifd0) = ifds.first_mut() else { return Ok(TiffMetadata::new()); }; - // One flat list, resolved over IFD 0's subtree and no other page's — see [`POINTER_TAGS`]. - resolve_pointers( - data, - order, - variant, - ifd0, - POINTER_TAGS, - &mut BTreeSet::new(), - 0, - )?; + // IFD 0's subtree and no other page's, with a per-level tag list — see [`pointer_tags`]. + resolve_pointers(data, order, variant, ifd0, &mut BTreeSet::new(), 0)?; let exif = ifd0 .sub_ifds() .iter() @@ -605,6 +664,82 @@ mod tests { assert_eq!(read_metadata(&bytes).expect("read").exif, Some(exif_ifd())); } + #[test] + fn the_writer_refuses_an_exif_group_under_a_tag_the_reader_does_not_resolve() { + // The reader resolves the four standard pointer tags inside the Exif subtree and nothing + // else, so a group hung off any other tag comes back as the raw offset the writer put + // there — the directory does not survive its own round trip. The *message* is the claim: + // this tree is one level deep, well inside the depth bound, so a refusal naming the + // nesting clause would be the wrong check answering. Depth is + // `the_writer_refuses_the_exif_nesting_the_reader_refuses` below. + let mut child = Ifd::new(); + child.set(1, Value::Byte(vec![9])); + let mut vendor = exif_ifd(); + vendor.set_sub_ifd(50000, vec![child]); // a private tag, not a standard pointer + let err = TiffMetadata::new() + .with_exif(vendor) + .check() + .expect_err("a group the reader hands back as an offset must not be written"); + assert!(err.to_string().contains("standard pointer tag"), "{err}"); + } + + #[test] + fn a_standard_pointer_group_inside_the_exif_directory_is_resolved() { + // `POINTER_TAGS` once listed `ExifIFD` and `InteroperabilityIFD` only, so a `SubIFDs` or + // `GPSInfo` group *inside* the Exif directory came back as a raw absolute offset into the + // source file. Which tag the reader resolves at which level is this crate's decision, so + // it is asserted here on the directory model; that the re-encode of such a directory is a + // clean file is `every_standard_pointer_inside_the_exif_directory_survives_a_round_trip` + // (tests/metadata.rs). + for tag in gamut_ifd::tags::STANDARD_POINTER_TAGS { + let mut child = Ifd::new(); + child.set(1, Value::Byte(vec![2, 3, 0, 0])); + let mut exif = exif_ifd(); + exif.set_sub_ifd(*tag, vec![child.clone()]); + let mut ifd0 = Ifd::new(); + ifd0.set_sub_ifd(tags::EXIF_IFD, vec![exif]); + + let back = read_metadata(&file_with(ifd0)) + .expect("read") + .exif + .unwrap_or_else(|| panic!("tag {tag}: an Exif directory")); + assert_eq!(back.get(*tag), None, "tag {tag}: left as a raw offset"); + assert_eq!( + back.sub_ifds() + .iter() + .find(|group| group.tag == *tag) + .map(|group| group.ifds.as_slice()), + Some(&[child][..]), + "tag {tag}: must come back parsed" + ); + } + } + + #[test] + fn a_standard_pointer_at_ifd_0_that_feeds_no_field_is_left_alone() { + // The other half of the per-level rule: at IFD 0 only `ExifIFD` is followed, so a + // `SubIFDs` or `GPSInfo` field on the page stays the plain integer field it was read as + // rather than becoming a group. What that buys — a dangling one of them not hiding the + // blocks — is `a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks` + // (tests/metadata.rs); this pins the resolution itself, on a pointer that is perfectly + // readable, so the two claims cannot be confused. + for tag in [tags::SUB_IFDS, tags::GPS_INFO] { + let mut ifd0 = Ifd::new(); + ifd0.set_sub_ifd(tag, vec![exif_ifd()]); + ifd0.set(tags::XMP, Value::Byte(b"x".to_vec())); + let bytes = file_with(ifd0); + let offset = read(&bytes).expect("read").ifds[0] + .get_u32(tag) + .unwrap_or_else(|| panic!("tag {tag}: a written pointer")); + assert!(offset > 0, "tag {tag}: the pointer must name a directory"); + assert_eq!( + read_metadata(&bytes).expect("metadata").exif, + None, + "tag {tag}: a page pointer must not become the Exif directory" + ); + } + } + #[test] fn the_writer_refuses_the_exif_nesting_the_reader_refuses() { // `metadata()` walks two levels below IFD 0 and refuses a third, so a set the encoder diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs index c8346c40..0a009699 100644 --- a/crates/gamut-tiff/tests/metadata.rs +++ b/crates/gamut-tiff/tests/metadata.rs @@ -173,6 +173,70 @@ fn a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file() { ); } +#[test] +fn every_standard_pointer_inside_the_exif_directory_survives_a_round_trip() { + // A pointer *inside* the Exif directory is a file offset, and that directory is the one the + // seam hands back — so a pointer the reader leaves unresolved is returned to the caller as an + // absolute offset into the source file, and re-encoding it writes that offset into a file laid + // out differently. `InteroperabilityIFD` is covered by + // `a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file` above; the reader once + // resolved only that one and `ExifIFD`, so a `SubIFDs` or `GPSInfo` group under `ExifIFD` + // encoded cleanly, came back as a raw `Long`, and re-encoded into a file this crate's own + // judge graded `Severity::Error`. Every standard pointer tag is swept, because which of the + // four a caller's directory happens to carry is not something the reader can know. + // + // The judge is the assertion, not a round-trip equality: gamut-tiff's v1 guarantee is that + // every file it writes is fully classified by `deconstruct`. + for tag in [ + tags::SUB_IFDS, + tags::EXIF_IFD, + tags::GPS_INFO, + tags::INTEROPERABILITY_IFD, + ] { + let mut child = Ifd::new(); + child.set(1, Value::Byte(vec![2, 3, 0, 0])); + let mut exif = exif(); + exif.set(37500, Value::Undefined(vec![0xAB; 6])); // MakerNote, so the directory is not tiny + exif.set_sub_ifd(tag, vec![child]); + + let pixels = rgb(8, 4); + let first = TiffEncoder::new() + .with_metadata(TiffMetadata::new().with_exif(exif)) + .encode_to_vec(image(&pixels, 8, 4)) + .unwrap_or_else(|e| panic!("tag {tag}: encode: {e}")); + let decoded = TiffDecoder::new() + .metadata(&first) + .unwrap_or_else(|e| panic!("tag {tag}: metadata: {e}")); + assert_eq!( + decoded + .exif + .as_ref() + .unwrap_or_else(|| panic!("tag {tag}: an Exif directory")) + .get(tag), + None, + "tag {tag}: handed back as a stale offset instead of a parsed group" + ); + + let second = TiffEncoder::new() + .with_metadata(decoded) + .encode_to_vec(image(&pixels, 8, 4)) + .unwrap_or_else(|e| panic!("tag {tag}: re-encode: {e}")); + let report = deconstruct(&second).expect("deconstruct"); + assert!( + report.segments.is_fully_classified(), + "tag {tag}: unclassified after a round trip: {:?}", + report.segments.unclassified + ); + assert!( + !report.anomalies.iter().any( + |a| matches!(a, Anomaly::Structure { severity, .. } if *severity == Severity::Error) + ), + "tag {tag}: structural errors after a round trip: {:?}", + report.anomalies + ); + } +} + #[test] fn the_encoder_refuses_an_exif_tree_its_own_decoder_could_not_read_back() { // The encoder used to write any nesting a caller built and `metadata()` refused a third level From 6c63188f2c2ba74a3afcd62b0c234e8e70690391 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:41:30 -0400 Subject: [PATCH 24/43] fix(tiff): keep an Exif directory whose only content is a group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `exif_ifd` filtered on `fields()` alone, so a directory holding nothing but a sub-IFD group counted as empty and was dropped: it encoded to a file with no Exif directory at all, and read back as absent, with no error to say so. That shape is not hypothetical — it is exactly what `read_metadata` returns for an Exif directory whose only entry is its `InteroperabilityIFD` pointer, so a decode/re-encode round trip lost the directory silently. A group is one on-disk entry, so a directory holding one is not an empty directory. The filter now accepts either, and `is_empty` says so. --- crates/gamut-tiff/src/metadata.rs | 42 ++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 06b75cf0..2c8949e1 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -173,10 +173,11 @@ impl TiffMetadata { self } - /// Whether there is nothing to embed: no payload set, and no Exif sub-IFD with fields in it. + /// Whether there is nothing to embed: no payload set, and no Exif sub-IFD with content in it. /// /// An `exif` directory with no entries counts as empty — writing it would add an `ExifIFD` - /// pointer to a directory with nothing in it. + /// pointer to a directory with nothing in it. A sub-IFD group *is* content: a directory whose + /// only entry is an `InteroperabilityIFD` pointer still writes one on-disk entry. #[must_use] pub fn is_empty(&self) -> bool { self.exif_ifd().is_none() @@ -187,8 +188,16 @@ impl TiffMetadata { } /// The Exif sub-IFD to write, or `None` when there is no Exif content worth a directory. + /// + /// A directory is worth writing when it holds **either** a field **or** a sub-IFD group. + /// Testing only [`fields`](Ifd::fields) dropped a directory whose sole content was a group — + /// an `ExifIFD` holding nothing but its `InteroperabilityIFD` pointer, which + /// [`read_metadata`] returns in exactly that shape — silently, into a file with no Exif + /// directory at all and no error to say so. fn exif_ifd(&self) -> Option<&Ifd> { - self.exif.as_ref().filter(|ifd| !ifd.fields().is_empty()) + self.exif + .as_ref() + .filter(|ifd| !ifd.fields().is_empty() || !ifd.sub_ifds().is_empty()) } /// Refuses a set this crate would write into a file its own [`read_metadata`] then rejects, @@ -664,6 +673,33 @@ mod tests { assert_eq!(read_metadata(&bytes).expect("read").exif, Some(exif_ifd())); } + #[test] + fn an_exif_directory_whose_only_content_is_a_group_is_still_written() { + // `exif_ifd` filtered on `fields()` alone, so a directory holding nothing but its + // `InteroperabilityIFD` group — the exact shape `read_metadata` returns for an Exif + // directory with one pointer and no scalar fields — was dropped: it encoded to a file with + // no Exif directory at all and read back as absent, with no error to say so. A group is one + // on-disk entry, so a directory holding one is not an empty directory. + let mut interop = Ifd::new(); + interop.set(1, Value::Ascii("R98".into())); // InteroperabilityIndex + let mut only_a_group = Ifd::new(); + only_a_group.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![interop.clone()]); + + let meta = TiffMetadata::new().with_exif(only_a_group); + assert!(!meta.is_empty(), "a group is content"); + let mut ifd0 = Ifd::new(); + meta.apply(&mut ifd0); + assert_eq!( + ifd0.sub_ifds() + .iter() + .find(|group| group.tag == tags::EXIF_IFD) + .and_then(|group| group.ifds.first()) + .map(|exif| exif.sub_ifds().len()), + Some(1), + "the Exif directory must be written, carrying its Interop group" + ); + } + #[test] fn the_writer_refuses_an_exif_group_under_a_tag_the_reader_does_not_resolve() { // The reader resolves the four standard pointer tags inside the Exif subtree and nothing From e1e888bf425a66e5e9c5d349602bbd65456f0e88 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:41:42 -0400 Subject: [PATCH 25/43] test(tiff): pin the configuration refusal on every public encode surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR restructured the encoder so that every entry point resolves the C2PA store and the metadata check at one chokepoint, before any pixel work. Nothing in the suite held that: reverting `encode_palette8` alone to the unchecked form and running the whole crate suite failed zero tests, because the per-path tests added in round 4 pin which store is resolved rather than that it was checked at all — a path that drops the check still refuses a bad C2PA store. The claim is about entry points, so the table is over entry points: all twelve public encode surfaces, given an Exif tree the decoder could not read back, each asserted on the refusal's message rather than on `is_err` so that a surface refusing for an unrelated reason is not mistaken for one honouring the bound. It replaces the single-surface test it subsumes. Verified against the regression it exists for: with `encode_palette8` reverted, it is the only test in the crate that fails. --- crates/gamut-tiff/tests/metadata.rs | 134 ++++++++++++++++++++++++---- 1 file changed, 119 insertions(+), 15 deletions(-) diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs index 0a009699..709d2433 100644 --- a/crates/gamut-tiff/tests/metadata.rs +++ b/crates/gamut-tiff/tests/metadata.rs @@ -4,9 +4,13 @@ //! Each test pins one encode path's use of the seam, so a path that stopped embedding metadata //! fails on its own rather than hiding behind another. -use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +use gamut_core::{ + Bilevel, Cmyk8, Dimensions, EncodeImage, Gray8, Gray16, ImageRef, Indexed8, Rgb8, Rgb16, Rgba8, + Rgba16, +}; use gamut_tiff::{ - Anomaly, Ifd, Severity, TiffDecoder, TiffEncoder, TiffMetadata, Value, deconstruct, read, tags, + Anomaly, Ifd, Palette8, Severity, TiffDecoder, TiffEncoder, TiffMetadata, Value, deconstruct, + read, tags, }; /// Distinct payloads per carrier, so a block written under the wrong tag is visible. @@ -238,13 +242,23 @@ fn every_standard_pointer_inside_the_exif_directory_survives_a_round_trip() { } #[test] -fn the_encoder_refuses_an_exif_tree_its_own_decoder_could_not_read_back() { - // The encoder used to write any nesting a caller built and `metadata()` refused a third level - // of it, so this crate emitted a well-formed file it could not itself read — the one shape a - // seam whose contract is "what the file holds is what the caller gets" must not have. The - // refusal is the encoder's, before any pixel work; the reader's side of the same bound is - // `a_directory_below_the_exif_interop_pair_is_too_deep` (src/metadata.rs), and the depth this - // pair *does* reach round-trips in +fn every_public_encode_surface_refuses_an_exif_tree_its_own_decoder_could_not_read_back() { + // The encoder used to write any nesting a caller built while `metadata()` refused a third + // level of it, so this crate emitted a well-formed file it could not itself read — the one + // shape a seam whose contract is "what the file holds is what the caller gets" must not have. + // The refusal is taken once, at the chokepoint every entry point shares, and *that* is why + // this test is table-driven over the entry points rather than over the chokepoint: an entry + // point that stops calling the checked form still refuses a bad C2PA store, so nothing but a + // per-surface claim can see it go. Reverting one surface to the unchecked form used to fail + // no test in this crate. + // + // Every public surface that encodes pixels is here: the eight `EncodeImage` impls, reached + // both by `encode_image` and by the two wrappers that are separate entry points + // (`encode_to_vec`, `encode_with_report`), plus the two inherent ones. The *message* is the + // claim, not `is_err`, so a surface refusing for some unrelated reason is not mistaken for a + // surface honouring the bound. The reader's side of that bound is + // `a_directory_below_the_exif_interop_pair_is_too_deep` (src/metadata.rs) and the depth this + // pair does reach round-trips in // `a_decoded_exif_sub_ifd_re_encodes_into_a_fully_classified_file` above. let mut interop = Ifd::new(); interop.set(1, Value::Ascii("R98".into())); // InteroperabilityIndex @@ -252,13 +266,103 @@ fn the_encoder_refuses_an_exif_tree_its_own_decoder_could_not_read_back() { inner.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![interop]); let mut deeper = exif(); deeper.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![inner]); + let enc = TiffEncoder::new().with_metadata(TiffMetadata::new().with_exif(deeper)); - let pixels = rgb(8, 4); - let err = TiffEncoder::new() - .with_metadata(TiffMetadata::new().with_exif(deeper)) - .encode_to_vec(image(&pixels, 8, 4)) - .expect_err("a tree the decoder refuses must not be written"); - assert!(err.to_string().contains("nests deeper"), "{err}"); + let dims = Dimensions { + width: 2, + height: 2, + }; + let palette = Palette8::from_rgb_triples(&[0u8; 768]).expect("palette"); + let rgb8 = ImageRef::::new(&[0u8; 12], dims).expect("rgb8"); + let mut out = Vec::new(); + let refusals = [ + ( + "encode_image::", + enc.encode_image( + ImageRef::::new(&[0u8; 4], dims).expect("gray8"), + &mut out, + ) + .map(|_| ()), + ), + ( + "encode_image::", + enc.encode_image(rgb8, &mut out).map(|_| ()), + ), + ( + "encode_image::", + enc.encode_image( + ImageRef::::new(&[0u8; 16], dims).expect("cmyk8"), + &mut out, + ) + .map(|_| ()), + ), + ( + "encode_image::", + enc.encode_image( + ImageRef::::new(&[0u8; 16], dims).expect("rgba8"), + &mut out, + ) + .map(|_| ()), + ), + ( + "encode_image::", + enc.encode_image( + ImageRef::::new(&[0u16; 4], dims).expect("gray16"), + &mut out, + ) + .map(|_| ()), + ), + ( + "encode_image::", + enc.encode_image( + ImageRef::::new(&[0u16; 12], dims).expect("rgb16"), + &mut out, + ) + .map(|_| ()), + ), + ( + "encode_image::", + enc.encode_image( + ImageRef::::new(&[0u16; 16], dims).expect("rgba16"), + &mut out, + ) + .map(|_| ()), + ), + ( + "encode_image::", + enc.encode_image( + ImageRef::::new(&[0u8; 4], dims).expect("bilevel"), + &mut out, + ) + .map(|_| ()), + ), + ( + "encode_palette8", + enc.encode_palette8( + ImageRef::::new(&[0u8; 4], dims).expect("indexed8"), + &palette, + &mut out, + ) + .map(|_| ()), + ), + ( + "encode_pages_rgb8", + enc.encode_pages_rgb8(&[rgb8], &mut out).map(|_| ()), + ), + ("encode_to_vec", enc.encode_to_vec(rgb8).map(|_| ())), + ( + "encode_with_report", + enc.encode_with_report(rgb8, &mut out).map(|_| ()), + ), + ]; + for (surface, result) in refusals { + let err = result.expect_err(surface); + assert!( + err.to_string().contains("nests deeper"), + "{surface} refused for the wrong reason: {err}" + ); + } + assert!(out.is_empty(), "a refused encode writes nothing"); } /// The uncompressed 2×2 RGB directory every hand-built page below starts from: enough fields for From 85d8277d54d53e423ca462ee8a811796d7efb511 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:41:51 -0400 Subject: [PATCH 26/43] refactor(tiff): make the classic count bound assertable at its boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard refusing a store no classic TIFF `count` word could describe was two lines inline, and its *magnitude* was unpinned: narrowing the conversion to sixteen bits fails zero tests, because every fixture is far below either bound. The mutation gate was satisfied only because substituting true and false for the whole condition is caught by other assertions — neither of which can see a bound that is merely wrong. No test going through `c2pa_store` can reach the boundary either: a length large enough to be refused there is large enough that `zeroed`'s reservation answers first, which is what the previous commit series deliberately arranged so the mutant could not time out. So the bound moves into a named predicate and is asserted directly, at `u32::MAX` and one past it, plus BigTIFF's freedom from it — three assertions, no allocation. Spelling it as the refusing condition keeps the call site free of a `!` for a mutant to delete. --- crates/gamut-tiff/src/encoder.rs | 54 +++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 351073e9..bdcbed1e 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -230,17 +230,7 @@ impl TiffEncoder { // nothing, so the refusal is taken here as well. It is *necessary*, not sufficient: the // store's own offset must also fit, and that depends on the size of the file it lands // after, which only `append_store` knows. - // - // Spelled as the *refusing* condition, with no `!` in front of it, because deleting a `!` - // is a mutation cargo-mutants makes: over `!countable` it turns the guard into its own - // opposite, and the length that reaches `zeroed` from the test below is one whose - // reservation the machine may well satisfy — 4 GiB of zero-fill, which is a timed-out - // mutant rather than a caught one, and timed out only on machines slow enough to notice. - let uncountable = match self.variant() { - Variant::Classic => u32::try_from(len).is_err(), - Variant::Big => false, - }; - if uncountable { + if uncountable_store_len(self.variant(), len) { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), "TIFF: a C2PA manifest store longer than 4 GiB cannot be counted by classic \ @@ -891,6 +881,27 @@ impl EncodeImage for TiffEncoder { } } +/// Whether `variant`'s count word is too narrow to describe a `len`-byte C2PA manifest store. +/// +/// Classic TIFF counts an `UNDEFINED` value with a 32-bit `LONG`, so `u32::MAX` is the longest +/// store it can name and one byte more is unnameable; BigTIFF's count is 64-bit and no `usize` +/// exceeds it, so nothing is refused there. +/// +/// A named predicate rather than the two lines inline, for two reasons that are the same reason. +/// The magnitude is the whole claim — a bound narrowed to sixteen bits refuses stores a classic +/// TIFF describes perfectly well — and no test in this crate can reach it through +/// [`TiffEncoder::c2pa_store`] without asking for a length whose *reservation* answers first, so +/// the boundary is only assertable here. And spelling it as the *refusing* condition leaves no `!` +/// for cargo-mutants to delete: inverting the guard at the call site would send an oversized +/// length on to [`zeroed`], which on a machine whose allocator grants it is a 4 GiB zero-fill and +/// a timed-out mutant rather than a caught one. +fn uncountable_store_len(variant: Variant, len: usize) -> bool { + match variant { + Variant::Classic => u32::try_from(len).is_err(), + Variant::Big => false, + } +} + /// A `len`-byte zero-filled C2PA reservation, or a typed error where `vec![0; len]` would panic. /// /// [`TiffEncoder::with_c2pa_reserved`] returns `Self`, so it cannot refuse anything itself and the @@ -1004,6 +1015,27 @@ mod tests { assert!(err.to_string().contains("cannot be allocated"), "{err}"); } + #[test] + #[cfg(target_pointer_width = "64")] + fn the_classic_count_bound_is_the_width_of_a_tiff_long() { + // The *magnitude* of the bound, which no test going through `c2pa_store` can reach: a + // length large enough to be refused there is also large enough that some other bound — + // `zeroed`'s reservation — answers first, so a bound narrowed to sixteen bits would refuse + // stores a classic TIFF describes perfectly well and every existing test would still pass. + // Asserted on the predicate instead, at the boundary and one past it, allocating nothing. + assert!( + !uncountable_store_len(Variant::Classic, u32::MAX as usize), + "the longest store a 32-bit LONG counts" + ); + assert!( + uncountable_store_len(Variant::Classic, u32::MAX as usize + 1), + "one byte past what a 32-bit LONG counts" + ); + // BigTIFF's count is 64-bit, so no `usize` is uncountable there — the arm that makes the + // refusal classic TIFF's alone rather than every container's. + assert!(!uncountable_store_len(Variant::Big, usize::MAX)); + } + #[test] #[cfg(target_pointer_width = "64")] fn a_classic_tiff_store_its_count_word_cannot_describe_is_refused_before_the_pixels() { From 1aa39fb527834400e0bce6f953cda4e629fc2660 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:42:00 -0400 Subject: [PATCH 27/43] docs(tiff): correct what the seam claims about pointers and offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places asserted a round trip cannot emit a stale offset — the README's "what the encoder writes the decoder reads back", the status file's "the writer is bounded by what the reader accepts", and the module docs. Two of the four standard pointer tags did not hold it, so all three said more than was true. They now state the rule that is actually implemented: which pointers are resolved depends on the level, `ExifIFD` alone at IFD 0 and all four standard tags inside the returned Exif directory, with the cost of the second named rather than left implicit — an unreadable target under any of the four fails the read. The vendor-private caveat the crate already carried is extended to say plainly that such a field is not round-trip safe and that a round trip through it proves nothing. The writer's bound is restated as the two refusals it now is, tag and depth, each with its own message. Also rewraps the paragraphs this branch introduced to the ~100 columns the surrounding files use, instead of the 110-125 they had drifted to. --- crates/gamut-tiff/README.md | 32 ++++++++----- crates/gamut-tiff/STATUS.md | 89 +++++++++++++++++++++---------------- 2 files changed, 71 insertions(+), 50 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index f8d3302b..7910ac7a 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -68,19 +68,29 @@ compression schemes land additively on this frozen surface (see Status). (+ horizontal differencing on strips or tiles), plus the bilevel CCITT schemes Modified Huffman (Group 3 1-D) and Group 4 (T.6). - **Metadata** — `TiffEncoder::with_metadata` / `TiffDecoder::metadata` carry an Exif sub-IFD - (`ExifIFD`, 34665, as a `gamut_ifd::Ifd`, its own `InteroperabilityIFD` resolved into a child - directory rather than a stale offset; other pointer tags, whose targets the seam does not - return, are left alone so a broken one cannot hide the blocks) plus opaque XMP (700), - IPTC-IIM (33723), ICC (34675) and - C2PA (52545) payloads — the raw blocks the workspace's metadata facade consumes. Byte payloads - are verbatim; the Exif directory's *entries* are carried unchanged but its ordering is + (`ExifIFD`, 34665, as a `gamut_ifd::Ifd`) plus opaque XMP (700), IPTC-IIM (33723), ICC (34675) + and C2PA (52545) payloads — the raw blocks the workspace's metadata facade consumes. Byte + payloads are verbatim; the Exif directory's *entries* are carried unchanged but its ordering is normalised (ascending tag, duplicate tags collapsed, a child's next-IFD pointer ignored). The blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone must - look at IFD 0 for them. What the encoder writes the decoder reads back: the Exif directory may - nest the one further directory `InteroperabilityIFD` (EXIF 2.3 §4.6.3), which is as deep as the - reader walks, and a caller's directory nested deeper is refused by the encode rather than - written into a file this crate could not read. The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared - `gamut_ifd::c2pa` helper it and `gamut-dng` both call: the entry in the last IFD of the main + look at IFD 0 for them. + Which pointers are resolved depends on the level, because the two levels answer opposite + questions. **Inside the returned Exif directory all four standard pointer tags** — `SubIFDs` + (330), `ExifIFD` (34665), `GPSInfo` (34853), `InteroperabilityIFD` (40965) — come back as child + directories rather than as stale offsets, since anything left unresolved there is handed to the + caller as an absolute offset into the source file. **At IFD 0 only `ExifIFD` is followed**, + since no other target feeds the seam and a broken one would otherwise hide the blocks. The price + is stated: inside the Exif directory an unreadable target under any of the four fails the read. + A *vendor-private* tag whose value happens to be an offset is not a pointer to anything this + crate can see, so it is carried through and re-encoded verbatim, still holding the source file's + offset — a round trip through such a field is **not** proof the result is pointer-safe. + What the encoder writes the decoder reads back: the Exif directory may nest one further + directory (`InteroperabilityIFD`, EXIF 2.3 §4.6.3, is the one a camera writes), which is as deep + as the reader walks, and it may hang that group only off a standard pointer tag. A caller's + directory nested deeper, or hung off any other tag, is refused by the encode — with its own + message per case — rather than written into a file this crate could not read back unchanged. + The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared `gamut_ifd::c2pa` helper it + and `gamut-dng` both call: the entry in the last IFD of the main chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by `TiffEncoder::encode_with_report` or recovered from any file by `gamut_tiff::c2pa_exclusions`. `with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 2ba9dce9..670433ba 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -83,30 +83,41 @@ Three consequences are contractual rather than incidental, and are documented wh **(a)** The Exif directory's *entries* are carried unchanged but its **ordering is normalised** — ascending tag (TIFF 6.0 §2 requires it on disk), duplicate tags collapsed to the last, a child's next-IFD pointer ignored — so "verbatim" is claimed for byte payloads, not for a directory model. -**(b)** The reader resolves `ExifIFD` **and** `InteroperabilityIFD`, and deliberately no other -pointer tag. Interop is in the list because it sits *inside* the Exif directory, which is returned -to the caller and may be written back: returning it as a raw offset would let a caller re-encode a -dangling pointer into a file laid out differently, which the crate's own `deconstruct` then -rejects. `SubIFDs` and `GPSInfo` are *out* of the list because their targets feed no field of -`TiffMetadata` and are never re-encoded, so following them could only add failure modes — and did: -a single dangling `SubIFDs` offset made XMP, IPTC, ICC and C2PA unreachable on a file whose pixels -decode perfectly, and two pages sharing one thumbnail directory tripped the reader's cross-chain -loop guard. Only *standard* pointer tags are recognised; a vendor private tag holding an offset is -carried through unchanged, and nothing in this crate can grade that. The same rule scopes *where* -the pair is resolved: **IFD 0's subtree and no other page's**. Every later page of a multi-page -document feeds one field — the C2PA manifest store, whose entry holds the store's bytes rather -than an offset — so a *pointer* there is a discarded target too, and a dangling `ExifIFD` on page -1, or two pages naming one Exif directory, used to fail the whole read. Within that subtree it is -still one flat list at every node, which leaves one harmless over-reach: `InteroperabilityIFD` is -followed at IFD 0 as well, where a spec-conformant file never puts it. **(c)** The blocks live in **IFD 0 only**, so a reader decoding +**(b)** Which pointer tags the reader resolves depends on the **level**, and one rule decides it +at both: a pointer is followed exactly when its target belongs to a directory `TiffMetadata` hands +back. At **IFD 0** that is `ExifIFD` alone. `SubIFDs` and `GPSInfo` are out, because their targets +feed no field of `TiffMetadata` and are never re-encoded, so following them there could only add +failure modes — and did: a single dangling `SubIFDs` offset made XMP, IPTC, ICC and C2PA +unreachable on a file whose pixels decode perfectly, and two pages sharing one thumbnail directory +tripped the reader's cross-chain loop guard. **Inside the Exif subtree all four** +`gamut_ifd::tags::STANDARD_POINTER_TAGS` are resolved — `SubIFDs`, `ExifIFD`, `GPSInfo`, +`InteroperabilityIFD` — because that directory *is* returned to the caller and may be written +back, so a pointer left unresolved there is handed over as a raw absolute offset into the source +file; re-encoding it writes a dangling pointer into a file laid out differently, which the crate's +own `deconstruct` grades `Severity::Error`. `InteroperabilityIFD` is the one EXIF 2.3 §4.6.3 puts +there, but a `GPSInfo` or `SubIFDs` group under `ExifIFD` re-encodes just as badly and the reader +cannot tell a hand-built directory from a camera's. The cost is stated rather than hidden: inside +the Exif subtree an unreadable target under any of the four fails the whole read, the same trade +`ExifIFD` itself already makes. Only *standard* pointer tags are recognised anywhere; a **vendor +private tag holding an offset** is indistinguishable from an integer field, so it is carried +through and re-encoded verbatim, still holding the source file's offset — neither this crate nor +`deconstruct` can grade that, and a round trip through such a field is not proof the result is +pointer-safe. The same rule scopes *where* the walk runs: **IFD 0's subtree and no other page's**. +Every later page of a multi-page document feeds one field — the C2PA manifest store, whose entry +holds the store's bytes rather than an offset — so a *pointer* there is a discarded target too, +and a dangling `ExifIFD` on page 1, or two pages naming one Exif directory, used to fail the whole +read. `gamut_ifd::read_tree` can be scoped neither way: it resolves the one flat list it is given +at every node of every page. **(c)** The blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone sees none of them; duplicating an ICC profile onto every page is the worse outcome, and IFD 0 is where a reader conventionally looks. **(d)** The **writer -is bounded by what the reader accepts**: the walk above stops two levels under IFD 0 — the deepest -tree `ExifIFD` and `InteroperabilityIFD` legitimately reach (EXIF 2.3 §4.6.3) — and an Exif -directory a caller nested deeper is refused by `with_metadata`'s encode, as `Error::InvalidInput` -before any pixel work, rather than written into a well-formed file this crate cannot read back. -The bound is the spec's; that a narrower one is also easier to assert is not on its own a reason -to narrow a contract. +is bounded by what the reader accepts**, on both axes of (b) and with a distinct message for each, +since they are distinct mistakes. *Depth*: the walk stops two levels under IFD 0 — the deepest +tree the followed tags legitimately reach (EXIF 2.3 §4.6.3) — and an Exif directory a caller +nested deeper is refused. *Tag*: a group hung off anything outside the standard pointer tags comes +back as a raw offset, so it is refused too. Both are `Error::InvalidInput` from `with_metadata`'s +encode before any pixel work, on **every** public encode surface, rather than a well-formed file +this crate cannot read back unchanged. The bound is the spec's; that a narrower one is also easier +to assert is not on its own a reason to narrow a contract. The C2PA manifest store is the one carrier with a placement rule of its own, and that rule is not restated here: `gamut_ifd::c2pa` owns C2PA 2.4 §A.3.6 (tag 52545 / `0xCD41`, type `UNDEFINED`, one @@ -114,26 +125,26 @@ store per asset, its entry in the **last IFD of the main chain**, its bytes at t file**) and §18.5.5 (the two disjoint exclusion ranges — the store, and the `count` field of its entry — that a `c2pa.hash.data` binding excludes; §18.7.3.3 leaves that the only binding a TIFF asset has), and `gamut-dng` calls the same helper, so the two formats cannot drift. -`with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in -place. It is an infallible builder, so every bound on `len` is enforced by the **encode** that -follows, as `Error::InvalidInput`, on every entry point: below the store's minimum (8 bytes, 9 in -BigTIFF — the JUMBF box header, and one more than the variant's inline threshold, since a value -that packs inline is not the run at the end of the file §A.3.6 wants), above what a buffer can -hold, since past `isize::MAX` a `Vec` cannot exist and `vec![0; len]` said so by panicking with -a capacity overflow, and — in a classic TIFF — above the 4 GiB its entry's 32-bit `LONG` `count` -could describe, which the encode would otherwise discover only after compressing the image and +`with_c2pa_reserved` writes a zero-filled reservation for an external signer to overwrite in place. +It is an infallible builder, so every bound on `len` is enforced by the **encode** that follows, as +`Error::InvalidInput`, on every entry point: below the store's minimum (8 bytes, 9 in BigTIFF — the +JUMBF box header, and one more than the variant's inline threshold, since a value that packs inline +is not the run at the end of the file §A.3.6 wants), above what a buffer can hold, since past +`isize::MAX` a `Vec` cannot exist and `vec![0; len]` said so by panicking with a capacity +overflow, and — in a classic TIFF — above the 4 GiB its entry's 32-bit `LONG` `count` could +describe, which the encode would otherwise discover only after compressing the image and zero-filling the reservation (BigTIFF's count is 64-bit and has no such bound). The reservation is taken fallibly instead, so a caller's number cannot panic a library path. A store whose *offset* would pass classic TIFF's 4 GiB limit depends on the size of the file it lands after, so that one -stays `gamut_ifd::c2pa::append_store`'s, refused once the file exists. What stays outside this crate's reach is the allocator's: a reservation the machine -has no memory for aborts, as any oversized allocation in Rust does. `encode_with_report` reports -the ranges, and `c2pa_exclusions` recovers them from any -TIFF's bytes — including files written through `encode_palette8` or `encode_pages_rgb8`, which the -object-safe `EncodeImage` seam cannot report through. The store's bytes are never byte-swapped: -the header's `ByteOrder` does not govern them (§A.3.6). Tag 52545 joins `is_known_tag`, so the -v1 zero-tolerance byte accounting claims the store as its entry's typed value span rather than -reporting an unknown private tag and an unaccounted trailer. Evidence: `tests/c2pa.rs`, -`tests/metadata.rs`, and libtiff decoding a store-carrying file pixel-exact +stays `gamut_ifd::c2pa::append_store`'s, refused once the file exists. What stays outside this +crate's reach is the allocator's: a reservation the machine has no memory for aborts, as any +oversized allocation in Rust does. `encode_with_report` reports the ranges, and `c2pa_exclusions` +recovers them from any TIFF's bytes — including files written through `encode_palette8` or +`encode_pages_rgb8`, which the object-safe `EncodeImage` seam cannot report through. The store's +bytes are never byte-swapped: the header's `ByteOrder` does not govern them (§A.3.6). Tag 52545 +joins `is_known_tag`, so the v1 zero-tolerance byte accounting claims the store as its entry's typed +value span rather than reporting an unknown private tag and an unaccounted trailer. Evidence: +`tests/c2pa.rs`, `tests/metadata.rs`, and libtiff decoding a store-carrying file pixel-exact (`tests/oracle_metadata.rs`). **Deferred (planned, additive).** Each plugs into the existing strip/tile pipeline and libtiff From 81cab24f542a29eb0f2d33fa281e77ace1190c7f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:10:19 -0400 Subject: [PATCH 28/43] test(tiff): assert the group a written Exif directory carries, not its count Self-review of the previous commit: the new test cloned the Interop directory it built and then compared only `sub_ifds().len()`, so the clone was needless and the assertion could not tell the written group from any other group of one. Comparing against the directory itself is both stronger and shorter. --- crates/gamut-tiff/src/metadata.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 2c8949e1..26b16cad 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -689,14 +689,20 @@ mod tests { assert!(!meta.is_empty(), "a group is content"); let mut ifd0 = Ifd::new(); meta.apply(&mut ifd0); + let written = ifd0 + .sub_ifds() + .iter() + .find(|group| group.tag == tags::EXIF_IFD) + .and_then(|group| group.ifds.first()) + .expect("the Exif directory must be written"); assert_eq!( - ifd0.sub_ifds() + written + .sub_ifds() .iter() - .find(|group| group.tag == tags::EXIF_IFD) - .and_then(|group| group.ifds.first()) - .map(|exif| exif.sub_ifds().len()), - Some(1), - "the Exif directory must be written, carrying its Interop group" + .find(|group| group.tag == tags::INTEROPERABILITY_IFD) + .map(|group| group.ifds.as_slice()), + Some(&[interop][..]), + "carrying the group that was its only content" ); } From a908afad909be418eec1e03f2a0913ccf581c10c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:13:58 -0400 Subject: [PATCH 29/43] fix(tiff): refuse an Exif pointer tag carried as a plain field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_exif_subtree` inspected a directory's sub-IFD *groups*; `resolve_pointers` inspects its *fields*. That asymmetry was the defect: the one shape the reader misreads was the one shape the writer never looked at. A standard pointer tag carried as a plain `LONG` — `SubIFDs` (330), `ExifIFD` (34665), `GPSInfo` (34853) or `InteroperabilityIFD` (40965) — passed the check, encoded cleanly, and then failed this crate's own `metadata()` with `read out of bounds` for a large integer or `sub-IFD pointer loop` for a small one. That is precisely the file `TiffMetadata::check` documents it exists to prevent: one "read back as something other than what was written". The refusal now inspects what the resolver inspects, at every level of the subtree, and is shaped by the value's *type* rather than by its tag: `pointer_offsets` accepts only LONG/IFD/LONG8/IFD8, so a `SHORT` under `SubIFDs` is a pointer to neither side and is written and read back unchanged. Both refusal messages become named constants so the tag set the public contract enumerates in prose has one place that fails when it stops being true — the set itself is a sibling crate's constant, and a fifth member arriving upstream would otherwise widen the contract silently. --- crates/gamut-tiff/src/metadata.rs | 173 ++++++++++++++++++++++++++---- 1 file changed, 151 insertions(+), 22 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 26b16cad..435cdfd2 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -203,25 +203,36 @@ impl TiffMetadata { /// Refuses a set this crate would write into a file its own [`read_metadata`] then rejects, /// or reads back as something other than what was written. /// - /// The Exif sub-IFD is a caller's directory and may carry sub-IFD groups of its own, and - /// nothing about a directory in memory stops it nesting a hundred levels down or hanging a - /// group off a tag no reader treats as a pointer. Two bounds therefore apply, and - /// [`check_exif_subtree`] reports them as **two distinct refusals** because they are two + /// The Exif sub-IFD is a caller's directory, and nothing about a directory in memory stops it + /// nesting a hundred levels down, hanging a group off a tag no reader treats as a pointer, or + /// carrying a bare integer under a tag every reader does. Three bounds therefore apply, and + /// [`check_exif_subtree`] reports them as **three distinct refusals** because they are three /// distinct mistakes: /// - /// 1. **the tag.** A group's tag must be one the reader resolves inside the Exif subtree + /// 1. **the field.** No field under a tag in [`EXIF_SUBTREE_POINTER_TAGS`] may carry a + /// pointer's own type ([`pointer_offsets`]). The reader decides "pointer" from the field, + /// not from the group a caller built, so such a field is followed as an offset into a file + /// it never came from — the round trip returns a parsed directory, an error, or nothing, + /// but never the field that was written. + /// 2. **the tag.** A group's tag must be one the reader resolves inside the Exif subtree /// ([`EXIF_SUBTREE_POINTER_TAGS`]). Under any other tag the writer emits a pointer the /// reader hands back as a raw offset into the file it came from, so the directory does not /// survive a round trip. - /// 2. **the depth.** The reader follows [`MAX_POINTER_DEPTH`] levels below IFD 0 and refuses + /// 3. **the depth.** The reader follows [`MAX_POINTER_DEPTH`] levels below IFD 0 and refuses /// what is deeper. The Exif directory occupies the first of those levels, so its own /// nesting may use the rest — one further directory, which for a decoded camera EXIF is /// `InteroperabilityIFD` (EXIF 2.3 §4.6.3). /// + /// Only the Exif subtree is checked, because it is the only directory a caller supplies: the + /// blocks [`apply`](Self::apply) writes into IFD 0 sit under `XMP`, `IPTC_NAA` and + /// `ICC_PROFILE`, none of which any level treats as a pointer, and the rest of IFD 0 is the + /// encoder's own. + /// /// # Errors /// - /// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if the Exif sub-IFD hangs - /// a group off a tag the reader does not resolve, or nests deeper than the reader walks back. + /// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if the Exif sub-IFD + /// carries a pointer-typed field under a pointer tag, hangs a group off a tag the reader does + /// not resolve, or nests deeper than the reader walks back. pub(crate) fn check(&self) -> Result<()> { match self.exif_ifd() { Some(exif) => check_exif_subtree(exif, MAX_POINTER_DEPTH - 1), @@ -325,35 +336,72 @@ fn pointer_tags(depth: usize) -> &'static [u16] { /// generic reader needs sixteen because it is handed arbitrary tags, and this one is not. const MAX_POINTER_DEPTH: usize = 2; -/// The writer's side of what the reader delivers: refuses an Exif subtree whose groups this crate -/// could not hand back unchanged, `depth` further levels being all that is left below `ifd`. +/// The refusal earned by a field under a tag in [`EXIF_SUBTREE_POINTER_TAGS`] whose value +/// [`pointer_offsets`] accepts — [`check_exif_subtree`]'s first clause. +/// +/// A named constant rather than a literal in place because it **enumerates the tag set in prose**, +/// as this crate's public documentation does, while the set itself is a sibling crate's constant. +/// Naming it lets the enumeration be pinned in one assertion instead of drifting silently. +const POINTER_FIELD_REFUSAL: &str = "TIFF: an Exif sub-IFD may not carry a plain field under a \ + standard pointer tag (SubIFDs, ExifIFD, GPSInfo, InteroperabilityIFD) with a pointer's own \ + type (LONG, IFD, LONG8 or IFD8), which the reader would follow as a file offset"; + +/// The refusal earned by a sub-IFD group under a tag *outside* [`EXIF_SUBTREE_POINTER_TAGS`] — +/// [`check_exif_subtree`]'s second clause. Named for the same reason as +/// [`POINTER_FIELD_REFUSAL`]. +const FOREIGN_GROUP_REFUSAL: &str = "TIFF: an Exif sub-IFD may only nest a group under a standard \ + pointer tag (SubIFDs, ExifIFD, GPSInfo, InteroperabilityIFD) and this one uses another, \ + which would read back as a raw file offset"; + +/// The writer's side of what the reader delivers: refuses an Exif subtree this crate could not +/// hand back unchanged, `depth` further levels being all that is left below `ifd`. +/// +/// **This inspects what [`resolve_pointers`] inspects, and that symmetry is the whole design.** +/// The reader decides "pointer" from a directory's *fields* — `ifd.get(tag)` under a tag in +/// [`EXIF_SUBTREE_POINTER_TAGS`] whose value [`pointer_offsets`] accepts — while a caller builds +/// one from [`sub_ifds`](Ifd::sub_ifds) *groups*. Checking only the groups left the writer blind +/// to the very shape the reader misreads: a pointer tag carried as a plain `LONG`, which encoded +/// cleanly and then failed this crate's own [`read_metadata`] with `read out of bounds` or +/// `sub-IFD pointer loop` depending on the integer. So both are checked, at every level. /// -/// Two refusals, deliberately distinct, because they are two different mistakes and a caller +/// Three refusals, deliberately distinct, because they are three different mistakes and a caller /// reading the message has to know which one it made: /// -/// * a group under a tag outside [`EXIF_SUBTREE_POINTER_TAGS`] — the reader leaves that pointer -/// as a raw absolute offset, so what came back would not be what was written; +/// * a **field** under a tag *in* [`EXIF_SUBTREE_POINTER_TAGS`] whose type is a pointer's own — +/// the reader follows it as a file offset into a file it did not come from, so what came back +/// is a parsed directory, an error, or nothing, but never the field that was written. Only the +/// pointer *types* are refused: `pointer_offsets` rejects every other type, so a `SHORT` under +/// `SubIFDs` is left in place by the reader and is left alone here too; +/// * a **group** under a tag *outside* [`EXIF_SUBTREE_POINTER_TAGS`] — the reader leaves that +/// pointer as a raw absolute offset, so what came back would not be what was written; /// * a *child directory* nested past `depth` — the reader refuses to walk that far /// ([`MAX_POINTER_DEPTH`]). A group with no children reaches no further level, so it is the /// children and not the group that the bound counts. /// -/// The tag is checked first: a group under an unfollowed tag is unreturnable whatever its depth, -/// and naming a depth clause for it is the message a reader cannot act on. Both stop at the first -/// offender and the depth bound stops at the bound rather than measuring the whole tree, so a -/// directory a caller nested a hundred levels deep costs a hundred levels of neither recursion nor -/// time. +/// The order is field, then group tag, then depth, and it is the order of how little the rest of +/// the tree matters to each: a pointer-typed field is unreturnable whatever else the directory +/// holds, a group under an unfollowed tag is unreturnable whatever its depth, and only the depth +/// clause needs the tree walked. All three stop at the first offender and the depth bound stops at +/// the bound rather than measuring the whole tree, so a directory a caller nested a hundred levels +/// deep costs a hundred levels of neither recursion nor time. /// /// # Errors /// -/// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) for either refusal. +/// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) for any of the three refusals. fn check_exif_subtree(ifd: &Ifd, depth: usize) -> Result<()> { + for &tag in EXIF_SUBTREE_POINTER_TAGS { + if ifd.get(tag).and_then(pointer_offsets).is_some() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + POINTER_FIELD_REFUSAL, + )); + } + } for group in ifd.sub_ifds() { if !EXIF_SUBTREE_POINTER_TAGS.contains(&group.tag) { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), - "TIFF: an Exif sub-IFD may only nest a group under a standard pointer tag \ - (SubIFDs, ExifIFD, GPSInfo, InteroperabilityIFD) and this one uses another, \ - which would read back as a raw file offset", + FOREIGN_GROUP_REFUSAL, )); } for child in &group.ifds { @@ -725,6 +773,87 @@ mod tests { assert!(err.to_string().contains("standard pointer tag"), "{err}"); } + #[test] + fn the_writer_refuses_an_exif_pointer_tag_carried_as_a_pointer_typed_field() { + // `check` inspected only `sub_ifds()` while `resolve_pointers` inspects `get(tag)`, so the + // one shape the reader misreads was the one shape the writer never looked at: a standard + // pointer tag carried as a plain `LONG`. All four encoded cleanly and then failed this + // crate's own `read_metadata` — `read out of bounds` for a large integer, `sub-IFD pointer + // loop` for a small one — which is exactly what `check` documents it refuses. Every + // pointer type is swept, because which one a caller's directory happens to use is not + // something the writer can know. The *message* is the claim: this tree has no group and no + // nesting at all, so a refusal naming either of the other two clauses would be the wrong + // check answering. + let pointer_typed = [ + Value::Long(vec![8]), + Value::Ifd(vec![8]), + Value::Long8(vec![8]), + Value::Ifd8(vec![8]), + ]; + for tag in EXIF_SUBTREE_POINTER_TAGS { + for value in &pointer_typed { + let mut exif = exif_ifd(); + exif.set(*tag, value.clone()); + let Err(err) = TiffMetadata::new().with_exif(exif).check() else { + panic!("tag {tag}, {value:?}: a field the reader follows as an offset"); + }; + assert!( + err.to_string().contains("may not carry a plain field"), + "tag {tag}, {value:?}: {err}" + ); + } + } + } + + #[test] + fn a_value_no_reader_would_follow_survives_under_a_pointer_tag() { + // The refusal above is shaped by the value's *type*, not by its tag, and that is the whole + // difference between bounding the writer by what the reader misreads and banning four tags + // outright. `pointer_offsets` accepts only LONG/IFD/LONG8/IFD8, so a `SHORT` under + // `SubIFDs` is left in place by the reader — and must therefore be written and handed back + // unchanged rather than refused. + for tag in EXIF_SUBTREE_POINTER_TAGS { + let mut exif = exif_ifd(); + exif.set(*tag, Value::Short(vec![8])); + let meta = TiffMetadata::new().with_exif(exif); + meta.check() + .unwrap_or_else(|e| panic!("tag {tag}: a SHORT is not a pointer: {e}")); + + let mut ifd0 = Ifd::new(); + meta.apply(&mut ifd0); + let back = read_metadata(&file_with(ifd0)) + .expect("read") + .exif + .unwrap_or_else(|| panic!("tag {tag}: an Exif directory")); + assert_eq!( + back.get(*tag), + Some(&Value::Short(vec![8])), + "tag {tag}: must come back the field that was written" + ); + } + } + + #[test] + fn the_exif_subtree_pointer_tags_are_the_four_this_crate_documents() { + // The set is a sibling crate's constant, `gamut_ifd::tags::STANDARD_POINTER_TAGS`, while + // this crate's public contract enumerates its four members one by one — in + // `TiffMetadata::exif`, `TiffEncoder::with_metadata`, `TiffDecoder::metadata`, README.md, + // STATUS.md, and both refusal messages. A fifth member added upstream would widen every + // one of those silently, and a public contract must not widen without someone deciding it. + // So the enumeration is pinned once, here, against the literal numbers the prose gives and + // the names the messages give. + assert_eq!( + EXIF_SUBTREE_POINTER_TAGS, + [330, 34665, 34853, 40965], + "the four pointer tags this crate's documentation and messages enumerate" + ); + for message in [POINTER_FIELD_REFUSAL, FOREIGN_GROUP_REFUSAL] { + for name in ["SubIFDs", "ExifIFD", "GPSInfo", "InteroperabilityIFD"] { + assert!(message.contains(name), "{name} unnamed by: {message}"); + } + } + } + #[test] fn a_standard_pointer_group_inside_the_exif_directory_is_resolved() { // `POINTER_TAGS` once listed `ExifIFD` and `InteroperabilityIFD` only, so a `SubIFDs` or From ef93840c8c0ab64647d865a0ae4f56f24a086be2 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:14:07 -0400 Subject: [PATCH 30/43] docs(tiff): restate the Exif subtree's refusals where each is claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set of sites was derived by sweeping the crate for the contract's own terms — every `///`/`//!` line naming a pointer tag by name or number, every one naming the follow/resolve contract, the crate's markdown, and the error-message strings — rather than by listing them from memory. That sweep found five restatements of this contract and two that had gone false: `TiffDecoder::metadata` still said only `ExifIFD` and `InteroperabilityIFD` are followed, which was false in both directions: a dangling non-listed tag *inside* the Exif directory now fails the call, which the doc implied cannot happen, and a dangling `InteroperabilityIFD` at IFD 0 now succeeds, which the doc said is followed. It now states the per-level rule and what each level costs. `TiffEncoder::with_metadata` said nesting is "the one thing" that could break the agreement between what it writes and what the decoder reads. There are three, and each has its own message; all three are now named there, in README.md and in STATUS.md. --- crates/gamut-tiff/README.md | 15 ++++++++++----- crates/gamut-tiff/STATUS.md | 20 +++++++++++++------- crates/gamut-tiff/src/decoder.rs | 24 +++++++++++++++--------- crates/gamut-tiff/src/encoder.rs | 20 +++++++++++++++----- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index 7910ac7a..6ff9d644 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -84,11 +84,16 @@ compression schemes land additively on this frozen surface (see Status). A *vendor-private* tag whose value happens to be an offset is not a pointer to anything this crate can see, so it is carried through and re-encoded verbatim, still holding the source file's offset — a round trip through such a field is **not** proof the result is pointer-safe. - What the encoder writes the decoder reads back: the Exif directory may nest one further - directory (`InteroperabilityIFD`, EXIF 2.3 §4.6.3, is the one a camera writes), which is as deep - as the reader walks, and it may hang that group only off a standard pointer tag. A caller's - directory nested deeper, or hung off any other tag, is refused by the encode — with its own - message per case — rather than written into a file this crate could not read back unchanged. + What the encoder writes the decoder reads back, and the writer is bounded by exactly what the + reader would misread. The Exif directory may nest one further directory + (`InteroperabilityIFD`, EXIF 2.3 §4.6.3, is the one a camera writes), which is as deep as the + reader walks; it may hang a group only off a standard pointer tag; and it may not carry a + *plain field* under one of those four tags whose type is a pointer's own (`LONG`, `IFD`, + `LONG8`, `IFD8`), because the reader decides "pointer" from the field and would follow that + integer as a file offset. A value of any other type under those tags is not a pointer to either + side and round-trips unchanged. A caller's directory nested deeper, hung off any other tag, or + carrying such a field is refused by the encode — with its own message per case — rather than + written into a file this crate could not read back unchanged. The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared `gamut_ifd::c2pa` helper it and `gamut-dng` both call: the entry in the last IFD of the main chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 670433ba..75b3b76e 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -110,13 +110,19 @@ read. `gamut_ifd::read_tree` can be scoped neither way: it resolves the one flat at every node of every page. **(c)** The blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone sees none of them; duplicating an ICC profile onto every page is the worse outcome, and IFD 0 is where a reader conventionally looks. **(d)** The **writer -is bounded by what the reader accepts**, on both axes of (b) and with a distinct message for each, -since they are distinct mistakes. *Depth*: the walk stops two levels under IFD 0 — the deepest -tree the followed tags legitimately reach (EXIF 2.3 §4.6.3) — and an Exif directory a caller -nested deeper is refused. *Tag*: a group hung off anything outside the standard pointer tags comes -back as a raw offset, so it is refused too. Both are `Error::InvalidInput` from `with_metadata`'s -encode before any pixel work, on **every** public encode surface, rather than a well-formed file -this crate cannot read back unchanged. The bound is the spec's; that a narrower one is also easier +is bounded by what the reader accepts**, and it is bounded by inspecting *what the reader +inspects*, with a distinct message per case since they are distinct mistakes. *Depth*: the walk +stops two levels under IFD 0 — the deepest tree the followed tags legitimately reach (EXIF 2.3 +§4.6.3) — and an Exif directory a caller nested deeper is refused. *Tag*: a group hung off +anything outside the standard pointer tags comes back as a raw offset, so it is refused too. +*Field*: the reader decides "pointer" from a directory's **fields**, while a caller builds one +from **groups**, so checking only the groups left the writer blind to the one shape the reader +misreads — a standard pointer tag carried as a plain `LONG`, which encoded cleanly and then failed +this crate's own reader with `read out of bounds` or `sub-IFD pointer loop`. A field under one of +the four tags whose type is a pointer's own (`LONG`, `IFD`, `LONG8`, `IFD8`) is therefore refused; +any other type under those tags is not a pointer to either side and round-trips unchanged. All +three are `Error::InvalidInput` from `with_metadata`'s encode before any pixel work, on **every** +public encode surface, rather than a well-formed file this crate cannot read back unchanged. The bound is the spec's; that a narrower one is also easier to assert is not on its own a reason to narrow a contract. The C2PA manifest store is the one carrier with a placement rule of its own, and that rule is not diff --git a/crates/gamut-tiff/src/decoder.rs b/crates/gamut-tiff/src/decoder.rs index 7581b166..858c8c25 100644 --- a/crates/gamut-tiff/src/decoder.rs +++ b/crates/gamut-tiff/src/decoder.rs @@ -210,16 +210,22 @@ impl TiffDecoder { /// /// # Errors /// - /// Returns [`Error::InvalidInput`] for a malformed header or IFD chain, or for a pointer - /// **inside IFD 0's subtree** that does not resolve into a tree: an out-of-bounds or - /// unparseable target, two pointers naming one directory, or nesting below the - /// `ExifIFD` → `InteroperabilityIFD` pair — two levels under IFD 0, the deepest tree those - /// two tags legitimately reach (EXIF 2.3 §4.6.3), and the same bound + /// Returns [`Error::InvalidInput`] for a malformed header or IFD chain, or for a **followed** + /// pointer that does not resolve into a tree: an out-of-bounds or unparseable target, two + /// pointers naming one directory, or nesting below the `ExifIFD` → `InteroperabilityIFD` pair + /// — two levels under IFD 0, the deepest tree those two tags legitimately reach (EXIF 2.3 + /// §4.6.3), and the same bound /// [`TiffEncoder::with_metadata`](crate::TiffEncoder::with_metadata) writes within. - /// Only `ExifIFD` (34665) and `InteroperabilityIFD` (40965) are followed, and only from - /// IFD 0 downwards — a pointer on any later page of a multi-page document is never resolved, - /// so however broken it is it cannot fail this call, not even by naming a directory IFD 0's - /// own subtree also names. + /// + /// Which pointers are followed depends on the **level**, so which ones can fail this call does + /// too. At **IFD 0** only `ExifIFD` (34665) is followed: a `SubIFDs` (330), `GPSInfo` (34853) + /// or `InteroperabilityIFD` (40965) field on the page itself is left as the integer it was + /// read as, so however broken it is it cannot fail this call. **Inside the returned Exif + /// directory all four** are followed, because that directory is handed back and an unresolved + /// pointer in it would be a raw offset into the source file — so there, unlike at IFD 0, an + /// unreadable target under any of the four *does* fail the call. And only IFD 0's subtree is + /// walked at all: a pointer on any later page of a multi-page document is never resolved, not + /// even one naming a directory IFD 0's own subtree also names. /// /// **This can fail on a file [`decode_image`](DecodeImage::decode_image) decodes happily**, /// and that is deliberate. Pixel decoding never follows a metadata pointer, so a broken diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index bdcbed1e..aa9000e1 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -140,11 +140,21 @@ impl TiffEncoder { /// [`encode_pages_rgb8`](Self::encode_pages_rgb8) they are the first and last page. /// /// **What this encoder writes, [`TiffDecoder::metadata`](crate::TiffDecoder::metadata) reads - /// back.** The one thing that could break the agreement is nesting: an Exif sub-IFD may carry - /// sub-IFD groups of its own, the reader follows the `ExifIFD` → `InteroperabilityIFD` pair - /// (EXIF 2.3 §4.6.3) and no deeper, so a directory nested below that pair is refused here — - /// a typed [`Error::InvalidInput`] raised before any pixel work, not a well-formed file this - /// crate's own reader then rejects. + /// back.** What could break the agreement is the caller's own Exif directory, which is the one + /// directory here this crate did not build, and three shapes of it are refused — each a typed + /// [`Error::InvalidInput`] raised before any pixel work, with its own message, rather than a + /// well-formed file this crate's own reader then rejects: + /// + /// * a **field** under one of the four standard pointer tags — `SubIFDs` (330), `ExifIFD` + /// (34665), `GPSInfo` (34853), `InteroperabilityIFD` (40965) — whose type is a pointer's own + /// (`LONG`, `IFD`, `LONG8`, `IFD8`). The reader decides "pointer" from the field, so it would + /// follow that integer as an offset into a file it never came from. A value of any other + /// type under those tags is not a pointer to either side, and is written and read back + /// unchanged; + /// * a **group** under any other tag: the reader resolves only those four inside the Exif + /// subtree, so a group elsewhere comes back as the raw offset this encoder gave it; + /// * a directory nested **below** the `ExifIFD` → `InteroperabilityIFD` pair (EXIF 2.3 + /// §4.6.3), which is as deep as the reader walks. #[must_use] pub fn with_metadata(mut self, metadata: TiffMetadata) -> Self { self.metadata = metadata; From 31c08a6335b3a47039d7849c6867c3270f8769af Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:14:16 -0400 Subject: [PATCH 31/43] test(tiff): fail the IFD-0 scoping test under the regression it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a_standard_pointer_at_ifd_0_that_feeds_no_field_is_left_alone` passed under the exact defect its comment describes — `pointer_tags` returning the full set at every level. Its first assertion called `read`, which resolves no pointer under any configuration, and its second asked for `exif`, which stays `None` whether a `SubIFDs` pointer at IFD 0 was resolved or not. The matching mutant was caught, but by the other half of the rule, so that axis was green for the wrong reason. It now drives `resolve_pointers` directly at the depth `read_metadata` calls it with, and asserts the field stays the integer it was read as and becomes no group — which resolution at IFD 0 would break. Verified: the previous body passes under that change and this one fails, as its only failure. Also names `IFD0_POINTER_TAGS`, which is what the round-6 split left of the `POINTER_TAGS` a neighbouring comment still referred to in the present tense. --- crates/gamut-tiff/src/metadata.rs | 37 ++++++++++++++++++++++------- crates/gamut-tiff/tests/metadata.rs | 2 +- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 435cdfd2..f990d437 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -894,19 +894,40 @@ mod tests { // blocks — is `a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks` // (tests/metadata.rs); this pins the resolution itself, on a pointer that is perfectly // readable, so the two claims cannot be confused. + // + // `resolve_pointers` is driven directly, at the depth `read_metadata` calls it with, + // because IFD 0 is the one directory the seam never hands back: asking `read_metadata` + // instead can only observe `exif`, which stays `None` whether the pointer was resolved or + // not, so the regression this names — `pointer_tags` returning the full set at every + // level — would pass unnoticed. Here it does not: resolution turns the field into a group. for tag in [tags::SUB_IFDS, tags::GPS_INFO] { - let mut ifd0 = Ifd::new(); - ifd0.set_sub_ifd(tag, vec![exif_ifd()]); - ifd0.set(tags::XMP, Value::Byte(b"x".to_vec())); - let bytes = file_with(ifd0); - let offset = read(&bytes).expect("read").ifds[0] + let mut source = Ifd::new(); + source.set_sub_ifd(tag, vec![exif_ifd()]); + source.set(tags::XMP, Value::Byte(b"x".to_vec())); + let bytes = file_with(source); + let mut ifd0 = read(&bytes).expect("read").ifds.swap_remove(0); + let offset = ifd0 .get_u32(tag) .unwrap_or_else(|| panic!("tag {tag}: a written pointer")); assert!(offset > 0, "tag {tag}: the pointer must name a directory"); + + resolve_pointers( + &bytes, + ByteOrder::LittleEndian, + Variant::Classic, + &mut ifd0, + &mut BTreeSet::new(), + 0, + ) + .unwrap_or_else(|e| panic!("tag {tag}: resolving IFD 0: {e}")); assert_eq!( - read_metadata(&bytes).expect("metadata").exif, - None, - "tag {tag}: a page pointer must not become the Exif directory" + ifd0.get_u32(tag), + Some(offset), + "tag {tag}: must stay the integer field it was read as" + ); + assert!( + ifd0.sub_ifds().is_empty(), + "tag {tag}: must not become a group at IFD 0" ); } } diff --git a/crates/gamut-tiff/tests/metadata.rs b/crates/gamut-tiff/tests/metadata.rs index 709d2433..1456744c 100644 --- a/crates/gamut-tiff/tests/metadata.rs +++ b/crates/gamut-tiff/tests/metadata.rs @@ -450,7 +450,7 @@ fn a_broken_exif_pointer_is_still_an_error() { #[test] fn a_broken_pointer_on_a_page_the_metadata_discards_does_not_fail_the_read() { - // The same rule that keeps `SubIFDs` and `GPSInfo` out of `POINTER_TAGS`, applied to whole + // The same rule that keeps `SubIFDs` and `GPSInfo` out of `IFD0_POINTER_TAGS`, applied to whole // *pages*: the blocks come from IFD 0 and the C2PA store from the last IFD, so a pointer // anywhere else feeds nothing this returns and following it can only add failure modes. A // dangling `ExifIFD` on page 1 of a two-page document used to fail the whole call. From 23271abbb0f60f831abac4fc538f075753ff2c54 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:29:06 -0400 Subject: [PATCH 32/43] fix(tiff): decide an Exif pointer field by its on-disk type code `check_exif_subtree` asked `pointer_offsets` about a caller's `Value`, so it classified by the in-memory variant. The reader classifies by the type code the entry carries, and `Value::Unknown` is the one shape where the two disagree: its constructor validates only the value word's width, so an `Unknown` built at code 4, 13, 16 or 18 is a plain field to the variant-shaped predicate and a sub-IFD pointer to the reader. `write` emits the code verbatim, and 64 of 384 end-to-end cases encoded cleanly and then failed this crate's own `read_metadata` with `read out of bounds` or `value offset out of bounds`, or -- in BigTIFF at the top level -- read back as a group where a field was written. The writer now asks what the reader asks: `Value::type_code`, total over every variant, against `POINTER_TYPE_CODES`. That membership is pinned against `pointer_offsets` by sweeping the whole `u16` code space rather than by repeating four numbers, and the boundary itself is swept by a matrix derived from the type -- every representable entry type, natural and `Unknown`, across the four pointer tags, classic and BigTIFF, on the Exif directory and one level below it. The sibling half, a `gamut-ifd` constructor that admits a recognised code into `Unknown` at all, is #608 and is not fixable from this crate. Refs #446 --- crates/gamut-tiff/src/metadata.rs | 233 ++++++++++++++++++++++++++++-- 1 file changed, 218 insertions(+), 15 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index f990d437..bd3aa596 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -210,10 +210,10 @@ impl TiffMetadata { /// distinct mistakes: /// /// 1. **the field.** No field under a tag in [`EXIF_SUBTREE_POINTER_TAGS`] may carry a - /// pointer's own type ([`pointer_offsets`]). The reader decides "pointer" from the field, - /// not from the group a caller built, so such a field is followed as an offset into a file - /// it never came from — the round trip returns a parsed directory, an error, or nothing, - /// but never the field that was written. + /// pointer's own on-disk type code ([`POINTER_TYPE_CODES`]). The reader decides "pointer" + /// from the entry it parses, not from the group a caller built, so such a field is followed + /// as an offset into a file it never came from — the round trip returns a parsed directory, + /// an error, or nothing, but never the field that was written. /// 2. **the tag.** A group's tag must be one the reader resolves inside the Exif subtree /// ([`EXIF_SUBTREE_POINTER_TAGS`]). Under any other tag the writer emits a pointer the /// reader hands back as a raw offset into the file it came from, so the directory does not @@ -336,8 +336,37 @@ fn pointer_tags(depth: usize) -> &'static [u16] { /// generic reader needs sixteen because it is handed arbitrary tags, and this one is not. const MAX_POINTER_DEPTH: usize = 2; -/// The refusal earned by a field under a tag in [`EXIF_SUBTREE_POINTER_TAGS`] whose value -/// [`pointer_offsets`] accepts — [`check_exif_subtree`]'s first clause. +/// The on-disk field-type codes that make a directory entry a sub-IFD pointer: `LONG` (4), +/// the typed `IFD` (13) of TIFF Technical Note 1, and BigTIFF's `LONG8` (16) / `IFD8` (18). +/// +/// The same set [`pointer_offsets`] accepts, stated as **codes** rather than as [`Value`] +/// variants, and that difference is the whole of this constant's reason to exist. +/// `pointer_offsets` answers about a value the *reader* parsed, where the variant and the on-disk +/// code agree by construction. [`check_exif_subtree`] answers about a value a *caller* built, +/// which nothing has written yet — and there the two can disagree: [`gamut_ifd::UnknownValue`] +/// carries an arbitrary type code beside its value word (its constructor validates only the +/// word's width), [`gamut_ifd::write`] emits that code verbatim, and the reader classifies the +/// entry by it. A `Value::Unknown` built at 4, 13, 16 or 18 is therefore a plain field to a +/// variant-shaped predicate and a **pointer** to the reader: it encoded cleanly and then failed +/// this crate's own [`read_metadata`] with `read out of bounds` or `value offset out of bounds`. +/// The discriminator that survives the write/read boundary is the code, so the writer asks about +/// the code. +/// +/// The membership is pinned against `pointer_offsets` over the whole code space by +/// `the_pointer_type_codes_are_exactly_the_codes_the_resolver_follows`. The sibling half — a +/// `gamut-ifd` constructor that accepts a *known* code into `Unknown` at all — is issue #608 and +/// is not fixable from this crate. +const POINTER_TYPE_CODES: &[u16] = &[4, 13, 16, 18]; + +/// Whether the reader would follow `value` as a sub-IFD pointer once it has been written out and +/// read back: its on-disk type code ([`Value::type_code`], total over every variant including +/// `Unknown`) is one of [`POINTER_TYPE_CODES`]. +fn is_pointer_typed(value: &Value) -> bool { + POINTER_TYPE_CODES.contains(&value.type_code()) +} + +/// The refusal earned by a field under a tag in [`EXIF_SUBTREE_POINTER_TAGS`] whose on-disk type +/// code is in [`POINTER_TYPE_CODES`] — [`check_exif_subtree`]'s first clause. /// /// A named constant rather than a literal in place because it **enumerates the tag set in prose**, /// as this crate's public documentation does, while the set itself is a sibling crate's constant. @@ -358,11 +387,17 @@ const FOREIGN_GROUP_REFUSAL: &str = "TIFF: an Exif sub-IFD may only nest a group /// /// **This inspects what [`resolve_pointers`] inspects, and that symmetry is the whole design.** /// The reader decides "pointer" from a directory's *fields* — `ifd.get(tag)` under a tag in -/// [`EXIF_SUBTREE_POINTER_TAGS`] whose value [`pointer_offsets`] accepts — while a caller builds -/// one from [`sub_ifds`](Ifd::sub_ifds) *groups*. Checking only the groups left the writer blind -/// to the very shape the reader misreads: a pointer tag carried as a plain `LONG`, which encoded -/// cleanly and then failed this crate's own [`read_metadata`] with `read out of bounds` or -/// `sub-IFD pointer loop` depending on the integer. So both are checked, at every level. +/// [`EXIF_SUBTREE_POINTER_TAGS`] whose entry carries one of [`POINTER_TYPE_CODES`] — while a +/// caller builds one from [`sub_ifds`](Ifd::sub_ifds) *groups*. Checking only the groups left the +/// writer blind to the very shape the reader misreads: a pointer tag carried as a plain `LONG`, +/// which encoded cleanly and then failed this crate's own [`read_metadata`] with `read out of +/// bounds` or `sub-IFD pointer loop` depending on the integer. So both are checked, at every +/// level. +/// +/// The symmetry is stated across the **write/read boundary**, not on a parsed value, because that +/// boundary is where it kept breaking: a caller's [`Value`] and the entry the reader will parse +/// agree on nothing but the on-disk type code, so the code is what both sides ask about — see +/// [`POINTER_TYPE_CODES`]. /// /// Three refusals, deliberately distinct, because they are three different mistakes and a caller /// reading the message has to know which one it made: @@ -370,8 +405,8 @@ const FOREIGN_GROUP_REFUSAL: &str = "TIFF: an Exif sub-IFD may only nest a group /// * a **field** under a tag *in* [`EXIF_SUBTREE_POINTER_TAGS`] whose type is a pointer's own — /// the reader follows it as a file offset into a file it did not come from, so what came back /// is a parsed directory, an error, or nothing, but never the field that was written. Only the -/// pointer *types* are refused: `pointer_offsets` rejects every other type, so a `SHORT` under -/// `SubIFDs` is left in place by the reader and is left alone here too; +/// pointer *type codes* are refused: the reader leaves every other type in place, so a `SHORT` +/// under `SubIFDs` is left alone here too; /// * a **group** under a tag *outside* [`EXIF_SUBTREE_POINTER_TAGS`] — the reader leaves that /// pointer as a raw absolute offset, so what came back would not be what was written; /// * a *child directory* nested past `depth` — the reader refuses to walk that far @@ -390,7 +425,7 @@ const FOREIGN_GROUP_REFUSAL: &str = "TIFF: an Exif sub-IFD may only nest a group /// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) for any of the three refusals. fn check_exif_subtree(ifd: &Ifd, depth: usize) -> Result<()> { for &tag in EXIF_SUBTREE_POINTER_TAGS { - if ifd.get(tag).and_then(pointer_offsets).is_some() { + if ifd.get(tag).is_some_and(is_pointer_typed) { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), POINTER_FIELD_REFUSAL, @@ -604,14 +639,99 @@ mod tests { /// A minimal one-page file carrying `ifd0`, so `read_metadata` has a chain to walk. fn file_with(ifd0: Ifd) -> Vec { + file_with_variant(ifd0, Variant::Classic) + } + + /// [`file_with`] in a named container variant, for the axes on which classic and BigTIFF + /// differ: the width of a pointer's value word, and therefore its field type. + fn file_with_variant(ifd0: Ifd, variant: Variant) -> Vec { write(&TiffFile { order: ByteOrder::LittleEndian, - variant: Variant::Classic, + variant, ifds: vec![ifd0], }) .expect("write") } + /// One value of every on-disk field type, keyed by the type's code. + /// + /// Exhaustive over [`gamut_ifd::FieldType`] by construction: the compiler rejects this match + /// the day a field type is added upstream, so no caller of it can silently sweep a set short + /// by one — which is exactly the defect that produced the pointer-field regression. + fn canonical_value(ty: gamut_ifd::FieldType) -> Value { + use gamut_ifd::FieldType as T; + match ty { + T::Byte => Value::Byte(vec![8]), + T::Ascii => Value::Ascii("ab".into()), + T::Short => Value::Short(vec![8]), + T::Long => Value::Long(vec![8]), + T::Rational => Value::Rational(vec![(1, 2)]), + T::SByte => Value::SByte(vec![8]), + T::Undefined => Value::Undefined(vec![8]), + T::SShort => Value::SShort(vec![8]), + T::SLong => Value::SLong(vec![8]), + T::SRational => Value::SRational(vec![(1, 2)]), + T::Float => Value::Float(vec![1.0]), + T::Double => Value::Double(vec![1.0]), + T::Ifd => Value::Ifd(vec![8]), + T::Utf8 => Value::Utf8("ab".into()), + T::Long8 => Value::Long8(vec![8]), + T::SLong8 => Value::SLong8(vec![8]), + T::Ifd8 => Value::Ifd8(vec![8]), + } + } + + /// Every on-disk field-type code a directory entry can carry, derived by sweeping the whole + /// `u16` code space rather than listed by hand. + fn every_type_code() -> Vec { + (0..=u16::MAX) + .filter(|&code| gamut_ifd::FieldType::from_code(code).is_some()) + .collect() + } + + /// One `Value` per **well-formed** directory entry representable in a file of `variant`: the + /// natural variant of every recognised type code, the [`Value::Unknown`] form carrying that + /// same code with an opaque value word, and the `Unknown` form at a few codes no field type + /// claims. + /// + /// Derived from [`every_type_code`], so a type added upstream enters this sweep without an + /// edit here; the `Unknown` arm exists because it is the one shape whose in-memory variant and + /// on-disk code disagree, which is the whole subject of the sweep. + /// + /// **Well-formed** excludes one thing, and only for the `Unknown` arm: a single value of a + /// recognised type wider than the variant's value word would be written *out of line*, and the + /// word an `UnknownValue` carries is then a raw file offset a test cannot know. Such an entry + /// is unreadable whatever tag it sits under — `Rational` under `Classic` fails + /// `read_metadata` with `value offset out of bounds` — so it is a malformed entry rather than a + /// misclassified pointer, and this crate cannot refuse it without refusing every vendor entry + /// of an unrecognised type read out of a real file. That gap is issue #608, on the constructor + /// that admits it. All four pointer codes are still swept: 4 and 13 fit both variants' words, + /// 16 and 18 fit BigTIFF's. + fn every_representable_value(variant: Variant) -> Vec<(String, Value)> { + let word = vec![8u8; variant.offset_size()]; + let unknown = |code: u16| { + Value::Unknown( + gamut_ifd::UnknownValue::new(code, 1, &word, ByteOrder::LittleEndian, variant) + .expect("an unknown-type entry of the file's own width"), + ) + }; + let mut values = Vec::new(); + for code in every_type_code() { + let ty = gamut_ifd::FieldType::from_code(code).expect("a swept code"); + values.push((format!("{ty:?}({code})"), canonical_value(ty))); + if ty.size() <= variant.offset_size() { + values.push((format!("Unknown({code})"), unknown(code))); + } + } + // Controls: codes no field type claims, so the entry is unsizable and stays `Unknown` on + // the way back — the word is never followed. + let unclaimed = (0..=u16::MAX).filter(|&c| gamut_ifd::FieldType::from_code(c).is_none()); + for code in unclaimed.take(3) { + values.push((format!("Unknown({code}, unclaimed)"), unknown(code))); + } + values + } + #[test] fn empty_metadata_writes_nothing() { let mut ifd = Ifd::new(); @@ -805,6 +925,89 @@ mod tests { } } + #[test] + fn the_pointer_type_codes_are_exactly_the_codes_the_resolver_follows() { + // `POINTER_TYPE_CODES` restates, as on-disk codes, the set `pointer_offsets` restates as + // `Value` variants — and the writer now asks the code while the reader still asks the + // variant, so the two must not drift. Derived by sweeping the whole `u16` code space + // through `FieldType::from_code` and asking `pointer_offsets` about a value of each type, + // never by repeating the four numbers: a hand list short by one is the defect this + // constant exists to close. + let derived: Vec = every_type_code() + .into_iter() + .filter(|&code| { + let ty = gamut_ifd::FieldType::from_code(code).expect("a swept code"); + pointer_offsets(&canonical_value(ty)).is_some() + }) + .collect(); + assert_eq!( + derived, POINTER_TYPE_CODES, + "the codes the reader follows must be exactly the codes the writer refuses" + ); + } + + #[test] + fn every_value_the_writer_accepts_under_a_pointer_tag_reads_back_as_a_field() { + // The regression this crate kept reopening, stated at the boundary it kept breaking at. + // `check` was shaped by the `Value` variant while the reader classifies by the on-disk + // type code, and `Value::Unknown` is the one shape where those disagree: built at code 4, + // 13, 16 or 18 it is a plain field to a variant-shaped predicate and a pointer to the + // reader, so it encoded cleanly and then failed `read_metadata` — or, in BigTIFF at the + // top level, came back as a *group* where a field was written. + // + // The sweep is derived, not listed: every representable entry type (natural and `Unknown` + // at every code) x the four pointer tags x classic and BigTIFF x on the Exif directory + // itself and one level below it. Only accepted sets are exercised; that the refused ones + // are exactly the pointer-coded ones is + // `the_pointer_type_codes_are_exactly_the_codes_the_resolver_follows`. + for variant in [Variant::Classic, Variant::Big] { + for (name, value) in every_representable_value(variant) { + for &tag in EXIF_SUBTREE_POINTER_TAGS { + for nested in [false, true] { + let mut carrier = exif_ifd(); + carrier.set(tag, value.clone()); + let mut exif = exif_ifd(); + if nested { + exif.set_sub_ifd(tags::INTEROPERABILITY_IFD, vec![carrier]); + } else { + exif = carrier; + } + let meta = TiffMetadata::new().with_exif(exif.clone()); + if meta.check().is_err() { + continue; + } + let where_ = format!("{name} under {tag}, {variant:?}, nested={nested}"); + let mut ifd0 = Ifd::new(); + meta.apply(&mut ifd0); + let bytes = file_with_variant(ifd0, variant); + let back = read_metadata(&bytes) + .unwrap_or_else(|e| panic!("{where_}: accepted but unreadable: {e}")) + .exif + .unwrap_or_else(|| panic!("{where_}: accepted but no Exif directory")); + let carrier = if nested { + back.sub_ifds() + .iter() + .find(|group| group.tag == tags::INTEROPERABILITY_IFD) + .and_then(|group| group.ifds.first()) + .cloned() + .unwrap_or_else(|| panic!("{where_}: the nested directory")) + } else { + back + }; + assert!( + carrier.get(tag).is_some(), + "{where_}: must come back a field, not be followed as an offset" + ); + assert!( + carrier.sub_ifds().iter().all(|group| group.tag != tag), + "{where_}: a field must not come back a group" + ); + } + } + } + } + } + #[test] fn a_value_no_reader_would_follow_survives_under_a_pointer_tag() { // The refusal above is shaped by the value's *type*, not by its tag, and that is the whole From bd05a4388396f566fdce2d04a805599a7730a775 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:30:22 -0400 Subject: [PATCH 33/43] fix(tiff): refuse a field and a group under one Exif tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Ifd` keeps fields and sub-IFD groups in two lists, so one tag can sit in both. The writer then emitted two entries under that tag -- not a TIFF directory (TIFF 6.0 §2) -- and the field was silently lost: this crate's model collapses a duplicated tag to its last occurrence, and the group is written last. Verified on disk: an Exif directory given `InteroperabilityIFD` as both a `SHORT` field and a group encoded to entries `[(33434, 5, 1), (40965, 3, 1), (40965, 4, 1)]`, and read back without the field. Three documentation sites promised the opposite unconditionally -- that what the caller supplies is what the file gets. The encode now refuses the pair with its own message, because the alternative is a non-conformant file plus a reader that drops a field the caller set, and normalising it would mean choosing silently which of the two the caller meant. A non-minimal writer is the cheaper cost. Refs #446 --- crates/gamut-tiff/src/metadata.rs | 88 +++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index bd3aa596..3dd800e2 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -17,7 +17,9 @@ //! [`gamut_ifd::Ifd`] rather than as bytes saves every caller from re-parsing a directory the //! decoder already walked. Its fields are neither validated nor completed — what the caller //! supplies is what the file gets, and what the file holds is what the caller gets — subject to -//! the three normalisations a directory model implies, named on [`TiffMetadata::exif`]. +//! the three normalisations a directory model implies, named on [`TiffMetadata::exif`], and to +//! the four shapes the encode **refuses** outright rather than write a file it could not read +//! back ([`TiffMetadata::check`]). //! //! # Where the blocks live, and what that costs a page-at-a-time reader //! @@ -84,6 +86,11 @@ pub struct TiffMetadata { /// silently repaired, which is worth knowing before using a re-encode to prove a file /// unmodified. /// + /// The one shape the model can express and the file cannot is a tag holding **both** a field + /// and a sub-IFD group: two entries under one tag, which TIFF 6.0 §2 does not allow. That is + /// refused by the encode rather than normalised, because normalising it would mean choosing — + /// silently — which of the two the caller meant; see [`TiffMetadata::check`]. + /// /// **Every standard pointer tag inside this directory is resolved.** All four members of /// [`gamut_ifd::tags::STANDARD_POINTER_TAGS`] — `SubIFDs` (330), `ExifIFD` (34665), `GPSInfo` /// (34853) and `InteroperabilityIFD` (40965) — come back as parsed @@ -204,10 +211,10 @@ impl TiffMetadata { /// or reads back as something other than what was written. /// /// The Exif sub-IFD is a caller's directory, and nothing about a directory in memory stops it - /// nesting a hundred levels down, hanging a group off a tag no reader treats as a pointer, or - /// carrying a bare integer under a tag every reader does. Three bounds therefore apply, and - /// [`check_exif_subtree`] reports them as **three distinct refusals** because they are three - /// distinct mistakes: + /// nesting a hundred levels down, hanging a group off a tag no reader treats as a pointer, + /// carrying a bare integer under a tag every reader does, or naming one tag twice. Four bounds + /// therefore apply, and [`check_exif_subtree`] reports them as **four distinct refusals** + /// because they are four distinct mistakes: /// /// 1. **the field.** No field under a tag in [`EXIF_SUBTREE_POINTER_TAGS`] may carry a /// pointer's own on-disk type code ([`POINTER_TYPE_CODES`]). The reader decides "pointer" @@ -218,7 +225,9 @@ impl TiffMetadata { /// ([`EXIF_SUBTREE_POINTER_TAGS`]). Under any other tag the writer emits a pointer the /// reader hands back as a raw offset into the file it came from, so the directory does not /// survive a round trip. - /// 3. **the depth.** The reader follows [`MAX_POINTER_DEPTH`] levels below IFD 0 and refuses + /// 3. **the pair.** One tag may carry a field **or** a group, never both: two entries under + /// one tag is not a TIFF directory (TIFF 6.0 §2), and the field is what a reader drops. + /// 4. **the depth.** The reader follows [`MAX_POINTER_DEPTH`] levels below IFD 0 and refuses /// what is deeper. The Exif directory occupies the first of those levels, so its own /// nesting may use the rest — one further directory, which for a decoded camera EXIF is /// `InteroperabilityIFD` (EXIF 2.3 §4.6.3). @@ -232,7 +241,8 @@ impl TiffMetadata { /// /// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) if the Exif sub-IFD /// carries a pointer-typed field under a pointer tag, hangs a group off a tag the reader does - /// not resolve, or nests deeper than the reader walks back. + /// not resolve, carries a field and a group under one tag, or nests deeper than the reader + /// walks back. pub(crate) fn check(&self) -> Result<()> { match self.exif_ifd() { Some(exif) => check_exif_subtree(exif, MAX_POINTER_DEPTH - 1), @@ -382,6 +392,20 @@ const FOREIGN_GROUP_REFUSAL: &str = "TIFF: an Exif sub-IFD may only nest a group pointer tag (SubIFDs, ExifIFD, GPSInfo, InteroperabilityIFD) and this one uses another, \ which would read back as a raw file offset"; +/// The refusal earned by a tag carrying **both** a plain field and a sub-IFD group — +/// [`check_exif_subtree`]'s third clause. +/// +/// [`gamut_ifd::Ifd`] holds fields and groups in two lists, so one tag can appear in both; the +/// writer then emits **two entries under that tag**, which TIFF 6.0 §2 does not allow, and every +/// reader keeps exactly one of them. Which one differs: this crate's model collapses a duplicated +/// tag to its **last** occurrence, while libtiff marks every occurrence after the **first** to be +/// ignored (`tif_dirread.c`, "Mark duplicates of any tag to be ignored") and warns that the +/// directory is not sorted in ascending order. So the entry that survives depends on the reader, +/// and the field the caller set is dropped by at least one of them. +const FIELD_BESIDE_GROUP_REFUSAL: &str = "TIFF: an Exif sub-IFD may not carry both a plain field \ + and a sub-IFD group under one tag, which would be written as two entries under that tag \ + (TIFF 6.0 §2 allows one) and read back as only one of them"; + /// The writer's side of what the reader delivers: refuses an Exif subtree this crate could not /// hand back unchanged, `depth` further levels being all that is left below `ifd`. /// @@ -399,7 +423,7 @@ const FOREIGN_GROUP_REFUSAL: &str = "TIFF: an Exif sub-IFD may only nest a group /// agree on nothing but the on-disk type code, so the code is what both sides ask about — see /// [`POINTER_TYPE_CODES`]. /// -/// Three refusals, deliberately distinct, because they are three different mistakes and a caller +/// Four refusals, deliberately distinct, because they are four different mistakes and a caller /// reading the message has to know which one it made: /// /// * a **field** under a tag *in* [`EXIF_SUBTREE_POINTER_TAGS`] whose type is a pointer's own — @@ -409,20 +433,24 @@ const FOREIGN_GROUP_REFUSAL: &str = "TIFF: an Exif sub-IFD may only nest a group /// under `SubIFDs` is left alone here too; /// * a **group** under a tag *outside* [`EXIF_SUBTREE_POINTER_TAGS`] — the reader leaves that /// pointer as a raw absolute offset, so what came back would not be what was written; +/// * a **field beside a group** under one tag — the writer emits two entries under it, which +/// TIFF 6.0 §2 does not allow, and the field is the one a reader drops +/// ([`FIELD_BESIDE_GROUP_REFUSAL`]); /// * a *child directory* nested past `depth` — the reader refuses to walk that far /// ([`MAX_POINTER_DEPTH`]). A group with no children reaches no further level, so it is the /// children and not the group that the bound counts. /// -/// The order is field, then group tag, then depth, and it is the order of how little the rest of -/// the tree matters to each: a pointer-typed field is unreturnable whatever else the directory -/// holds, a group under an unfollowed tag is unreturnable whatever its depth, and only the depth -/// clause needs the tree walked. All three stop at the first offender and the depth bound stops at -/// the bound rather than measuring the whole tree, so a directory a caller nested a hundred levels -/// deep costs a hundred levels of neither recursion nor time. +/// The order is field, then group tag, then field-beside-group, then depth, and it is the order of +/// how little the rest of the tree matters to each: a pointer-typed field is unreturnable whatever +/// else the directory holds, a group under an unfollowed tag is unreturnable whatever its depth, a +/// duplicated tag is unreturnable whatever is below it, and only the depth clause needs the tree +/// walked. All four stop at the first offender and the depth bound stops at the bound rather than +/// measuring the whole tree, so a directory a caller nested a hundred levels deep costs a hundred +/// levels of neither recursion nor time. /// /// # Errors /// -/// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) for any of the three refusals. +/// Returns [`Error::InvalidInput`](gamut_core::Error::InvalidInput) for any of the four refusals. fn check_exif_subtree(ifd: &Ifd, depth: usize) -> Result<()> { for &tag in EXIF_SUBTREE_POINTER_TAGS { if ifd.get(tag).is_some_and(is_pointer_typed) { @@ -439,6 +467,12 @@ fn check_exif_subtree(ifd: &Ifd, depth: usize) -> Result<()> { FOREIGN_GROUP_REFUSAL, )); } + if ifd.get(group.tag).is_some() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + FIELD_BESIDE_GROUP_REFUSAL, + )); + } for child in &group.ifds { let Some(left) = depth.checked_sub(1) else { return Err(Error::invalid_input( @@ -1008,6 +1042,30 @@ mod tests { } } + #[test] + fn the_writer_refuses_a_field_and_a_group_under_one_tag() { + // `Ifd` keeps fields and groups in two lists, so one tag can sit in both; the writer then + // emits two entries under it — not a TIFF directory (TIFF 6.0 §2) — and the field is what + // a reader drops. It encoded cleanly, read back without the field, and this crate's own + // `deconstruct` graded the file clean, so nothing in the round trip could see it. The + // *message* is the claim: the tag is a standard pointer tag and the value is a `SHORT`, so + // neither of the other clauses applies to this tree. + for &tag in EXIF_SUBTREE_POINTER_TAGS { + let mut child = Ifd::new(); + child.set(1, Value::Ascii("R98".into())); + let mut exif = exif_ifd(); + exif.set(tag, Value::Short(vec![7])); + exif.set_sub_ifd(tag, vec![child]); + let Err(err) = TiffMetadata::new().with_exif(exif).check() else { + panic!("tag {tag}: two entries under one tag is not a directory"); + }; + assert!( + err.to_string().contains("both a plain field"), + "tag {tag}: {err}" + ); + } + } + #[test] fn a_value_no_reader_would_follow_survives_under_a_pointer_tag() { // The refusal above is shaped by the value's *type*, not by its tag, and that is the whole From f18fdb07eab91bf944e355bf4287aee4c23e3b8a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:32:09 -0400 Subject: [PATCH 34/43] fix(tiff): grade a directory that repeats a tag `deconstruct` is this crate's own judge, and it graded a file carrying two entries under one tag clean -- which is why a round trip could not see the writer that emitted one. The report walked the parsed tree, and `Ifd` is a directory model: by the time the tree exists a repeated tag has already collapsed to its last occurrence, so the defect is invisible there by construction. The scan now re-reads the raw entry records of every directory the audit walked (`SpanKind::IfdBody`), a second pass over bytes already claimed rather than a second walk of the pointer graph, and reports `Anomaly::DuplicateTag` with the directory's offset, the tag and the count. `Anomaly` is `#[non_exhaustive]`, so the variant is additive. Which entry survives a repeated tag is a property of the reader, not of the file: this crate keeps the last occurrence, libtiff marks every occurrence after the first to be ignored and warns the directory is unsorted. Refs #446 --- crates/gamut-tiff/src/deconstruct.rs | 117 ++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/crates/gamut-tiff/src/deconstruct.rs b/crates/gamut-tiff/src/deconstruct.rs index dbb3d133..0da53cc5 100644 --- a/crates/gamut-tiff/src/deconstruct.rs +++ b/crates/gamut-tiff/src/deconstruct.rs @@ -12,9 +12,12 @@ //! unreadable (a malformed header or a truncated top-level chain), exactly as //! [`gamut_ifd::read`] would. +use std::collections::BTreeMap; + use gamut_core::{ImageBuf, Result, Rgb8}; use gamut_ifd::{ - AuditFinding, Ifd, SegmentReport, SkipReason, StandardAuditSpec, Value, audit as ifd_audit, + AuditFinding, Ifd, IfdReader, SegmentReport, SkipReason, SpanKind, StandardAuditSpec, Value, + audit as ifd_audit, }; use crate::compression::Compression; @@ -111,6 +114,27 @@ pub enum Anomaly { /// How serious the condition is. severity: Severity, }, + /// A directory carrying more than one entry under the same tag. + /// + /// TIFF 6.0 §2 gives a directory one entry per tag, in ascending order. A repeated tag is + /// therefore a directory no reader can resolve without choosing — and readers choose + /// differently: this crate's model keeps the **last** occurrence, while libtiff marks every + /// occurrence after the **first** to be ignored (`tif_dirread.c`, "Mark duplicates of any tag + /// to be ignored") and warns that the directory is not sorted in ascending order. Which entry + /// survives is therefore a property of the reader, not of the file. + /// + /// Named by **offset** rather than by page, because the directory may be a metadata sub-IFD + /// several levels below one: the offset is the identity + /// [`SpanKind::IfdBody`](gamut_ifd::SpanKind::IfdBody) already uses. + #[non_exhaustive] + DuplicateTag { + /// The file offset of the directory holding the repeated tag. + ifd: u64, + /// The repeated tag. + tag: u16, + /// How many entries that directory carries under it. + entries: usize, + }, } /// The result of a strict deconstruct: byte-level classification plus TIFF-specific findings. @@ -169,6 +193,7 @@ pub fn deconstruct(data: &[u8]) -> Result { findings.check_image_ifd(ifd, page); } findings.map_audit_findings(&audit.findings); + findings.check_duplicate_tags(data, &audit.report); Ok(DeconstructReport { segments: audit.report, unknown_fields: findings.unknown_fields, @@ -320,6 +345,44 @@ impl Findings { } /// Maps the audit walk's lenient findings onto this crate's anomaly taxonomy. + /// Flags every directory that carries two entries under one tag. + /// + /// This re-reads the **raw** entry records rather than consulting the parsed tree, and that is + /// the whole point: [`Ifd`] is a directory model, so by the time the tree exists a duplicated + /// tag has already collapsed to its last occurrence and the defect is invisible there. It was + /// invisible here too, and that is why a round trip through this crate could not see a writer + /// that emitted a field and a sub-IFD group under one tag — the report graded such a file + /// clean. + /// + /// The directories to re-read are the ones the audit says it walked + /// ([`SpanKind::IfdBody`](gamut_ifd::SpanKind::IfdBody)), so this is a second pass over bytes + /// already claimed rather than a second walk of the pointer graph. A directory the audit + /// reached parses again by construction; one that does not is already reported by the audit's + /// own finding, so it is skipped here rather than reported twice. + fn check_duplicate_tags(&mut self, data: &[u8], report: &SegmentReport) { + let Ok(mut reader) = IfdReader::open(data) else { + return; + }; + for segment in &report.segments { + let SpanKind::IfdBody { ifd } = segment.kind else { + continue; + }; + let Ok(raw) = reader.read_ifd(ifd) else { + continue; + }; + let mut counts: BTreeMap = BTreeMap::new(); + for entry in &raw.entries { + *counts.entry(entry.tag).or_default() += 1; + } + for (tag, entries) in counts { + if entries > 1 { + self.anomalies + .push(Anomaly::DuplicateTag { ifd, tag, entries }); + } + } + } + } + fn map_audit_findings(&mut self, findings: &[AuditFinding]) { for finding in findings { match *finding { @@ -432,6 +495,58 @@ mod tests { } } + #[test] + fn flags_a_directory_that_repeats_a_tag() { + // The report is this crate's own judge, and it graded a duplicated tag clean — which is + // why a round trip could not see a writer that emitted a field and a sub-IFD group under + // one tag. `Ifd` collapses a repeated tag to its last occurrence, so the defect exists + // only in the raw entry records; libtiff keeps the *first* instead, so which entry + // survives is a property of the reader rather than of the file. + // + // Both sides are asserted, because the anomaly must not fire on a conforming directory: + // the same image without the extra entry is graded fully accounted. + let mut child = Ifd::new(); + child.set(1, Value::Ascii("R98".into())); + let mut plain = image_ifd(); + plain.set_sub_ifd(tags::SUB_IFDS, vec![child.clone()]); + let clean = write_image( + ByteOrder::LittleEndian, + Variant::Classic, + &plain, + &[vec![0u8; 4]], + ) + .expect("write"); + let report = deconstruct(&clean).expect("deconstruct"); + assert!( + !report + .anomalies + .iter() + .any(|a| matches!(a, Anomaly::DuplicateTag { .. })), + "one entry per tag is a conforming directory: {report:?}" + ); + + let mut repeated = image_ifd(); + repeated.set(tags::SUB_IFDS, Value::Short(vec![7])); + repeated.set_sub_ifd(tags::SUB_IFDS, vec![child]); + let duplicated = write_image( + ByteOrder::LittleEndian, + Variant::Classic, + &repeated, + &[vec![0u8; 4]], + ) + .expect("write"); + let report = deconstruct(&duplicated).expect("deconstruct"); + assert!( + report.anomalies.iter().any(|a| matches!( + a, + Anomaly::DuplicateTag { tag, entries, .. } + if *tag == tags::SUB_IFDS && *entries == 2 + )), + "two entries under one tag must be graded: {report:?}" + ); + assert!(!report.is_fully_accounted(), "{report:?}"); + } + #[test] fn flags_unknown_private_tag() { let mut ifd = image_ifd(); From 26a3579b6d05ad335170a430b1e27e199917e980 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:32:47 -0400 Subject: [PATCH 35/43] test(tiff): pin the IFD-0 pointer set against the constant it names The scoping decision -- IFD 0 resolves `ExifIFD` and nothing else -- was held by a test that iterated a hand-written list of the two tags it expected to be left alone. Adding the fourth standard pointer tag to `IFD0_POINTER_TAGS` therefore left all 25 of this crate's test binaries green, and a `const`'s contents are not a mutable expression, so the mutation gate cannot see it either. The membership is now asserted directly and the sweep derived from the constant by difference, so it can never claim "left alone" about a tag the constant says is followed. Verified: the widening now fails this test and only this test. Refs #446 --- crates/gamut-tiff/src/metadata.rs | 37 ++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 3dd800e2..2069dbca 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -1148,20 +1148,37 @@ mod tests { } #[test] - fn a_standard_pointer_at_ifd_0_that_feeds_no_field_is_left_alone() { - // The other half of the per-level rule: at IFD 0 only `ExifIFD` is followed, so a - // `SubIFDs` or `GPSInfo` field on the page stays the plain integer field it was read as - // rather than becoming a group. What that buys — a dangling one of them not hiding the - // blocks — is `a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks` + fn ifd_0_resolves_the_exif_directory_and_no_other_standard_pointer() { + // The other half of the per-level rule, and the whole of it: at IFD 0 only `ExifIFD` is + // followed, so every *other* standard pointer field on the page stays the plain integer + // field it was read as rather than becoming a group. What that buys — a dangling one of + // them not hiding the blocks — is + // `a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks` // (tests/metadata.rs); this pins the resolution itself, on a pointer that is perfectly // readable, so the two claims cannot be confused. // + // Both halves are asserted here because neither holds the behaviour alone. The membership + // assertion is what a widened `IFD0_POINTER_TAGS` fails: a `const`'s contents are not a + // mutable expression, so the mutation gate cannot see this at all, and adding + // `InteroperabilityIFD` to the set left all 25 of this crate's test binaries green. The + // sweep is derived *from* the constant rather than repeating a list by hand — a hand list + // is what let that widening through — so it can never assert "left alone" about a tag the + // constant says is followed. + // // `resolve_pointers` is driven directly, at the depth `read_metadata` calls it with, - // because IFD 0 is the one directory the seam never hands back: asking `read_metadata` - // instead can only observe `exif`, which stays `None` whether the pointer was resolved or - // not, so the regression this names — `pointer_tags` returning the full set at every - // level — would pass unnoticed. Here it does not: resolution turns the field into a group. - for tag in [tags::SUB_IFDS, tags::GPS_INFO] { + // because IFD 0 is the one directory the seam never hands back; the public observation + // that also sees this is `a_broken_pointer_the_metadata_does_not_use_does_not_hide_the_blocks`, + // which fails on a dangling target rather than on a resolved one. + assert_eq!( + IFD0_POINTER_TAGS, + [tags::EXIF_IFD], + "IFD 0 follows the one pointer whose target `TiffMetadata` returns, and no other" + ); + let unresolved = gamut_ifd::tags::STANDARD_POINTER_TAGS + .iter() + .copied() + .filter(|tag| !IFD0_POINTER_TAGS.contains(tag)); + for tag in unresolved { let mut source = Ifd::new(); source.set_sub_ifd(tag, vec![exif_ifd()]); source.set(tags::XMP, Value::Byte(b"x".to_vec())); From 1ad07f2e132b4c66b8533bc0f09dae832f12ec5c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:34:20 -0400 Subject: [PATCH 36/43] test(tiff): ask libtiff what a directory repeating a tag means Nothing in this pull request consulted the crate's oracle about the seam, and a repeated tag is exactly the defect a round trip cannot see: gamut writes and reads by one rule, so a rule that is wrong is wrong symmetrically. libtiff marks every occurrence after the first to be ignored and warns that the directory is unsorted; this crate's directory model keeps the last. A hand-built 2x2 RGB TIFF carrying `PhotometricInterpretation` twice -- `RGB` then `BlackIsZero` -- is therefore decoded as RGB by libtiff and reported as `BlackIsZero` by `TiffDecoder::info`. That disagreement is the whole reason the encode refuses a tag carrying both a field and a group, and the reason `deconstruct` grades one. The fixture is built byte by byte because no directory model can express two entries under one tag, which is the normalisation the test exists to look underneath. Refs #446 --- crates/gamut-tiff/tests/oracle_metadata.rs | 79 +++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/gamut-tiff/tests/oracle_metadata.rs b/crates/gamut-tiff/tests/oracle_metadata.rs index 9cb6f17a..5157172f 100644 --- a/crates/gamut-tiff/tests/oracle_metadata.rs +++ b/crates/gamut-tiff/tests/oracle_metadata.rs @@ -8,7 +8,7 @@ //! reader, is the judge. use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; -use gamut_tiff::{Ifd, TiffEncoder, TiffMetadata, Value}; +use gamut_tiff::{Ifd, PhotometricInterpretation, TiffDecoder, TiffEncoder, TiffMetadata, Value}; mod common; @@ -76,3 +76,80 @@ fn libtiff_decodes_a_gamut_image_carrying_a_c2pa_manifest_store() { assert_eq!(dec.pixels, src, "RGB mismatch at {w}x{h} with a C2PA store"); } } + +/// The 2x2 RGB pixel block the hand-built fixture below carries. +const REPEATED_TAG_PIXELS: [u8; 12] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + +/// A 2x2 8-bit RGB classic TIFF whose IFD 0 carries `PhotometricInterpretation` (262) **twice**: +/// `RGB` (2) first, then `BlackIsZero` (1). +/// +/// Built byte by byte because no directory model can express it: `gamut_ifd::Ifd` collapses a +/// repeated tag, which is the very normalisation this fixture exists to look underneath. +fn tiff_repeating_photometric() -> Vec { + // 11 entries; the directory occupies 2 + 11*12 + 4 = 138 bytes from offset 8. + const ENTRIES: u16 = 11; + let bits_per_sample = 8 + 2 + u32::from(ENTRIES) * 12 + 4; + let strip = bits_per_sample + 6; + + let mut out = Vec::new(); + out.extend_from_slice(b"II"); + out.extend_from_slice(&42u16.to_le_bytes()); + out.extend_from_slice(&8u32.to_le_bytes()); + out.extend_from_slice(&ENTRIES.to_le_bytes()); + let mut entry = |tag: u16, ty: u16, count: u32, word: u32| { + out.extend_from_slice(&tag.to_le_bytes()); + out.extend_from_slice(&ty.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&word.to_le_bytes()); + }; + entry(256, 3, 1, 2); // ImageWidth + entry(257, 3, 1, 2); // ImageLength + entry(258, 3, 3, bits_per_sample); // BitsPerSample, out of line + entry(259, 3, 1, 1); // Compression = none + entry(262, 3, 1, 2); // PhotometricInterpretation = RGB + entry(262, 3, 1, 1); // PhotometricInterpretation = BlackIsZero -- the repeat + entry(273, 4, 1, strip); // StripOffsets + entry(277, 3, 1, 3); // SamplesPerPixel + entry(278, 3, 1, 2); // RowsPerStrip + entry(279, 4, 1, REPEATED_TAG_PIXELS.len() as u32); // StripByteCounts + entry(284, 3, 1, 1); // PlanarConfiguration = chunky + out.extend_from_slice(&0u32.to_le_bytes()); // no next IFD + assert_eq!( + out.len() as u32, + bits_per_sample, + "directory layout drifted" + ); + for _ in 0..3 { + out.extend_from_slice(&8u16.to_le_bytes()); + } + assert_eq!(out.len() as u32, strip, "value layout drifted"); + out.extend_from_slice(&REPEATED_TAG_PIXELS); + out +} + +#[test] +fn libtiff_and_this_crate_resolve_a_repeated_tag_to_different_entries() { + // Why `TiffMetadata::check` refuses a tag carrying both a field and a sub-IFD group, and why + // `deconstruct` grades a repeated tag: the file is not a directory a reader can resolve + // without choosing, and the two readers choose opposite ends. libtiff marks every occurrence + // after the **first** to be ignored (`tif_dirread.c`, "Mark duplicates of any tag to be + // ignored"); this crate's directory model keeps the **last**. A round trip through gamut + // cannot see that -- it writes and reads by the same rule -- so the oracle is what makes the + // disagreement observable at all. + let bytes = tiff_repeating_photometric(); + + let decoded = libtiff_oracle::decode_tiff(&bytes).expect("libtiff decode"); + assert_eq!( + (decoded.width, decoded.height, decoded.samples_per_pixel), + (2, 2, 3), + "libtiff must take the first entry, which says RGB" + ); + assert_eq!(decoded.pixels, REPEATED_TAG_PIXELS); + + let info = TiffDecoder::new().info(&bytes).expect("gamut info"); + assert_eq!( + info.photometric, + PhotometricInterpretation::BlackIsZero, + "this crate must take the last entry, which says BlackIsZero" + ); +} From dc49d235c65cfabc9ec5f29352cb918c02856e09 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:37:10 -0400 Subject: [PATCH 37/43] docs(tiff): state the repeated-tag refusal and the type-code rule where each is claimed Five sites state this seam's writer/reader bound, derived by sweeping the crate for the contract's own terms rather than listed by hand: `with_metadata`, `TiffDecoder::metadata`, `TiffMetadata::exif`, README.md and STATUS.md. Three of them promised unconditionally that what the caller supplies is what the file gets, which a tag carrying both a field and a group falsified, and all of them named the refused set by the in-memory `Value` variant, which `Value::Unknown` falsified. Each now says what the code does: the discriminator is the on-disk type code, and a tag may carry a field or a group but not both. The STATUS paragraph round 7 left at 152 columns is rewrapped with the rest; no line this branch adds to README.md or STATUS.md now exceeds 100 columns, measured rather than asserted. Refs #446 --- crates/gamut-tiff/README.md | 21 +++++++++++++-------- crates/gamut-tiff/STATUS.md | 21 ++++++++++++++++----- crates/gamut-tiff/src/decoder.rs | 3 +++ crates/gamut-tiff/src/encoder.rs | 18 ++++++++++++------ 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index 6ff9d644..646b42a2 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -71,7 +71,9 @@ compression schemes land additively on this frozen surface (see Status). (`ExifIFD`, 34665, as a `gamut_ifd::Ifd`) plus opaque XMP (700), IPTC-IIM (33723), ICC (34675) and C2PA (52545) payloads — the raw blocks the workspace's metadata facade consumes. Byte payloads are verbatim; the Exif directory's *entries* are carried unchanged but its ordering is - normalised (ascending tag, duplicate tags collapsed, a child's next-IFD pointer ignored). The + normalised (ascending tag, duplicate tags collapsed, a child's next-IFD pointer ignored), and a + tag the caller gave both a field and a sub-IFD group is refused rather than normalised — that + would be two entries under one tag, and no reader keeps both. The blocks live in **IFD 0 only**, so a reader decoding page 3 of a multi-page document alone must look at IFD 0 for them. Which pointers are resolved depends on the level, because the two levels answer opposite @@ -87,13 +89,16 @@ compression schemes land additively on this frozen surface (see Status). What the encoder writes the decoder reads back, and the writer is bounded by exactly what the reader would misread. The Exif directory may nest one further directory (`InteroperabilityIFD`, EXIF 2.3 §4.6.3, is the one a camera writes), which is as deep as the - reader walks; it may hang a group only off a standard pointer tag; and it may not carry a - *plain field* under one of those four tags whose type is a pointer's own (`LONG`, `IFD`, - `LONG8`, `IFD8`), because the reader decides "pointer" from the field and would follow that - integer as a file offset. A value of any other type under those tags is not a pointer to either - side and round-trips unchanged. A caller's directory nested deeper, hung off any other tag, or - carrying such a field is refused by the encode — with its own message per case — rather than - written into a file this crate could not read back unchanged. + reader walks; it may hang a group only off a standard pointer tag; it may not give one tag both + a field and a group; and it may not carry a *plain field* under one of those four tags whose + **on-disk type code** is a pointer's own (`LONG` 4, `IFD` 13, `LONG8` 16, `IFD8` 18), because + the reader decides "pointer" from the entry it parses and would follow that integer as a file + offset. It is the code and not the in-memory `Value` variant that is checked, since a + `Value::Unknown` carries an arbitrary code beside its word and the writer emits that code + verbatim. A value of any other type under those tags is not a pointer to either side and + round-trips unchanged. A caller's directory nested deeper, hung off any other tag, repeating a + tag, or carrying such a field is refused by the encode — with its own message per case — rather + than written into a file this crate could not read back unchanged. The C2PA manifest store follows C2PA 2.4 §A.3.6 through the shared `gamut_ifd::c2pa` helper it and `gamut-dng` both call: the entry in the last IFD of the main chain, the store at the end of the file, and the two §18.5.5 exclusion ranges reported by diff --git a/crates/gamut-tiff/STATUS.md b/crates/gamut-tiff/STATUS.md index 75b3b76e..a9738421 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -83,6 +83,9 @@ Three consequences are contractual rather than incidental, and are documented wh **(a)** The Exif directory's *entries* are carried unchanged but its **ordering is normalised** — ascending tag (TIFF 6.0 §2 requires it on disk), duplicate tags collapsed to the last, a child's next-IFD pointer ignored — so "verbatim" is claimed for byte payloads, not for a directory model. +The one shape the model can express and a file cannot — a tag holding both a field and a sub-IFD +group, which is two entries under one tag — is refused by the encode rather than normalised, +because normalising it means silently choosing which of the two the caller meant. **(b)** Which pointer tags the reader resolves depends on the **level**, and one rule decides it at both: a pointer is followed exactly when its target belongs to a directory `TiffMetadata` hands back. At **IFD 0** that is `ExifIFD` alone. `SubIFDs` and `GPSInfo` are out, because their targets @@ -119,11 +122,19 @@ anything outside the standard pointer tags comes back as a raw offset, so it is from **groups**, so checking only the groups left the writer blind to the one shape the reader misreads — a standard pointer tag carried as a plain `LONG`, which encoded cleanly and then failed this crate's own reader with `read out of bounds` or `sub-IFD pointer loop`. A field under one of -the four tags whose type is a pointer's own (`LONG`, `IFD`, `LONG8`, `IFD8`) is therefore refused; -any other type under those tags is not a pointer to either side and round-trips unchanged. All -three are `Error::InvalidInput` from `with_metadata`'s encode before any pixel work, on **every** -public encode surface, rather than a well-formed file this crate cannot read back unchanged. The bound is the spec's; that a narrower one is also easier -to assert is not on its own a reason to narrow a contract. +the four tags whose **on-disk type code** is a pointer's own (`LONG` 4, `IFD` 13, `LONG8` 16, +`IFD8` 18) is therefore refused; any other type under those tags is not a pointer to either side +and round-trips unchanged. The discriminator is the *code*, not the in-memory `Value` variant, +because the two disagree for exactly one shape: `Value::Unknown` carries an arbitrary code beside +its value word, the writer emits the code verbatim, and the reader classifies by it — so an +`Unknown` built at 4, 13, 16 or 18 was a plain field to a variant-shaped check and a pointer to +the reader. *Pair*: a tag given both a field and a group is two entries under one tag, which +TIFF 6.0 §2 does not allow and which readers resolve in opposite directions — this crate keeps the +last occurrence, libtiff ignores everything after the first — so it is refused too. All four are +`Error::InvalidInput` from `with_metadata`'s encode before any pixel work, on **every** public +encode surface, rather than a well-formed file this crate cannot read back unchanged. The bound is +the spec's; that a narrower one is also easier to assert is not on its own a reason to narrow a +contract. The C2PA manifest store is the one carrier with a placement rule of its own, and that rule is not restated here: `gamut_ifd::c2pa` owns C2PA 2.4 §A.3.6 (tag 52545 / `0xCD41`, type `UNDEFINED`, one diff --git a/crates/gamut-tiff/src/decoder.rs b/crates/gamut-tiff/src/decoder.rs index 858c8c25..2ec659eb 100644 --- a/crates/gamut-tiff/src/decoder.rs +++ b/crates/gamut-tiff/src/decoder.rs @@ -192,6 +192,9 @@ impl TiffDecoder { /// of the main chain supplies the C2PA manifest store (C2PA 2.4 §A.3.6). Every byte-carried /// payload comes back **verbatim** — this crate parses none of them — so a block written by /// [`TiffEncoder::with_metadata`](crate::TiffEncoder::with_metadata) reads back identical. + /// The Exif directory is a directory model rather than a byte range, so what it promises is + /// narrower and is stated on [`TiffMetadata::exif`](crate::TiffMetadata::exif); the shapes it + /// could not promise for are refused by the encode rather than written. /// Use [`c2pa_exclusions`](crate::c2pa_exclusions) for *where* the store sits. /// /// ``` diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index aa9000e1..9b19680f 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -141,18 +141,24 @@ impl TiffEncoder { /// /// **What this encoder writes, [`TiffDecoder::metadata`](crate::TiffDecoder::metadata) reads /// back.** What could break the agreement is the caller's own Exif directory, which is the one - /// directory here this crate did not build, and three shapes of it are refused — each a typed + /// directory here this crate did not build, and four shapes of it are refused — each a typed /// [`Error::InvalidInput`] raised before any pixel work, with its own message, rather than a /// well-formed file this crate's own reader then rejects: /// /// * a **field** under one of the four standard pointer tags — `SubIFDs` (330), `ExifIFD` - /// (34665), `GPSInfo` (34853), `InteroperabilityIFD` (40965) — whose type is a pointer's own - /// (`LONG`, `IFD`, `LONG8`, `IFD8`). The reader decides "pointer" from the field, so it would - /// follow that integer as an offset into a file it never came from. A value of any other - /// type under those tags is not a pointer to either side, and is written and read back - /// unchanged; + /// (34665), `GPSInfo` (34853), `InteroperabilityIFD` (40965) — whose **on-disk type code** + /// is a pointer's own (`LONG` 4, `IFD` 13, `LONG8` 16, `IFD8` 18). The reader decides + /// "pointer" from the entry it parses, so it would follow that integer as an offset into a + /// file it never came from. The code and not the [`Value`](gamut_ifd::Value) variant is what + /// is checked, because a `Value::Unknown` carries an arbitrary code beside its word and the + /// writer emits that code verbatim. A value of any other type under those tags is not a + /// pointer to either side, and is written and read back unchanged; /// * a **group** under any other tag: the reader resolves only those four inside the Exif /// subtree, so a group elsewhere comes back as the raw offset this encoder gave it; + /// * a tag carrying **both** a field and a group: that is two entries under one tag, which + /// TIFF 6.0 §2 does not allow, and readers disagree about which one survives — this crate + /// keeps the last, libtiff the first — so the field the caller set is dropped by at least + /// one of them; /// * a directory nested **below** the `ExifIFD` → `InteroperabilityIFD` pair (EXIF 2.3 /// §4.6.3), which is as deep as the reader walks. #[must_use] From b73c8fe6bed8b2c02abf8636cab20f9d8fd9dc22 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:24:36 -0400 Subject: [PATCH 38/43] test(tiff): repeat the tag the oracle's readout actually depends on The fixture repeated `PhotometricInterpretation`, but the oracle harness never queries that tag and for uncompressed chunky data the scanline bytes do not depend on it: with the two entries swapped, libtiff's decode is byte-identical, and with libtiff patched to keep the *last* duplicate the test still passes. Only this crate's half was load-bearing, so the test could not fail for the disagreement it is named for. Repeat `StripOffsets` instead, with the two entries pointing at two different strips that are both in the file. The scanline bytes are then a function of which entry the reader keeps: libtiff returns the first entry's strip, this crate the last, and the assertion fails if either reader changes its rule. Both orderings are built, so "follows the first entry" is separated from "follows the lower offset". --- crates/gamut-tiff/tests/oracle_metadata.rs | 73 ++++++++++++++-------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/crates/gamut-tiff/tests/oracle_metadata.rs b/crates/gamut-tiff/tests/oracle_metadata.rs index 5157172f..ff7952b8 100644 --- a/crates/gamut-tiff/tests/oracle_metadata.rs +++ b/crates/gamut-tiff/tests/oracle_metadata.rs @@ -8,7 +8,7 @@ //! reader, is the judge. use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; -use gamut_tiff::{Ifd, PhotometricInterpretation, TiffDecoder, TiffEncoder, TiffMetadata, Value}; +use gamut_tiff::{Ifd, TiffDecoder, TiffEncoder, TiffMetadata, Value}; mod common; @@ -77,19 +77,31 @@ fn libtiff_decodes_a_gamut_image_carrying_a_c2pa_manifest_store() { } } -/// The 2x2 RGB pixel block the hand-built fixture below carries. -const REPEATED_TAG_PIXELS: [u8; 12] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; +/// The two 2x2 RGB pixel blocks the hand-built fixture below carries, one per candidate strip. +/// +/// Distinct in every byte, so a reader that returns one of them says which entry it followed. +const REPEATED_TAG_STRIPS: [[u8; 12]; 2] = [ + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + [101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112], +]; -/// A 2x2 8-bit RGB classic TIFF whose IFD 0 carries `PhotometricInterpretation` (262) **twice**: -/// `RGB` (2) first, then `BlackIsZero` (1). +/// A 2x2 8-bit RGB classic TIFF whose IFD 0 carries `StripOffsets` (273) **twice**, each entry +/// pointing at a different one of the two strips in the file: the leading entry at +/// `REPEATED_TAG_STRIPS[first]`, the repeat at `REPEATED_TAG_STRIPS[1 - first]`. +/// +/// `StripOffsets` rather than a descriptive tag because the repeat has to reach the **pixels**: +/// for uncompressed chunky data the scanline bytes a reader hands back are a function of this tag +/// and of no other repeated tag, so both readers' answers move when either changes which entry it +/// keeps. `first` is a parameter so both orderings are exercised, which is what separates +/// "follows the first entry" from "follows the lower offset". /// /// Built byte by byte because no directory model can express it: `gamut_ifd::Ifd` collapses a /// repeated tag, which is the very normalisation this fixture exists to look underneath. -fn tiff_repeating_photometric() -> Vec { +fn tiff_repeating_strip_offsets(first: usize) -> Vec { // 11 entries; the directory occupies 2 + 11*12 + 4 = 138 bytes from offset 8. const ENTRIES: u16 = 11; let bits_per_sample = 8 + 2 + u32::from(ENTRIES) * 12 + 4; - let strip = bits_per_sample + 6; + let strips = [bits_per_sample + 6, bits_per_sample + 6 + 12]; let mut out = Vec::new(); out.extend_from_slice(b"II"); @@ -107,11 +119,11 @@ fn tiff_repeating_photometric() -> Vec { entry(258, 3, 3, bits_per_sample); // BitsPerSample, out of line entry(259, 3, 1, 1); // Compression = none entry(262, 3, 1, 2); // PhotometricInterpretation = RGB - entry(262, 3, 1, 1); // PhotometricInterpretation = BlackIsZero -- the repeat - entry(273, 4, 1, strip); // StripOffsets + entry(273, 4, 1, strips[first]); // StripOffsets + entry(273, 4, 1, strips[1 - first]); // StripOffsets = the other strip -- the repeat entry(277, 3, 1, 3); // SamplesPerPixel entry(278, 3, 1, 2); // RowsPerStrip - entry(279, 4, 1, REPEATED_TAG_PIXELS.len() as u32); // StripByteCounts + entry(279, 4, 1, REPEATED_TAG_STRIPS[0].len() as u32); // StripByteCounts entry(284, 3, 1, 1); // PlanarConfiguration = chunky out.extend_from_slice(&0u32.to_le_bytes()); // no next IFD assert_eq!( @@ -122,8 +134,9 @@ fn tiff_repeating_photometric() -> Vec { for _ in 0..3 { out.extend_from_slice(&8u16.to_le_bytes()); } - assert_eq!(out.len() as u32, strip, "value layout drifted"); - out.extend_from_slice(&REPEATED_TAG_PIXELS); + assert_eq!(out.len() as u32, strips[0], "value layout drifted"); + out.extend_from_slice(&REPEATED_TAG_STRIPS[0]); + out.extend_from_slice(&REPEATED_TAG_STRIPS[1]); out } @@ -135,21 +148,27 @@ fn libtiff_and_this_crate_resolve_a_repeated_tag_to_different_entries() { // after the **first** to be ignored (`tif_dirread.c`, "Mark duplicates of any tag to be // ignored"); this crate's directory model keeps the **last**. A round trip through gamut // cannot see that -- it writes and reads by the same rule -- so the oracle is what makes the - // disagreement observable at all. - let bytes = tiff_repeating_photometric(); + // disagreement observable at all. Repeating `StripOffsets` is what puts the disagreement in + // the decoded pixels: either reader changing its rule changes the block it returns, so this + // fails if libtiff ever keeps the last occurrence just as surely as if this crate keeps the + // first. + for first in 0..REPEATED_TAG_STRIPS.len() { + let bytes = tiff_repeating_strip_offsets(first); - let decoded = libtiff_oracle::decode_tiff(&bytes).expect("libtiff decode"); - assert_eq!( - (decoded.width, decoded.height, decoded.samples_per_pixel), - (2, 2, 3), - "libtiff must take the first entry, which says RGB" - ); - assert_eq!(decoded.pixels, REPEATED_TAG_PIXELS); + let decoded = libtiff_oracle::decode_tiff(&bytes).expect("libtiff decode"); + assert_eq!( + decoded.pixels, + REPEATED_TAG_STRIPS[first].as_slice(), + "libtiff must read the strip the FIRST entry points at (leading entry: {first})" + ); - let info = TiffDecoder::new().info(&bytes).expect("gamut info"); - assert_eq!( - info.photometric, - PhotometricInterpretation::BlackIsZero, - "this crate must take the last entry, which says BlackIsZero" - ); + let image = TiffDecoder::new() + .decode_page(&bytes, 0) + .expect("gamut decode"); + assert_eq!( + image.as_samples(), + REPEATED_TAG_STRIPS[1 - first].as_slice(), + "this crate must read the strip the LAST entry points at (leading entry: {first})" + ); + } } From cc13be1885ea2b87584af73b8171a4118f60b89a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:25:03 -0400 Subject: [PATCH 39/43] fix(tiff): give the duplicate-tag anomaly the severity its sibling carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Anomaly::Structure` reports a `Severity`; `Anomaly::DuplicateTag`, added in this pull request, did not, so a caller triaging a report had to know which variants carry one and infer the rest. A repeated tag is a structural defect of the same kind — TIFF 6.0 §2 gives a directory one entry per tag, and the field a caller set is dropped by at least one reader — so it is graded `Severity::Error`. Added now rather than later because the variant is new and unreleased: the same field arriving afterwards would be a breaking change to a `#[non_exhaustive]` match that binds it. `flags_a_directory_that_repeats_a_tag` matches the severity, so grading it a warning fails that test alone. --- crates/gamut-tiff/src/deconstruct.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/gamut-tiff/src/deconstruct.rs b/crates/gamut-tiff/src/deconstruct.rs index 0da53cc5..7c552d73 100644 --- a/crates/gamut-tiff/src/deconstruct.rs +++ b/crates/gamut-tiff/src/deconstruct.rs @@ -134,6 +134,13 @@ pub enum Anomaly { tag: u16, /// How many entries that directory carries under it. entries: usize, + /// How serious the condition is — always [`Severity::Error`], as for every other + /// structural defect: TIFF 6.0 §2 gives a directory one entry per tag, so a field the + /// caller set is lost by at least one reader. Carried as a field rather than left + /// implicit so that a caller triaging a report reads severity the same way off every + /// variant that has one, and so that a later condition graded `Warning` needs no + /// breaking change to say so. + severity: Severity, }, } @@ -376,8 +383,12 @@ impl Findings { } for (tag, entries) in counts { if entries > 1 { - self.anomalies - .push(Anomaly::DuplicateTag { ifd, tag, entries }); + self.anomalies.push(Anomaly::DuplicateTag { + ifd, + tag, + entries, + severity: Severity::Error, + }); } } } @@ -539,7 +550,7 @@ mod tests { assert!( report.anomalies.iter().any(|a| matches!( a, - Anomaly::DuplicateTag { tag, entries, .. } + Anomaly::DuplicateTag { tag, entries, severity: Severity::Error, .. } if *tag == tags::SUB_IFDS && *entries == 2 )), "two entries under one tag must be graded: {report:?}" From 2cac68ddabf762bc31e023faeac312e794c07128 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:25:11 -0400 Subject: [PATCH 40/43] test(tiff): pin what the duplicate-tag pass's silent skip promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_duplicate_tags` walks the audit's `IfdBody` spans and `continue`s past anything it cannot re-read, on the recorded ground that such a directory "is already reported by the audit's own finding". That is a contract a caller may rely on — a file is never graded fully accounted on the strength of a directory nobody read — and nothing failed when it stopped being true. State it precisely at the site, separating the two silent skips: a span the audit claimed re-parses by construction, so that arm is unreachable; a directory the audit could not parse is claimed as no span at all and arrives as `AuditFinding::SkippedSubIfd`. Pin the second with a file whose `SubIFDs` target lies past the end, the plain-unreadable reason that neither the cycle nor the depth guard produces, and which no test reached before. --- crates/gamut-tiff/src/deconstruct.rs | 58 ++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/gamut-tiff/src/deconstruct.rs b/crates/gamut-tiff/src/deconstruct.rs index 7c552d73..5cbca33d 100644 --- a/crates/gamut-tiff/src/deconstruct.rs +++ b/crates/gamut-tiff/src/deconstruct.rs @@ -363,9 +363,26 @@ impl Findings { /// /// The directories to re-read are the ones the audit says it walked /// ([`SpanKind::IfdBody`](gamut_ifd::SpanKind::IfdBody)), so this is a second pass over bytes - /// already claimed rather than a second walk of the pointer graph. A directory the audit - /// reached parses again by construction; one that does not is already reported by the audit's - /// own finding, so it is skipped here rather than reported twice. + /// already claimed rather than a second walk of the pointer graph. + /// + /// Two skips are therefore silent in this loop, and the contract is that neither one costs a + /// finding: + /// + /// * a directory the audit **did** claim as an `IfdBody` span re-parses here by construction — + /// the span exists only because [`IfdReader`] already read a directory at that offset during + /// the audit, so the `continue` is unreachable rather than lenient; + /// * a directory the audit could **not** parse is claimed as no span at all, so this pass never + /// reaches it. It arrives instead as + /// [`AuditFinding::SkippedSubIfd`](gamut_ifd::AuditFinding::SkippedSubIfd), which + /// `map_audit_findings` turns into an [`Anomaly::Structure`] of [`Severity::Error`]. So no + /// file is graded [`is_fully_accounted`](DeconstructReport::is_fully_accounted) on the + /// strength of a directory nobody read — which is what a caller may rely on, and what + /// `an_unparsable_sub_ifd_is_reported_rather_than_silently_skipped` fails for when it stops + /// being true. + /// + /// Reporting it there rather than here is deliberate: the audit's finding names the pointer tag + /// and the offset that failed, which is strictly more than this pass could say about a + /// directory it never parsed. fn check_duplicate_tags(&mut self, data: &[u8], report: &SegmentReport) { let Ok(mut reader) = IfdReader::open(data) else { return; @@ -744,6 +761,41 @@ mod tests { ); } + /// A sub-IFD the audit could not parse is reported by the audit, not lost to the + /// duplicate-tag pass that never sees it. + #[test] + fn an_unparsable_sub_ifd_is_reported_rather_than_silently_skipped() { + // `check_duplicate_tags` iterates the `IfdBody` spans and skips anything it cannot re-read, + // and its contract is that the skip costs no finding: a directory the audit could not parse + // is claimed as no span at all, so it arrives as `AuditFinding::SkippedSubIfd` instead. + // Nothing held that. The cycle and depth guards have their own reasons (`SkipReason::Cycle` + // and `TooDeep`); this is the plain unreadable target, the reason a truncated or hostile + // file gives, and the file must not be graded fully accounted on the strength of a + // directory nobody read. + let mut ifd = image_ifd(); + ifd.set(tags::SUB_IFDS, Value::Long(vec![0xFFFF_FF00])); + let bytes = write_image( + ByteOrder::LittleEndian, + Variant::Classic, + &ifd, + &[vec![0u8; 4]], + ) + .expect("write"); + let report = deconstruct(&bytes).expect("deconstruct"); + assert!( + report.anomalies.iter().any(|a| matches!( + a, + Anomaly::Structure { detail, severity: Severity::Error, .. } + if detail.contains("could not be parsed") + )), + "an unreadable sub-IFD target must be reported: {report:?}" + ); + assert!( + !report.is_fully_accounted(), + "a file with a directory nobody read is not fully accounted: {report:?}" + ); + } + #[test] fn flags_strip_offset_count_mismatch() { // Two offsets but one byte count: a structural defect the deconstruct must surface. From d2f45bf2a905c4a7d87abd022538a03bab396c91 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:25:31 -0400 Subject: [PATCH 41/43] test(tiff): pin the one direction in which the writer is stricter than the reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TiffMetadata::check` refuses a sub-IFD group hung off a tag outside the four the reader resolves, while the reader takes such a file without error and hands the tag back as the raw absolute offset it was written as — the child directory is lost silently. That asymmetry is the stated reason refusing is the conservative choice, and it was stated only in prose: nothing failed if the reader began resolving the tag, and nothing failed if it began refusing it, either of which would remove the reason. Drive the reader over a file the writer would refuse, built through `gamut_ifd::write` so `check` is not in the path, and assert both halves — the tag comes back as a bare offset, and no group carries it. --- crates/gamut-tiff/src/metadata.rs | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 2069dbca..1058ce16 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -927,6 +927,40 @@ mod tests { assert!(err.to_string().contains("standard pointer tag"), "{err}"); } + #[test] + fn the_reader_accepts_the_exif_group_tag_the_writer_refuses() { + // The one direction in which `check` is deliberately *stricter* than the reader, stated on + // `TiffMetadata::check` as clause 2 and nowhere held: a group under a tag outside + // `EXIF_SUBTREE_POINTER_TAGS` is refused by the writer, while the reader takes such a file + // without error and hands the tag back as the raw absolute offset it was written as -- the + // child directory is lost, silently. That asymmetry is the *reason* the refusal is the + // conservative choice, so it has to fail if it ever stops being true: if the reader began + // resolving the tag there would be nothing left to refuse, and if it began erroring the + // refusal would no longer be the stricter side. The refusal itself is + // `the_writer_refuses_an_exif_group_under_a_tag_the_reader_does_not_resolve`; this is the + // reader's half, on the same shape. + const VENDOR: u16 = 50000; + let mut child = Ifd::new(); + child.set(1, Value::Byte(vec![9])); + let mut exif = exif_ifd(); + exif.set_sub_ifd(VENDOR, vec![child]); + let mut ifd0 = Ifd::new(); + ifd0.set_sub_ifd(tags::EXIF_IFD, vec![exif]); + + let back = read_metadata(&file_with(ifd0)) + .expect("the reader takes the file the writer refuses") + .exif + .expect("an Exif directory"); + assert!( + back.get_u32(VENDOR).is_some_and(|offset| offset > 0), + "the vendor group comes back as the bare file offset the writer wrote: {back:?}" + ); + assert!( + back.sub_ifds().iter().all(|group| group.tag != VENDOR), + "a tag outside the standard four is not resolved into a group: {back:?}" + ); + } + #[test] fn the_writer_refuses_an_exif_pointer_tag_carried_as_a_pointer_typed_field() { // `check` inspected only `sub_ifds()` while `resolve_pointers` inspects `get(tag)`, so the From 8c09576e4053a1731c027951c1eb4ccbef57cabb Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:25:31 -0400 Subject: [PATCH 42/43] docs(tiff): name the constant the IFD-0 sweep is actually derived from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment said the sweep is derived "from the constant" beside a paragraph about `IFD0_POINTER_TAGS`, while the code iterates `gamut_ifd::tags::STANDARD_POINTER_TAGS`. Same members — this crate's `EXIF_SUBTREE_POINTER_TAGS` is defined as that constant — but a repair whose point is deriving a set from the constant it names should name the right one. Say which, and why the sibling crate's constant is the stronger choice: the domain the sweep must cover is every standard pointer tag that exists, so a fifth added upstream has to enter it whatever this crate's alias does, and deriving from the alias would let a later narrowing shrink the sweep silently. --- crates/gamut-tiff/src/metadata.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/gamut-tiff/src/metadata.rs b/crates/gamut-tiff/src/metadata.rs index 1058ce16..b8212e4f 100644 --- a/crates/gamut-tiff/src/metadata.rs +++ b/crates/gamut-tiff/src/metadata.rs @@ -1195,9 +1195,17 @@ mod tests { // assertion is what a widened `IFD0_POINTER_TAGS` fails: a `const`'s contents are not a // mutable expression, so the mutation gate cannot see this at all, and adding // `InteroperabilityIFD` to the set left all 25 of this crate's test binaries green. The - // sweep is derived *from* the constant rather than repeating a list by hand — a hand list - // is what let that widening through — so it can never assert "left alone" about a tag the - // constant says is followed. + // sweep is derived rather than repeating a list by hand — a hand list is what let that + // widening through — so it can never assert "left alone" about a tag the constant says is + // followed. + // + // It is derived from `gamut_ifd::tags::STANDARD_POINTER_TAGS`, the sibling crate's own + // constant, and not from this crate's `EXIF_SUBTREE_POINTER_TAGS`, which is *defined* as + // that constant. Same members today, and deliberately the wider name: the domain this + // sweep has to cover is "every standard pointer tag that exists", so a fifth one added + // upstream must enter it whatever this crate's alias is doing. Deriving from the alias + // would let a later narrowing of the alias shrink the sweep silently — the same + // hand-maintained-set failure one indirection further out. // // `resolve_pointers` is driven directly, at the depth `read_metadata` calls it with, // because IFD 0 is the one directory the seam never hands back; the public observation From 41178154b1d886e353ed3b2a9cdfc52e0b2030e4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:37:18 -0400 Subject: [PATCH 43/43] test(tiff): derive the second strip's offset from the strip length Self-review of the previous commit: the two strips are laid out back to back and the second one's offset was written as `+ 12`, the first strip's length as a literal, while `StripByteCounts` derived the same number from the array. The "value layout drifted" assertion only covers the first strip, so widening the pixel blocks would have overlapped them silently. Both now come from one binding. --- crates/gamut-tiff/tests/oracle_metadata.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/gamut-tiff/tests/oracle_metadata.rs b/crates/gamut-tiff/tests/oracle_metadata.rs index ff7952b8..34b8e068 100644 --- a/crates/gamut-tiff/tests/oracle_metadata.rs +++ b/crates/gamut-tiff/tests/oracle_metadata.rs @@ -101,7 +101,8 @@ fn tiff_repeating_strip_offsets(first: usize) -> Vec { // 11 entries; the directory occupies 2 + 11*12 + 4 = 138 bytes from offset 8. const ENTRIES: u16 = 11; let bits_per_sample = 8 + 2 + u32::from(ENTRIES) * 12 + 4; - let strips = [bits_per_sample + 6, bits_per_sample + 6 + 12]; + let strip_len = REPEATED_TAG_STRIPS[0].len() as u32; + let strips = [bits_per_sample + 6, bits_per_sample + 6 + strip_len]; let mut out = Vec::new(); out.extend_from_slice(b"II"); @@ -123,7 +124,7 @@ fn tiff_repeating_strip_offsets(first: usize) -> Vec { entry(273, 4, 1, strips[1 - first]); // StripOffsets = the other strip -- the repeat entry(277, 3, 1, 3); // SamplesPerPixel entry(278, 3, 1, 2); // RowsPerStrip - entry(279, 4, 1, REPEATED_TAG_STRIPS[0].len() as u32); // StripByteCounts + entry(279, 4, 1, strip_len); // StripByteCounts entry(284, 3, 1, 1); // PlanarConfiguration = chunky out.extend_from_slice(&0u32.to_le_bytes()); // no next IFD assert_eq!(