diff --git a/crates/gamut-tiff/README.md b/crates/gamut-tiff/README.md index bc49213e..646b42a2 100644 --- a/crates/gamut-tiff/README.md +++ b/crates/gamut-tiff/README.md @@ -67,13 +67,54 @@ 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 — 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), 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 + 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, 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; 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 + `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 — 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). **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..a9738421 100644 --- a/crates/gamut-tiff/STATUS.md +++ b/crates/gamut-tiff/STATUS.md @@ -68,6 +68,102 @@ 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. + +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. +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 +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**, 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 **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 +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. +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 +(`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 +180,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 +225,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. diff --git a/crates/gamut-tiff/src/decoder.rs b/crates/gamut-tiff/src/decoder.rs index d867a98d..2ec659eb 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,62 @@ 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; 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. + /// 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. + /// + /// ``` + /// 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 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. + /// + /// 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 + /// `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) + } + /// 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/deconstruct.rs b/crates/gamut-tiff/src/deconstruct.rs index dbb3d133..5cbca33d 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,34 @@ 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, + /// 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, + }, } /// The result of a strict deconstruct: byte-level classification plus TIFF-specific findings. @@ -169,6 +200,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 +352,65 @@ 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. + /// + /// 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; + }; + 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, + severity: Severity::Error, + }); + } + } + } + } + fn map_audit_findings(&mut self, findings: &[AuditFinding]) { for finding in findings { match *finding { @@ -432,6 +523,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, severity: Severity::Error, .. } + 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(); @@ -618,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. diff --git a/crates/gamut-tiff/src/encoder.rs b/crates/gamut-tiff/src/encoder.rs index 5519f99a..9b19680f 100644 --- a/crates/gamut-tiff/src/encoder.rs +++ b/crates/gamut-tiff/src/encoder.rs @@ -1,13 +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, c2pa_exclusions}; use crate::palette::Palette8; use crate::{tags, writer}; @@ -33,6 +37,27 @@ pub struct TiffEncoder { predictor: Predictor, 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 { @@ -43,6 +68,8 @@ impl Default for TiffEncoder { predictor: Predictor::None, tiling: None, big_tiff: false, + metadata: TiffMetadata::new(), + c2pa_reserve: None, } } } @@ -103,6 +130,201 @@ impl TiffEncoder { self } + /// Returns a copy of this encoder that embeds `metadata` — an Exif sub-IFD plus opaque + /// 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**; 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. + /// + /// **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 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 **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] + 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, 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 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. 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] + 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, if the store is shorter than + /// [`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`. + 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) => store.len(), + (None, Some(len)) => len, + (None, None) => return Ok(None), + }; + 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)", + )); + } + // 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. + 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 \ + 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)?), + })) + } + + /// 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. + /// + /// 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 { @@ -125,6 +347,7 @@ impl TiffEncoder { palette: &Palette8, out: &mut Vec, ) -> Result { + let store = self.checked_store()?; let w = indices.width() as usize; let colormap = palette.to_tiff_colormap(); self.encode_packed( @@ -137,6 +360,7 @@ impl TiffEncoder { photometric: PhotometricInterpretation::Palette, }, &[(tags::COLOR_MAP, Value::Short(colormap))], + store, out, ) } @@ -151,6 +375,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.checked_store()?; let row_bytes = dims.width as usize * spp; debug_assert_eq!(pixels.len(), row_bytes * dims.height as usize); self.encode_packed( @@ -163,6 +388,7 @@ impl TiffEncoder { photometric, }, &[], + store, out, ) } @@ -181,6 +407,8 @@ impl TiffEncoder { extra_fields: &[(u16, Value)], out: &mut Vec, ) -> Result { + // Before the serialisation buffer below, so a bad C2PA configuration costs no allocation. + 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); @@ -201,27 +429,42 @@ 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 { 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 (ifd, strips) = self.build_strip_image(packed, dims, layout, extra_fields)?; 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 @@ -322,6 +565,7 @@ impl TiffEncoder { "TIFF: no pages to encode", )); } + 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() { @@ -342,9 +586,19 @@ impl TiffEncoder { &extra, )?); } + // 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 store.is_some() + && let Some((last, _)) = images.last_mut() + { + 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. @@ -401,7 +655,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, @@ -409,6 +663,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) { @@ -495,10 +750,13 @@ impl TiffEncoder { for (tag, value) in extra_fields { 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) } } @@ -542,6 +800,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.checked_store()?; let row_bytes = image.width() as usize * 4; self.encode_packed( image.as_samples(), @@ -553,6 +812,7 @@ impl EncodeImage for TiffEncoder { photometric: PhotometricInterpretation::Rgb, }, &[(tags::EXTRA_SAMPLES, Value::Short(vec![2]))], + store, out, ) } @@ -606,6 +866,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. + 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); @@ -629,11 +891,56 @@ impl EncodeImage for TiffEncoder { photometric: PhotometricInterpretation::BlackIsZero, }, &[], + store, out, ) } } +/// 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 +/// 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) { @@ -658,6 +965,224 @@ 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 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}" + ); + } + } + + #[test] + 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. 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 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() { + // 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`. + // + // `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(usize::MAX) + .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(); + 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 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. + // + // 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) + .with_tiling(17, 17); + let mut out = Vec::new(); + 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, + ), + ), + ( + "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); + assert!( + err.to_string().contains("not both"), + "{path} refused for the wrong reason: {err}" + ); + } + 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 diff --git a/crates/gamut-tiff/src/lib.rs b/crates/gamut-tiff/src/lib.rs index 614c2a1c..a4015a6a 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; @@ -82,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`/ @@ -94,5 +99,6 @@ pub use gamut_ifd::{ }; pub use ifd::{PhotometricInterpretation, Predictor, SampleFormat}; pub use info::TiffInfo; +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 new file mode 100644 index 00000000..b8212e4f --- /dev/null +++ b/crates/gamut-tiff/src/metadata.rs @@ -0,0 +1,1378 @@ +//! 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 +//! [`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), 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 — subject to +//! 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 +//! +//! 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 +//! +//! 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 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}; + +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 / 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 +/// 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. + /// + /// **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. + /// + /// 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 + /// [`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 + /// 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>, + /// 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>, + /// 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 { + /// 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 + } + + /// 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 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. 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() + && 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. + /// + /// 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() || !ifd.sub_ifds().is_empty()) + } + + /// 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 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, + /// 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" + /// 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 + /// survive a round trip. + /// 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). + /// + /// 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 + /// carries a pointer-typed field under a pointer tag, hangs a group off a tag the reader does + /// 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), + None => 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 + /// 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())); + } + 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()]); + } + } +} + +/// The pointer tags [`read_metadata`] resolves **at IFD 0**, scoped to what [`TiffMetadata`] +/// actually returns. +/// +/// 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 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 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. [`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. +/// +/// 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 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; + +/// 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. +/// 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 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`. +/// +/// **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 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`]. +/// +/// 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 — +/// 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 *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 **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 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 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) { + 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"), + 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( + 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 +/// `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. +/// +/// 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()), + Value::Long8(v) | Value::Ifd8(v) => Some(v.clone()), + _ => None, + } +} + +/// 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, 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 +/// 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 +/// 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, + visited: &mut BTreeSet, + 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 pointer_tags(depth) { + 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.insert(offset) { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "TIFF: sub-IFD pointer loop", + )); + } + let mut child = read_ifd_at(data, offset, order, variant)?; + resolve_pointers(data, order, variant, &mut child, 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) +} + +/// 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). +/// +/// 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. +/// +/// 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 { + 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()); + }; + // 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() + .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 ifds + .last() + .and_then(|ifd| ifd.get(tags::C2PA_MANIFEST_STORE)) + { + Some(Value::Undefined(store)) if located => Some(store.clone()), + _ => None, + }; + Ok(TiffMetadata { + exif, + xmp, + iptc, + icc, + 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}; + + 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 { + 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, + 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(); + 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]), + TiffMetadata::new().with_c2pa(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]) + .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] + 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 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 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 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); + 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!( + written + .sub_ifds() + .iter() + .find(|group| group.tag == tags::INTEROPERABILITY_IFD) + .map(|group| group.ifds.as_slice()), + Some(&[interop][..]), + "carrying the group that was its only content" + ); + } + + #[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 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 + // 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 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 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 + // 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 + // `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 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 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 + // 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())); + 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!( + 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" + ); + } + } + + #[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 + // 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(); + 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); + 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 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::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] + 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()); + } + + #[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..5239c09d --- /dev/null +++ b/crates/gamut-tiff/tests/c2pa.rs @@ -0,0 +1,308 @@ +//! 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::{Bilevel, Dimensions, EncodeImage, ImageRef, Indexed8, Rgb8, Rgb16, Rgba8}; +use gamut_tiff::{ + 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 +/// 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_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 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, + // 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 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); + 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/metadata.rs b/crates/gamut-tiff/tests/metadata.rs new file mode 100644 index 00000000..1456744c --- /dev/null +++ b/crates/gamut-tiff/tests/metadata.rs @@ -0,0 +1,527 @@ +//! 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::{ + Bilevel, Cmyk8, Dimensions, EncodeImage, Gray8, Gray16, ImageRef, Indexed8, Rgb8, Rgb16, Rgba8, + Rgba16, +}; +use gamut_tiff::{ + 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. +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_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 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 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 + 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 enc = TiffEncoder::new().with_metadata(TiffMetadata::new().with_exif(deeper)); + + 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 +/// 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])); + 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 +} + +/// 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, + &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") +} + +#[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. 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])); + 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 `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. + // + // 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])); + 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"); +} + +/// 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] +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..34b8e068 --- /dev/null +++ b/crates/gamut-tiff/tests/oracle_metadata.rs @@ -0,0 +1,175 @@ +//! 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, 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, TiffDecoder, 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"); + } +} + +#[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"); + } +} + +/// 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 `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_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_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"); + 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(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, strip_len); // 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, strips[0], "value layout drifted"); + out.extend_from_slice(&REPEATED_TAG_STRIPS[0]); + out.extend_from_slice(&REPEATED_TAG_STRIPS[1]); + 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. 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.pixels, + REPEATED_TAG_STRIPS[first].as_slice(), + "libtiff must read the strip the FIRST entry points at (leading entry: {first})" + ); + + 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})" + ); + } +} 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); } }