From 016412a0d388b304177f79da63a6c54ccafe6df4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 00:19:43 -0400 Subject: [PATCH 1/4] feat(xmp)!: register the dcterms namespace and open WellKnownNs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2PA 2.4 §11.5 has a claim generator whose manifest store lives outside the file add a `dcterms:provenance` key to the asset's XMP, its value the URL of that store; §15.5.3.1 lists the key among the places a validator looks and makes it external-only. gamut-xmp had no DCMI Metadata Terms entry, so such a property serialized under a synthesized `ns1` prefix rather than the `dcterms` one XMPCore's own registry knows. Register `WellKnownNs::DcTerms` (`dcterms`, `http://purl.org/dc/terms/`) in the registry and `ALL`, so the writer picks the conventional prefix and `from_uri` recovers the schema. The crate stays a namespace registry: what the property means is gamut-metadata's business. `WellKnownNs` becomes `#[non_exhaustive]`. It was exhaustively matchable, so every schema the format and metadata crates go on to need would have been a major bump; the attribute pays that once, here, where a major is already due. No workspace crate matches on it. The oracle test serializes a `dcterms:provenance` URI value and asserts that exiv2 (Adobe XMPCore) reads it back under `Xmp.dcterms.provenance`, the key its schema registry defines, and that gamut re-parses XMPCore's output to the same URL; the unit test pins the URI and prefix strings exactly, since `/dc/terms/` and `/dc/elements/1.1/` share a vendor path. BREAKING CHANGE: `WellKnownNs` is `#[non_exhaustive]`; an exhaustive `match` on it needs a wildcard arm. Refs #449 --- crates/gamut-xmp/README.md | 13 +++++++++++- crates/gamut-xmp/STATUS.md | 8 ++++++++ crates/gamut-xmp/src/namespace.rs | 26 +++++++++++++++++++---- crates/gamut-xmp/tests/oracle.rs | 34 +++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/gamut-xmp/README.md b/crates/gamut-xmp/README.md index f70d9e78..c4831afa 100644 --- a/crates/gamut-xmp/README.md +++ b/crates/gamut-xmp/README.md @@ -71,7 +71,11 @@ padding) and `XmpPacket::parse` the graph — `from_packet` is exactly that comp Adobe XMPCore does not materialize it either (pinned in `tests/oracle.rs`); gamut keeps parity with the reference engine. Per-property and per-item `xml:lang` are fully supported. - **Part 2 (standard schemas) is a namespace registry** (`WellKnownNs`), not per-property - validation: values are uninterpreted text in the model, as the wire format allows. + validation: values are uninterpreted text in the model, as the wire format allows. The registry + also carries the external schemas image-metadata standards layer on XMP — `dcterms` (DCMI + Metadata Terms), which C2PA uses for `dcterms:provenance`, the URL of an *external* manifest + store (C2PA 2.4 §11.5). gamut-xmp registers the namespace; reading that property as a + provenance signal is [`gamut-metadata`](../gamut-metadata)'s job. - **Part 3 (storage in files) belongs to the format crates by design.** This crate supplies what they need — wrapper-optional parse, bare-body serialization (`to_rdf` / `serialize_body`), and the writability/padding envelope for in-place editing. Locating packets inside JPEG/TIFF/PNG @@ -86,6 +90,13 @@ padding) and `XmpPacket::parse` the graph — `from_packet` is exactly that comp data model (simple / URI / structured / `Bag`·`Seq`·`Alt`, qualifiers, language alternatives) and the `` wrapper. See [STATUS.md](STATUS.md). +## Migrating from 1.x + +`WellKnownNs` is `#[non_exhaustive]` from 2.0: the registry grows with the schemas gamut's crates +need, and each addition is now a minor change instead of a major one. An exhaustive `match` on it +needs a wildcard arm; `WellKnownNs::ALL` still enumerates every registered schema. Nothing else +changed — every existing variant, URI, prefix and method keeps its meaning. + ## Validation - **Golden vectors** transcribed from the Part 1 examples pin the canonical output byte-for-byte diff --git a/crates/gamut-xmp/STATUS.md b/crates/gamut-xmp/STATUS.md index dea87a89..dd0a5a34 100644 --- a/crates/gamut-xmp/STATUS.md +++ b/crates/gamut-xmp/STATUS.md @@ -22,6 +22,14 @@ placement, and array/struct nesting so output is stable, diffable, and round-tri linked exiv2 + expat (`tooling/exiv2-oracle`, built from the `third_party/exiv2` + `third_party/ expat` submodules). Byte-exact correctness is pinned independently by golden vectors transcribed from the Part 1 examples (`tests/golden.rs`). +- **The schema registry is open — `WellKnownNs` is `#[non_exhaustive]`** (issue #449, a 2.0 + change). It was exhaustively matchable through 1.x, so every schema the format and metadata crates + need would have been a major bump; the attribute pays that once. The first non-Adobe entry is + `dcterms` (`http://purl.org/dc/terms/`, DCMI Metadata Terms), registered because C2PA 2.4 §11.5 / + §15.5.3.1 point at an *external* manifest store through `dcterms:provenance`. This crate registers + the namespace only — what the property *means* is read by `gamut-metadata`, consistent with the + registry-not-validator posture below — and `tests/oracle.rs` pins that XMPCore reads the property + back under the `Xmp.dcterms.provenance` key its own registry defines. ## Phases diff --git a/crates/gamut-xmp/src/namespace.rs b/crates/gamut-xmp/src/namespace.rs index f58cc275..ad4f0e3e 100644 --- a/crates/gamut-xmp/src/namespace.rs +++ b/crates/gamut-xmp/src/namespace.rs @@ -40,10 +40,16 @@ impl From for Namespace { } } -/// The standard XMP schemas (Adobe XMP Parts 1–2). Each maps to a fixed namespace URI and a -/// conventional prefix via [`WellKnownNs::uri`] / [`WellKnownNs::prefix`]; [`WellKnownNs::from_uri`] -/// recovers the schema from a URI. +/// The standard XMP schemas (Adobe XMP Parts 1–2), plus the external schemas image metadata +/// standards layer on XMP. Each maps to a fixed namespace URI and a conventional prefix via +/// [`WellKnownNs::uri`] / [`WellKnownNs::prefix`]; [`WellKnownNs::from_uri`] recovers the schema +/// from a URI. +/// +/// Marked `#[non_exhaustive]`: the registry grows as gamut's format and metadata crates need +/// further schemas, and each addition must not be a breaking change. Match with a wildcard arm, +/// or iterate [`WellKnownNs::ALL`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum WellKnownNs { /// `dc` — Dublin Core (title, creator, description, subject, rights, …). DublinCore, @@ -79,6 +85,10 @@ pub enum WellKnownNs { Dimensions, /// `stRef` — the ResourceRef structure type (used by `xmpMM` references). ResourceRef, + /// `dcterms` — DCMI Metadata Terms (qualified Dublin Core). Home of `dcterms:provenance`, the + /// key C2PA 2.4 §11.5 / §15.5.3.1 uses to point at an **external** manifest store; gamut only + /// registers the namespace — the C2PA reading of the property lives in `gamut-metadata`. + DcTerms, } impl WellKnownNs { @@ -101,13 +111,15 @@ impl WellKnownNs { WellKnownNs::Pdf, WellKnownNs::Dimensions, WellKnownNs::ResourceRef, + WellKnownNs::DcTerms, ]; /// The schema's namespace URI — its canonical identity. /// /// `dc` is Dublin Core (Part 1 §8.3); the `xmp*` schemas are Part 1 §8.4–8.6; `photoshop`, /// `crs` are Part 2 §3.2–3.3. `exif`/`tiff` mirror the EXIF tags into XMP (Part 2 §3.4, defined - /// by CIPA DC-010); `Iptc4xmpCore`/`Iptc4xmpExt` are the IPTC Photo Metadata schemas. + /// by CIPA DC-010); `Iptc4xmpCore`/`Iptc4xmpExt` are the IPTC Photo Metadata schemas; + /// `dcterms` is the DCMI Metadata Terms namespace (`http://purl.org/dc/terms/`). #[must_use] pub const fn uri(self) -> &'static str { match self { @@ -128,6 +140,7 @@ impl WellKnownNs { WellKnownNs::Pdf => "http://ns.adobe.com/pdf/1.3/", WellKnownNs::Dimensions => "http://ns.adobe.com/xap/1.0/sType/Dimensions#", WellKnownNs::ResourceRef => "http://ns.adobe.com/xap/1.0/sType/ResourceRef#", + WellKnownNs::DcTerms => "http://purl.org/dc/terms/", } } @@ -153,6 +166,7 @@ impl WellKnownNs { WellKnownNs::Pdf => "pdf", WellKnownNs::Dimensions => "stDim", WellKnownNs::ResourceRef => "stRef", + WellKnownNs::DcTerms => "dcterms", } } @@ -183,6 +197,10 @@ mod tests { ); assert_eq!(WellKnownNs::Dimensions.prefix(), "stDim"); assert_eq!(WellKnownNs::Pdf.uri(), "http://ns.adobe.com/pdf/1.3/"); + // DCMI Metadata Terms: distinct from Dublin Core *elements* (`/dc/elements/1.1/`) — the + // two share a vendor path, so a copy-paste of the wrong one is the likely defect. + assert_eq!(WellKnownNs::DcTerms.uri(), "http://purl.org/dc/terms/"); + assert_eq!(WellKnownNs::DcTerms.prefix(), "dcterms"); for &ns in WellKnownNs::ALL { assert_eq!(WellKnownNs::from_uri(ns.uri()), Some(ns)); diff --git a/crates/gamut-xmp/tests/oracle.rs b/crates/gamut-xmp/tests/oracle.rs index 48ba4156..cb066b89 100644 --- a/crates/gamut-xmp/tests/oracle.rs +++ b/crates/gamut-xmp/tests/oracle.rs @@ -108,6 +108,40 @@ fn every_well_known_namespace_survives_xmpcore() { } } +#[test] +fn dcterms_provenance_reads_back_from_xmpcore_under_the_dcterms_key() { + // C2PA 2.4 §11.5 / §15.5.3.1: the pointer to an *external* manifest store is + // `dcterms:provenance`, "a URI reference". Registering the DCMI Terms namespace is what makes + // gamut serialize it under the `dcterms` prefix exiv2's own schema registry knows, so the + // reference engine reads the property by the key a validator would look for — not under a + // synthesized `ns1` — and hands the URL back unchanged. + let url = "https://example.com/manifests/photo.c2pa"; + let dcterms = WellKnownNs::DcTerms.uri(); + let mut meta = XmpMeta::new(); + meta.set(XmpProperty::new( + dcterms, + "provenance", + XmpValue::Uri(url.into()), + )); + let packet = meta.to_packet(); + assert!( + std::str::from_utf8(&packet) + .unwrap() + .contains("xmlns:dcterms=\"http://purl.org/dc/terms/\""), + "the registered prefix must be the one serialized" + ); + + exiv2_oracle::validate(&packet).expect("exiv2 (Adobe XMPCore) must accept the packet"); + assert_eq!( + exiv2_oracle::get_property(&packet, "Xmp.dcterms.provenance").unwrap(), + url + ); + + let out = exiv2_oracle::roundtrip(&packet).expect("exiv2 round-trip"); + let parsed = XmpMeta::from_packet(&out).expect("gamut parses exiv2's output"); + assert_eq!(parsed.get_text(dcterms, "provenance"), Some(url)); +} + #[test] fn registered_prefix_packet_is_valid_for_xmpcore() { // A packet serialized under a registered custom prefix is real XMP to the reference engine, From f809390a38d106b8e75e5c65fa8dc2a5dbb234e3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 00:19:43 -0400 Subject: [PATCH 2/4] feat(metadata): report provenance from the store and dcterms:provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file with no embedded manifest store but a `dcterms:provenance` URL in its XMP has Content Credentials (C2PA 2.4 §11.5); a caller testing `Metadata::c2pa.is_some()` was told, confidently, that it did not. And because §15.5.3.1 reserves the key for external manifests, a file may carry both an embedded store and a URL to a newer one, so a boolean is the wrong answer too. Add `ProvenanceState` — `None`, `Remote(url)`, `Embedded`, `EmbeddedAndRemote(url)`; `#[non_exhaustive]`, `#[repr(u8)]` with explicit append-only discriminants, plus `is_embedded()` / `remote_url()` so a caller need not match — and `Metadata::provenance()`, a computed lens over the two independent sources: `c2pa.is_some()` and the simple `dcterms:provenance` property of the XMP graph, read with the same `get_text` accessor gamut-iptc uses for its properties (so both the element-text and `rdf:resource` forms count). Neither source suppresses the other. An empty value is treated as no URL: the spec makes the value a URI reference, which an empty string is not. gamut never resolves the URL — that is a validator's job and a network operation — and the HTTP `Link` header route of §15.5.3.2 is recorded as deliberately out of scope: a header is a property of a transfer, not of the file's bytes. The facade tests drive all four states through `Metadata::from_blocks` with real `MetadataBlock::Xmp` packets and a `MetadataBlock::C2pa` store; the inline tests pin the empty-value rule, namespace discrimination (`dc:provenance` is a different property) and the two accessors over every variant. Closes #449 --- crates/gamut-metadata/README.md | 45 ++++++++ crates/gamut-metadata/src/lib.rs | 39 +++++++ crates/gamut-metadata/src/metadata.rs | 81 +++++++++++++- crates/gamut-metadata/src/provenance.rs | 123 ++++++++++++++++++++++ crates/gamut-metadata/tests/provenance.rs | 75 +++++++++++++ 5 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 crates/gamut-metadata/src/provenance.rs create mode 100644 crates/gamut-metadata/tests/provenance.rs diff --git a/crates/gamut-metadata/README.md b/crates/gamut-metadata/README.md index c6711558..5acbf910 100644 --- a/crates/gamut-metadata/README.md +++ b/crates/gamut-metadata/README.md @@ -114,6 +114,51 @@ Deferred deliberately, and tracked by the C2PA epic rather than here: parsing th and any manifest validation, signing, or ingredient authoring — all of which need a trust model this facade does not have. +## Provenance: embedded, remote, both, or none + +An embedded store is not the only way a file carries provenance. C2PA 2.4 §11.5 recommends that a +claim generator whose manifest lives *externally* add a `dcterms:provenance` key (namespace +`http://purl.org/dc/terms/`, registered as `gamut_xmp::WellKnownNs::DcTerms`) to the asset's XMP, +its value the URL of the manifest store, and is explicit that the mechanism is *only* for external +manifests; §15.5.3.1 lists that key among the places a validator looks when no store is embedded. So +`c2pa.is_some()` is the wrong question — a file with no embedded store and a `dcterms:provenance` URL +has Content Credentials — and a boolean is the wrong answer, because a file may carry both. + +`Metadata::provenance()` is the lens, a `ProvenanceState` computed from the two independent sources +and stored nowhere: + +| `c2pa` | `dcterms:provenance` | `provenance()` | +| --- | --- | --- | +| `None` | absent | `ProvenanceState::None` | +| `None` | URL | `ProvenanceState::Remote(url)` | +| `Some` | absent | `ProvenanceState::Embedded` | +| `Some` | URL | `ProvenanceState::EmbeddedAndRemote(url)` — both reported; a validator uses the embedded store and does not consult the URL (§15.5.2.1, §15.5.3.1) | + +`is_embedded()` and `remote_url()` answer the two underlying questions without matching (the enum is +`#[non_exhaustive]`). An empty `dcterms:provenance` value counts as absent — the spec makes the value +a URI reference, which an empty string is not. The lens reports what the file carries; it is not a +validity verdict and does not choose between the two sources. + +```rust +use gamut_metadata::{Metadata, MetadataBlock, ProvenanceState}; + +let meta = Metadata::from_blocks(&[MetadataBlock::Xmp(xmp_payload)])?; +match meta.provenance() { + ProvenanceState::Remote(url) => println!("external manifest at {url}"), // not fetched + ProvenanceState::Embedded => println!("manifest store embedded"), + ProvenanceState::EmbeddedAndRemote(url) => println!("embedded; the XMP also names {url}"), + _ => println!("no provenance in the file"), +} +``` + +Two things this deliberately does **not** do. **gamut never resolves the URL** — fetching it and +judging what it points at is a validator's job and a network operation, and the workspace ships +neither (see [`references/c2pa/README.md`](../../references/c2pa/README.md)). And the **HTTP `Link` +header route of §15.5.3.2** — the same pointer carried as a `Link` relation when the asset is served +over HTTP — is out of scope: a header is a property of a transfer, not of the file's bytes, so a +file-format library cannot observe it. A caller that fetched the asset holds the header and may +consult it before this lens. + ## Usage ```rust diff --git a/crates/gamut-metadata/src/lib.rs b/crates/gamut-metadata/src/lib.rs index 77c80ceb..1634349a 100644 --- a/crates/gamut-metadata/src/lib.rs +++ b/crates/gamut-metadata/src/lib.rs @@ -119,6 +119,43 @@ //! Deferred deliberately: parsing the JUMBF interior, and any manifest validation, signing, or //! ingredient authoring — all of which need a trust model this facade does not have. //! +//! # Provenance: embedded, remote, both, or none +//! +//! An embedded store is not the only way a file carries provenance. C2PA 2.4 §11.5 recommends that +//! a claim generator whose manifest lives *externally* add a `dcterms:provenance` URL to the asset's +//! XMP, and §15.5.3.1 lists it among the places a validator looks when nothing is embedded. A caller +//! asking "does this image have Content Credentials?" therefore needs more than `c2pa.is_some()`; +//! [`Metadata::provenance`] answers with a [`ProvenanceState`] that keeps the two sources apart — +//! [`None`](ProvenanceState::None), [`Remote`](ProvenanceState::Remote), +//! [`Embedded`](ProvenanceState::Embedded), or [`EmbeddedAndRemote`](ProvenanceState::EmbeddedAndRemote) +//! — because the key is reserved for external manifests (§11.5) yet a file may carry both, and the +//! lens reports what the file carries. The URL is reported as found; **gamut never +//! resolves it**, and the HTTP `Link` header route (§15.5.3.2) is out of scope for a file-format +//! library — see the [`provenance`] module for both. +//! +//! ``` +//! use gamut_metadata::{Metadata, MetadataBlock, ProvenanceState}; +//! use gamut_metadata::xmp::{WellKnownNs, XmpMeta}; +//! +//! // A file with no embedded manifest store, whose XMP points at an external one. +//! let mut graph = XmpMeta::new(); +//! graph.set_text( +//! WellKnownNs::DcTerms.uri(), +//! "provenance", +//! "https://example.com/manifests/photo.c2pa", +//! ); +//! let packet = graph.to_packet(); +//! +//! let meta = Metadata::from_blocks(&[MetadataBlock::Xmp(&packet)])?; +//! assert_eq!(meta.c2pa, None); // nothing embedded... +//! assert_eq!( +//! meta.provenance().remote_url(), // ...yet not "no provenance" +//! Some("https://example.com/manifests/photo.c2pa") +//! ); +//! assert!(matches!(meta.provenance(), ProvenanceState::Remote(_))); +//! # Ok::<(), gamut_metadata::MetadataError>(()) +//! ``` +//! //! # Quick start //! //! ``` @@ -146,6 +183,7 @@ pub mod error; pub mod extension; pub mod extract; pub mod metadata; +pub mod provenance; pub mod source; // Re-export the per-format crates so consumers reach everything through one entry point. @@ -161,4 +199,5 @@ pub use gamut_iptc as iptc; pub use gamut_iptc::{ConflictPolicy, FieldConflict}; pub use gamut_xmp as xmp; pub use metadata::Metadata; +pub use provenance::ProvenanceState; pub use source::MetadataBlock; diff --git a/crates/gamut-metadata/src/metadata.rs b/crates/gamut-metadata/src/metadata.rs index 56d4ed67..9bc0e329 100644 --- a/crates/gamut-metadata/src/metadata.rs +++ b/crates/gamut-metadata/src/metadata.rs @@ -3,12 +3,13 @@ use gamut_exif::{Exif, Value}; use gamut_icc::IccProfile; use gamut_iptc::PhotoMetadata; -use gamut_xmp::XmpMeta; +use gamut_xmp::{WellKnownNs, XmpMeta}; use crate::embed::{EncodedMetadata, MetadataEmbedder}; use crate::error::Result; use crate::extension::MetadataExtension; use crate::extract::MetadataExtractor; +use crate::provenance::ProvenanceState; use crate::source::MetadataBlock; /// All of an image's metadata, unified across the carriers a container holds. @@ -70,6 +71,10 @@ pub struct Metadata { /// There is deliberately no byte range beside it: an offset is a property of one file, and /// would become a lie the moment this model were embedded into another. Ranges stay with the /// format crate that knows the file. + /// + /// `Some` here is one of two provenance sources — the other is a `dcterms:provenance` URL in + /// [`xmp`](Self::xmp) — so ask [`provenance`](Self::provenance) rather than `is_some()` when + /// the question is "does this image have Content Credentials?". pub c2pa: Option>, /// Data none of the carriers above models, in namespaces the caller owns. /// @@ -137,6 +142,35 @@ impl Metadata { (!pm.xmp.properties.is_empty()).then_some(pm) } + /// Where this image's C2PA provenance lives: embedded, remote, both, or nowhere. + /// + /// A *computed lens* over two independent sources, stored nowhere: [`c2pa`](Self::c2pa) being + /// `Some` means a manifest store is embedded, and a simple `dcterms:provenance` property in + /// [`xmp`](Self::xmp) (namespace [`WellKnownNs::DcTerms`], C2PA 2.4 §11.5 / §15.5.3.1) means + /// an external manifest lives at that URL. Neither source suppresses the other: the key is + /// reserved for external manifests (§11.5), but nothing stops a file from carrying both, and + /// this reports what the file carries rather than choosing between them. + /// + /// The URL comes back as the XMP carried it; **gamut never resolves it** (see + /// [`ProvenanceState`]). An empty value is treated as no URL — §11.5 makes the value a URI + /// reference, which an empty string is not — and a non-simple value (an array or structure) is + /// ignored. The HTTP `Link` header route of §15.5.3.2 is deliberately not modelled: see the + /// [`provenance`](crate::provenance) module. + #[must_use] + pub fn provenance(&self) -> ProvenanceState { + let remote = self + .xmp + .as_ref() + .and_then(|xmp| xmp.get_text(WellKnownNs::DcTerms.uri(), "provenance")) + .filter(|url| !url.is_empty()); + match (self.c2pa.is_some(), remote) { + (false, None) => ProvenanceState::None, + (false, Some(url)) => ProvenanceState::Remote(url.to_owned()), + (true, None) => ProvenanceState::Embedded, + (true, Some(url)) => ProvenanceState::EmbeddedAndRemote(url.to_owned()), + } + } + /// The value bound to `key` in `namespace`, or `None` when the model carries no such /// [extension](Self::extensions). #[must_use] @@ -251,6 +285,51 @@ mod tests { ); } + #[test] + fn provenance_treats_an_empty_dcterms_value_as_no_url() { + // §11.5 makes the value a URI reference; an empty element is not one, so it must not + // surface as Remote("") for a caller to try to fetch. + let empty = Metadata { + xmp: Some(xmp_with(WellKnownNs::DcTerms.uri(), "provenance", "")), + ..Default::default() + }; + assert_eq!(empty.provenance(), ProvenanceState::None); + + let with_store = Metadata { + c2pa: Some(vec![0x00, 0x00, 0x00, 0x14]), + ..empty + }; + assert_eq!(with_store.provenance(), ProvenanceState::Embedded); + } + + #[test] + fn provenance_reads_only_the_dcterms_namespace() { + // Same local name in Dublin Core *elements* (`dc:`) is a different property; the two + // namespaces share a vendor path, so the mix-up is the likely defect. + let dc = Metadata { + xmp: Some(xmp_with( + WellKnownNs::DublinCore.uri(), + "provenance", + "https://example.com/m.c2pa", + )), + ..Default::default() + }; + assert_eq!(dc.provenance(), ProvenanceState::None); + + let dcterms = Metadata { + xmp: Some(xmp_with( + WellKnownNs::DcTerms.uri(), + "provenance", + "https://example.com/m.c2pa", + )), + ..Default::default() + }; + assert_eq!( + dcterms.provenance(), + ProvenanceState::Remote("https://example.com/m.c2pa".to_owned()) + ); + } + #[test] fn from_carriers_leaves_the_manifest_store_empty() { // `c2pa` is not a `from_carriers` parameter: a model built to embed carries no store. diff --git a/crates/gamut-metadata/src/provenance.rs b/crates/gamut-metadata/src/provenance.rs new file mode 100644 index 00000000..25294efa --- /dev/null +++ b/crates/gamut-metadata/src/provenance.rs @@ -0,0 +1,123 @@ +//! Where an image's C2PA provenance lives — embedded in the file, at a remote URL, both, or +//! nowhere. +//! +//! C2PA 2.4 gives a still image two independent ways to carry provenance. The manifest store can be +//! **embedded** in the file (§11.1.4.2; the facade holds it verbatim in +//! [`Metadata::c2pa`](crate::Metadata::c2pa)), or it can be **external**, in which case §11.5 +//! recommends the claim generator add a `dcterms:provenance` key to the asset's XMP whose value — +//! "a URI reference" — says where to find it. §11.5 is explicit that the mechanism is *only* for +//! external manifests; §15.5.3.1 lists the key among the places a validator looks when no store is +//! embedded. The two sources are independent bytes in the file, so a file may carry both, and the +//! lens reports both rather than letting one hide the other — what a validator then does with the +//! pair is the spec's business (§15.5.2.1 / §15.5.3.1: it uses the embedded store and does not +//! consult the URL), not this crate's. +//! +//! [`ProvenanceState`] is the facade's answer to "does this image have Content Credentials, and +//! where?" — four states, never collapsed to a boolean, so a file with no embedded store and a +//! remote URL reports [`Remote`](ProvenanceState::Remote) rather than a confident +//! [`None`](ProvenanceState::None). It is a *lens* computed by +//! [`Metadata::provenance`](crate::Metadata::provenance), not stored state. +//! +//! # What gamut does not do +//! +//! - **It never fetches the URL.** Resolving it, and judging whatever it points at, is a +//! validator's job and a network operation; the workspace ships neither (see +//! `references/c2pa/README.md`). The URL is handed over as the string the XMP carried. +//! - **The HTTP `Link` header route is out of scope.** §15.5.3.2 defines an HTTP `Link` relation +//! that carries the same pointer for an asset served over HTTP. A header is a property of a +//! *transfer*, not of the file's bytes, so a file-format library cannot observe it; a caller that +//! fetched the asset itself holds the header and may consult it before this lens. This is a +//! deliberate boundary, not an omission. + +/// Where the C2PA manifest store that vouches for an image lives, as far as the image's own +/// metadata says. +/// +/// Returned by [`Metadata::provenance`](crate::Metadata::provenance), which combines two +/// independent sources: whether the container located an embedded store +/// ([`Metadata::c2pa`](crate::Metadata::c2pa)) and whether the XMP graph carries a +/// `dcterms:provenance` URL (C2PA 2.4 §11.5, §15.5.3.1). Because the sources are independent the +/// type has four states, not three and not a boolean: [`EmbeddedAndRemote`](Self::EmbeddedAndRemote) +/// is a real case — the key is reserved for external manifests (§11.5), yet nothing stops a file +/// from carrying both — and neither source suppresses the other. This is a report of what the file +/// carries, not a validity verdict and not a choice between the two. +/// +/// The remote URL is carried as the string the XMP held. **gamut never resolves it**; see the +/// [module docs](self) for why, and for the HTTP `Link` header route this type deliberately does +/// not model. +/// +/// Marked `#[non_exhaustive]` so a further provenance source can be added without a breaking +/// change; match with a wildcard arm, or use [`is_embedded`](Self::is_embedded) and +/// [`remote_url`](Self::remote_url), which answer the two underlying questions directly. The +/// discriminants are explicit, append-only, and stable. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +#[non_exhaustive] +#[repr(u8)] +pub enum ProvenanceState { + /// No embedded manifest store and no `dcterms:provenance` URL. This is what the metadata + /// says, not a validity verdict — the asset may still carry provenance by a route the file + /// cannot express (see the [module docs](self) on the HTTP `Link` header). + #[default] + None = 0, + /// No embedded store; the XMP points at an external manifest at this URL (C2PA 2.4 §11.5). + /// The string is the `dcterms:provenance` value verbatim, unresolved and unvalidated. + Remote(String) = 1, + /// A manifest store is embedded in the file ([`Metadata::c2pa`](crate::Metadata::c2pa) is + /// `Some`) and the XMP carries no `dcterms:provenance` URL. + Embedded = 2, + /// Both: a manifest store is embedded *and* the XMP carries a `dcterms:provenance` URL. The + /// URL is reported because the file carries it; §11.5 makes the key external-only, and a + /// validator that finds an embedded store uses it and does not consult the URL (§15.5.2.1, + /// §15.5.3.1), so this variant says nothing about which manifest is authoritative. + EmbeddedAndRemote(String) = 3, +} + +impl ProvenanceState { + /// Whether a manifest store is embedded in the file — `true` for + /// [`Embedded`](Self::Embedded) and [`EmbeddedAndRemote`](Self::EmbeddedAndRemote). + #[must_use] + pub fn is_embedded(&self) -> bool { + matches!(self, Self::Embedded | Self::EmbeddedAndRemote(_)) + } + + /// The `dcterms:provenance` URL of an external manifest, if the XMP carried one — `Some` for + /// [`Remote`](Self::Remote) and [`EmbeddedAndRemote`](Self::EmbeddedAndRemote). Never + /// resolved by gamut. + #[must_use] + pub fn remote_url(&self) -> Option<&str> { + match self { + Self::Remote(url) | Self::EmbeddedAndRemote(url) => Some(url), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const URL: &str = "https://example.com/m.c2pa"; + + #[test] + fn default_is_none() { + assert_eq!(ProvenanceState::default(), ProvenanceState::None); + } + + #[test] + fn is_embedded_is_true_for_exactly_the_embedded_variants() { + assert!(!ProvenanceState::None.is_embedded()); + assert!(!ProvenanceState::Remote(URL.into()).is_embedded()); + assert!(ProvenanceState::Embedded.is_embedded()); + assert!(ProvenanceState::EmbeddedAndRemote(URL.into()).is_embedded()); + } + + #[test] + fn remote_url_is_some_for_exactly_the_remote_variants() { + assert_eq!(ProvenanceState::None.remote_url(), None); + assert_eq!(ProvenanceState::Remote(URL.into()).remote_url(), Some(URL)); + assert_eq!(ProvenanceState::Embedded.remote_url(), None); + assert_eq!( + ProvenanceState::EmbeddedAndRemote(URL.into()).remote_url(), + Some(URL) + ); + } +} diff --git a/crates/gamut-metadata/tests/provenance.rs b/crates/gamut-metadata/tests/provenance.rs new file mode 100644 index 00000000..c71a4916 --- /dev/null +++ b/crates/gamut-metadata/tests/provenance.rs @@ -0,0 +1,75 @@ +//! The provenance lens through the facade: `Metadata::provenance()` over blocks a container +//! located. Four states from two independent sources — an embedded manifest store +//! (`MetadataBlock::C2pa`) and a `dcterms:provenance` URL in the XMP packet (C2PA 2.4 §11.5, +//! §15.5.3.1) — with neither source suppressing the other, and no attempt to resolve the URL. + +use gamut_metadata::xmp::{WellKnownNs, XmpMeta}; +use gamut_metadata::{Metadata, MetadataBlock, ProvenanceState}; + +const URL: &str = "https://example.com/manifests/photo.c2pa"; + +/// An XMP packet whose only property is `dcterms:provenance` as element text. +fn xmp_with_provenance_text(url: &str) -> Vec { + let mut xmp = XmpMeta::new(); + xmp.set_text(WellKnownNs::DcTerms.uri(), "provenance", url); + xmp.to_packet() +} + +/// An XMP packet with an unrelated property, so the graph is present but says nothing about +/// provenance. +fn xmp_without_provenance() -> Vec { + let mut xmp = XmpMeta::new(); + xmp.set_text(WellKnownNs::Xmp.uri(), "CreatorTool", "gamut"); + xmp.to_packet() +} + +/// Bytes shaped like a JUMBF superbox header; the facade never looks inside, so nothing here +/// needs to be a valid manifest. +fn c2pa_store() -> Vec { + let mut store = vec![0x00, 0x00, 0x00, 0x1C, b'j', b'u', b'm', b'b']; + store.extend_from_slice(b"c2pa\xFF\x00not a real manifest"); + store +} + +#[test] +fn no_store_and_no_url_is_none() { + let xmp = xmp_without_provenance(); + let meta = Metadata::from_blocks(&[MetadataBlock::Xmp(&xmp)]).unwrap(); + assert_eq!(meta.provenance(), ProvenanceState::None); + + // No metadata at all is None too, not a panic or a synthesized state. + assert_eq!( + Metadata::from_blocks(&[]).unwrap().provenance(), + ProvenanceState::None + ); +} + +#[test] +fn url_without_a_store_is_remote() { + // The issue's motivating case: a file with no embedded manifest store but a + // `dcterms:provenance` URL must not report None. + let xmp = xmp_with_provenance_text(URL); + let meta = Metadata::from_blocks(&[MetadataBlock::Xmp(&xmp)]).unwrap(); + assert_eq!(meta.provenance(), ProvenanceState::Remote(URL.to_owned())); +} + +#[test] +fn store_without_a_url_is_embedded() { + let (xmp, store) = (xmp_without_provenance(), c2pa_store()); + let meta = + Metadata::from_blocks(&[MetadataBlock::Xmp(&xmp), MetadataBlock::C2pa(&store)]).unwrap(); + assert_eq!(meta.provenance(), ProvenanceState::Embedded); +} + +#[test] +fn store_and_url_is_embedded_and_remote() { + // §11.5 reserves the key for external manifests, yet nothing stops a file from carrying both: + // the embedded store must not hide the URL, and the URL must not hide the store. + let (xmp, store) = (xmp_with_provenance_text(URL), c2pa_store()); + let meta = + Metadata::from_blocks(&[MetadataBlock::Xmp(&xmp), MetadataBlock::C2pa(&store)]).unwrap(); + assert_eq!( + meta.provenance(), + ProvenanceState::EmbeddedAndRemote(URL.to_owned()) + ); +} From ce3e91a2e00c87efd6628dd66fd1481669f4fad8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 01:01:48 -0400 Subject: [PATCH 3/4] refactor(metadata): drop the repr and Default from ProvenanceState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProvenanceState` carried `#[repr(u8)]` with explicit discriminants and a `Default` of `None`. Neither earns its place. A data-carrying enum's tag is not observable from safe code and `String` is not FFI-safe, so the repr promised a C layout nothing can consume; the C-portable surface of the type is `is_embedded()` and `remote_url()`, which already satisfy AGENTS.md's "payloads reachable through accessors". And a report type defaulting to a confident "no provenance" is the wrong default for a value that is always computed from the file — `Metadata::provenance()` never needs one. Remove both, and the test that pinned the default. The docs now say why the accessors are the portable surface and why there is no `Default`. Refs #449 --- crates/gamut-metadata/src/provenance.rs | 26 +++++++++++-------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/crates/gamut-metadata/src/provenance.rs b/crates/gamut-metadata/src/provenance.rs index 25294efa..06fd8283 100644 --- a/crates/gamut-metadata/src/provenance.rs +++ b/crates/gamut-metadata/src/provenance.rs @@ -47,28 +47,29 @@ /// /// Marked `#[non_exhaustive]` so a further provenance source can be added without a breaking /// change; match with a wildcard arm, or use [`is_embedded`](Self::is_embedded) and -/// [`remote_url`](Self::remote_url), which answer the two underlying questions directly. The -/// discriminants are explicit, append-only, and stable. -#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +/// [`remote_url`](Self::remote_url), which answer the two underlying questions directly and are +/// the C-portable surface of this type (a data-carrying enum has no observable tag, and `String` +/// is not FFI-safe). There is deliberately no `Default`: this is a computed report, and a default +/// of "no provenance" would be a confident answer nobody asked for. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[non_exhaustive] -#[repr(u8)] pub enum ProvenanceState { /// No embedded manifest store and no `dcterms:provenance` URL. This is what the metadata /// says, not a validity verdict — the asset may still carry provenance by a route the file /// cannot express (see the [module docs](self) on the HTTP `Link` header). - #[default] - None = 0, + None, /// No embedded store; the XMP points at an external manifest at this URL (C2PA 2.4 §11.5). - /// The string is the `dcterms:provenance` value verbatim, unresolved and unvalidated. - Remote(String) = 1, + /// The string is the `dcterms:provenance` value with surrounding whitespace trimmed, + /// otherwise verbatim — unresolved and unvalidated. + Remote(String), /// A manifest store is embedded in the file ([`Metadata::c2pa`](crate::Metadata::c2pa) is /// `Some`) and the XMP carries no `dcterms:provenance` URL. - Embedded = 2, + Embedded, /// Both: a manifest store is embedded *and* the XMP carries a `dcterms:provenance` URL. The /// URL is reported because the file carries it; §11.5 makes the key external-only, and a /// validator that finds an embedded store uses it and does not consult the URL (§15.5.2.1, /// §15.5.3.1), so this variant says nothing about which manifest is authoritative. - EmbeddedAndRemote(String) = 3, + EmbeddedAndRemote(String), } impl ProvenanceState { @@ -97,11 +98,6 @@ mod tests { const URL: &str = "https://example.com/m.c2pa"; - #[test] - fn default_is_none() { - assert_eq!(ProvenanceState::default(), ProvenanceState::None); - } - #[test] fn is_embedded_is_true_for_exactly_the_embedded_variants() { assert!(!ProvenanceState::None.is_embedded()); From d6fd0a08920fbafdff8a24a7bf7192cb13116809 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 01:01:48 -0400 Subject: [PATCH 4/4] fix(metadata): trim dcterms:provenance and treat whitespace-only as absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-value rule was there because §11.5 makes the value a URI reference and `Remote("")` hands a caller nothing to fetch. The same reason applies to a whitespace-only value, which slipped through as `Remote(" ")`, and to a padded value, which kept its padding around an otherwise good URL. Trim surrounding whitespace before the emptiness check, so whitespace-only reads as no URL (`None`, or `Embedded` when a store is present) and a padded URL comes back clean. Pinned by a unit test beside the empty-value one. Also document, on `Metadata::provenance()`, that if a non-canonical graph carries the property twice the first occurrence wins — inherited from `XmpMeta::get`, not a choice made here. Refs #449 --- crates/gamut-metadata/src/metadata.rs | 41 ++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/gamut-metadata/src/metadata.rs b/crates/gamut-metadata/src/metadata.rs index 9bc0e329..0d230567 100644 --- a/crates/gamut-metadata/src/metadata.rs +++ b/crates/gamut-metadata/src/metadata.rs @@ -151,10 +151,12 @@ impl Metadata { /// reserved for external manifests (§11.5), but nothing stops a file from carrying both, and /// this reports what the file carries rather than choosing between them. /// - /// The URL comes back as the XMP carried it; **gamut never resolves it** (see - /// [`ProvenanceState`]). An empty value is treated as no URL — §11.5 makes the value a URI - /// reference, which an empty string is not — and a non-simple value (an array or structure) is - /// ignored. The HTTP `Link` header route of §15.5.3.2 is deliberately not modelled: see the + /// The URL comes back as the XMP carried it, with surrounding whitespace trimmed; **gamut + /// never resolves it** (see [`ProvenanceState`]). A value that is empty or whitespace-only is + /// treated as no URL — §11.5 makes the value a URI reference, which neither is — and a + /// non-simple value (an array or structure) is ignored. Should a non-canonical graph carry + /// the property twice, the first occurrence wins, as [`XmpMeta::get`] defines. The HTTP `Link` + /// header route of §15.5.3.2 is deliberately not modelled: see the /// [`provenance`](crate::provenance) module. #[must_use] pub fn provenance(&self) -> ProvenanceState { @@ -162,6 +164,7 @@ impl Metadata { .xmp .as_ref() .and_then(|xmp| xmp.get_text(WellKnownNs::DcTerms.uri(), "provenance")) + .map(str::trim) .filter(|url| !url.is_empty()); match (self.c2pa.is_some(), remote) { (false, None) => ProvenanceState::None, @@ -302,6 +305,36 @@ mod tests { assert_eq!(with_store.provenance(), ProvenanceState::Embedded); } + #[test] + fn provenance_trims_the_url_and_treats_whitespace_only_as_no_url() { + // The same reason as the empty value: a URI reference has no surrounding whitespace, and + // `Remote(" ")` would hand a caller nothing to fetch. Padding around a real URL is + // pretty-printing noise, not part of the reference. + let blank = Metadata { + xmp: Some(xmp_with(WellKnownNs::DcTerms.uri(), "provenance", " \n\t ")), + ..Default::default() + }; + assert_eq!(blank.provenance(), ProvenanceState::None); + let blank_with_store = Metadata { + c2pa: Some(vec![0x00, 0x00, 0x00, 0x14]), + ..blank + }; + assert_eq!(blank_with_store.provenance(), ProvenanceState::Embedded); + + let padded = Metadata { + xmp: Some(xmp_with( + WellKnownNs::DcTerms.uri(), + "provenance", + "\n https://example.com/m.c2pa \n", + )), + ..Default::default() + }; + assert_eq!( + padded.provenance(), + ProvenanceState::Remote("https://example.com/m.c2pa".to_owned()) + ); + } + #[test] fn provenance_reads_only_the_dcterms_namespace() { // Same local name in Dublin Core *elements* (`dc:`) is a different property; the two