Skip to content

gamut-metadata: C2PA manifest store as a fourth carrier, with a no-copy-forward policy #428

Description

@justin13888

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.rsopaque 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)]
pub enum C2paPolicy {
    /// 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.
  • mise run test, lint, fmt-check and coverage pass.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions