You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of #239. Blocked by #427. Blocks every container slice of that epic.
Goal
Add the C2PA manifest store as gamut-metadata's fourth carrier, and — the substantive half —
make it impossible to silently launder a stale manifest across a re-encode or a metadata-only
rewrite.
Scope
The carrier
MetadataBlock::C2pa(&[u8]) in src/source.rs.
Metadata::c2pa: Option<Vec<u8>> in src/metadata.rs — opaque bytes, deliberately not a
parsed model. Unlike exif/xmp/icc there is no typed leaf crate behind it: the store is a
JUMBF superbox whose bytes are covered by a COSE signature, so re-serializing it is not merely
unnecessary, it is not permitted. Integrate C2PA #239 keeps the JUMBF interior out of scope.
EncodedMetadata::c2pa: Option<Vec<u8>> in src/embed.rs.
All three types are already #[non_exhaustive] — their doc comments name exactly this case ("a
later carrier can add a variant without a breaking change") — so this is semver-minor for gamut-metadata. Metadata::from_carriers keeps its three-argument signature; c2pa is set by
field assignment, as extensions already is.
Why it is a carrier and not an extension
Metadata::extensions is explicitly "not a fourth carrier" and "never serialized" — extraction
never produces one and embedding never emits one. A manifest store is the opposite on both counts:
it comes out of a file and it must go back into one. It is a genuinely distinct serialization, so
the crate's "one carrier, one field" rule admits it. Say this in README.md, because the rule as
written currently reads as closed at three.
The no-copy-forward policy — the substantive half
A C2PA hard binding digests the finished file with the store's own range excluded (§15.12.1.1).
Any re-encode, and any rewrite that moves a byte, invalidates it. C2PA's model for a derivative is
a new manifest carrying the parent as an ingredient — never the parent's manifest copied
forward. So a facade round-trip that carries the store across produces a file that fails
validation while looking perfectly well-formed.
This is not hypothetical: crates/gamut/tests/interoperability.rs:25-47 is exactly that pattern —
read metadata out of one encoded file, feed it into another encoder — and it is the documented way
to use this crate.
Proposed shape, mirroring the existing ExtensionPolicy (src/embed.rs:16-26):
#[derive(Debug,Clone,Copy,PartialEq,Eq,Default)]#[non_exhaustive]#[repr(u8)]pubenumC2paPolicy{/// Drop the manifest store on embed (default): a re-encoded asset needs a new manifest.#[default]Drop = 0,/// Refuse to embed, so losing provenance is an error rather than a silent omission.Reject = 1,}
with MetadataEmbedder::c2pa_policy, and a MetadataError::UnembeddableC2pa variant for Reject.
Deliberately no Preserve variant. Preserving is correct only when the caller knows the bytes
did not move, and the container-agnostic facade cannot know that — it never sees the file. A format
crate that legitimately re-embeds a reserved store does it through that crate's own with_c2pa_manifest, not through the facade's round-trip. Adding Preserve here would make the
easy path the wrong one.
The keystone exception, documented
README.md's "extract → embed → extract is a true equality" keystone must gain an explicit
carve-out for c2pa, with the reason — otherwise the first person to notice the round-trip is not
an equality will file it as a bug.
Research areas
Whether Metadata::c2pa should carry the byte range it was found at. Recommendation: no.
Ranges belong to the format crates, which know the file; an offset stored in a container-agnostic
model becomes a lie the moment that model is embedded somewhere else.
Whether gamut-metadata should gain a STATUS.md. It is the only metadata crate without
one, and Integrate C2PA #239 creates real deferrals (the JUMBF interior, validation) that have nowhere
conventional to be recorded.
Reference
C2PA Technical Specification 2.4 §9.1 (exactly one hard binding per standard manifest), §11.1.1
(the store is a JUMBF superbox), §15.12.1.1 (the exclusion contract), §9.2.6 (asset metadata should be included in the binding, not excluded). Staked by #427.
Acceptance criteria
The three additions land and cargo build -p gamut-metadata succeeds; the change is semver-minor (no existing signature changes).
A Metadata carrying c2pa, embedded under the default policy, yields EncodedMetadata.c2pa == None — covered by a test.
C2paPolicy::Reject returns a typed error, and the test asserts the error message, not
just is_err() — an is_err() assertion cannot distinguish this rejection from any other.
No Preserve-style variant ships.
README.md explains why C2PA is a carrier rather than an extension, and documents the
keystone carve-out with its reason.
Part of #239. Blocked by #427. Blocks every container slice of that epic.
Goal
Add the C2PA manifest store as
gamut-metadata's fourth carrier, and — the substantive half —make it impossible to silently launder a stale manifest across a re-encode or a metadata-only
rewrite.
Scope
The carrier
MetadataBlock::C2pa(&[u8])insrc/source.rs.Metadata::c2pa: Option<Vec<u8>>insrc/metadata.rs— opaque bytes, deliberately not aparsed model. Unlike
exif/xmp/iccthere is no typed leaf crate behind it: the store is aJUMBF superbox whose bytes are covered by a COSE signature, so re-serializing it is not merely
unnecessary, it is not permitted. Integrate C2PA #239 keeps the JUMBF interior out of scope.
EncodedMetadata::c2pa: Option<Vec<u8>>insrc/embed.rs.All three types are already
#[non_exhaustive]— their doc comments name exactly this case ("alater carrier can add a variant without a breaking change") — so this is semver-minor for
gamut-metadata.Metadata::from_carrierskeeps its three-argument signature;c2pais set byfield assignment, as
extensionsalready is.Why it is a carrier and not an extension
Metadata::extensionsis explicitly "not a fourth carrier" and "never serialized" — extractionnever produces one and embedding never emits one. A manifest store is the opposite on both counts:
it comes out of a file and it must go back into one. It is a genuinely distinct serialization, so
the crate's "one carrier, one field" rule admits it. Say this in
README.md, because the rule aswritten currently reads as closed at three.
The no-copy-forward policy — the substantive half
A C2PA hard binding digests the finished file with the store's own range excluded (§15.12.1.1).
Any re-encode, and any rewrite that moves a byte, invalidates it. C2PA's model for a derivative is
a new manifest carrying the parent as an ingredient — never the parent's manifest copied
forward. So a facade round-trip that carries the store across produces a file that fails
validation while looking perfectly well-formed.
This is not hypothetical:
crates/gamut/tests/interoperability.rs:25-47is exactly that pattern —read metadata out of one encoded file, feed it into another encoder — and it is the documented way
to use this crate.
Proposed shape, mirroring the existing
ExtensionPolicy(src/embed.rs:16-26):with
MetadataEmbedder::c2pa_policy, and aMetadataError::UnembeddableC2pavariant forReject.Deliberately no
Preservevariant. Preserving is correct only when the caller knows the bytesdid not move, and the container-agnostic facade cannot know that — it never sees the file. A format
crate that legitimately re-embeds a reserved store does it through that crate's own
with_c2pa_manifest, not through the facade's round-trip. AddingPreservehere would make theeasy path the wrong one.
The keystone exception, documented
README.md's "extract → embed → extract is a true equality" keystone must gain an explicitcarve-out for
c2pa, with the reason — otherwise the first person to notice the round-trip is notan equality will file it as a bug.
Research areas
Metadata::c2pashould carry the byte range it was found at. Recommendation: no.Ranges belong to the format crates, which know the file; an offset stored in a container-agnostic
model becomes a lie the moment that model is embedded somewhere else.
gamut-metadatashould gain aSTATUS.md. It is the only metadata crate withoutone, and Integrate C2PA #239 creates real deferrals (the JUMBF interior, validation) that have nowhere
conventional to be recorded.
Reference
C2PA Technical Specification 2.4 §9.1 (exactly one hard binding per standard manifest), §11.1.1
(the store is a JUMBF superbox), §15.12.1.1 (the exclusion contract), §9.2.6 (asset metadata
should be included in the binding, not excluded). Staked by #427.
Acceptance criteria
cargo build -p gamut-metadatasucceeds; the change issemver-minor (no existing signature changes).
Metadatacarryingc2pa, embedded under the default policy, yieldsEncodedMetadata.c2pa == None— covered by a test.C2paPolicy::Rejectreturns a typed error, and the test asserts the error message, notjust
is_err()— anis_err()assertion cannot distinguish this rejection from any other.Preserve-style variant ships.README.mdexplains why C2PA is a carrier rather than an extension, and documents thekeystone carve-out with its reason.
mise run test,lint,fmt-checkandcoveragepass.