Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/gamut-metadata/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions crates/gamut-metadata/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
//! ```
Expand Down Expand Up @@ -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.
Expand All @@ -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;
114 changes: 113 additions & 1 deletion crates/gamut-metadata/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Vec<u8>>,
/// Data none of the carriers above models, in namespaces the caller owns.
///
Expand Down Expand Up @@ -137,6 +142,38 @@ 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, 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 {
let remote = self
.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,
(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]
Expand Down Expand Up @@ -251,6 +288,81 @@ 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_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
// 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.
Expand Down
119 changes: 119 additions & 0 deletions crates/gamut-metadata/src/provenance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! 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 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]
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).
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 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,
/// 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),
}

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 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)
);
}
}
Loading
Loading