From 5936154da22994a7abf296ce3d2993b325a33a51 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:23:53 -0400 Subject: [PATCH 01/36] feat(c2pa-oracle): add a differential oracle over c2pa-rs The #239 epic splits the work: gamut locates, bounds, carries and reserves a C2PA manifest store, and validation belongs to c2pa-rs. Nothing has stood on the far side of that seam until now. The crate is workspace-excluded and invoked by manifest path, the `tooling/gamut-dng-real-conformance` shape, so no shipped crate can gain an edge to it and `check-release-deps` never has to reason about it. `c2pa` is built `default-features = false, features = ["rust_native_crypto"]`: its defaults are `["openssl", "default_http"]` and `openssl` is pulled vendored, which would compile OpenSSL from C source into a dev build of a repository whose whole point here is that no crypto reaches the shipped graph. `EphemeralSigner` mints an Ed25519 chain in memory, so no key material is committed and no fixture expires. Trust-list checking is off, since an ephemeral certificate is on no list and this oracle measures whether gamut moved a byte, not whose key signed the file. `c2pa::jumbf_io` is deliberately not built on: its stream entry points name the crate-private `CAIRead`/`CAIReadWrite`, and its usable half returns bytes without offsets, which is not the question a container library asks. Refs #447. --- Cargo.toml | 7 + mise.toml | 17 ++ tooling/c2pa-oracle/Cargo.toml | 39 +++ tooling/c2pa-oracle/README.md | 138 ++++++++++ tooling/c2pa-oracle/examples/probe.rs | 174 ++++++++++++ tooling/c2pa-oracle/src/lib.rs | 365 ++++++++++++++++++++++++++ 6 files changed, 740 insertions(+) create mode 100644 tooling/c2pa-oracle/Cargo.toml create mode 100644 tooling/c2pa-oracle/README.md create mode 100644 tooling/c2pa-oracle/examples/probe.rs create mode 100644 tooling/c2pa-oracle/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 28eb775e..a880a8fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,13 @@ exclude = [ # pins stable, and because a coverage-guided engine is unbounded and so cannot sit in the # `coverage` job, the only gate that runs tests. Run it with `mise run fuzz`. "tooling/gamut-fuzz", + # Dev-only differential oracle against `c2pa-rs`, the C2PA reference implementation (issue + # #447 under the #239 epic). Excluded for the usual reason and one more: `c2pa-rs` is a large + # dependency tree carrying signing and verification crypto, and the epic's "no crypto in the + # shipped graph" criterion means no shipped crate may ever reach it. Being outside the + # workspace also puts it outside `mise run check-release-deps`. Run it with `mise run + # test-c2pa`; `mise run check-c2pa` is the compile-only half the per-PR lint lane affords. + "tooling/c2pa-oracle", "tooling/gamut-iptc-oracle", "tooling/zlib-oracle", "tooling/libpng-oracle", diff --git a/mise.toml b/mise.toml index 50ccb405..6453c3d0 100644 --- a/mise.toml +++ b/mise.toml @@ -210,6 +210,23 @@ run = "cargo test --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml" description = "Compile the real-camera DNG conformance tier (no corpus needed)" run = "cargo check --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml --all-targets" +# The C2PA differential oracle (issue #447 under the #239 epic). Like the tier above it is +# workspace-excluded *and* nothing depends on it, so no per-PR gate reaches it; unlike that tier it +# needs no corpus and no native toolchain — `c2pa-rs` is a pure-Rust crate built +# `--no-default-features --features rust_native_crypto`, which is what keeps its vendored OpenSSL +# out of this repository entirely. It is still kept off `mise run test` because a shipped crate +# must never gain an edge to it, and the excluded manifest is what enforces that. +[tasks.test-c2pa] +description = "Cross-check gamut's C2PA carriage against c2pa-rs, both directions (issue #447)" +run = "cargo test --manifest-path tooling/c2pa-oracle/Cargo.toml" + +# The compile half, for the same reason `check-dng-real` exists: a `gamut-avif` or `gamut-heic` API +# change can break this crate while every per-PR gate stays green, and `check` catches that in +# seconds without signing anything. +[tasks.check-c2pa] +description = "Compile the C2PA differential oracle (no signing, no corpus)" +run = "cargo check --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets" + # Doctests only. On stable, `cargo llvm-cov` cannot instrument doctests (that needs nightly), so # the coverage gate — which CI uses as its green-test gate — silently skips them. This task is the # missing slice: CI's lint lane runs it, reusing the `--all-targets --all-features` build it just diff --git a/tooling/c2pa-oracle/Cargo.toml b/tooling/c2pa-oracle/Cargo.toml new file mode 100644 index 00000000..0768eb45 --- /dev/null +++ b/tooling/c2pa-oracle/Cargo.toml @@ -0,0 +1,39 @@ +# Dev-only differential oracle: NOT a member of the gamut workspace (listed under +# `[workspace].exclude` in the root manifest). Nothing depends on it — it is invoked directly by +# manifest path, via `mise run test-c2pa`, so `cargo test --workspace --all-features` never builds +# or runs it. +# +# It drives `c2pa-rs`, the C2PA reference implementation, against gamut's container crates in both +# of the directions the #239 epic names: gamut reserves a manifest-store slot and an external +# signer completes it, and `c2pa-rs` embeds a store that gamut must locate at the identical byte +# range. See README.md for why gamut owns the locate/bound step at all. +[package] +name = "c2pa-oracle" +version = "0.0.0" +edition = "2024" +publish = false +description = "Dev-only differential oracle: cross-checks gamut's C2PA manifest-store carriage against c2pa-rs, in both directions (issue #447)." +license = "MIT OR Apache-2.0" + +[lib] +doctest = false + +[dependencies] +# The oracle itself: the C2PA reference implementation (Apache-2.0 OR MIT). +# +# `default-features = false` is NOT optional, and `tests/build_configuration.rs` fails if this +# line ever loses it. c2pa's default feature set is `["openssl", "default_http"]`; the `openssl` +# feature pulls OpenSSL in **vendored**, compiling it from C source into a dev build. The #239 +# epic's "no crypto in the shipped graph" criterion exists to keep that out, and a vendored +# OpenSSL build would also make this the slowest thing in CI. `rust_native_crypto` is the +# pure-Rust signing/verification backend that replaces it; dropping `default_http` additionally +# drops reqwest and ureq, which this oracle never needs because it fetches no remote manifests. +c2pa = { version = "0.90.21", default-features = false, features = ["rust_native_crypto"] } + +[dev-dependencies] +# The crates under test. Both locate a C2PA manifest store in a top-level ISOBMFF `uuid` +# `ContentProvenanceBox`; `gamut-avif` additionally *reserves* and *writes* one, which is the only +# direction that needs a gamut encoder. +gamut-avif = { path = "../../crates/gamut-avif" } +gamut-heic = { path = "../../crates/gamut-heic" } +gamut-core = { path = "../../crates/gamut-core" } diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md new file mode 100644 index 00000000..71ac1c40 --- /dev/null +++ b/tooling/c2pa-oracle/README.md @@ -0,0 +1,138 @@ +# c2pa-oracle + +Dev-only differential oracle against [`c2pa-rs`](https://github.com/contentauth/c2pa-rs), the C2PA +reference implementation, for the container half of the C2PA epic (issue #239, this crate is issue +#447). + +Not a member of the gamut workspace — it is listed under `[workspace].exclude` in the root +manifest and nothing depends on it, so `cargo build`/`test --workspace` never reaches it. Run it by +manifest path: + +```bash +mise run test-c2pa # the differential tests +mise run check-c2pa # compile only, which is what the per-PR lint lane affords +``` + +## What it checks + +The epic splits the work in two: **gamut locates, bounds, carries and reserves** a C2PA manifest +store; **validation belongs to `c2pa-rs`**. This crate is the seam between those halves, exercised +in both directions. + +| direction | test file | what it pins | +| --- | --- | --- | +| gamut reserves → an external signer completes → c2pa-rs validates | `tests/reserve_then_fill.rs` | a store signed over the reserved file validates once patched into the range `encode_with_report` gave, and exactly fills it | +| c2pa-rs embeds → gamut locates the identical byte range | `tests/locate_embedded.rs` | `gamut-avif` and `gamut-heic` report the *same span* as the store's own JUMBF header, and c2pa-rs re-validates the bytes gamut extracted | +| the box itself | `tests/box_framing.rs` | `gamut-avif`'s `ContentProvenanceBox` is byte-identical to c2pa-rs's for the same store | +| a derivative carries no parent store | `tests/no_copy_forward.rs` | a re-encode reads back as **unsigned** (`JumbfNotFound`), not as invalid | +| the build stays crypto-free where it must | `tests/build_configuration.rs` | the `c2pa` dependency never regains its default `openssl` feature, in the manifest and in the resolved graph | +| the one BMFF layout gamut cannot discriminate | `tests/update_manifest.rs` | what c2pa-rs actually emits for `box_purpose = update` — see below | + +## The `update`-purpose finding + +`crates/gamut-heic/STATUS.md` carries a deferred row. C2PA 2.4 §A.5.3 states the framing for +`box_purpose` `manifest` and `original` — an 8-byte merkle offset, then the store — and for +`update` says nothing at all about the bytes ahead of the store. gamut therefore *probes*: offset 8 +first, offset 0 as a fallback. A store written without the offset under `update` is the one in-spec +layout that probe cannot discriminate, and the row records it as mis-bounded rather than rejected, +on the assumption that "no known writer emits either shape". + +`tests/update_manifest.rs` turns that assumption into an observation. Driven through +`BuilderIntent::Update` — c2pa-rs exposes no way to set the purpose string directly — the reference +implementation: + +- **writes the 8-byte merkle offset in front of an `update` store exactly as it does for the other + two purposes** (its `write_c2pa_box` takes the same branch for every purpose except `merkle`); +- relabels the file's earlier store `original`, as §A.5.3 requires; +- accepts the resulting two-store file as `Valid`. + +So the ambiguous layout is not one the reference implementation emits, the probe's offset-8 arm is +the one that fires on real files, and its offset-0 fallback is dead weight against everything in +circulation rather than a source of mis-bounding. `tests/locate_embedded.rs` adds the other half: +every store c2pa-rs writes opens `LBox` + `jumb`, which is the `TBox` check that would make the +bound self-checking. Neither observation makes the `LBox` bound self-checking on its own — that is +issue #505 — but together they replace an assumption with evidence. + +## Why gamut owns the locate/bound step at all + +The obvious objection to `gamut-heic::HeifContainer::c2pa` and `gamut_avif::AvifContainer::c2pa` is +that they duplicate something `c2pa-rs` already does, and that a consumer who wants the store could +just call the reference implementation. That objection is wrong, and it is written down here so +nobody deletes gamut's locator as redundant later. + +**`c2pa-rs` has no cheap parse-only mode.** Its reading entry point is `Reader`, which validates: +it parses the store, checks the hard binding against the asset, verifies the COSE signature and +consults a trust list. There is no "tell me where the bytes are and do not judge them" path. The +settings can *disable* verification — and that is exactly the trap, because +`ValidationState::Invalid` is documented as covering both outcomes: + +> The manifest store fails to meet `ValidationState::WellFormed` requirements, meaning it cannot +> even be parsed or its basic structure is non-compliant. +> +> **This case may also occur if validation is disabled in the SDK.** +> +> — `c2pa` 0.90.21, `src/validation_results.rs:36-41` + +So a caller who turns validation off to use `c2pa-rs` as a locator gets back a verdict +indistinguishable from "this file is broken". A container library cannot build on that. Worse, the +answer a *container* needs — the store's byte range, for byte accounting, for extraction, and for +patching a reserved slot — is not something `Reader` returns at all. + +Two further consequences follow, and both are load-bearing: + +- **gamut must not depend on `c2pa-rs`.** Reaching for it would drag COSE, X.509 and RSA/ECDSA into + the shipped dependency graph of an image library, which the epic forbids outright ("no crypto in + the shipped graph"). It would also break `mise run check-cross wasm32-unknown-unknown`. +- **gamut must never report a validity verdict.** Its types are named for what they are — a + `C2paSlot`, a `C2paManifestStore`, a byte `Range` — and document that the range is + *observability*, not a hash exclusion range. + +`c2pa-rs` is therefore the right tool for exactly one job, validation, and it does that job here, +in `tooling/`, where a dev-dependency tree costs a shipped consumer nothing. + +## Build configuration is not optional + +```toml +c2pa = { version = "0.90.21", default-features = false, features = ["rust_native_crypto"] } +``` + +`c2pa`'s default feature set is `["openssl", "default_http"]`. The `openssl` feature pulls OpenSSL +in **vendored**, compiling it from C source into a dev build — precisely the thing the epic's +no-crypto criterion exists to keep out, and it would make this oracle the slowest thing in CI. +`rust_native_crypto` is the pure-Rust signing and verification backend that replaces it; dropping +`default_http` additionally drops `reqwest` and `ureq`, which this oracle never needs because it +resolves no remote manifests. + +`tests/build_configuration.rs` fails if that line ever loses either half. This crate is `c2pa`'s +only dependent in the repository, so nothing else can turn the feature back on by unification: the +manifest line is the whole determinant, which is what makes a drift guard over it sufficient. + +## The signing identity + +`c2pa::EphemeralSigner` mints a self-signed CA and an end-entity certificate in memory, Ed25519, +with the key usage and EKU the C2PA certificate profile wants. No key material is committed to this +tree and no fixture has to be rotated when it expires, because it never persists. + +An ephemeral certificate is on no trust list, so `verify.verify_trust` is turned off in +`signing_context()` and every assertion is written against `ValidationState::Valid` — well-formed, +hard binding intact, signature verified. `ValidationState::Trusted` is unreachable here by +construction and nothing asks for it. That is the right target: this oracle measures whether gamut +moved a byte it should not have, not whose key signed the file. + +## Not built on: `c2pa::jumbf_io` + +The module is `pub` and looks like exactly the low-level seam this crate wants. It is not used, for +two reasons. + +Half of it is not usable at all. `load_jumbf_from_stream` and `save_jumbf_to_stream` +(`src/jumbf_io.rs:246`, `:258`) take `&mut dyn CAIRead` / `&mut dyn CAIReadWrite`, and `asset_io` — +the module those traits live in — is crate-private. An external caller cannot spell the argument +types. That is a private-in-public leak, not an API. + +The other half (`load_jumbf_from_memory`, `save_jumbf_to_memory`) *is* spellable, and is +deliberately still not used. Those functions return the store's **bytes**, never its offsets, so +they cannot answer the question a container library asks; and building on an undocumented +lower-level entry point would tie this oracle to internals that carry no stability promise, when +`Builder` and `Reader` express everything the two directions need. Where the oracle needs an +independent view of where a store sits, it derives it from the store's own JUMBF header — see +`find_jumbf_superbox` in `src/lib.rs`. diff --git a/tooling/c2pa-oracle/examples/probe.rs b/tooling/c2pa-oracle/examples/probe.rs new file mode 100644 index 00000000..e6d5e243 --- /dev/null +++ b/tooling/c2pa-oracle/examples/probe.rs @@ -0,0 +1,174 @@ +//! Prints what c2pa-rs actually does with a gamut-written AVIF, so the assertions in `tests/` are +//! written against observed behaviour rather than against the documentation. +//! +//! Run it with `cargo run --manifest-path tooling/c2pa-oracle/Cargo.toml --example probe`. It is a +//! developer aid, not a check: nothing here fails. + +use c2pa::{Builder, BuilderIntent}; +use c2pa_oracle::{ + AVIF_MIME, HEIC_MIME, OracleError, Result, declared_store_len, embed, jumbf_superbox_span, + manifest_builder, read, reserve_then_fill, signing_context, split_composed_box, +}; +use gamut_avif::{AvifContainer, AvifEncoder}; +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; + +const W: u32 = 34; +const H: u32 = 18; + +fn source_rgb() -> Vec { + let mut rgb = vec![0u8; (W * H * 3) as usize]; + for y in 0..H { + for x in 0..W { + let i = ((y * W + x) * 3) as usize; + rgb[i] = ((x * 7 + y * 3) & 0xff) as u8; + rgb[i + 1] = ((x * x + y) & 0xff) as u8; + rgb[i + 2] = ((x ^ (y * 5)) & 0xff) as u8; + } + } + rgb +} + +fn plain_avif() -> Vec { + let rgb = source_rgb(); + AvifEncoder::new() + .encode_to_vec( + ImageRef::::new( + &rgb, + Dimensions { + width: W, + height: H, + }, + ) + .expect("buffer matches dimensions"), + ) + .expect("encode") +} + +fn main() -> Result<()> { + let plain = plain_avif(); + println!("plain gamut AVIF: {} bytes", plain.len()); + + // --- composed framing ------------------------------------------------------------------ + let mut builder = manifest_builder()?; + println!("hash_type(avif) = {:?}", builder.hash_type(AVIF_MIME)); + println!( + "needs_placeholder(avif) = {}", + builder.needs_placeholder(AVIF_MIME) + ); + let ph = split_composed_box(builder.placeholder(AVIF_MIME)?)?; + println!( + "placeholder: composed {} bytes, store at +{}, store {} bytes, LBox {:?}", + ph.composed.len(), + ph.store_offset, + ph.store().len(), + declared_store_len(ph.store()) + ); + println!("framing: {:02x?}", &ph.composed[..ph.store_offset]); + + // --- direction 1 ----------------------------------------------------------------------- + let rgb = source_rgb(); + let filled = reserve_then_fill(AVIF_MIME, |len| { + let (bytes, report) = AvifEncoder::new() + .with_c2pa_reserved(len) + .encode_with_report( + ImageRef::::new( + &rgb, + Dimensions { + width: W, + height: H, + }, + ) + .expect("buffer matches dimensions"), + ) + .map_err(|e| OracleError::Asset(e.to_string()))?; + let range = report + .c2pa + .ok_or_else(|| OracleError::Asset("no c2pa range reported".into()))?; + Ok((bytes, range)) + })?; + println!( + "direction 1: asset {} bytes, slot {:?}, store {} bytes (placeholder asked {}), LBox {:?}", + filled.asset.len(), + filled.slot, + filled.store.len(), + filled.placeholder_store_len, + declared_store_len(&filled.store), + ); + println!( + "direction 1 validation: {:?}", + read(AVIF_MIME, &filled.asset) + ); + + // --- direction 2 ----------------------------------------------------------------------- + let signed = embed(AVIF_MIME, &plain)?; + println!("direction 2: signed {} bytes", signed.len()); + println!("direction 2 validation: {:?}", read(AVIF_MIME, &signed)); + let span = jumbf_superbox_span(&signed)?; + println!("direction 2: independent JUMBF span {span:?}"); + + match AvifContainer::parse(&signed) { + Ok(container) => match container.c2pa() { + Some(slot) => println!( + "gamut-avif slot: range {:?}, {} bytes, purpose {:?}, LBox {:?}, tail zeros {}", + slot.range, + slot.slot_bytes.len(), + slot.purpose, + declared_store_len(slot.slot_bytes), + slot.slot_bytes[span.len().min(slot.slot_bytes.len())..] + .iter() + .all(|b| *b == 0), + ), + None => println!("gamut-avif slot: none"), + }, + Err(error) => println!("gamut-avif parse failed: {error}"), + } + + match gamut_heic::HeifContainer::parse(&signed) { + Ok(container) => match container.c2pa() { + Some(store) => println!( + "gamut-heic store: range {:?}, {} bytes, purpose {:?}", + store.range, + store.bytes.len(), + store.purpose + ), + None => println!("gamut-heic store: none"), + }, + Err(error) => println!("gamut-heic parse failed: {error}"), + } + println!( + "heic-mime read of the same bytes: {:?}", + read(HEIC_MIME, &signed) + ); + + // --- unsigned derivative ---------------------------------------------------------------- + println!("plain asset read: {:?}", read(AVIF_MIME, &plain)); + + // --- update manifest (decision 4) ------------------------------------------------------- + let mut update = Builder::from_context(signing_context()?); + update.set_intent(BuilderIntent::Update); + let mut source = std::io::Cursor::new(signed.clone()); + let mut dest = std::io::Cursor::new(Vec::new()); + match update.save_to_stream(AVIF_MIME, &mut source, &mut dest) { + Ok(_) => { + let updated = dest.into_inner(); + println!("update: {} bytes", updated.len()); + match AvifContainer::parse(&updated) { + Ok(container) => { + for slot in container.c2pa_manifest_stores() { + println!( + " update slot: range {:?}, purpose {:?}, LBox {:?}", + slot.range, + slot.purpose, + declared_store_len(slot.slot_bytes) + ); + } + } + Err(error) => println!(" parse failed: {error}"), + } + println!(" validation: {:?}", read(AVIF_MIME, &updated)); + } + Err(error) => println!("update refused: {error}"), + } + + Ok(()) +} diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs new file mode 100644 index 00000000..e170aa87 --- /dev/null +++ b/tooling/c2pa-oracle/src/lib.rs @@ -0,0 +1,365 @@ +//! Dev-only differential oracle over [`c2pa-rs`], the C2PA reference implementation, for gamut's +//! half of the C2PA epic (issue #239): **locating, bounding, carrying and reserving** a manifest +//! store. gamut never renders a validity verdict; this crate is where the verdict comes from, and +//! it lives only under `tooling/`. +//! +//! What is here is the *plumbing* both directions need — a signing identity, a manifest +//! definition, the reserve-then-fill dance, and the one search that recovers a raw JUMBF store +//! from a composed `ContentProvenanceBox`. The claims themselves are in `tests/`. +//! +//! # The two directions +//! +//! 1. **gamut reserves → an external signer completes → c2pa-rs validates.** [`reserve_then_fill`] +//! drives c2pa-rs's own placeholder workflow (`Builder::placeholder` → +//! `Builder::update_hash_from_stream` → `Builder::sign_embeddable`) over bytes *gamut* wrote, +//! so the store is bound to a file c2pa-rs never touched. +//! 2. **c2pa-rs embeds → gamut locates the identical byte range.** [`embed`] hands the asset to +//! `Builder::save_to_stream`; a test then asks gamut for the store's range and hands the bytes +//! at exactly that range straight back to [`read_with_external_store`], which is c2pa-rs +//! re-validating gamut's own bounds. +//! +//! # Why there is no "parse but do not judge" mode +//! +//! See `README.md`. In short: [`ValidationState::Invalid`] is also what c2pa-rs reports when +//! verification is *disabled*, so it cannot stand in for a locator. gamut owns that step. +//! +//! [`c2pa-rs`]: https://github.com/contentauth/c2pa-rs + +use std::io::Cursor; +use std::ops::Range; + +use c2pa::{Builder, Context, EphemeralSigner, Reader, Settings, ValidationState}; + +/// The oracle's own errors, kept separate from [`c2pa::Error`] so a failure names which side of +/// the differential produced it. +#[derive(Debug)] +pub enum OracleError { + /// c2pa-rs refused an operation. Carries its error unchanged: the reference implementation's + /// own classification is the diagnostic, so it is never re-coded into one of ours. + C2pa(c2pa::Error), + /// A gamut crate refused to produce or read the asset the oracle asked for. Raised only by a + /// caller's closure, never by this crate. + Asset(String), + /// A composed `ContentProvenanceBox` carried no JUMBF superbox: no [`JUMBF_SUPERBOX_TYPE`] was + /// found in it at all. Only [`split_composed_box`] raises this. + NoJumbfSuperbox, +} + +impl std::fmt::Display for OracleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::C2pa(error) => write!(f, "c2pa-rs: {error}"), + Self::Asset(message) => write!(f, "asset: {message}"), + Self::NoJumbfSuperbox => { + f.write_str("composed ContentProvenanceBox carries no JUMBF superbox") + } + } + } +} + +impl std::error::Error for OracleError {} + +impl From for OracleError { + fn from(error: c2pa::Error) -> Self { + Self::C2pa(error) + } +} + +/// Shorthand for a fallible oracle operation. +pub type Result = std::result::Result; + +/// The MIME type gamut-avif's output is handed to c2pa-rs under. +pub const AVIF_MIME: &str = "image/avif"; + +/// The MIME type a HEIF/HEIC asset is handed to c2pa-rs under. +pub const HEIC_MIME: &str = "image/heic"; + +/// The JUMBF box type that opens a manifest store's outer superbox: `jumb`. +/// +/// Read here as an *observation about what c2pa-rs writes*, never as a gamut constant. The C2PA +/// specification names it only in JPEG XL clauses that attribute it to ISO/IEC 18181-2 §9.3 +/// (§A.3.9, §15.12.3.2), which is why `gamut-heic`'s locator deliberately does not assert it — see +/// the deferred row in `crates/gamut-heic/STATUS.md`. An oracle observing it is exactly the +/// empirical evidence that row asks for. +pub const JUMBF_SUPERBOX_TYPE: &[u8; 4] = b"jumb"; + +/// The manifest definition every store this oracle signs is built from. +/// +/// Deliberately minimal: one claim generator and one `c2pa.created` action. The epic's subject is +/// *carriage*, so nothing here exercises assertions, ingredients or thumbnails — a bigger manifest +/// would only make the store longer without testing another byte of gamut. +pub const MANIFEST_DEFINITION: &str = r#"{ + "claim_generator_info": [{ "name": "gamut-c2pa-oracle", "version": "0.0.0" }], + "title": "gamut c2pa-oracle fixture", + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia" + } + ] + } + } + ] +}"#; + +/// A [`Context`] carrying an ephemeral Ed25519 signing identity, with trust-list checking off. +/// +/// [`EphemeralSigner`] mints a self-signed CA and an end-entity certificate in memory, carrying +/// the key usage and EKU the C2PA certificate profile requires. Nothing is committed to the tree +/// and no OpenSSL is involved: the chain is built by c2pa-rs's `rust_native_crypto` backend. +/// +/// `verify.verify_trust` is turned **off** deliberately. An ephemeral certificate is on no trust +/// list, so leaving it on would make every read fail `signingCredential.untrusted` — a verdict +/// about *provenance of the key*, which is not what this oracle measures. With it off, a store +/// whose cryptographic integrity and hard binding hold reads back [`ValidationState::Valid`], and +/// anything less means gamut moved a byte it should not have. [`ValidationState::Trusted`] is +/// therefore unreachable here by construction, and no assertion asks for it. +/// +/// # Errors +/// +/// [`OracleError::C2pa`] if the settings are rejected or the ephemeral chain cannot be built. +pub fn signing_context() -> Result { + let settings = Settings::new().with_value("verify.verify_trust", false)?; + Ok(Context::new() + .with_settings(settings)? + .with_signer(EphemeralSigner::new("gamut-c2pa-oracle.test")?)) +} + +/// A [`Builder`] over [`MANIFEST_DEFINITION`], signing through [`signing_context`]. +/// +/// # Errors +/// +/// [`OracleError::C2pa`] if the signing identity cannot be built or the definition does not parse. +pub fn manifest_builder() -> Result { + Ok(Builder::from_context(signing_context()?).with_definition(MANIFEST_DEFINITION)?) +} + +/// Where a raw JUMBF manifest store sits inside a **composed** `ContentProvenanceBox` — the whole +/// `uuid` box c2pa-rs returns from `Builder::placeholder` and `Builder::sign_embeddable`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposedBox { + /// The whole composed box: ISOBMFF header, 16-byte user type, `FullBox` version and flags, + /// NUL-terminated `box_purpose`, merkle offset, then the store. + pub composed: Vec, + /// The offset of the store within [`composed`](Self::composed) — equivalently, the length of + /// everything C2PA 2.4 §A.5.1.2 puts in front of it. + pub store_offset: usize, +} + +impl ComposedBox { + /// The raw JUMBF manifest store: what a host embeds in its own framing, and what gamut's + /// locators report. + #[must_use] + pub fn store(&self) -> &[u8] { + &self.composed[self.store_offset..] + } +} + +/// Where the first JUMBF superbox in `buffer` begins — a composed `ContentProvenanceBox`, or a +/// whole asset file. +/// +/// # Why this searches rather than parses +/// +/// The oracle must not re-derive gamut's own C2PA 2.4 §A.5.1.2 walk: a second copy of the parser +/// under test would prove nothing about it. So the store is found by its *own* header instead — +/// the first [`JUMBF_SUPERBOX_TYPE`] in the buffer, whose four preceding bytes are the superbox's +/// big-endian `LBox`. Nested superboxes inside the store carry the same type, so the first +/// occurrence is the outermost one, and nothing ahead of it can match: the ISOBMFF framing between +/// them is a box header, a UUID, four zero bytes, an ASCII purpose and an eight-byte offset. +/// +/// The returned offset is therefore an *independent* claim about where the store begins, which is +/// what makes "gamut reports the same range" a differential rather than a tautology. +/// +/// # Errors +/// +/// [`OracleError::NoJumbfSuperbox`] if no `jumb` box type appears at or after offset 4. +pub fn find_jumbf_superbox(buffer: &[u8]) -> Result { + buffer + .windows(JUMBF_SUPERBOX_TYPE.len()) + .position(|window| window == JUMBF_SUPERBOX_TYPE) + .filter(|type_offset| *type_offset >= 4) + .map(|type_offset| type_offset - 4) + .ok_or(OracleError::NoJumbfSuperbox) +} + +/// The exact byte span the first JUMBF superbox in `buffer` occupies: from its `LBox` through the +/// number of bytes that `LBox` declares. +/// +/// This is the span a container-agnostic reader would call "the manifest store", derived from the +/// store's own header and nothing else. A gamut locator's reported range is compared against it. +/// +/// # Errors +/// +/// [`OracleError::NoJumbfSuperbox`] if no superbox is found, or if the length it declares runs off +/// the end of `buffer`. +pub fn jumbf_superbox_span(buffer: &[u8]) -> Result> { + let start = find_jumbf_superbox(buffer)?; + let len = declared_store_len(&buffer[start..]).ok_or(OracleError::NoJumbfSuperbox)?; + let end = start + .checked_add(len) + .filter(|end| *end <= buffer.len()) + .ok_or(OracleError::NoJumbfSuperbox)?; + Ok(start..end) +} + +/// Splits a composed `ContentProvenanceBox` into its framing and the JUMBF store inside it. +/// +/// # Errors +/// +/// [`OracleError::NoJumbfSuperbox`] if the box carries no JUMBF superbox — see +/// [`find_jumbf_superbox`]. +pub fn split_composed_box(composed: Vec) -> Result { + let store_offset = find_jumbf_superbox(&composed)?; + Ok(ComposedBox { + composed, + store_offset, + }) +} + +/// The outer JUMBF `LBox` a store declares for itself: its length in bytes, big-endian, read from +/// the store's own first four bytes. `None` when `store` is shorter than that field. +/// +/// This is the bound `gamut-heic`'s locator trims to, so a test can state the length it expects +/// without borrowing gamut's reading of it. +#[must_use] +pub fn declared_store_len(store: &[u8]) -> Option { + let field: [u8; 4] = store.get(..4)?.try_into().ok()?; + Some(u32::from_be_bytes(field) as usize) +} + +/// What [`reserve_then_fill`] produced. +#[derive(Debug, Clone)] +pub struct Filled { + /// The asset with the signed store patched into its reserved slot. + pub asset: Vec, + /// The byte range the caller reserved, and where the store was written. + pub slot: Range, + /// The signed store, exactly as patched in. + pub store: Vec, + /// The store length `Builder::placeholder` asked the caller to reserve, before signing. + /// + /// Equal to `store.len()`: `sign_embeddable` zero-pads the signed JUMBF back to the length it + /// pinned when the placeholder was made, so the patch cannot move a byte. A test asserts the + /// equality rather than trusting it. + pub placeholder_store_len: usize, +} + +/// Direction 1: completes a store for an asset **whose bytes a gamut encoder produced**, without +/// c2pa-rs writing a single byte of the container. +/// +/// This is c2pa-rs's own placeholder workflow, which is reserve-then-fill by another name: +/// +/// 1. `Builder::placeholder` sizes the composed box and pins the JUMBF length internally; +/// 2. `reserve` is handed that store length, reserves a slot of exactly that size, and returns the +/// finished asset with the byte range the slot occupies — the shape +/// `gamut_avif::AvifEncoder::encode_with_report` already has; +/// 3. `Builder::update_hash_from_stream` computes the `c2pa.hash.bmff.v3` binding over the +/// finished asset; +/// 4. `Builder::sign_embeddable` signs and zero-pads back to the pinned length, so the store the +/// caller patches in is exactly as long as the slot and nothing after it moves. +/// +/// The hard binding is computed over the asset *as reserved* — an all-zero slot. That is sound +/// because a BMFF asset's binding excludes the `ContentProvenanceBox` by box path (C2PA 2.4 §18.6, +/// §A.5.6), so the slot's contents are outside the digest; only its *size* matters, and the size +/// does not change. +/// +/// # Errors +/// +/// [`OracleError::C2pa`] if any c2pa-rs step fails, [`OracleError::NoJumbfSuperbox`] if a composed +/// box carries no store, and whatever `reserve` returns. +pub fn reserve_then_fill( + format: &str, + mut reserve: impl FnMut(usize) -> Result<(Vec, Range)>, +) -> Result { + let mut builder = manifest_builder()?; + let placeholder = split_composed_box(builder.placeholder(format)?)?; + let placeholder_store_len = placeholder.store().len(); + + let (mut asset, slot) = reserve(placeholder_store_len)?; + + builder.update_hash_from_stream(format, &mut Cursor::new(asset.clone()))?; + let store = split_composed_box(builder.sign_embeddable(format)?)? + .store() + .to_vec(); + + if store.len() != slot.len() { + return Err(OracleError::Asset(format!( + "signed store is {} bytes but the reserved slot is {}", + store.len(), + slot.len() + ))); + } + asset[slot.clone()].copy_from_slice(&store); + + Ok(Filled { + asset, + slot, + store, + placeholder_store_len, + }) +} + +/// Direction 2: lets c2pa-rs embed a store into `asset` itself, choosing the placement. +/// +/// # Errors +/// +/// [`OracleError::C2pa`] if the manifest cannot be built, signed or embedded. +pub fn embed(format: &str, asset: &[u8]) -> Result> { + let mut builder = manifest_builder()?; + let mut source = Cursor::new(asset.to_vec()); + let mut dest = Cursor::new(Vec::new()); + builder.save_to_stream(format, &mut source, &mut dest)?; + Ok(dest.into_inner()) +} + +/// c2pa-rs's verdict on an asset that carries its own store. +/// +/// # Errors +/// +/// [`OracleError::C2pa`] — notably a stringified [`c2pa::Error::JumbfNotFound`] when the asset +/// carries no store at all. That is a *different* outcome from a store that fails to validate, and +/// the no-copy-forward test turns on the difference; use [`is_jumbf_not_found`] to tell them +/// apart. +pub fn read(format: &str, asset: &[u8]) -> Result { + let context = signing_context()?; + Ok(Reader::from_context(context) + .with_stream(format, Cursor::new(asset.to_vec()))? + .validation_state()) +} + +/// c2pa-rs's verdict on `asset` when the store is supplied **out of band** — the bytes a gamut +/// locator reported, handed back as if they were a sidecar. +/// +/// This is the sharpest form of direction 2. The hard binding digests the asset, and the store +/// carries its own JUMBF length, so a span that starts one byte early or late does not parse and a +/// span cut short fails its own length check: only the range c2pa-rs actually embedded validates +/// here. +/// +/// # Errors +/// +/// [`OracleError::C2pa`] if the store does not parse, or does not bind to the asset. +pub fn read_with_external_store( + format: &str, + store: &[u8], + asset: &[u8], +) -> Result { + let context = signing_context()?; + Ok(Reader::from_context(context) + .with_manifest_data_and_stream(store, format, Cursor::new(asset.to_vec()))? + .validation_state()) +} + +/// Whether an error is c2pa-rs reporting that the asset carries **no manifest at all**, as opposed +/// to carrying one that fails to validate. +/// +/// The distinction is the whole point of the no-copy-forward claim: a derivative must read back as +/// *unsigned*, not as *invalid*. A file whose store was copied forward across a re-encode would +/// still be found and would then fail its hard binding, which is a different error entirely. +#[must_use] +pub fn is_jumbf_not_found(error: &OracleError) -> bool { + matches!(error, OracleError::C2pa(c2pa::Error::JumbfNotFound)) +} From c6bc972bf078fb188804eec379340ca5be2e867c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:24:01 -0400 Subject: [PATCH 02/36] test(c2pa-oracle): pin both directions against c2pa-rs Direction 1: gamut-avif reserves a slot, c2pa-rs signs a store over the finished file through its own placeholder workflow, the host patches it in at the range `encode_with_report` gave, and the file validates. The store exactly fills the slot, so nothing after it moves. Direction 2: c2pa-rs embeds a store and chooses the placement; both gamut-avif and gamut-heic must report the *identical* span, compared against one derived from the store's own JUMBF header rather than from gamut's parse of the ISOBMFF framing. Handing gamut's extraction back to c2pa-rs as an external store is the sharpest form: a span off by a byte at either end does not survive it. Two observations settle assumptions gamut-heic's STATUS.md records as deferred. Every store the reference implementation writes opens LBox + `jumb`, the TBox check gamut declines to assert. And driven through `BuilderIntent::Update`, c2pa-rs writes the 8-byte merkle offset in front of an `update` store exactly as for the other two purposes, so the one in-spec layout gamut's probe cannot discriminate is not one the reference implementation emits. Refs #447. --- tooling/c2pa-oracle/tests/box_framing.rs | 39 ++++++ .../c2pa-oracle/tests/build_configuration.rs | 62 +++++++++ tooling/c2pa-oracle/tests/common/mod.rs | 71 ++++++++++ tooling/c2pa-oracle/tests/locate_embedded.rs | 128 +++++++++++++++++ tooling/c2pa-oracle/tests/no_copy_forward.rs | 40 ++++++ .../c2pa-oracle/tests/reserve_then_fill.rs | 88 ++++++++++++ tooling/c2pa-oracle/tests/update_manifest.rs | 130 ++++++++++++++++++ 7 files changed, 558 insertions(+) create mode 100644 tooling/c2pa-oracle/tests/box_framing.rs create mode 100644 tooling/c2pa-oracle/tests/build_configuration.rs create mode 100644 tooling/c2pa-oracle/tests/common/mod.rs create mode 100644 tooling/c2pa-oracle/tests/locate_embedded.rs create mode 100644 tooling/c2pa-oracle/tests/no_copy_forward.rs create mode 100644 tooling/c2pa-oracle/tests/reserve_then_fill.rs create mode 100644 tooling/c2pa-oracle/tests/update_manifest.rs diff --git a/tooling/c2pa-oracle/tests/box_framing.rs b/tooling/c2pa-oracle/tests/box_framing.rs new file mode 100644 index 00000000..51b502c7 --- /dev/null +++ b/tooling/c2pa-oracle/tests/box_framing.rs @@ -0,0 +1,39 @@ +//! The `ContentProvenanceBox` `gamut-avif` writes around a store must be the box the reference +//! implementation writes around the same store — byte for byte. +//! +//! `AvifEncoder`'s own suite pins the framing against a fixture built from the C2PA 2.4 §A.5.1.2 +//! field list, which catches a transcription slip but not a *reading* of the clause that differs +//! from everyone else's. This is the differential that does: c2pa-rs's `Builder::composed_manifest` +//! wraps raw store bytes in the same box for the same format, and the two renderings are compared +//! whole — header, user type, `FullBox` version and flags, `box_purpose`, merkle offset and store. +//! +//! It is also what licenses the rest of this crate to hand gamut and c2pa-rs the same files: if the +//! two framings agreed only in the fields a locator happens to read, "the identical byte range" +//! would be a weaker statement than it looks. + +mod common; + +use c2pa::Builder; +use c2pa_oracle::{AVIF_MIME, reserve_then_fill}; +use common::reserve_avif; + +#[test] +fn the_box_gamut_avif_writes_is_byte_identical_to_the_one_c2pa_rs_composes() { + let filled = reserve_then_fill(AVIF_MIME, reserve_avif).expect("reserve, sign and patch"); + let composed = Builder::composed_manifest(&filled.store, AVIF_MIME) + .expect("c2pa-rs composes a box around the same store"); + + // The store sits at the end of the composed box, so the box starts that far ahead of the slot. + let framing = composed.len() - filled.store.len(); + let start = filled + .slot + .start + .checked_sub(framing) + .expect("the box begins inside the file"); + + assert_eq!( + &filled.asset[start..start + composed.len()], + composed.as_slice(), + "gamut-avif's ContentProvenanceBox must be byte-identical to c2pa-rs's for the same store" + ); +} diff --git a/tooling/c2pa-oracle/tests/build_configuration.rs b/tooling/c2pa-oracle/tests/build_configuration.rs new file mode 100644 index 00000000..042ed4d5 --- /dev/null +++ b/tooling/c2pa-oracle/tests/build_configuration.rs @@ -0,0 +1,62 @@ +//! The one thing about this crate that is not a differential: the `c2pa` dependency must never +//! regain its default features. +//! +//! `c2pa`'s defaults are `["openssl", "default_http"]`, and `openssl` is pulled **vendored** — +//! enabling it would compile OpenSSL from C source into a dev build of this repository. The #239 +//! epic's "no crypto in the shipped graph" criterion is about the *shipped* crates, but the +//! vendored build is also simply the slowest thing that could be added to CI, and a switch back +//! would be silent: everything would still pass, just far more slowly and with a C toolchain +//! newly on the critical path. +//! +//! Two checks, weakest sufficient technique each. The manifest check is a drift guard on the line +//! a human would edit; the lockfile check is a **resolved-graph** assertion and is the stronger of +//! the two, because it would also catch the feature arriving by unification from somewhere else. + +use std::path::Path; + +/// The crate's own manifest, read at compile time so the test cannot silently pass against a +/// different file. +const MANIFEST: &str = include_str!("../Cargo.toml"); + +/// Package names that only appear in the resolved graph when the `openssl` feature is on. +const OPENSSL_PACKAGES: [&str; 3] = ["openssl", "openssl-sys", "openssl-src"]; + +#[test] +fn the_c2pa_dependency_line_disables_default_features_and_asks_only_for_rust_native_crypto() { + let line = MANIFEST + .lines() + .map(str::trim) + .find(|line| line.starts_with("c2pa = ")) + .expect("the manifest declares a `c2pa` dependency on one line"); + + assert!( + line.contains("default-features = false"), + "the `c2pa` dependency must disable default features (they are [\"openssl\", \ + \"default_http\"], and `openssl` is vendored): {line}" + ); + assert!( + line.contains(r#"features = ["rust_native_crypto"]"#), + "the `c2pa` dependency must ask for exactly the pure-Rust crypto backend: {line}" + ); + assert!( + !line.contains("openssl"), + "the `c2pa` dependency must never name the `openssl` feature: {line}" + ); +} + +#[test] +fn the_resolved_dependency_graph_contains_no_openssl_package() { + // Cargo writes this before it builds the test, so it always exists by the time the test runs; + // it is `.gitignore`d because a `tooling/` oracle resolves standalone. + let lock = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock"); + let lock = std::fs::read_to_string(&lock) + .unwrap_or_else(|error| panic!("reading {}: {error}", lock.display())); + + for package in OPENSSL_PACKAGES { + assert!( + !lock.contains(&format!("name = \"{package}\"")), + "`{package}` reached the resolved graph, so the `c2pa` dependency has regained its \ + default `openssl` feature and this oracle now compiles OpenSSL from C source" + ); + } +} diff --git a/tooling/c2pa-oracle/tests/common/mod.rs b/tooling/c2pa-oracle/tests/common/mod.rs new file mode 100644 index 00000000..85e57991 --- /dev/null +++ b/tooling/c2pa-oracle/tests/common/mod.rs @@ -0,0 +1,71 @@ +//! The one AVIF fixture every direction is measured on, plus the two gamut encodes that produce +//! it. Shared so a difference between two test files can only come from what they assert, never +//! from a different source image. +#![allow(dead_code)] // each integration-test binary uses a different subset + +use c2pa_oracle::{OracleError, Result}; +use gamut_avif::AvifEncoder; +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; + +/// Fixture width. Deliberately not a multiple of 16, so the AV1 tile padding is exercised and a +/// container that mis-sizes a box cannot pass by luck. +pub const W: u32 = 34; +/// Fixture height, chosen with [`W`] for the same reason. +pub const H: u32 = 18; + +/// The fixture's dimensions. +pub fn dims() -> Dimensions { + Dimensions { + width: W, + height: H, + } +} + +/// A structured source: every channel varies along both axes, so a byte moved by a container bug +/// changes the decoded image rather than landing in a uniform run. +pub fn source_rgb() -> Vec { + let mut rgb = vec![0u8; (W * H * 3) as usize]; + for y in 0..H { + for x in 0..W { + let i = ((y * W + x) * 3) as usize; + rgb[i] = ((x * 7 + y * 3) & 0xff) as u8; + rgb[i + 1] = ((x * x + y) & 0xff) as u8; + rgb[i + 2] = ((x ^ (y * 5)) & 0xff) as u8; + } + } + rgb +} + +/// The fixture encoded with no C2PA box at all: the asset a claim generator is handed. +/// +/// # Panics +/// +/// If the fixture does not encode, which would be a `gamut-avif` defect unrelated to C2PA and is +/// not what any test here is measuring. +pub fn plain_avif() -> Vec { + let rgb = source_rgb(); + AvifEncoder::new() + .encode_to_vec(ImageRef::::new(&rgb, dims()).expect("buffer matches dimensions")) + .expect("the fixture encodes") +} + +/// The fixture encoded with a `len`-byte **reserved** C2PA slot, with the byte range +/// `AvifEncoder::encode_with_report` says the slot occupies. +/// +/// Shaped to be handed straight to `c2pa_oracle::reserve_then_fill`. +/// +/// # Errors +/// +/// [`OracleError::Asset`] if the encode fails or reports no range for a slot it was asked to +/// reserve. +pub fn reserve_avif(len: usize) -> Result<(Vec, std::ops::Range)> { + let rgb = source_rgb(); + let (bytes, report) = AvifEncoder::new() + .with_c2pa_reserved(len) + .encode_with_report(ImageRef::::new(&rgb, dims()).expect("buffer matches dimensions")) + .map_err(|error| OracleError::Asset(error.to_string()))?; + let range = report.c2pa.ok_or_else(|| { + OracleError::Asset("encode_with_report reported no range for the reserved slot".into()) + })?; + Ok((bytes, range)) +} diff --git a/tooling/c2pa-oracle/tests/locate_embedded.rs b/tooling/c2pa-oracle/tests/locate_embedded.rs new file mode 100644 index 00000000..4caf14a6 --- /dev/null +++ b/tooling/c2pa-oracle/tests/locate_embedded.rs @@ -0,0 +1,128 @@ +//! **Direction 2** of the epic's oracle: c2pa-rs embeds a manifest store and chooses where it +//! goes; gamut must locate the **identical byte range**. +//! +//! "Identical", not "overlapping", is the whole claim. `gamut-heic`'s locator bounds a store by its +//! JUMBF `LBox` alone, which is content-dependent: reading an `LBox` from a wrong offset can land +//! on an interior box's own length — small, plausible and in bounds — and silently trim the store +//! to a fragment rather than fail. `crates/gamut-heic/STATUS.md` records that as a deferred hazard +//! and names this crate as the fixture that settles it. A range compared against a store c2pa-rs +//! actually wrote is what catches it. +//! +//! The span the two locators are compared against is derived independently, from the store's own +//! JUMBF header (`c2pa_oracle::jumbf_superbox_span`), never from gamut's parse of the ISOBMFF +//! framing — otherwise the comparison would be a tautology. +//! +//! # Why `gamut-heic` is measured on an AVIF +//! +//! C2PA 2.4 Appendix A defines **one** placement for every BMFF-based asset — a top-level `uuid` +//! box with user type `D8FEC3D6-…` — and names HEIF and AVIF together; c2pa-rs likewise serves +//! `image/avif` and `image/heic` from the same BMFF handler with the same writer. `HeifContainer` +//! is a container lens over ISOBMFF/MIAF, and an AVIF is a MIAF file, so pointing it at one +//! exercises exactly the locator under test. The alternative — hand-building a HEIF around real +//! HEVC — would test the fixture, not the locator. + +mod common; + +use c2pa::ValidationState; +use c2pa_oracle::{ + AVIF_MIME, HEIC_MIME, JUMBF_SUPERBOX_TYPE, embed, jumbf_superbox_span, read, + read_with_external_store, +}; +use common::plain_avif; +use gamut_avif::AvifContainer; +use gamut_heic::HeifContainer; + +/// The fixture both locators are pointed at: a gamut-encoded AVIF that c2pa-rs then signed and +/// embedded a store into, itself choosing the placement. +fn signed_avif() -> Vec { + embed(AVIF_MIME, &plain_avif()).expect("c2pa-rs embeds a store into the gamut-encoded AVIF") +} + +#[test] +fn gamut_avif_reports_the_exact_span_c2pa_rs_embedded() { + let asset = signed_avif(); + let expected = jumbf_superbox_span(&asset).expect("the signed asset carries a JUMBF superbox"); + + let container = AvifContainer::parse(&asset).expect("the signed asset parses"); + let slot = container.c2pa().expect("gamut-avif locates the slot"); + + assert_eq!( + slot.range, expected, + "gamut-avif's reported range must be the span c2pa-rs embedded, not a superset or a \ + fragment of it" + ); + assert_eq!( + slot.slot_bytes, &asset[expected], + "the bytes gamut-avif hands back must be the bytes at that range" + ); +} + +#[test] +fn gamut_heic_reports_the_exact_span_c2pa_rs_embedded() { + let asset = signed_avif(); + let expected = jumbf_superbox_span(&asset).expect("the signed asset carries a JUMBF superbox"); + + let container = HeifContainer::parse(&asset).expect("the signed asset parses"); + let store = container.c2pa().expect("gamut-heic locates the store"); + + assert_eq!( + store.range, expected, + "gamut-heic bounds the store by its `LBox`; against a store c2pa-rs really wrote, that \ + bound must land on the store exactly (crates/gamut-heic/STATUS.md's deferred row)" + ); + assert_eq!( + store.bytes, &asset[expected], + "the bytes gamut-heic hands back must be the bytes at that range" + ); +} + +#[test] +fn c2pa_rs_validates_the_store_read_out_of_gamut_avifs_reported_range() { + let asset = signed_avif(); + let container = AvifContainer::parse(&asset).expect("the signed asset parses"); + let located = container + .c2pa() + .expect("gamut-avif locates the slot") + .slot_bytes + .to_vec(); + + // The sharpest form of the claim: hand gamut's own extraction back to c2pa-rs as if it were a + // sidecar, against the same asset. A span starting a byte early or late does not parse as + // JUMBF, and one cut short fails its own length field, so only the exact range survives — and + // the hard binding still has to verify against the asset on top of that. + assert_eq!( + read_with_external_store(AVIF_MIME, &located, &asset) + .expect("the located bytes parse as a manifest store"), + ValidationState::Valid, + "c2pa-rs must accept the store gamut-avif extracted, bound to the same asset" + ); +} + +#[test] +fn every_store_c2pa_rs_writes_opens_with_the_jumb_superbox_type() { + let asset = signed_avif(); + let span = jumbf_superbox_span(&asset).expect("the signed asset carries a JUMBF superbox"); + + // The empirical half of `crates/gamut-heic/STATUS.md`'s deferred row. gamut deliberately does + // not assert `TBox == "jumb"`, because the C2PA specification names the constant only in JPEG + // XL clauses attributing it to ISO/IEC 18181-2. The oracle can say what the reference + // implementation does: a store's first eight bytes are its `LBox` and that type. + assert_eq!( + &asset[span.start + 4..span.start + 8], + JUMBF_SUPERBOX_TYPE, + "the reference implementation's manifest store opens LBox + `jumb`, which is the check \ + that would close gamut-heic's content-dependent `LBox` bound" + ); +} + +#[test] +fn the_same_bytes_validate_when_offered_to_c2pa_rs_as_heic() { + let asset = signed_avif(); + + // The placement is one placement for all BMFF-based assets: c2pa-rs reads the same file under + // either MIME type. This is what licenses measuring `gamut-heic`'s locator on this fixture. + assert_eq!( + read(HEIC_MIME, &asset).expect("the signed asset carries a store"), + read(AVIF_MIME, &asset).expect("the signed asset carries a store"), + ); +} diff --git a/tooling/c2pa-oracle/tests/no_copy_forward.rs b/tooling/c2pa-oracle/tests/no_copy_forward.rs new file mode 100644 index 00000000..f55472ec --- /dev/null +++ b/tooling/c2pa-oracle/tests/no_copy_forward.rs @@ -0,0 +1,40 @@ +//! A derivative must not carry its parent's manifest store, and c2pa-rs must report it as +//! **unsigned** rather than **invalid**. +//! +//! The distinction is the epic's, and it is not cosmetic. Re-encoding invalidates the hard binding, +//! so a store copied forward would still be *found* — and would then fail validation, presenting a +//! derivative that is merely a new rendition as a tampered file. The correct outcome is that there +//! is no store to find at all; a derivative that wants provenance needs a *new* manifest naming the +//! parent as an ingredient, which is a claim generator's job and not a container library's. +//! +//! `gamut-avif` reaches that outcome structurally: a `ContentProvenanceBox` appears only when +//! `with_c2pa_reserved` or `with_c2pa` asked for one, and no encoder input can introduce one. +//! These tests pin the observable half of that — what c2pa-rs says about the two files. + +mod common; + +use c2pa::ValidationState; +use c2pa_oracle::{AVIF_MIME, embed, is_jumbf_not_found, read}; +use common::plain_avif; + +#[test] +fn a_gamut_re_encode_of_a_signed_parent_reads_back_as_unsigned_not_as_invalid() { + let parent = embed(AVIF_MIME, &plain_avif()).expect("c2pa-rs signs the parent"); + assert_eq!( + read(AVIF_MIME, &parent).expect("the parent carries a store"), + ValidationState::Valid, + "the parent must be valid, or the derivative's outcome proves nothing" + ); + + // The derivative: the same pixels encoded again, with no C2PA knob set — which is every + // re-encode this crate can perform. + let derivative = plain_avif(); + + let error = + read(AVIF_MIME, &derivative).expect_err("a derivative must carry no manifest store at all"); + assert!( + is_jumbf_not_found(&error), + "c2pa-rs must report the derivative as unsigned (`JumbfNotFound`), not as a file whose \ + store fails to validate; got {error}" + ); +} diff --git a/tooling/c2pa-oracle/tests/reserve_then_fill.rs b/tooling/c2pa-oracle/tests/reserve_then_fill.rs new file mode 100644 index 00000000..7d4f5b51 --- /dev/null +++ b/tooling/c2pa-oracle/tests/reserve_then_fill.rs @@ -0,0 +1,88 @@ +//! **Direction 1** of the epic's oracle: gamut reserves a manifest-store slot, an external signer +//! completes it, and c2pa-rs validates the result. +//! +//! The point of the direction is that c2pa-rs never writes a byte of the container. It is handed a +//! finished AVIF that `gamut-avif` produced, computes the `c2pa.hash.bmff.v3` hard binding over +//! those bytes, and returns a store the host patches into the range the *encoder* reported before +//! any signer existed. If gamut's reported range were wrong by a byte, or if anything after the +//! slot moved when it was filled, the binding would not verify and c2pa-rs would say so. + +mod common; + +use c2pa::ValidationState; +use c2pa_oracle::{AVIF_MIME, declared_store_len, read, reserve_then_fill}; +use common::reserve_avif; + +#[test] +fn a_reserved_but_unfilled_slot_never_validates() { + // Reserving is not signing. A slot nobody filled is `len` zero bytes inside a box whose + // `box_purpose` says `manifest`, so c2pa-rs finds the box and then cannot parse its contents. + // + // The observed verdict is a *parse* error ("unexpected end of file"), not `JumbfNotFound`: + // c2pa-rs distinguishes "no store here" from "a store here that is not one", and a half-built + // file is honestly the second. What matters for the reserve seam is only the negative — the + // zeros must never come back `Valid` — so that is what is asserted, and c2pa-rs's choice of + // error is left as c2pa-rs's business rather than pinned as a promise it never made. + let (asset, slot) = reserve_avif(4096).expect("reserve a slot"); + assert!( + asset[slot].iter().all(|byte| *byte == 0), + "an unfilled slot is zeros" + ); + + assert_ne!( + read(AVIF_MIME, &asset).ok(), + Some(ValidationState::Valid), + "an unfilled slot must never validate as a manifest store" + ); +} + +#[test] +fn a_store_signed_over_the_reserved_file_validates_once_patched_into_the_reported_slot() { + let filled = reserve_then_fill(AVIF_MIME, reserve_avif).expect("reserve, sign and patch"); + + assert_eq!( + read(AVIF_MIME, &filled.asset).expect("the patched asset carries a store"), + ValidationState::Valid, + "c2pa-rs must accept a store signed over the reserved file and written at the range \ + `AvifEncoder::encode_with_report` reported" + ); +} + +#[test] +fn the_signed_store_exactly_fills_the_slot_that_was_reserved() { + let filled = reserve_then_fill(AVIF_MIME, reserve_avif).expect("reserve, sign and patch"); + + // `Builder::placeholder` pins the JUMBF length and `sign_embeddable` zero-pads back to it, so + // the store is the size the caller was told to reserve. Nothing in the file after the slot can + // move, which is the encoder-side criterion the epic states. + assert_eq!( + filled.store.len(), + filled.placeholder_store_len, + "the signed store must be exactly the length the placeholder asked the host to reserve" + ); + assert_eq!( + filled.slot.len(), + filled.store.len(), + "the reserved slot must be exactly the store's length" + ); + assert_eq!( + &filled.asset[filled.slot.clone()], + filled.store.as_slice(), + "the slot's bytes must be the store's bytes" + ); +} + +#[test] +fn the_store_declares_its_own_length_as_the_whole_slot() { + let filled = reserve_then_fill(AVIF_MIME, reserve_avif).expect("reserve, sign and patch"); + + // The store's outer JUMBF `LBox` is what `gamut-heic`'s locator trims to. When the slot is + // sized from the placeholder there is no padding, so the two bounds coincide — which is what + // makes it safe for `gamut-avif` (box-bounded) and `gamut-heic` (`LBox`-bounded) to report the + // same range for the same file. + assert_eq!( + declared_store_len(&filled.store), + Some(filled.slot.len()), + "the store's own `LBox` must account for the whole reserved slot" + ); +} diff --git a/tooling/c2pa-oracle/tests/update_manifest.rs b/tooling/c2pa-oracle/tests/update_manifest.rs new file mode 100644 index 00000000..7a1ba37f --- /dev/null +++ b/tooling/c2pa-oracle/tests/update_manifest.rs @@ -0,0 +1,130 @@ +//! The one in-spec BMFF layout `gamut-heic`'s and `gamut-avif`'s locators cannot discriminate: a +//! manifest store written **without** the 8-byte merkle offset under `box_purpose = update`. +//! +//! # What the deferred row asks +//! +//! C2PA 2.4 §A.5.3 states the framing for `manifest` and `original` — the 8-byte merkle offset, +//! then the store — and for `update` says nothing at all about the bytes ahead of the store; its +//! only sentence about that purpose constrains the store's *contents*. gamut therefore **probes** +//! offset 8 first and falls back to 0, and `crates/gamut-heic/STATUS.md` records the offset-less +//! `update` layout as a shape that would be mis-bounded rather than rejected, noting "no known +//! writer emits either shape". +//! +//! "No known writer" is a claim about the world, and this file is where it stops being an +//! assumption. The finding, recorded here and in `README.md`: +//! +//! > **c2pa-rs, driven through its public API to emit a `box_purpose = update` box, writes the +//! > 8-byte merkle offset in front of the store exactly as it does for `manifest` and `original`.** +//! +//! So the reference implementation does not emit the ambiguous layout, the probe's offset-8 arm is +//! the one that fires on real files, and its offset-0 fallback is dead weight against every store +//! in circulation rather than a source of mis-bounding. That is the empirical evidence the +//! deferred row asked for; it does not make the `LBox` bound self-checking, which remains #505's. +//! +//! Driving it needs `BuilderIntent::Update` (c2pa-rs exposes no way to set the purpose string +//! directly) over a file that already carries a store, which is why every test here signs twice. + +mod common; + +use std::io::Cursor; + +use c2pa::{Builder, BuilderIntent, ValidationState}; +use c2pa_oracle::{ + AVIF_MIME, OracleError, Result, declared_store_len, embed, read, signing_context, +}; +use common::plain_avif; +use gamut_avif::{AvifContainer, C2paBoxPurpose}; + +/// A file mid-update: a gamut AVIF signed once, then signed again with an update intent, so +/// c2pa-rs relabels the first store `original` and appends an `update` box. +fn mid_update_avif() -> Result> { + let parent = embed(AVIF_MIME, &plain_avif())?; + let mut builder = Builder::from_context(signing_context()?); + builder.set_intent(BuilderIntent::Update); + let mut source = Cursor::new(parent); + let mut dest = Cursor::new(Vec::new()); + builder.save_to_stream(AVIF_MIME, &mut source, &mut dest)?; + Ok(dest.into_inner()) +} + +/// The `update` store in a mid-update file, as gamut reports it. +fn update_slot(asset: &[u8]) -> Result> { + let container = + AvifContainer::parse(asset).map_err(|error| OracleError::Asset(error.to_string()))?; + container + .c2pa_manifest_stores() + .find(|slot| slot.purpose == C2paBoxPurpose::Update) + .map(|slot| slot.range) + .ok_or_else(|| OracleError::Asset("no `update` store in the mid-update file".into())) +} + +#[test] +fn c2pa_rs_writes_the_merkle_offset_in_front_of_an_update_store_too() { + let asset = mid_update_avif().expect("c2pa-rs produces a mid-update file"); + let update = update_slot(&asset).expect("gamut locates the `update` store"); + + // The eight bytes immediately before the store are the merkle offset §A.5.3 states for the + // other two purposes, written as zero because a still image carries no `merkle` box. Ahead of + // them is the NUL that terminates the `box_purpose` string. + assert_eq!( + &asset[update.start - 8..update.start], + &[0u8; 8], + "the reference implementation writes an 8-byte merkle offset in front of an `update` \ + store, so the offset-less layout gamut cannot discriminate is not one it emits" + ); + assert_eq!( + asset[update.start - 9], + 0, + "and immediately before it, the NUL terminating `box_purpose`" + ); + assert_eq!( + &asset[update.start - 15..update.start - 9], + b"update", + "the purpose really is `update`" + ); +} + +#[test] +fn the_update_store_is_bounded_by_its_own_lbox_at_the_probed_offset() { + let asset = mid_update_avif().expect("c2pa-rs produces a mid-update file"); + let update = update_slot(&asset).expect("gamut locates the `update` store"); + + // gamut's probe accepted offset 8 for this purpose. If it had fallen through to offset 0 it + // would have read the merkle offset's leading bytes as an `LBox`, so the store it reported + // would not account for its own range. + assert_eq!( + declared_store_len(&asset[update.clone()]), + Some(update.len()), + "the located `update` store must declare exactly the range gamut reported" + ); +} + +#[test] +fn the_earlier_store_is_relabelled_original_when_an_update_box_is_added() { + let asset = mid_update_avif().expect("c2pa-rs produces a mid-update file"); + let container = AvifContainer::parse(&asset).expect("the mid-update file parses"); + + // §A.5.3: once a file carries an `update` box, the store it had before is re-labelled + // `original`. Both are reported, in file order, and neither is judged — which is why + // `AvifContainer::c2pa` promises only "the first one". + let purposes: Vec<_> = container + .c2pa_manifest_stores() + .map(|slot| slot.purpose) + .collect(); + assert_eq!( + purposes, + vec![C2paBoxPurpose::Original, C2paBoxPurpose::Update], + "a mid-update file carries an `original` store followed by an `update` store" + ); +} + +#[test] +fn c2pa_rs_still_validates_the_mid_update_file_gamut_read() { + let asset = mid_update_avif().expect("c2pa-rs produces a mid-update file"); + + assert_eq!( + read(AVIF_MIME, &asset).expect("the mid-update file carries a store"), + ValidationState::Valid, + "the two-store layout gamut reports above is one the reference implementation accepts" + ); +} From e0a0f9f8133d5f6b92e6182058b9947d92bdfafb Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:01:25 -0400 Subject: [PATCH 03/36] fix(c2pa-oracle): read the two reserved JUMBF LBox values, and keep searching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find_jumbf_superbox` discarded a match below offset 4 instead of continuing past it, so a stray `jumb` ahead of the real superbox made the whole buffer look store-less. `declared_store_len` read `LBox` as a plain 32-bit length, so the two values ISO box syntax reserves came back as lengths of 0 and 1 — a silently wrong span on the side of the differential whose answers are treated as the reference. A JUMBF box is a JPEG-family standard box; C2PA 2.4 §8.4.2.3 restates that syntax where it defines the C2PA salt. `LBox == 0` means the box runs to the end of the file, `LBox == 1` means the 8-byte `XLBox` after `TBox` carries the length. Both are now read, `LBox` 2..=7 is refused with a typed `UnusableSuperboxLength` naming why, and no length is ever guessed. Also corrects `OracleError::Asset`'s doc, which said the variant was raised only by a caller's closure while `reserve_then_fill` raises it too. Refs #447 --- tooling/c2pa-oracle/src/lib.rs | 213 +++++++++++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 13 deletions(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index e170aa87..a4c6cfe2 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -37,12 +37,23 @@ pub enum OracleError { /// c2pa-rs refused an operation. Carries its error unchanged: the reference implementation's /// own classification is the diagnostic, so it is never re-coded into one of ours. C2pa(c2pa::Error), - /// A gamut crate refused to produce or read the asset the oracle asked for. Raised only by a - /// caller's closure, never by this crate. + /// The asset side of the differential went wrong: a gamut crate refused to produce or read + /// what the oracle asked for, or the asset it produced does not fit what c2pa-rs signed. + /// Raised by a caller's closure, and by [`reserve_then_fill`] when a signed store and the slot + /// reserved for it are not the same length. Asset(String), /// A composed `ContentProvenanceBox` carried no JUMBF superbox: no [`JUMBF_SUPERBOX_TYPE`] was /// found in it at all. Only [`split_composed_box`] raises this. NoJumbfSuperbox, + /// A JUMBF superbox header is present, but the length it declares cannot be read: its + /// `LBox`/`XLBox` fields are truncated, `LBox` is one of the values ISO box syntax leaves + /// undefined (2..=7, all shorter than the 8-byte header they sit in), or the declared length + /// does not fit this platform's `usize`. Carries which of those it was. + /// + /// This exists so the length is never *guessed*. A span silently derived from an + /// unrepresentable `LBox` would be an oracle handing gamut a wrong answer and calling it a + /// reference one; see [`declared_store_len`]. + UnusableSuperboxLength(&'static str), } impl std::fmt::Display for OracleError { @@ -53,6 +64,9 @@ impl std::fmt::Display for OracleError { Self::NoJumbfSuperbox => { f.write_str("composed ContentProvenanceBox carries no JUMBF superbox") } + Self::UnusableSuperboxLength(what) => { + write!(f, "JUMBF superbox declares no usable length: {what}") + } } } } @@ -174,15 +188,22 @@ impl ComposedBox { /// The returned offset is therefore an *independent* claim about where the store begins, which is /// what makes "gamut reports the same range" a differential rather than a tautology. /// +/// A `jumb` in the first four bytes cannot be a superbox type — there would be no room for the +/// `LBox` in front of it — so the search **continues past** one rather than giving up on it. That +/// costs nothing on today's fixtures, where the framing ahead of the store is fixed; it matters +/// the moment this crate is pointed at a container whose store follows arbitrary bytes. +/// /// # Errors /// /// [`OracleError::NoJumbfSuperbox`] if no `jumb` box type appears at or after offset 4. pub fn find_jumbf_superbox(buffer: &[u8]) -> Result { buffer .windows(JUMBF_SUPERBOX_TYPE.len()) - .position(|window| window == JUMBF_SUPERBOX_TYPE) - .filter(|type_offset| *type_offset >= 4) - .map(|type_offset| type_offset - 4) + .enumerate() + // Offsets 0..4 have no room for an `LBox`, so skip those windows and keep looking. + .skip(4) + .find(|(_, window)| *window == JUMBF_SUPERBOX_TYPE) + .map(|(type_offset, _)| type_offset - 4) .ok_or(OracleError::NoJumbfSuperbox) } @@ -195,10 +216,11 @@ pub fn find_jumbf_superbox(buffer: &[u8]) -> Result { /// # Errors /// /// [`OracleError::NoJumbfSuperbox`] if no superbox is found, or if the length it declares runs off -/// the end of `buffer`. +/// the end of `buffer`; [`OracleError::UnusableSuperboxLength`] if that length cannot be read at +/// all (see [`declared_store_len`]). pub fn jumbf_superbox_span(buffer: &[u8]) -> Result> { let start = find_jumbf_superbox(buffer)?; - let len = declared_store_len(&buffer[start..]).ok_or(OracleError::NoJumbfSuperbox)?; + let len = declared_store_len(&buffer[start..])?; let end = start .checked_add(len) .filter(|end| *end <= buffer.len()) @@ -220,15 +242,64 @@ pub fn split_composed_box(composed: Vec) -> Result { }) } -/// The outer JUMBF `LBox` a store declares for itself: its length in bytes, big-endian, read from -/// the store's own first four bytes. `None` when `store` is shorter than that field. +/// The total length in bytes the JUMBF superbox at the start of `store` declares for itself. /// /// This is the bound `gamut-heic`'s locator trims to, so a test can state the length it expects /// without borrowing gamut's reading of it. -#[must_use] -pub fn declared_store_len(store: &[u8]) -> Option { - let field: [u8; 4] = store.get(..4)?.try_into().ok()?; - Some(u32::from_be_bytes(field) as usize) +/// +/// # The two reserved `LBox` values +/// +/// A JUMBF box is a JPEG-family *standard box* — `LBox` (4 bytes, big-endian), `TBox` (4 bytes), +/// then optionally `XLBox` — and C2PA 2.4 §8.4.2.3 spells that syntax out where it defines the +/// C2PA salt as "a standard box consisting of: a box length (LBox, as a 4-byte big-endian unsigned +/// integer); a box type (TBox, 4-byte big-endian unsigned integer …)". The same syntax reserves two +/// `LBox` values, and both are read here rather than taken at face value: +/// +/// * **`LBox == 0`** — the box runs to the end of the file. `store` begins at the superbox's own +/// first byte, so that end is the end of `store`, and the declared length is `store.len()`. +/// * **`LBox == 1`** — the real length is the 8-byte big-endian `XLBox` that follows `TBox`, i.e. +/// `store[8..16]`, and it counts the whole box including that 16-byte header. +/// +/// Taking either literally would return 0 or 1 as a length: a wrong span, produced silently, on +/// the side of the differential whose answers are treated as the reference. `LBox` values 2..=7 +/// are shorter than the header they sit in and describe no box at all, so they are refused rather +/// than resolved. +/// +/// No store this crate has seen uses either reserved value — c2pa-rs writes a plain 32-bit `LBox` +/// — which is exactly why the handling is here rather than assumed away. +/// +/// # Errors +/// +/// [`OracleError::UnusableSuperboxLength`] when the `LBox`/`XLBox` fields are truncated, when +/// `LBox` is 2..=7, or when the declared length does not fit a `usize`. +pub fn declared_store_len(store: &[u8]) -> Result { + let field: [u8; 4] = store + .get(..4) + .and_then(|field| field.try_into().ok()) + .ok_or(OracleError::UnusableSuperboxLength( + "the LBox field is truncated", + ))?; + + match u32::from_be_bytes(field) { + 0 => Ok(store.len()), + 1 => { + let field: [u8; 8] = store + .get(8..16) + .and_then(|field| field.try_into().ok()) + .ok_or(OracleError::UnusableSuperboxLength( + "LBox is 1 but the XLBox field that carries the length is truncated", + ))?; + usize::try_from(u64::from_be_bytes(field)).map_err(|_| { + OracleError::UnusableSuperboxLength("XLBox does not fit this platform's usize") + }) + } + 2..=7 => Err(OracleError::UnusableSuperboxLength( + "LBox is between 2 and 7, shorter than the LBox+TBox header it is part of", + )), + lbox => usize::try_from(lbox).map_err(|_| { + OracleError::UnusableSuperboxLength("LBox does not fit this platform's usize") + }), + } } /// What [`reserve_then_fill`] produced. @@ -363,3 +434,119 @@ pub fn read_with_external_store( pub fn is_jumbf_not_found(error: &OracleError) -> bool { matches!(error, OracleError::C2pa(c2pa::Error::JumbfNotFound)) } + +#[cfg(test)] +mod tests { + //! The JUMBF header reading this crate does *not* borrow from gamut, on the inputs c2pa-rs + //! never produces. Everything c2pa-rs does produce is pinned in `tests/` against c2pa-rs + //! itself; these are the arms an oracle has to get right before it is pointed at a container + //! whose store follows arbitrary bytes. + + use super::{OracleError, declared_store_len, find_jumbf_superbox, jumbf_superbox_span}; + + /// A buffer with a stray `jumb` at offset 0 — too early to be a superbox type, since there is + /// no room for an `LBox` in front of it — and a genuine `LBox` + `jumb` superbox at offset 12. + fn stray_jumb_then_real_superbox() -> Vec { + let mut buffer = Vec::new(); + buffer.extend_from_slice(b"jumb"); // offset 0: too early to be a superbox type + buffer.extend_from_slice(&[0xAA; 8]); // filler + buffer.extend_from_slice(&24u32.to_be_bytes()); // offset 12: the real LBox + buffer.extend_from_slice(b"jumb"); // offset 16: the real TBox + buffer.extend_from_slice(&[0x11; 16]); // the store's body, to LBox's 24 bytes + buffer + } + + #[test] + fn the_search_continues_past_a_jumb_too_early_to_carry_an_lbox() { + assert_eq!( + find_jumbf_superbox(&stray_jumb_then_real_superbox()).expect("the real superbox"), + 12, + "a `jumb` in the first four bytes has no room for an `LBox` in front of it, so it must \ + be skipped and the search continued, not treated as the end of it" + ); + } + + #[test] + fn a_span_is_still_found_when_a_stray_jumb_precedes_the_superbox() { + assert_eq!( + jumbf_superbox_span(&stray_jumb_then_real_superbox()).expect("the real superbox"), + 12..36, + ); + } + + #[test] + fn an_lbox_of_zero_declares_the_rest_of_the_buffer() { + let mut store = vec![0u8; 76]; + store[..4].copy_from_slice(&0u32.to_be_bytes()); + store[4..8].copy_from_slice(b"jumb"); + + assert_eq!( + declared_store_len(&store).expect("LBox 0 is a length, not a literal zero"), + 76, + "ISO box syntax reads `LBox = 0` as \"to the end of the file\"; taken literally it \ + would make the store zero bytes long" + ); + } + + #[test] + fn an_lbox_of_one_takes_its_length_from_the_xlbox_field() { + let mut store = vec![0u8; 40]; + store[..4].copy_from_slice(&1u32.to_be_bytes()); + store[4..8].copy_from_slice(b"jumb"); + store[8..16].copy_from_slice(&40u64.to_be_bytes()); + + assert_eq!( + declared_store_len(&store).expect("LBox 1 defers to XLBox"), + 40, + "ISO box syntax reads `LBox = 1` as \"the 8-byte XLBox after TBox holds the length\"; \ + taken literally it would make the store one byte long" + ); + } + + #[test] + fn an_lbox_of_one_without_room_for_an_xlbox_is_refused() { + let mut store = vec![0u8; 12]; + store[..4].copy_from_slice(&1u32.to_be_bytes()); + store[4..8].copy_from_slice(b"jumb"); + + let error = declared_store_len(&store).expect_err("there is no XLBox to read"); + assert!( + error + .to_string() + .contains("XLBox field that carries the length is truncated"), + "the refusal must name the truncated XLBox rather than any other unusable length; got \ + {error}" + ); + } + + #[test] + fn an_lbox_between_two_and_seven_is_refused_rather_than_resolved() { + let mut store = vec![0u8; 32]; + store[..4].copy_from_slice(&7u32.to_be_bytes()); + store[4..8].copy_from_slice(b"jumb"); + + let error = declared_store_len(&store).expect_err("7 is shorter than the header itself"); + assert!( + error + .to_string() + .contains("shorter than the LBox+TBox header"), + "the refusal must name the undersized LBox rather than any other unusable length; got \ + {error}" + ); + } + + #[test] + fn a_declared_length_running_past_the_buffer_is_not_a_span() { + let mut buffer = vec![0u8; 32]; + buffer[..4].copy_from_slice(&4096u32.to_be_bytes()); + buffer[4..8].copy_from_slice(b"jumb"); + + assert!( + matches!( + jumbf_superbox_span(&buffer), + Err(OracleError::NoJumbfSuperbox) + ), + "a length that runs off the end of the buffer bounds nothing" + ); + } +} From 71bcd8698414afaceff55c0be27c41933524257f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:01:33 -0400 Subject: [PATCH 04/36] test(c2pa-oracle): assert the located range, and tolerate a padded slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test billed as the sharpest form of "gamut locates the identical byte range" read `slot_bytes` rather than `range`, so a one-byte shift injected into the reported range left it green while four other tests failed. It now cuts the store out of the asset at the range gamut reported, which is what its name claims. `gamut-avif` bounds a slot by the box, so it reports the store and anything after it; the box-bounded assertion is therefore containment, and the equality — that c2pa-rs sizes the box to the store exactly, writing no padding — is asserted on its own and named for c2pa-rs. A future release that padded fails that one test instead of being misread as gamut mis-bounding. Also guards the box-framing slice at both ends: only the start was checked, so a wrong offset would have surfaced as an index panic naming no side. Refs #447 --- tooling/c2pa-oracle/tests/box_framing.rs | 9 ++- tooling/c2pa-oracle/tests/locate_embedded.rs | 63 ++++++++++++++----- .../c2pa-oracle/tests/reserve_then_fill.rs | 4 +- tooling/c2pa-oracle/tests/update_manifest.rs | 4 +- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/tooling/c2pa-oracle/tests/box_framing.rs b/tooling/c2pa-oracle/tests/box_framing.rs index 51b502c7..bd6135f8 100644 --- a/tooling/c2pa-oracle/tests/box_framing.rs +++ b/tooling/c2pa-oracle/tests/box_framing.rs @@ -24,15 +24,22 @@ fn the_box_gamut_avif_writes_is_byte_identical_to_the_one_c2pa_rs_composes() { .expect("c2pa-rs composes a box around the same store"); // The store sits at the end of the composed box, so the box starts that far ahead of the slot. + // Both ends are checked before slicing: a wrong `store_offset` on either side of the + // differential is a defect this test should *report*, not one it should panic on with an index + // out of bounds that names no side. let framing = composed.len() - filled.store.len(); let start = filled .slot .start .checked_sub(framing) .expect("the box begins inside the file"); + let written = start + .checked_add(composed.len()) + .and_then(|end| filled.asset.get(start..end)) + .expect("the box ends inside the file"); assert_eq!( - &filled.asset[start..start + composed.len()], + written, composed.as_slice(), "gamut-avif's ContentProvenanceBox must be byte-identical to c2pa-rs's for the same store" ); diff --git a/tooling/c2pa-oracle/tests/locate_embedded.rs b/tooling/c2pa-oracle/tests/locate_embedded.rs index 4caf14a6..c134abd6 100644 --- a/tooling/c2pa-oracle/tests/locate_embedded.rs +++ b/tooling/c2pa-oracle/tests/locate_embedded.rs @@ -39,24 +39,58 @@ fn signed_avif() -> Vec { } #[test] -fn gamut_avif_reports_the_exact_span_c2pa_rs_embedded() { +fn gamut_avif_bounds_the_store_c2pa_rs_embedded_by_the_box_that_carries_it() { let asset = signed_avif(); let expected = jumbf_superbox_span(&asset).expect("the signed asset carries a JUMBF superbox"); let container = AvifContainer::parse(&asset).expect("the signed asset parses"); let slot = container.c2pa().expect("gamut-avif locates the slot"); + // `gamut-avif` bounds the slot by the *box*, so it reports the store and anything the writer + // left after it (`C2paSlot::slot_bytes`: "the store, then any padding"). The claim this test + // holds gamut to is therefore containment, not equality: the slot begins exactly where the + // store begins and holds every byte of it. Whether c2pa-rs leaves padding at all is c2pa-rs's + // business, and it is pinned on its own below — so a future padding c2pa-rs fails *that* test + // rather than being misread here as gamut mis-locating. assert_eq!( - slot.range, expected, - "gamut-avif's reported range must be the span c2pa-rs embedded, not a superset or a \ - fragment of it" + slot.range.start, expected.start, + "gamut-avif's reported range must begin at the store c2pa-rs embedded, not before or \ + after it" + ); + assert!( + slot.range.end >= expected.end, + "gamut-avif's reported range must hold the whole store, not a fragment of it: reported \ + {:?}, store at {expected:?}", + slot.range ); assert_eq!( - slot.slot_bytes, &asset[expected], + &slot.slot_bytes[..expected.len()], + &asset[expected], "the bytes gamut-avif hands back must be the bytes at that range" ); } +#[test] +fn c2pa_rs_leaves_no_padding_between_the_store_and_the_end_of_its_box() { + let asset = signed_avif(); + let expected = jumbf_superbox_span(&asset).expect("the signed asset carries a JUMBF superbox"); + + let container = AvifContainer::parse(&asset).expect("the signed asset parses"); + let slot = container.c2pa().expect("gamut-avif locates the slot"); + + // An observation about the reference implementation, recorded in `README.md` beside the + // `update`-purpose finding and asserted here for the same reason: it is what makes the + // box-bounded bound (`gamut-avif`) and the `LBox`-bounded bound (`gamut-heic`) report the + // *same* range for the same file. Nothing in C2PA 2.4 §A.5.1.2 forbids a writer from sizing + // the box larger than the store, so this is evidence, not a rule — and when it stops holding, + // this is the test that says so. + assert_eq!( + slot.range, expected, + "c2pa-rs sizes the ContentProvenanceBox to the store exactly; a difference here is the \ + reference implementation having started to pad, not gamut-avif mis-locating" + ); +} + #[test] fn gamut_heic_reports_the_exact_span_c2pa_rs_embedded() { let asset = signed_avif(); @@ -80,16 +114,15 @@ fn gamut_heic_reports_the_exact_span_c2pa_rs_embedded() { fn c2pa_rs_validates_the_store_read_out_of_gamut_avifs_reported_range() { let asset = signed_avif(); let container = AvifContainer::parse(&asset).expect("the signed asset parses"); - let located = container - .c2pa() - .expect("gamut-avif locates the slot") - .slot_bytes - .to_vec(); - - // The sharpest form of the claim: hand gamut's own extraction back to c2pa-rs as if it were a - // sidecar, against the same asset. A span starting a byte early or late does not parse as - // JUMBF, and one cut short fails its own length field, so only the exact range survives — and - // the hard binding still has to verify against the asset on top of that. + let range = container.c2pa().expect("gamut-avif locates the slot").range; + let located = asset[range].to_vec(); + + // The sharpest form of the claim, and it is `range` that is exercised: the bytes are cut out + // of the asset *at the range gamut reported*, not taken from the `slot_bytes` the same call + // hands over, so a range wrong by one byte reaches c2pa-rs as wrong bytes. A span starting a + // byte early or late does not parse as JUMBF, and one cut short fails its own length field, + // so only the exact range survives — and the hard binding still has to verify against the + // asset on top of that. assert_eq!( read_with_external_store(AVIF_MIME, &located, &asset) .expect("the located bytes parse as a manifest store"), diff --git a/tooling/c2pa-oracle/tests/reserve_then_fill.rs b/tooling/c2pa-oracle/tests/reserve_then_fill.rs index 7d4f5b51..e990ab20 100644 --- a/tooling/c2pa-oracle/tests/reserve_then_fill.rs +++ b/tooling/c2pa-oracle/tests/reserve_then_fill.rs @@ -81,8 +81,8 @@ fn the_store_declares_its_own_length_as_the_whole_slot() { // makes it safe for `gamut-avif` (box-bounded) and `gamut-heic` (`LBox`-bounded) to report the // same range for the same file. assert_eq!( - declared_store_len(&filled.store), - Some(filled.slot.len()), + declared_store_len(&filled.store).expect("the store declares its own length"), + filled.slot.len(), "the store's own `LBox` must account for the whole reserved slot" ); } diff --git a/tooling/c2pa-oracle/tests/update_manifest.rs b/tooling/c2pa-oracle/tests/update_manifest.rs index 7a1ba37f..be5cd3a5 100644 --- a/tooling/c2pa-oracle/tests/update_manifest.rs +++ b/tooling/c2pa-oracle/tests/update_manifest.rs @@ -93,8 +93,8 @@ fn the_update_store_is_bounded_by_its_own_lbox_at_the_probed_offset() { // would have read the merkle offset's leading bytes as an `LBox`, so the store it reported // would not account for its own range. assert_eq!( - declared_store_len(&asset[update.clone()]), - Some(update.len()), + declared_store_len(&asset[update.clone()]).expect("the store declares its own length"), + update.len(), "the located `update` store must declare exactly the range gamut reported" ); } From 126ba1cdf261f848932d9c7f38eaa7b10e692f7b Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:01:39 -0400 Subject: [PATCH 05/36] build(c2pa-oracle): pin c2pa exactly and stop the manifest escaping its directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md cites c2pa-rs's own source by line number for two of the claims it records, and `^0.90.21` lets a patch release move those lines under a citation nobody re-checks. There is no committed lockfile to hold the resolution instead — `.gitignore` excludes `tooling/*/Cargo.lock` because a workspace-excluded oracle resolves standalone — so the version is pinned with `=` and a third drift guard in tests/build_configuration.rs keeps it that way. Adds the empty `[workspace]` table `tooling/gamut-fuzz` already carries, so cargo stops walking past `tooling/` for a workspace root and the crate builds the same wherever the checkout sits. Refs #447 --- tooling/c2pa-oracle/Cargo.toml | 18 +++++++- .../c2pa-oracle/tests/build_configuration.rs | 42 +++++++++++++++---- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/tooling/c2pa-oracle/Cargo.toml b/tooling/c2pa-oracle/Cargo.toml index 0768eb45..6e265d70 100644 --- a/tooling/c2pa-oracle/Cargo.toml +++ b/tooling/c2pa-oracle/Cargo.toml @@ -28,7 +28,13 @@ doctest = false # OpenSSL build would also make this the slowest thing in CI. `rust_native_crypto` is the # pure-Rust signing/verification backend that replaces it; dropping `default_http` additionally # drops reqwest and ureq, which this oracle never needs because it fetches no remote manifests. -c2pa = { version = "0.90.21", default-features = false, features = ["rust_native_crypto"] } +# +# Pinned to an exact version, not a caret range. `README.md` cites c2pa-rs's own source **by line +# number** for two claims it records, and a `^` range lets a patch release move those lines under +# a citation nobody re-checked. There is no committed lockfile to hold them either: `.gitignore` +# excludes `tooling/*/Cargo.lock`, because a workspace-excluded oracle resolves standalone. The +# pin is the smaller of the two fixes and it is the one that makes the citations honest. +c2pa = { version = "=0.90.21", default-features = false, features = ["rust_native_crypto"] } [dev-dependencies] # The crates under test. Both locate a C2PA manifest store in a top-level ISOBMFF `uuid` @@ -37,3 +43,13 @@ c2pa = { version = "0.90.21", default-features = false, features = ["rust_native gamut-avif = { path = "../../crates/gamut-avif" } gamut-heic = { path = "../../crates/gamut-heic" } gamut-core = { path = "../../crates/gamut-core" } +# The metadata facade, for `tests/no_copy_forward.rs` only: its `C2paPolicy` is the mechanism that +# refuses to carry a parent's manifest store into a derivative, and the test drives it rather than +# asserting a re-encode happens not to emit one. +gamut-metadata = { path = "../../crates/gamut-metadata" } + +# Keeps this manifest from being read as a member of any workspace above it — the same guard +# `tooling/gamut-fuzz/Cargo.toml` carries. Without it, cargo walks past `tooling/` looking for a +# workspace root, which in a nested git worktree finds the primary checkout's manifest and makes +# every invocation depend on where the tree happens to sit. +[workspace] diff --git a/tooling/c2pa-oracle/tests/build_configuration.rs b/tooling/c2pa-oracle/tests/build_configuration.rs index 042ed4d5..9cf584cb 100644 --- a/tooling/c2pa-oracle/tests/build_configuration.rs +++ b/tooling/c2pa-oracle/tests/build_configuration.rs @@ -8,9 +8,13 @@ //! would be silent: everything would still pass, just far more slowly and with a C toolchain //! newly on the critical path. //! -//! Two checks, weakest sufficient technique each. The manifest check is a drift guard on the line -//! a human would edit; the lockfile check is a **resolved-graph** assertion and is the stronger of -//! the two, because it would also catch the feature arriving by unification from somewhere else. +//! Three checks, weakest sufficient technique each. Two are about features: the manifest check is a +//! drift guard on the line a human would edit, and the lockfile check is a **resolved-graph** +//! assertion, the stronger of the two, because it would also catch the feature arriving by +//! unification from somewhere else. The third is about the version, and guards a different thing: +//! `README.md` cites c2pa-rs's own source **by line number**, and only an exact pin holds those +//! citations still. There is no committed lockfile to do it instead — `.gitignore` excludes +//! `tooling/*/Cargo.lock`, because a workspace-excluded oracle resolves standalone. use std::path::Path; @@ -21,13 +25,21 @@ const MANIFEST: &str = include_str!("../Cargo.toml"); /// Package names that only appear in the resolved graph when the `openssl` feature is on. const OPENSSL_PACKAGES: [&str; 3] = ["openssl", "openssl-sys", "openssl-src"]; -#[test] -fn the_c2pa_dependency_line_disables_default_features_and_asks_only_for_rust_native_crypto() { - let line = MANIFEST +/// The `c2pa` version `README.md`'s line-number citations were read against. +const CITED_C2PA_VERSION: &str = "0.90.21"; + +/// The one line of the manifest that declares `c2pa`. +fn c2pa_dependency_line() -> &'static str { + MANIFEST .lines() .map(str::trim) .find(|line| line.starts_with("c2pa = ")) - .expect("the manifest declares a `c2pa` dependency on one line"); + .expect("the manifest declares a `c2pa` dependency on one line") +} + +#[test] +fn the_c2pa_dependency_line_disables_default_features_and_asks_only_for_rust_native_crypto() { + let line = c2pa_dependency_line(); assert!( line.contains("default-features = false"), @@ -60,3 +72,19 @@ fn the_resolved_dependency_graph_contains_no_openssl_package() { ); } } + +#[test] +fn the_c2pa_dependency_pins_the_exact_version_the_readmes_citations_were_read_against() { + let line = c2pa_dependency_line(); + + // `README.md` quotes `c2pa`'s `src/validation_results.rs:36-41` and names `src/jumbf_io.rs:246` + // and `:258`, and the `update`-purpose finding is attributed to a branch in its `bmff_io.rs`. + // A caret range lets a patch release move every one of those lines while the citation stays as + // written, and there is no lockfile in the tree to hold the resolution instead. `=` is the + // smaller of the two fixes and it is the one that keeps the prose honest. + assert!( + line.contains(&format!(r#"version = "={CITED_C2PA_VERSION}""#)), + "the `c2pa` dependency must pin `={CITED_C2PA_VERSION}` exactly, because README.md cites \ + that release's source by line number and nothing else holds those lines still: {line}" + ); +} From 19e24f800f946da79e32041b7f4d8c16bd97d66a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:01:52 -0400 Subject: [PATCH 06/36] test(c2pa-oracle): drive the copy-forward refusal through C2paPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old test built its derivative with a fresh `plain_avif()` and asserted c2pa-rs found no store in it. There was no causal path from the signed parent: with no parent created at all, the same assertion holds, so what it pinned was only "the AVIF encoder does not spontaneously emit a ContentProvenanceBox". Issue #447's third bullet names #428's `C2paPolicy`, and reaching it needs no new library code. The derivative is now encoded from what `MetadataEmbedder` returned for a model carrying the store `gamut-avif` located in the parent, so deleting the parent breaks the test and a policy that forwarded the store would fail it. Verified by assigning the store to `EncodedMetadata::c2pa` by hand: c2pa-rs then reports `Valid` — a bit-identical re-encode wearing another party's claim — which the file now records as the failure mode a `Preserve` arm would open. The second test drives `C2paPolicy::Reject` and asserts the refusal names the parent's store by its located length. Uses the `gamut-metadata` dev-dependency added in the preceding commit. Refs #447, #428 --- tooling/c2pa-oracle/tests/no_copy_forward.rs | 100 +++++++++++++++++-- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/tooling/c2pa-oracle/tests/no_copy_forward.rs b/tooling/c2pa-oracle/tests/no_copy_forward.rs index f55472ec..f6346878 100644 --- a/tooling/c2pa-oracle/tests/no_copy_forward.rs +++ b/tooling/c2pa-oracle/tests/no_copy_forward.rs @@ -7,28 +7,90 @@ //! is no store to find at all; a derivative that wants provenance needs a *new* manifest naming the //! parent as an ingredient, which is a claim generator's job and not a container library's. //! -//! `gamut-avif` reaches that outcome structurally: a `ContentProvenanceBox` appears only when -//! `with_c2pa_reserved` or `with_c2pa` asked for one, and no encoder input can introduce one. -//! These tests pin the observable half of that — what c2pa-rs says about the two files. +//! # The parent has to be in the causal path +//! +//! Encoding a fresh image and observing that it carries no store proves nothing: it is true of any +//! encoder that was never handed a store. So every test here runs the real pipeline — locate the +//! parent's store with `gamut-avif`, carry it into a [`Metadata`] model as the +//! [`MetadataBlock::C2pa`] carrier `gamut-metadata` defines for exactly this, then ask +//! [`MetadataEmbedder`] what to write into the derivative — and the derivative is encoded from +//! *what the embedder returned*. If [`C2paPolicy`] ever handed the store back, the derivative would +//! carry it and c2pa-rs would find one, which is the failure this file exists to see. +//! +//! Issue #428 names `C2paPolicy` as the mechanism, and it has two arms: +//! +//! * [`C2paPolicy::Drop`] (the default) — the store is not emitted, and the derivative reads back +//! as unsigned. That is the whole round trip, end to end. +//! * [`C2paPolicy::Reject`] — the same refusal, made loud, for a caller that must be told +//! provenance is being lost rather than discover it downstream. +//! +//! Forcing the forward (assigning the model's store to `EncodedMetadata::c2pa` by hand, the arm +//! `C2paPolicy` deliberately does not offer) makes the first test fail with c2pa-rs reporting +//! `Valid` — not `Invalid`. That is not a softening of the hazard, it is a sharpening of it: this +//! fixture re-encodes deterministically, and a BMFF hard binding excludes the `ContentProvenanceBox` +//! by box path, so a bit-identical derivative wearing its parent's store validates and presents +//! another party's claim as its own. The failure mode a `Preserve` arm would open is therefore not +//! "a file that looks tampered with" but "a file that looks signed". +//! +//! [`Metadata`]: gamut_metadata::Metadata +//! [`MetadataBlock::C2pa`]: gamut_metadata::MetadataBlock::C2pa +//! [`MetadataEmbedder`]: gamut_metadata::MetadataEmbedder +//! [`C2paPolicy`]: gamut_metadata::C2paPolicy mod common; use c2pa::ValidationState; use c2pa_oracle::{AVIF_MIME, embed, is_jumbf_not_found, read}; -use common::plain_avif; +use common::{dims, plain_avif, source_rgb}; +use gamut_avif::{AvifContainer, AvifEncoder}; +use gamut_core::{EncodeImage, ImageRef, Rgb8}; +use gamut_metadata::{ + C2paPolicy, MetadataBlock, MetadataEmbedder, MetadataError, MetadataExtractor, +}; -#[test] -fn a_gamut_re_encode_of_a_signed_parent_reads_back_as_unsigned_not_as_invalid() { +/// A gamut AVIF that c2pa-rs has signed, asserted `Valid` first so a later `JumbfNotFound` is +/// about the derivative rather than about a parent that never carried a store. +fn signed_parent() -> Vec { let parent = embed(AVIF_MIME, &plain_avif()).expect("c2pa-rs signs the parent"); assert_eq!( read(AVIF_MIME, &parent).expect("the parent carries a store"), ValidationState::Valid, - "the parent must be valid, or the derivative's outcome proves nothing" + "the parent must be valid, or a derivative's outcome proves nothing" ); + parent +} - // The derivative: the same pixels encoded again, with no C2PA knob set — which is every - // re-encode this crate can perform. - let derivative = plain_avif(); +/// The parent's manifest store, located by `gamut-avif` — the bytes a container hands the facade. +fn store_of(parent: &[u8]) -> Vec { + let container = AvifContainer::parse(parent).expect("the signed parent parses"); + let slot = container + .c2pa() + .expect("gamut-avif locates the parent's store"); + parent[slot.range].to_vec() +} + +/// Re-encodes the fixture, writing whatever C2PA block `embedder` returned for a model carrying +/// `store` — so the derivative's contents are downstream of the policy under test. +fn derivative_through(embedder: MetadataEmbedder, store: &[u8]) -> Vec { + let meta = MetadataExtractor::new() + .extract(&[MetadataBlock::C2pa(store)]) + .expect("a lone C2PA block extracts"); + let blocks = embedder.embed(&meta).expect("embedding the parent's model"); + + let rgb = source_rgb(); + let mut encoder = AvifEncoder::new(); + if let Some(forwarded) = blocks.c2pa.as_deref() { + encoder = encoder.with_c2pa(forwarded); + } + encoder + .encode_to_vec(ImageRef::::new(&rgb, dims()).expect("buffer matches dimensions")) + .expect("the derivative encodes") +} + +#[test] +fn a_derivative_built_from_the_parents_model_reads_back_as_unsigned_not_as_invalid() { + let parent = signed_parent(); + let derivative = derivative_through(MetadataEmbedder::new(), &store_of(&parent)); let error = read(AVIF_MIME, &derivative).expect_err("a derivative must carry no manifest store at all"); @@ -38,3 +100,21 @@ fn a_gamut_re_encode_of_a_signed_parent_reads_back_as_unsigned_not_as_invalid() store fails to validate; got {error}" ); } + +#[test] +fn the_reject_policy_refuses_the_parents_store_rather_than_losing_it_quietly() { + let parent = signed_parent(); + let store = store_of(&parent); + let meta = MetadataExtractor::new() + .extract(&[MetadataBlock::C2pa(&store)]) + .expect("a lone C2PA block extracts"); + + let error = MetadataEmbedder::new() + .c2pa_policy(C2paPolicy::Reject) + .embed(&meta) + .expect_err("`Reject` must refuse a model carrying a store"); + assert!( + matches!(error, MetadataError::UnembeddableC2pa { len } if len == store.len()), + "the refusal must name the parent's store, by the length gamut-avif located; got {error}" + ); +} From 429c5401dbf807f210bf5afde9d2292e042b3def Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:02:00 -0400 Subject: [PATCH 07/36] docs(c2pa-oracle): say what the crate's automated reach actually is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comments implied this crate is reached by CI the way the DNG conformance tier is. It is not: no workflow under .github/ names it, and being workspace-excluded with no dependents it is invisible to `clippy --workspace` and `test --workspace` too. Its whole automated reach is `fmt-tooling-check`. Both comments now say so and point at issue #541, which wires `check-c2pa` into the PR lane and `test-c2pa` into extended. README also gains four records the round-1 review asked for: c2pa-rs writes no padding after the store (the observation that lets a box-bounded and an LBox-bounded locator agree); §A.5.3 placement is out of scope here because c2pa-rs validates regardless, so gamut-avif's own suite is the authority; `ValidationState::Trusted` is unreachable by construction with `EphemeralSigner` and no trust list or certificate is checked in; and the spec basis for reading the two reserved JUMBF `LBox` values, with ISO 19566-5 noted as unvendored. Refs #447 --- mise.toml | 11 +++- tooling/c2pa-oracle/README.md | 112 ++++++++++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/mise.toml b/mise.toml index 6453c3d0..24aa9d73 100644 --- a/mise.toml +++ b/mise.toml @@ -220,9 +220,14 @@ run = "cargo check --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml description = "Cross-check gamut's C2PA carriage against c2pa-rs, both directions (issue #447)" run = "cargo test --manifest-path tooling/c2pa-oracle/Cargo.toml" -# The compile half, for the same reason `check-dng-real` exists: a `gamut-avif` or `gamut-heic` API -# change can break this crate while every per-PR gate stays green, and `check` catches that in -# seconds without signing anything. +# The compile half, shaped after `check-dng-real`: a `gamut-avif` or `gamut-heic` API change can +# break this crate while every per-PR gate stays green, and `check` catches that in seconds without +# signing anything. +# +# Unlike `check-dng-real`, **neither of these two tasks is called by any workflow in `.github/`.** +# The crate's whole automated reach today is `fmt-tooling-check`, which `fmt-check` hangs off, so a +# compile break or a differential regression is caught only when someone runs these by hand. +# Wiring them up — `check-c2pa` in the per-PR lint lane, `test-c2pa` in extended — is issue #541. [tasks.check-c2pa] description = "Compile the C2PA differential oracle (no signing, no corpus)" run = "cargo check --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets" diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 71ac1c40..60700635 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -10,9 +10,25 @@ manifest path: ```bash mise run test-c2pa # the differential tests -mise run check-c2pa # compile only, which is what the per-PR lint lane affords +mise run check-c2pa # compile only, in seconds, without signing anything + +# and, when a claim below needs re-deriving rather than re-checking: +cargo run --manifest-path tooling/c2pa-oracle/Cargo.toml --example probe ``` +**Nothing in CI runs either of those two tasks.** No workflow under `.github/` names this crate, +and being workspace-`exclude`d with no dependents it is invisible to `cargo clippy --workspace` and +`cargo test --workspace` as well. Its entire automated reach today is `fmt-tooling-check`, which +`mise run fmt-check` hangs off — formatting, and nothing else. Wiring `check-c2pa` into the per-PR +lint lane and `test-c2pa` into extended, mirroring `check-dng-real`/`test-dng-real`, is +[issue #541](https://github.com/visualcommons/gamut/issues/541); until it lands, run them by hand +after touching `gamut-avif` or `gamut-heic`. + +`examples/probe.rs` asserts nothing. It prints what c2pa-rs actually does with a gamut-written +AVIF — the composed box's framing bytes, both directions' offsets and lengths, what each locator +reports, and the layout of an update-manifest pair — so the findings recorded below can be checked +against a newer `c2pa-rs` without reading its source. + ## What it checks The epic splits the work in two: **gamut locates, bounds, carries and reserves** a C2PA manifest @@ -24,7 +40,7 @@ in both directions. | gamut reserves → an external signer completes → c2pa-rs validates | `tests/reserve_then_fill.rs` | a store signed over the reserved file validates once patched into the range `encode_with_report` gave, and exactly fills it | | c2pa-rs embeds → gamut locates the identical byte range | `tests/locate_embedded.rs` | `gamut-avif` and `gamut-heic` report the *same span* as the store's own JUMBF header, and c2pa-rs re-validates the bytes gamut extracted | | the box itself | `tests/box_framing.rs` | `gamut-avif`'s `ContentProvenanceBox` is byte-identical to c2pa-rs's for the same store | -| a derivative carries no parent store | `tests/no_copy_forward.rs` | a re-encode reads back as **unsigned** (`JumbfNotFound`), not as invalid | +| a derivative carries no parent store | `tests/no_copy_forward.rs` | the parent's located store, carried through `gamut-metadata`'s `C2paPolicy` and into a re-encode, reads back as **unsigned** (`JumbfNotFound`) — and `Reject` refuses it by name | | the build stays crypto-free where it must | `tests/build_configuration.rs` | the `c2pa` dependency never regains its default `openssl` feature, in the manifest and in the resolved graph | | the one BMFF layout gamut cannot discriminate | `tests/update_manifest.rs` | what c2pa-rs actually emits for `box_purpose = update` — see below | @@ -53,6 +69,47 @@ every store c2pa-rs writes opens `LBox` + `jumb`, which is the `TBox` check that bound self-checking. Neither observation makes the `LBox` bound self-checking on its own — that is issue #505 — but together they replace an assumption with evidence. +## The no-padding finding + +`gamut-avif` bounds a slot by the **box** that carries it; `gamut-heic` bounds a store by its own +JUMBF **`LBox`**. Those are different bounds, and `tests/locate_embedded.rs` asks both locators for +the same file and compares each against the same independently derived span. They agree — and the +reason they can is a second observation about the reference implementation, recorded here for the +same reason the `update`-purpose one is: + +> **c2pa-rs sizes the `ContentProvenanceBox` to the store exactly. It writes no padding between the +> end of the JUMBF superbox and the end of the box that carries it.** + +Nothing in C2PA 2.4 §A.5.1.2 requires that. A writer that reserved a slot larger than the store it +finally signed — which is exactly what `gamut-avif`'s own `with_c2pa_reserved` seam permits, and +what `C2paSlot::slot_bytes` documents as "the store, then any padding" — would produce a file where +the box-bounded and `LBox`-bounded answers legitimately differ. + +So the two claims are asserted separately. `gamut_avif_bounds_the_store_c2pa_rs_embedded_by_the_box_that_carries_it` +holds gamut only to *containment*: the slot begins where the store begins and holds every byte of +it. `c2pa_rs_leaves_no_padding_between_the_store_and_the_end_of_its_box` asserts the equality on its +own, and is named for c2pa-rs because c2pa-rs is what it measures. A future release that started +padding would fail that one test, and the diagnosis would be in its name rather than in a locator +test wrongly accusing gamut of mis-bounding. + +## What this oracle does not check + +Two limits, stated so nobody reads a green run as covering them. + +**Where the box sits.** C2PA 2.4 §A.5.3 constrains a `ContentProvenanceBox`'s *placement* among the +top-level boxes. c2pa-rs validates a store wherever it finds one, so no assertion here can +distinguish a conforming placement from a non-conforming one, and none tries: an oracle that cannot +see a property must not be read as having checked it. Placement is `gamut-avif`'s own suite's +business — `crates/gamut-avif/tests/c2pa.rs` — where the writer's byte layout is the subject. +`AvifContainer::c2pa_manifest_stores` deliberately reports a store outside that window as found, +with its true range, rather than rejecting it, so the container stays a lens over bytes. + +**Whose key signed the file.** `ValidationState::Trusted` is unreachable here *by construction* — +not merely unasserted — because the signing identity is ephemeral and on no trust list; see +[The signing identity](#the-signing-identity) below. Reaching it would need a trust list and a +committed certificate with a real expiry, a different subject belonging to a later slice of the +#239 epic. Nothing of the sort is checked into this tree, and no assertion here asks for it. + ## Why gamut owns the locate/bound step at all The obvious objection to `gamut-heic::HeifContainer::c2pa` and `gamut_avif::AvifContainer::c2pa` is @@ -93,12 +150,18 @@ in `tooling/`, where a dev-dependency tree costs a shipped consumer nothing. ## Build configuration is not optional ```toml -c2pa = { version = "0.90.21", default-features = false, features = ["rust_native_crypto"] } +c2pa = { version = "=0.90.21", default-features = false, features = ["rust_native_crypto"] } ``` -`c2pa`'s default feature set is `["openssl", "default_http"]`. The `openssl` feature pulls OpenSSL -in **vendored**, compiling it from C source into a dev build — precisely the thing the epic's -no-crypto criterion exists to keep out, and it would make this oracle the slowest thing in CI. +`c2pa`'s default feature set is `["openssl", "default_http"]`, and its own manifest declares + +```toml +openssl = { version = "0.10.80", features = ["vendored"], optional = true } +``` + +— so the default build compiles OpenSSL from C source into a dev build of this repository. That is +precisely the thing the epic's no-crypto criterion exists to keep out, and it would make this +oracle the slowest thing in CI. `rust_native_crypto` is the pure-Rust signing and verification backend that replaces it; dropping `default_http` additionally drops `reqwest` and `ureq`, which this oracle never needs because it resolves no remote manifests. @@ -107,6 +170,15 @@ resolves no remote manifests. only dependent in the repository, so nothing else can turn the feature back on by unification: the manifest line is the whole determinant, which is what makes a drift guard over it sufficient. +The version is pinned with `=`, not a caret range, and that is a separate guarantee from the +features. This file cites c2pa-rs's own source **by line number** — `src/validation_results.rs:36-41` +below, `src/jumbf_io.rs:246` and `:258` at the end — and a caret range lets any patch release move +those lines while the citation stands as written. There is no committed lockfile to hold the +resolution instead: `.gitignore` excludes `tooling/*/Cargo.lock`, because a workspace-excluded +oracle resolves standalone. The pin is the smaller fix and the one that keeps the prose honest; +`tests/build_configuration.rs` guards it too, so raising the version is a deliberate act that also +means re-reading every citation here. + ## The signing identity `c2pa::EphemeralSigner` mints a self-signed CA and an end-entity certificate in memory, Ed25519, @@ -136,3 +208,31 @@ lower-level entry point would tie this oracle to internals that carry no stabili `Builder` and `Reader` express everything the two directions need. Where the oracle needs an independent view of where a store sits, it derives it from the store's own JUMBF header — see `find_jumbf_superbox` in `src/lib.rs`. + +## Reading a JUMBF header, on the reference side + +That independent view is the one thing this crate parses itself, so it has to be right on inputs +c2pa-rs never produces: a wrong span here is an oracle handing gamut a wrong answer and calling it +the reference one. + +A JUMBF box is a JPEG-family *standard box*, and C2PA 2.4 §8.4.2.3 spells the syntax out where it +defines the C2PA salt as "a standard box consisting of: a box length (LBox, as a 4-byte big-endian +unsigned integer); a box type (TBox, 4-byte big-endian unsigned integer …)". The full grammar lives +in ISO 19566-5:2023, which is paywalled and not vendored here (its procurement is issue #441), and +it reserves two `LBox` values that a naive four-byte read gets wrong: + +- **`LBox == 0`** — the box runs to the end of the file, so its length is however much of the + buffer follows its own first byte; +- **`LBox == 1`** — the length is the 8-byte big-endian `XLBox` after `TBox`, counting the whole + box including that 16-byte header. + +`declared_store_len` implements both, refuses `LBox` 2..=7 (shorter than the header they sit in) +with a typed `OracleError::UnusableSuperboxLength`, and never returns a length it had to guess. +No store this crate has seen uses either reserved value — c2pa-rs writes a plain 32-bit `LBox` — +which is precisely why the handling is written rather than assumed, and why the arms are pinned by +unit tests in `src/lib.rs` rather than left to a fixture that cannot reach them. + +For the same reason `find_jumbf_superbox` **continues** past a `jumb` that appears too early to +carry an `LBox` in front of it, instead of concluding the buffer has no superbox. Today's fixtures +put a fixed ISOBMFF framing ahead of the store, so nothing can trip it; issue #534 points this +crate at PNG, TIFF and RIFF, where a store follows arbitrary compressed bytes. From c0f8b214f221971dd29b08c27dc832a5d10307f8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:39:25 -0400 Subject: [PATCH 08/36] fix(c2pa-oracle): refuse an XLBox shorter than the header it counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `declared_store_len` refused an `LBox` of 2..=7 — shorter than the 8-byte header it sits in — but took an `XLBox` at face value. `XLBox` of 0, 1, 4, 8 or 15 all came back verbatim, so a span of `8..8` (an empty store) or `8..12` was reported as a length, which is precisely the guessed answer the typed refusal exists to prevent. `gamut_isobmff`'s box reader applies one rule to both header sizes, `size < header_size`; apply it to both here too. Five values are pinned beside the existing arms, and 16 — the header alone, the smallest legal box — is pinned as accepted so the refusal cannot creep past it. Two error arms go with it. Both said a length "does not fit this platform's usize", and neither can fire: this is dev-only host tooling that is never cross-compiled, so `u32` and `u64` both reach `usize` losslessly. A compile- time assertion states that where the arms were, and defends something real. The rustdoc also over-claimed its source. C2PA 2.4 §8.4.2.3 mentions `LBox` once, in the salt sentence quoted, and `XLBox` not at all; the grammar with the reserved values is ISO 19566-5:2023, paywalled and not vendored here, procurement tracked as #441. Say so at the call site, as README.md already does, rather than attributing a convention to a clause that does not carry it. --- tooling/c2pa-oracle/src/lib.rs | 104 ++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 26 deletions(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index a4c6cfe2..9612888d 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -46,13 +46,13 @@ pub enum OracleError { /// found in it at all. Only [`split_composed_box`] raises this. NoJumbfSuperbox, /// A JUMBF superbox header is present, but the length it declares cannot be read: its - /// `LBox`/`XLBox` fields are truncated, `LBox` is one of the values ISO box syntax leaves - /// undefined (2..=7, all shorter than the 8-byte header they sit in), or the declared length - /// does not fit this platform's `usize`. Carries which of those it was. + /// `LBox`/`XLBox` fields are truncated, or the length it declares is shorter than the header + /// it is part of — `LBox` in 2..=7 against the 8-byte header, or `XLBox` below 16 against the + /// 16-byte one. Carries which of those it was. /// - /// This exists so the length is never *guessed*. A span silently derived from an - /// unrepresentable `LBox` would be an oracle handing gamut a wrong answer and calling it a - /// reference one; see [`declared_store_len`]. + /// This exists so the length is never *guessed*. A span silently derived from a length that + /// describes no box would be an oracle handing gamut a wrong answer and calling it a reference + /// one; see [`declared_store_len`]. UnusableSuperboxLength(&'static str), } @@ -250,10 +250,15 @@ pub fn split_composed_box(composed: Vec) -> Result { /// # The two reserved `LBox` values /// /// A JUMBF box is a JPEG-family *standard box* — `LBox` (4 bytes, big-endian), `TBox` (4 bytes), -/// then optionally `XLBox` — and C2PA 2.4 §8.4.2.3 spells that syntax out where it defines the -/// C2PA salt as "a standard box consisting of: a box length (LBox, as a 4-byte big-endian unsigned -/// integer); a box type (TBox, 4-byte big-endian unsigned integer …)". The same syntax reserves two -/// `LBox` values, and both are read here rather than taken at face value: +/// then optionally `XLBox`. C2PA 2.4 §8.4.2.3 is the only place the vendored specification writes +/// any of that down, and it writes down only part: defining the C2PA salt, it calls it "a standard +/// box consisting of: a box length (LBox, as a 4-byte big-endian unsigned integer); a box type +/// (TBox, 4-byte big-endian unsigned integer …)". It never mentions `XLBox`, and it states no +/// reserved `LBox` value. The full grammar — including both reserved values — is ISO 19566-5:2023, +/// which is paywalled and **not vendored in this repository**; its procurement is +/// [issue #441](https://github.com/visualcommons/gamut/issues/441). So the two arms below are the +/// convention as it is universally implemented, read against `c2pa-rs`'s behaviour, not a clause +/// this crate can cite: /// /// * **`LBox == 0`** — the box runs to the end of the file. `store` begins at the superbox's own /// first byte, so that end is the end of `store`, and the declared length is `store.len()`. @@ -261,18 +266,31 @@ pub fn split_composed_box(composed: Vec) -> Result { /// `store[8..16]`, and it counts the whole box including that 16-byte header. /// /// Taking either literally would return 0 or 1 as a length: a wrong span, produced silently, on -/// the side of the differential whose answers are treated as the reference. `LBox` values 2..=7 -/// are shorter than the header they sit in and describe no box at all, so they are refused rather -/// than resolved. +/// the side of the differential whose answers are treated as the reference. +/// +/// # Lengths shorter than the header they sit in +/// +/// A declared length counts the header, so it can never be less than one. Both header sizes are +/// refused on that one rule, the way `gamut_isobmff`'s box reader refuses `size < header_size`: +/// `LBox` in 2..=7 against the 8-byte header, and `XLBox` below 16 against the 16-byte one. +/// Accepting either would hand back a span that ends at or before the store's own first body byte +/// — an empty or four-byte "store" — which is exactly the guessed answer this function exists to +/// refuse. /// /// No store this crate has seen uses either reserved value — c2pa-rs writes a plain 32-bit `LBox` /// — which is exactly why the handling is here rather than assumed away. /// /// # Errors /// -/// [`OracleError::UnusableSuperboxLength`] when the `LBox`/`XLBox` fields are truncated, when -/// `LBox` is 2..=7, or when the declared length does not fit a `usize`. +/// [`OracleError::UnusableSuperboxLength`] when the `LBox`/`XLBox` fields are truncated, or when +/// the declared length is shorter than the header it counts. pub fn declared_store_len(store: &[u8]) -> Result { + // Both widths reach `usize` losslessly, so neither conversion below is fallible and neither + // needs a runtime arm. This is dev-only host tooling — nothing cross-compiles it — so the + // assumption is pinned here at compile time, where an error message about "this platform's + // usize" would only be defending something that cannot happen. + const _: () = assert!(usize::BITS >= u64::BITS); + let field: [u8; 4] = store .get(..4) .and_then(|field| field.try_into().ok()) @@ -282,23 +300,23 @@ pub fn declared_store_len(store: &[u8]) -> Result { match u32::from_be_bytes(field) { 0 => Ok(store.len()), - 1 => { - let field: [u8; 8] = store + 1 => match u64::from_be_bytes( + store .get(8..16) - .and_then(|field| field.try_into().ok()) + .and_then(|field| <[u8; 8]>::try_from(field).ok()) .ok_or(OracleError::UnusableSuperboxLength( "LBox is 1 but the XLBox field that carries the length is truncated", - ))?; - usize::try_from(u64::from_be_bytes(field)).map_err(|_| { - OracleError::UnusableSuperboxLength("XLBox does not fit this platform's usize") - }) - } + ))?, + ) { + 0..=15 => Err(OracleError::UnusableSuperboxLength( + "XLBox is below 16, shorter than the LBox+TBox+XLBox header it is part of", + )), + xlbox => Ok(xlbox as usize), + }, 2..=7 => Err(OracleError::UnusableSuperboxLength( "LBox is between 2 and 7, shorter than the LBox+TBox header it is part of", )), - lbox => usize::try_from(lbox).map_err(|_| { - OracleError::UnusableSuperboxLength("LBox does not fit this platform's usize") - }), + lbox => Ok(lbox as usize), } } @@ -519,6 +537,40 @@ mod tests { ); } + #[test] + fn an_xlbox_below_the_sixteen_byte_header_is_refused_rather_than_resolved() { + for xlbox in [0u64, 1, 8, 15] { + let mut store = vec![0u8; 40]; + store[..4].copy_from_slice(&1u32.to_be_bytes()); + store[4..8].copy_from_slice(b"jumb"); + store[8..16].copy_from_slice(&xlbox.to_be_bytes()); + + let error = declared_store_len(&store) + .expect_err("an XLBox below 16 is shorter than the header it counts"); + assert!( + error + .to_string() + .contains("shorter than the LBox+TBox+XLBox header"), + "the refusal must name the undersized XLBox rather than any other unusable \ + length; XLBox {xlbox} gave {error}" + ); + } + } + + #[test] + fn an_xlbox_of_exactly_the_header_size_is_a_length() { + let mut store = vec![0u8; 40]; + store[..4].copy_from_slice(&1u32.to_be_bytes()); + store[4..8].copy_from_slice(b"jumb"); + store[8..16].copy_from_slice(&16u64.to_be_bytes()); + + assert_eq!( + declared_store_len(&store).expect("16 is the header itself, the smallest legal box"), + 16, + "the refusal must stop exactly at the header size, not swallow the first legal length" + ); + } + #[test] fn an_lbox_between_two_and_seven_is_refused_rather_than_resolved() { let mut store = vec![0u8; 32]; From 6cbc377e45d2cb6f692517fcafaf6de0688db072 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:39:25 -0400 Subject: [PATCH 09/36] test(c2pa-oracle): drop the Reject duplicate and pin the model it needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crates/gamut-metadata/tests/roundtrip.rs` already asserts that `Reject` refuses a model carrying a store, and names the same `len` payload, from a synthetic 8-byte store with neither c2pa-rs nor an encoder in reach. The copy here minted an ephemeral chain, signed, read, encoded, parsed and located to reach a claim whose payload is a `usize`, and c2pa-rs judged nothing in it: five ways to fail for one thing being named. Its one contribution was proving `extract` populated the model, without which the surviving test passes vacuously — a policy asked to drop nothing drops nothing. That is now asserted where it is needed, in the helper that builds the derivative. --- tooling/c2pa-oracle/tests/no_copy_forward.rs | 40 ++++++-------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/tooling/c2pa-oracle/tests/no_copy_forward.rs b/tooling/c2pa-oracle/tests/no_copy_forward.rs index f6346878..9a2c1ffd 100644 --- a/tooling/c2pa-oracle/tests/no_copy_forward.rs +++ b/tooling/c2pa-oracle/tests/no_copy_forward.rs @@ -17,15 +17,14 @@ //! *what the embedder returned*. If [`C2paPolicy`] ever handed the store back, the derivative would //! carry it and c2pa-rs would find one, which is the failure this file exists to see. //! -//! Issue #428 names `C2paPolicy` as the mechanism, and it has two arms: -//! -//! * [`C2paPolicy::Drop`] (the default) — the store is not emitted, and the derivative reads back -//! as unsigned. That is the whole round trip, end to end. -//! * [`C2paPolicy::Reject`] — the same refusal, made loud, for a caller that must be told -//! provenance is being lost rather than discover it downstream. +//! Issue #428 names `C2paPolicy` as the mechanism, and its default arm — `Drop` — is what this +//! file drives end to end. Its other arm, `Reject`, refuses the same store loudly instead, and is +//! a claim about `gamut-metadata` alone: `crates/gamut-metadata/tests/roundtrip.rs` already pins +//! it against a synthetic store, with no c2pa-rs and no encoder in reach. Restating it here would +//! only re-run that assertion behind a signing chain that judges none of it. //! //! Forcing the forward (assigning the model's store to `EncodedMetadata::c2pa` by hand, the arm -//! `C2paPolicy` deliberately does not offer) makes the first test fail with c2pa-rs reporting +//! `C2paPolicy` deliberately does not offer) makes the test below fail with c2pa-rs reporting //! `Valid` — not `Invalid`. That is not a softening of the hazard, it is a sharpening of it: this //! fixture re-encodes deterministically, and a BMFF hard binding excludes the `ContentProvenanceBox` //! by box path, so a bit-identical derivative wearing its parent's store validates and presents @@ -44,9 +43,7 @@ use c2pa_oracle::{AVIF_MIME, embed, is_jumbf_not_found, read}; use common::{dims, plain_avif, source_rgb}; use gamut_avif::{AvifContainer, AvifEncoder}; use gamut_core::{EncodeImage, ImageRef, Rgb8}; -use gamut_metadata::{ - C2paPolicy, MetadataBlock, MetadataEmbedder, MetadataError, MetadataExtractor, -}; +use gamut_metadata::{MetadataBlock, MetadataEmbedder, MetadataExtractor}; /// A gamut AVIF that c2pa-rs has signed, asserted `Valid` first so a later `JumbfNotFound` is /// about the derivative rather than about a parent that never carried a store. @@ -75,6 +72,11 @@ fn derivative_through(embedder: MetadataEmbedder, store: &[u8]) -> Vec { let meta = MetadataExtractor::new() .extract(&[MetadataBlock::C2pa(store)]) .expect("a lone C2PA block extracts"); + assert!( + meta.c2pa.is_some(), + "the parent's store must be in the model handed to the embedder, or the policy is asked \ + to drop nothing and the derivative carries no store for a reason that is not the policy" + ); let blocks = embedder.embed(&meta).expect("embedding the parent's model"); let rgb = source_rgb(); @@ -100,21 +102,3 @@ fn a_derivative_built_from_the_parents_model_reads_back_as_unsigned_not_as_inval store fails to validate; got {error}" ); } - -#[test] -fn the_reject_policy_refuses_the_parents_store_rather_than_losing_it_quietly() { - let parent = signed_parent(); - let store = store_of(&parent); - let meta = MetadataExtractor::new() - .extract(&[MetadataBlock::C2pa(&store)]) - .expect("a lone C2PA block extracts"); - - let error = MetadataEmbedder::new() - .c2pa_policy(C2paPolicy::Reject) - .embed(&meta) - .expect_err("`Reject` must refuse a model carrying a store"); - assert!( - matches!(error, MetadataError::UnembeddableC2pa { len } if len == store.len()), - "the refusal must name the parent's store, by the length gamut-avif located; got {error}" - ); -} From 721097e284c2aee3d34a09bab7d684c95c568b0a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:39:38 -0400 Subject: [PATCH 10/36] test(c2pa-oracle): guard the offsets read ahead of the update store The three subtractions from `update.start` were unguarded, so a store gamut located within 15 bytes of the file start would abort the test with a subtraction overflow naming neither side of the differential. `box_framing.rs` writes a comment about exactly that hazard and guards with `checked_sub`; derive all three offsets from one guarded base the same way, so a wrong location is reported as the defect it is. --- tooling/c2pa-oracle/tests/update_manifest.rs | 21 +++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tooling/c2pa-oracle/tests/update_manifest.rs b/tooling/c2pa-oracle/tests/update_manifest.rs index be5cd3a5..b1c6bc90 100644 --- a/tooling/c2pa-oracle/tests/update_manifest.rs +++ b/tooling/c2pa-oracle/tests/update_manifest.rs @@ -63,22 +63,29 @@ fn c2pa_rs_writes_the_merkle_offset_in_front_of_an_update_store_too() { let asset = mid_update_avif().expect("c2pa-rs produces a mid-update file"); let update = update_slot(&asset).expect("gamut locates the `update` store"); - // The eight bytes immediately before the store are the merkle offset §A.5.3 states for the - // other two purposes, written as zero because a still image carries no `merkle` box. Ahead of - // them is the NUL that terminates the `box_purpose` string. + // The fifteen bytes ahead of the store are `update`, the NUL terminating `box_purpose`, and + // the 8-byte merkle offset §A.5.3 states for the other two purposes. Each end is checked + // before slicing, the way `box_framing.rs` does it: a store gamut located too close to the + // start of the file is a defect this test should *report*, not one it should panic on with a + // subtraction overflow that names no side. + let purpose = update + .start + .checked_sub(15) + .expect("the `box_purpose` string begins inside the file"); + let (nul, merkle) = (purpose + 6, purpose + 7); + assert_eq!( - &asset[update.start - 8..update.start], + &asset[merkle..update.start], &[0u8; 8], "the reference implementation writes an 8-byte merkle offset in front of an `update` \ store, so the offset-less layout gamut cannot discriminate is not one it emits" ); assert_eq!( - asset[update.start - 9], - 0, + asset[nul], 0, "and immediately before it, the NUL terminating `box_purpose`" ); assert_eq!( - &asset[update.start - 15..update.start - 9], + &asset[purpose..nul], b"update", "the purpose really is `update`" ); From 84178457bda04b703cb96dd0384e84b0184b5913 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:39:38 -0400 Subject: [PATCH 11/36] docs(c2pa-oracle): record the header minimums and the span's unchecked bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to what the README claims. The JUMBF-header section now states one rule for both header sizes rather than only the 8-byte one. The test table no longer credits `tests/no_copy_forward.rs` with the `Reject` arm it no longer restates. And a third limit joins "What this oracle does not check": `jumbf_superbox_span` bounds a store against the end of the buffer, never against the end of the `ContentProvenanceBox` around it. Closing that would mean parsing ISOBMFF framing here, making the oracle depend on the structural understanding it exists to check independently — a second copy of the walk proves nothing about the first. The independence is worth more than the check, so the limit is accepted and written down; #534, which points this crate at PNG, TIFF and RIFF, is where a store followed by further container bytes first appears. --- tooling/c2pa-oracle/README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 60700635..054bdd9b 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -40,7 +40,7 @@ in both directions. | gamut reserves → an external signer completes → c2pa-rs validates | `tests/reserve_then_fill.rs` | a store signed over the reserved file validates once patched into the range `encode_with_report` gave, and exactly fills it | | c2pa-rs embeds → gamut locates the identical byte range | `tests/locate_embedded.rs` | `gamut-avif` and `gamut-heic` report the *same span* as the store's own JUMBF header, and c2pa-rs re-validates the bytes gamut extracted | | the box itself | `tests/box_framing.rs` | `gamut-avif`'s `ContentProvenanceBox` is byte-identical to c2pa-rs's for the same store | -| a derivative carries no parent store | `tests/no_copy_forward.rs` | the parent's located store, carried through `gamut-metadata`'s `C2paPolicy` and into a re-encode, reads back as **unsigned** (`JumbfNotFound`) — and `Reject` refuses it by name | +| a derivative carries no parent store | `tests/no_copy_forward.rs` | the parent's located store, carried through `gamut-metadata`'s `C2paPolicy` and into a re-encode, reads back as **unsigned** (`JumbfNotFound`) | | the build stays crypto-free where it must | `tests/build_configuration.rs` | the `c2pa` dependency never regains its default `openssl` feature, in the manifest and in the resolved graph | | the one BMFF layout gamut cannot discriminate | `tests/update_manifest.rs` | what c2pa-rs actually emits for `box_purpose = update` — see below | @@ -94,7 +94,7 @@ test wrongly accusing gamut of mis-bounding. ## What this oracle does not check -Two limits, stated so nobody reads a green run as covering them. +Three limits, stated so nobody reads a green run as covering them. **Where the box sits.** C2PA 2.4 §A.5.3 constrains a `ContentProvenanceBox`'s *placement* among the top-level boxes. c2pa-rs validates a store wherever it finds one, so no assertion here can @@ -110,6 +110,16 @@ not merely unasserted — because the signing identity is ephemeral and on no tr committed certificate with a real expiry, a different subject belonging to a later slice of the #239 epic. Nothing of the sort is checked into this tree, and no assertion here asks for it. +**That a store ends inside its enclosing box.** `jumbf_superbox_span` takes the store's declared +length at its word and checks it only against the end of the *buffer*, never against the end of the +`ContentProvenanceBox` around it. A store whose `LBox` overruns its own box but still fits the file +is reported as a span. Bounding it properly would mean parsing ISOBMFF framing here — which would +make this oracle depend on exactly the structural understanding it exists to check independently, +and a second copy of gamut's §A.5.1.2 walk proves nothing about the first. The independence is +worth more than the extra check, so the limit is accepted rather than closed. It costs nothing on +today's fixtures, whose stores run to the end of their box; issue #534, which points this crate at +PNG, TIFF and RIFF, is where a store followed by more container bytes first appears. + ## Why gamut owns the locate/bound step at all The obvious objection to `gamut-heic::HeifContainer::c2pa` and `gamut_avif::AvifContainer::c2pa` is @@ -226,8 +236,11 @@ it reserves two `LBox` values that a naive four-byte read gets wrong: - **`LBox == 1`** — the length is the 8-byte big-endian `XLBox` after `TBox`, counting the whole box including that 16-byte header. -`declared_store_len` implements both, refuses `LBox` 2..=7 (shorter than the header they sit in) -with a typed `OracleError::UnusableSuperboxLength`, and never returns a length it had to guess. +`declared_store_len` implements both, and applies one rule to both header sizes — the rule +`gamut_isobmff`'s box reader already applies, that a length counting a header can never be less +than that header: `LBox` in 2..=7 against the 8-byte header, `XLBox` below 16 against the 16-byte +one. Either would otherwise yield a span ending at or before the store's first body byte. Both +refusals are the typed `OracleError::UnusableSuperboxLength`, and no length is ever guessed. No store this crate has seen uses either reserved value — c2pa-rs writes a plain 32-bit `LBox` — which is precisely why the handling is written rather than assumed, and why the arms are pinned by unit tests in `src/lib.rs` rather than left to a fixture that cannot reach them. From f87c08a3953c4530e21a5f0717d6583d1deae3b9 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:39:38 -0400 Subject: [PATCH 12/36] docs(c2pa-oracle): stop the root manifest claiming CI runs the oracle The `[workspace].exclude` comment said `mise run check-c2pa` "is the compile-only half the per-PR lint lane affords", in the present tense. `grep -rn c2pa .github/` returns nothing: no workflow calls either task, which is what `tooling/c2pa-oracle/README.md` and #541 both say. Say the same here. --- Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a880a8fd..b6353e07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,8 @@ exclude = [ # dependency tree carrying signing and verification crypto, and the epic's "no crypto in the # shipped graph" criterion means no shipped crate may ever reach it. Being outside the # workspace also puts it outside `mise run check-release-deps`. Run it with `mise run - # test-c2pa`; `mise run check-c2pa` is the compile-only half the per-PR lint lane affords. + # test-c2pa`, or `mise run check-c2pa` for the compile-only half. No workflow under `.github/` + # calls either task yet, so nothing in CI reaches this crate; wiring them up is issue #541. "tooling/c2pa-oracle", "tooling/gamut-iptc-oracle", "tooling/zlib-oracle", From c587591eac27aea31e7311bfb2ad2b8ab0a2d903 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:40:46 -0400 Subject: [PATCH 13/36] refactor(c2pa-oracle): follow gamut-avif's renamed C2PA read accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AvifContainer::c2pa` became `c2pa_slot` and `c2pa_manifest_stores` became `c2pa_slots` on the base branch, for what they report. `gamut-heic` keeps the old names — its bounds differ — so only the AVIF call sites move. --- tooling/c2pa-oracle/README.md | 2 +- tooling/c2pa-oracle/examples/probe.rs | 4 ++-- tooling/c2pa-oracle/tests/locate_embedded.rs | 9 ++++++--- tooling/c2pa-oracle/tests/no_copy_forward.rs | 2 +- tooling/c2pa-oracle/tests/update_manifest.rs | 7 ++----- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 054bdd9b..4b00b20f 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -101,7 +101,7 @@ top-level boxes. c2pa-rs validates a store wherever it finds one, so no assertio distinguish a conforming placement from a non-conforming one, and none tries: an oracle that cannot see a property must not be read as having checked it. Placement is `gamut-avif`'s own suite's business — `crates/gamut-avif/tests/c2pa.rs` — where the writer's byte layout is the subject. -`AvifContainer::c2pa_manifest_stores` deliberately reports a store outside that window as found, +`AvifContainer::c2pa_slots` deliberately reports a store outside that window as found, with its true range, rather than rejecting it, so the container stays a lens over bytes. **Whose key signed the file.** `ValidationState::Trusted` is unreachable here *by construction* — diff --git a/tooling/c2pa-oracle/examples/probe.rs b/tooling/c2pa-oracle/examples/probe.rs index e6d5e243..a34be2cd 100644 --- a/tooling/c2pa-oracle/examples/probe.rs +++ b/tooling/c2pa-oracle/examples/probe.rs @@ -107,7 +107,7 @@ fn main() -> Result<()> { println!("direction 2: independent JUMBF span {span:?}"); match AvifContainer::parse(&signed) { - Ok(container) => match container.c2pa() { + Ok(container) => match container.c2pa_slot() { Some(slot) => println!( "gamut-avif slot: range {:?}, {} bytes, purpose {:?}, LBox {:?}, tail zeros {}", slot.range, @@ -154,7 +154,7 @@ fn main() -> Result<()> { println!("update: {} bytes", updated.len()); match AvifContainer::parse(&updated) { Ok(container) => { - for slot in container.c2pa_manifest_stores() { + for slot in container.c2pa_slots() { println!( " update slot: range {:?}, purpose {:?}, LBox {:?}", slot.range, diff --git a/tooling/c2pa-oracle/tests/locate_embedded.rs b/tooling/c2pa-oracle/tests/locate_embedded.rs index c134abd6..426da94a 100644 --- a/tooling/c2pa-oracle/tests/locate_embedded.rs +++ b/tooling/c2pa-oracle/tests/locate_embedded.rs @@ -44,7 +44,7 @@ fn gamut_avif_bounds_the_store_c2pa_rs_embedded_by_the_box_that_carries_it() { let expected = jumbf_superbox_span(&asset).expect("the signed asset carries a JUMBF superbox"); let container = AvifContainer::parse(&asset).expect("the signed asset parses"); - let slot = container.c2pa().expect("gamut-avif locates the slot"); + let slot = container.c2pa_slot().expect("gamut-avif locates the slot"); // `gamut-avif` bounds the slot by the *box*, so it reports the store and anything the writer // left after it (`C2paSlot::slot_bytes`: "the store, then any padding"). The claim this test @@ -76,7 +76,7 @@ fn c2pa_rs_leaves_no_padding_between_the_store_and_the_end_of_its_box() { let expected = jumbf_superbox_span(&asset).expect("the signed asset carries a JUMBF superbox"); let container = AvifContainer::parse(&asset).expect("the signed asset parses"); - let slot = container.c2pa().expect("gamut-avif locates the slot"); + let slot = container.c2pa_slot().expect("gamut-avif locates the slot"); // An observation about the reference implementation, recorded in `README.md` beside the // `update`-purpose finding and asserted here for the same reason: it is what makes the @@ -114,7 +114,10 @@ fn gamut_heic_reports_the_exact_span_c2pa_rs_embedded() { fn c2pa_rs_validates_the_store_read_out_of_gamut_avifs_reported_range() { let asset = signed_avif(); let container = AvifContainer::parse(&asset).expect("the signed asset parses"); - let range = container.c2pa().expect("gamut-avif locates the slot").range; + let range = container + .c2pa_slot() + .expect("gamut-avif locates the slot") + .range; let located = asset[range].to_vec(); // The sharpest form of the claim, and it is `range` that is exercised: the bytes are cut out diff --git a/tooling/c2pa-oracle/tests/no_copy_forward.rs b/tooling/c2pa-oracle/tests/no_copy_forward.rs index 9a2c1ffd..9e0d239f 100644 --- a/tooling/c2pa-oracle/tests/no_copy_forward.rs +++ b/tooling/c2pa-oracle/tests/no_copy_forward.rs @@ -61,7 +61,7 @@ fn signed_parent() -> Vec { fn store_of(parent: &[u8]) -> Vec { let container = AvifContainer::parse(parent).expect("the signed parent parses"); let slot = container - .c2pa() + .c2pa_slot() .expect("gamut-avif locates the parent's store"); parent[slot.range].to_vec() } diff --git a/tooling/c2pa-oracle/tests/update_manifest.rs b/tooling/c2pa-oracle/tests/update_manifest.rs index b1c6bc90..415a7429 100644 --- a/tooling/c2pa-oracle/tests/update_manifest.rs +++ b/tooling/c2pa-oracle/tests/update_manifest.rs @@ -52,7 +52,7 @@ fn update_slot(asset: &[u8]) -> Result> { let container = AvifContainer::parse(asset).map_err(|error| OracleError::Asset(error.to_string()))?; container - .c2pa_manifest_stores() + .c2pa_slots() .find(|slot| slot.purpose == C2paBoxPurpose::Update) .map(|slot| slot.range) .ok_or_else(|| OracleError::Asset("no `update` store in the mid-update file".into())) @@ -114,10 +114,7 @@ fn the_earlier_store_is_relabelled_original_when_an_update_box_is_added() { // §A.5.3: once a file carries an `update` box, the store it had before is re-labelled // `original`. Both are reported, in file order, and neither is judged — which is why // `AvifContainer::c2pa` promises only "the first one". - let purposes: Vec<_> = container - .c2pa_manifest_stores() - .map(|slot| slot.purpose) - .collect(); + let purposes: Vec<_> = container.c2pa_slots().map(|slot| slot.purpose).collect(); assert_eq!( purposes, vec![C2paBoxPurpose::Original, C2paBoxPurpose::Update], From 06bc87424e74fd2522a21731ddd0409ff7eb8b14 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:46:25 -0400 Subject: [PATCH 14/36] fix(c2pa-oracle): report an overrunning length as a length, not as absence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `jumbf_superbox_span` raised `NoJumbfSuperbox` when a superbox's declared length ran past the end of the buffer. There is a superbox — the search found its `LBox` and `jumb` — and it is the length that is unusable, which is exactly what `UnusableSuperboxLength` was introduced to name. Reporting it as absence misattributes on the reference side of the differential, where a wrong answer becomes gamut's fault. `NoJumbfSuperbox`'s doc also claimed a single raising function while two raised it; it now names `find_jumbf_superbox` as the only one and says the other two propagate it. --- tooling/c2pa-oracle/src/lib.rs | 43 +++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index 9612888d..d12242ac 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -42,13 +42,19 @@ pub enum OracleError { /// Raised by a caller's closure, and by [`reserve_then_fill`] when a signed store and the slot /// reserved for it are not the same length. Asset(String), - /// A composed `ContentProvenanceBox` carried no JUMBF superbox: no [`JUMBF_SUPERBOX_TYPE`] was - /// found in it at all. Only [`split_composed_box`] raises this. + /// A composed `ContentProvenanceBox` — or any other buffer — carried no JUMBF superbox: no + /// [`JUMBF_SUPERBOX_TYPE`] was found in it at all. + /// + /// [`find_jumbf_superbox`] is the only function that raises this; [`split_composed_box`] and + /// [`jumbf_superbox_span`] call it and propagate the refusal unchanged. It is strictly about + /// *absence*: a superbox that is present but declares a length nothing can use is + /// [`UnusableSuperboxLength`](Self::UnusableSuperboxLength) instead, so the two are never + /// conflated. NoJumbfSuperbox, - /// A JUMBF superbox header is present, but the length it declares cannot be read: its - /// `LBox`/`XLBox` fields are truncated, or the length it declares is shorter than the header - /// it is part of — `LBox` in 2..=7 against the 8-byte header, or `XLBox` below 16 against the - /// 16-byte one. Carries which of those it was. + /// A JUMBF superbox header is present, but the length it declares cannot be used: its + /// `LBox`/`XLBox` fields are truncated, the length is shorter than the header it is part of — + /// `LBox` in 2..=7 against the 8-byte header, or `XLBox` below 16 against the 16-byte one — or + /// the length runs past the end of the buffer it is read from. Carries which of those it was. /// /// This exists so the length is never *guessed*. A span silently derived from a length that /// describes no box would be an oracle handing gamut a wrong answer and calling it a reference @@ -215,16 +221,20 @@ pub fn find_jumbf_superbox(buffer: &[u8]) -> Result { /// /// # Errors /// -/// [`OracleError::NoJumbfSuperbox`] if no superbox is found, or if the length it declares runs off -/// the end of `buffer`; [`OracleError::UnusableSuperboxLength`] if that length cannot be read at -/// all (see [`declared_store_len`]). +/// [`OracleError::NoJumbfSuperbox`] if no superbox is found at all; +/// [`OracleError::UnusableSuperboxLength`] if the superbox is there but its declared length cannot +/// be read (see [`declared_store_len`]) or runs past the end of `buffer`. A superbox that is +/// present but unbounded is never reported as one that is absent: `buffer` demonstrably carries a +/// store, and it is the *length* that is unusable — which is the case that variant exists to name. pub fn jumbf_superbox_span(buffer: &[u8]) -> Result> { let start = find_jumbf_superbox(buffer)?; let len = declared_store_len(&buffer[start..])?; let end = start .checked_add(len) .filter(|end| *end <= buffer.len()) - .ok_or(OracleError::NoJumbfSuperbox)?; + .ok_or(OracleError::UnusableSuperboxLength( + "the declared length runs past the end of the buffer", + ))?; Ok(start..end) } @@ -588,17 +598,18 @@ mod tests { } #[test] - fn a_declared_length_running_past_the_buffer_is_not_a_span() { + fn a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox() { let mut buffer = vec![0u8; 32]; buffer[..4].copy_from_slice(&4096u32.to_be_bytes()); buffer[4..8].copy_from_slice(b"jumb"); + let error = jumbf_superbox_span(&buffer) + .expect_err("a length that runs off the end of the buffer bounds nothing"); assert!( - matches!( - jumbf_superbox_span(&buffer), - Err(OracleError::NoJumbfSuperbox) - ), - "a length that runs off the end of the buffer bounds nothing" + matches!(&error, OracleError::UnusableSuperboxLength(what) + if what.contains("runs past the end of the buffer")), + "the buffer plainly carries a superbox, so the refusal must name its unusable length \ + rather than report the store as absent; got {error}" ); } } From 400c10fd9fe4d6f0ffb8e1cf0a07f5f5e5660d9d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:46:43 -0400 Subject: [PATCH 15/36] fix(c2pa-oracle): refuse a to-end-of-buffer length below its own header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `declared_store_len` applied the header minimum to the `LBox` 2..=7 and `XLBox` below 16 arms but not to `LBox == 0`, so a 4-to-7-byte buffer read back as `Ok(4)`..`Ok(7)` — a declared length shorter than the 8-byte header it counts, which is the defect the other two arms exist to refuse. The to-end-of-buffer reading is still a length counting that header, so it obeys the same rule. The 2..=7 arm also had a single mid-range example behind it, so widening or narrowing that range by one survived the suite. Both sides of both refusals now have their own test: a sweep over every reserved value, and a boundary case at the smallest legal length. --- tooling/c2pa-oracle/src/lib.rs | 92 +++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 17 deletions(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index d12242ac..8e06ad80 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -53,8 +53,9 @@ pub enum OracleError { NoJumbfSuperbox, /// A JUMBF superbox header is present, but the length it declares cannot be used: its /// `LBox`/`XLBox` fields are truncated, the length is shorter than the header it is part of — - /// `LBox` in 2..=7 against the 8-byte header, or `XLBox` below 16 against the 16-byte one — or - /// the length runs past the end of the buffer it is read from. Carries which of those it was. + /// `LBox` in 2..=7 against the 8-byte header, `XLBox` below 16 against the 16-byte one, or + /// `LBox == 0` in a buffer that ends inside that 8-byte header — or the length runs past the + /// end of the buffer it is read from. Carries which of those it was. /// /// This exists so the length is never *guessed*. A span silently derived from a length that /// describes no box would be an oracle handing gamut a wrong answer and calling it a reference @@ -280,12 +281,18 @@ pub fn split_composed_box(composed: Vec) -> Result { /// /// # Lengths shorter than the header they sit in /// -/// A declared length counts the header, so it can never be less than one. Both header sizes are -/// refused on that one rule, the way `gamut_isobmff`'s box reader refuses `size < header_size`: -/// `LBox` in 2..=7 against the 8-byte header, and `XLBox` below 16 against the 16-byte one. -/// Accepting either would hand back a span that ends at or before the store's own first body byte -/// — an empty or four-byte "store" — which is exactly the guessed answer this function exists to -/// refuse. +/// A declared length counts the header, so it can never be less than one. **Every** arm is refused +/// on that one rule, the way `gamut_isobmff`'s box reader refuses `size < header_size`: +/// +/// * `LBox` in 2..=7, against the 8-byte header; +/// * `XLBox` below 16, against the 16-byte one; +/// * `LBox == 0` in a buffer of fewer than 8 bytes — the to-end-of-buffer length is still a length +/// counting the 8-byte header, so a 4-to-7-byte buffer declares a box shorter than its own +/// header just as literally as an `LBox` of 7 does. +/// +/// Accepting any of them would hand back a span that ends at or before the store's own first body +/// byte — an empty or four-byte "store" — which is exactly the guessed answer this function exists +/// to refuse. /// /// No store this crate has seen uses either reserved value — c2pa-rs writes a plain 32-bit `LBox` /// — which is exactly why the handling is here rather than assumed away. @@ -309,7 +316,12 @@ pub fn declared_store_len(store: &[u8]) -> Result { ))?; match u32::from_be_bytes(field) { - 0 => Ok(store.len()), + 0 => match store.len() { + 0..=7 => Err(OracleError::UnusableSuperboxLength( + "LBox is 0 but the buffer ends inside the LBox+TBox header it would count", + )), + to_end_of_buffer => Ok(to_end_of_buffer), + }, 1 => match u64::from_be_bytes( store .get(8..16) @@ -583,17 +595,63 @@ mod tests { #[test] fn an_lbox_between_two_and_seven_is_refused_rather_than_resolved() { + for lbox in [2u32, 3, 4, 5, 6, 7] { + let mut store = vec![0u8; 32]; + store[..4].copy_from_slice(&lbox.to_be_bytes()); + store[4..8].copy_from_slice(b"jumb"); + + let error = declared_store_len(&store) + .expect_err("an LBox below 8 is shorter than the header it counts"); + assert!( + error + .to_string() + .contains("shorter than the LBox+TBox header"), + "the refusal must name the undersized LBox rather than any other unusable length; \ + LBox {lbox} gave {error}" + ); + } + } + + #[test] + fn an_lbox_of_exactly_the_header_size_is_a_length() { let mut store = vec![0u8; 32]; - store[..4].copy_from_slice(&7u32.to_be_bytes()); + store[..4].copy_from_slice(&8u32.to_be_bytes()); store[4..8].copy_from_slice(b"jumb"); - let error = declared_store_len(&store).expect_err("7 is shorter than the header itself"); - assert!( - error - .to_string() - .contains("shorter than the LBox+TBox header"), - "the refusal must name the undersized LBox rather than any other unusable length; got \ - {error}" + assert_eq!( + declared_store_len(&store).expect("8 is the header itself, the smallest legal box"), + 8, + "the refusal must stop exactly at the header size, not swallow the first legal length" + ); + } + + #[test] + fn an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused() { + for len in [4usize, 5, 6, 7] { + let store = vec![0u8; len]; + + let error = declared_store_len(&store) + .expect_err("the bytes to the end of the buffer do not reach the header itself"); + assert!( + error + .to_string() + .contains("the buffer ends inside the LBox+TBox header"), + "the to-end-of-buffer length obeys the same header minimum as the other arms, and \ + the refusal must name it; a {len}-byte buffer gave {error}" + ); + } + } + + #[test] + fn an_lbox_of_zero_in_a_buffer_of_exactly_the_header_size_is_a_length() { + let mut store = vec![0u8; 8]; + store[4..8].copy_from_slice(b"jumb"); + + assert_eq!( + declared_store_len(&store) + .expect("8 bytes is the header itself, the smallest legal box"), + 8, + "the refusal must stop exactly at the header size, not swallow the first legal length" ); } From 5cda4bb42c2fcdbd382f59ddadc2a2d5cd6dbc91 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:46:53 -0400 Subject: [PATCH 16/36] test(c2pa-oracle): compare the model's store against the parent's bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard asserting the parent's store reached the model checked only `is_some()`, while its message claimed the parent's store "must be in the model". `MetadataExtractor` carries a C2PA block through whatever its length, so a locator returning an empty vector left the test passing: it killed "no block arrived", not "the parent's bytes are what the embedder saw". It now compares the model's bytes against the oracle's own reading of the parent — the store's JUMBF header, not gamut's ISOBMFF walk. Comparing back against the value the model was built from would say nothing. --- tooling/c2pa-oracle/tests/no_copy_forward.rs | 29 ++++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/tooling/c2pa-oracle/tests/no_copy_forward.rs b/tooling/c2pa-oracle/tests/no_copy_forward.rs index 9e0d239f..e22f9f83 100644 --- a/tooling/c2pa-oracle/tests/no_copy_forward.rs +++ b/tooling/c2pa-oracle/tests/no_copy_forward.rs @@ -39,7 +39,7 @@ mod common; use c2pa::ValidationState; -use c2pa_oracle::{AVIF_MIME, embed, is_jumbf_not_found, read}; +use c2pa_oracle::{AVIF_MIME, embed, is_jumbf_not_found, jumbf_superbox_span, read}; use common::{dims, plain_avif, source_rgb}; use gamut_avif::{AvifContainer, AvifEncoder}; use gamut_core::{EncodeImage, ImageRef, Rgb8}; @@ -67,15 +67,26 @@ fn store_of(parent: &[u8]) -> Vec { } /// Re-encodes the fixture, writing whatever C2PA block `embedder` returned for a model carrying -/// `store` — so the derivative's contents are downstream of the policy under test. -fn derivative_through(embedder: MetadataEmbedder, store: &[u8]) -> Vec { +/// the store `gamut-avif` located in `parent` — so the derivative's contents are downstream of the +/// policy under test. +fn derivative_through(embedder: MetadataEmbedder, parent: &[u8]) -> Vec { + let store = store_of(parent); let meta = MetadataExtractor::new() - .extract(&[MetadataBlock::C2pa(store)]) + .extract(&[MetadataBlock::C2pa(&store)]) .expect("a lone C2PA block extracts"); - assert!( - meta.c2pa.is_some(), - "the parent's store must be in the model handed to the embedder, or the policy is asked \ - to drop nothing and the derivative carries no store for a reason that is not the policy" + + // Compared against the oracle's *own* reading of the parent — the store's JUMBF header, not + // gamut's ISOBMFF walk — so this says the parent's bytes are what the embedder saw. Comparing + // the model back against `store` would say nothing: the model is built from it, so the two + // agree however wrong `store_of` is. `meta.c2pa.is_some()` says even less, because + // `MetadataExtractor` carries a C2PA block through whatever its length, so an empty or + // truncated store satisfies it and the policy is then asked to drop nothing. + let expected = jumbf_superbox_span(parent).expect("the signed parent carries a JUMBF store"); + assert_eq!( + meta.c2pa.as_deref(), + Some(&parent[expected]), + "the model handed to the embedder must carry the parent's store byte for byte, or the \ + derivative carries no store for a reason that is not the policy" ); let blocks = embedder.embed(&meta).expect("embedding the parent's model"); @@ -92,7 +103,7 @@ fn derivative_through(embedder: MetadataEmbedder, store: &[u8]) -> Vec { #[test] fn a_derivative_built_from_the_parents_model_reads_back_as_unsigned_not_as_invalid() { let parent = signed_parent(); - let derivative = derivative_through(MetadataEmbedder::new(), &store_of(&parent)); + let derivative = derivative_through(MetadataEmbedder::new(), &parent); let error = read(AVIF_MIME, &derivative).expect_err("a derivative must carry no manifest store at all"); From f9895c410b2109abb166a8ed10ae6f3910de1261 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:46:53 -0400 Subject: [PATCH 17/36] docs(c2pa-oracle): cite the vendored specification for only what it says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two prose references still named `AvifContainer::c2pa`, which the base merge renamed to `c2pa_slot`; the README updated its other reference to the same method in that commit, so this finishes the sweep. The companion reference beside it, to `HeifContainer::c2pa`, is correct and stays. The README also said C2PA 2.4 §8.4.2.3 "spells the syntax out" and then listed the oversized-header field as part of it. `LBox` appears exactly once in the vendored document and `XLBox` not at all: the section writes down the 4-byte length and the type, mentions no oversized-header field and states no reserved value. The rustdoc already said so; the README now matches it, and records the header minimum as one rule over every arm. --- tooling/c2pa-oracle/README.md | 38 ++++++++++++-------- tooling/c2pa-oracle/tests/update_manifest.rs | 2 +- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 4b00b20f..c62442f2 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -122,10 +122,10 @@ PNG, TIFF and RIFF, is where a store followed by more container bytes first appe ## Why gamut owns the locate/bound step at all -The obvious objection to `gamut-heic::HeifContainer::c2pa` and `gamut_avif::AvifContainer::c2pa` is -that they duplicate something `c2pa-rs` already does, and that a consumer who wants the store could -just call the reference implementation. That objection is wrong, and it is written down here so -nobody deletes gamut's locator as redundant later. +The obvious objection to `gamut-heic::HeifContainer::c2pa` and +`gamut_avif::AvifContainer::c2pa_slot` is that they duplicate something `c2pa-rs` already does, and +that a consumer who wants the store could just call the reference implementation. That objection is +wrong, and it is written down here so nobody deletes gamut's locator as redundant later. **`c2pa-rs` has no cheap parse-only mode.** Its reading entry point is `Reader`, which validates: it parses the store, checks the hard binding against the asset, verifies the COSE signature and @@ -225,25 +225,33 @@ That independent view is the one thing this crate parses itself, so it has to be c2pa-rs never produces: a wrong span here is an oracle handing gamut a wrong answer and calling it the reference one. -A JUMBF box is a JPEG-family *standard box*, and C2PA 2.4 §8.4.2.3 spells the syntax out where it -defines the C2PA salt as "a standard box consisting of: a box length (LBox, as a 4-byte big-endian -unsigned integer); a box type (TBox, 4-byte big-endian unsigned integer …)". The full grammar lives -in ISO 19566-5:2023, which is paywalled and not vendored here (its procurement is issue #441), and -it reserves two `LBox` values that a naive four-byte read gets wrong: +A JUMBF box is a JPEG-family *standard box* — `LBox` (4 bytes, big-endian), `TBox` (4 bytes), then +optionally `XLBox`. C2PA 2.4 §8.4.2.3 is the only place the vendored specification writes any of +that down, and it writes down only part: defining the C2PA salt, it calls it "a standard box +consisting of: a box length (LBox, as a 4-byte big-endian unsigned integer); a box type (TBox, +4-byte big-endian unsigned integer …)". That is the whole of it — `LBox` occurs exactly once in +`references/c2pa/C2PA_Specification_2.4.html` and `XLBox` not at all — so the vendored document +never mentions the oversized-header field and states no reserved `LBox` value. The full grammar, +*including* the two reserved values, is ISO 19566-5:2023, which is paywalled and not vendored here +(its procurement is issue #441). The two arms below are therefore the convention as it is +universally implemented, read against `c2pa-rs`'s behaviour — not a clause this crate can cite: - **`LBox == 0`** — the box runs to the end of the file, so its length is however much of the buffer follows its own first byte; - **`LBox == 1`** — the length is the 8-byte big-endian `XLBox` after `TBox`, counting the whole box including that 16-byte header. -`declared_store_len` implements both, and applies one rule to both header sizes — the rule +`declared_store_len` implements both, and applies one rule to *every* arm — the rule `gamut_isobmff`'s box reader already applies, that a length counting a header can never be less than that header: `LBox` in 2..=7 against the 8-byte header, `XLBox` below 16 against the 16-byte -one. Either would otherwise yield a span ending at or before the store's first body byte. Both -refusals are the typed `OracleError::UnusableSuperboxLength`, and no length is ever guessed. -No store this crate has seen uses either reserved value — c2pa-rs writes a plain 32-bit `LBox` — -which is precisely why the handling is written rather than assumed, and why the arms are pinned by -unit tests in `src/lib.rs` rather than left to a fixture that cannot reach them. +one, and `LBox == 0` in a buffer of fewer than 8 bytes, whose to-end-of-buffer length counts that +same 8-byte header. Any of them would otherwise yield a span ending at or before the store's first +body byte. All three refusals are the typed `OracleError::UnusableSuperboxLength`, and no length is +ever guessed. No store this crate has seen uses either reserved value — c2pa-rs writes a plain +32-bit `LBox` — which is precisely why the handling is written rather than assumed, and why the +arms are pinned by unit tests in `src/lib.rs` rather than left to a fixture that cannot reach them: +each side of each refusal is pinned by its own test, so widening or narrowing a range by one is +caught. For the same reason `find_jumbf_superbox` **continues** past a `jumb` that appears too early to carry an `LBox` in front of it, instead of concluding the buffer has no superbox. Today's fixtures diff --git a/tooling/c2pa-oracle/tests/update_manifest.rs b/tooling/c2pa-oracle/tests/update_manifest.rs index 415a7429..838d4e77 100644 --- a/tooling/c2pa-oracle/tests/update_manifest.rs +++ b/tooling/c2pa-oracle/tests/update_manifest.rs @@ -113,7 +113,7 @@ fn the_earlier_store_is_relabelled_original_when_an_update_box_is_added() { // §A.5.3: once a file carries an `update` box, the store it had before is re-labelled // `original`. Both are reported, in file order, and neither is judged — which is why - // `AvifContainer::c2pa` promises only "the first one". + // `AvifContainer::c2pa_slot` promises only "the first one". let purposes: Vec<_> = container.c2pa_slots().map(|slot| slot.purpose).collect(); assert_eq!( purposes, From 1677990ab74baf3277f88320a38169c3aa804483 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:53:15 -0400 Subject: [PATCH 18/36] test(c2pa-oracle): pin both sides of every JUMBF header refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three consecutive reviews each found one more refusal arm in the header reader with a test on only one side of it, because each looked at the arm the last one had missed rather than at the set. Write the set down and close it. The module documentation now carries a table with one row per refusing branch across find_jumbf_superbox, declared_store_len and jumbf_superbox_span, naming the test that pins each direction — the input the branch refuses, and the nearest input it must not refuse. Four rows had a blank side: - the truncated LBox field: nothing exercised a buffer below four bytes, so deleting the refusal outright left every test green. Deleting it sends such a buffer into the LBox == 0 arm, which refuses it too, so the new test asserts the message rather than that an error occurred; - the skip that keeps a `jumb` too early to carry an LBox from being read as a superbox type. The decoy sat at offset 0, far from the boundary, so a search that considered one window too early was unobserved — and under it a buffer with the marker at offset 3 panics with a subtraction overflow. The decoy now sits at offset 3, and a superbox whose LBox opens the buffer pins the other direction; - a buffer carrying no `jumb` at all, which nothing asserted was reported as an absent superbox; - a declared length that overflows the offset it is added to, the one input on which dropping the checked add hands back a backwards range that passes the bound check by arithmetic accident. Every arm's two directions were then mutated by hand, in both directions, and each mutant dies. --- tooling/c2pa-oracle/src/lib.rs | 285 ++++++++++++++++++++++++++------- 1 file changed, 224 insertions(+), 61 deletions(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index 8e06ad80..10e9b1e3 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -481,47 +481,185 @@ mod tests { //! never produces. Everything c2pa-rs does produce is pinned in `tests/` against c2pa-rs //! itself; these are the arms an oracle has to get right before it is pointed at a container //! whose store follows arbitrary bytes. + //! + //! # Every arm, enumerated once + //! + //! Three rounds of review each found one more untested arm here, because each round looked at + //! the arm the last one had missed rather than at the set. So the set is written down. Every + //! branch that can refuse an input across the three header-reading functions is listed below + //! with the test that pins each of its two directions — the input it refuses, and the nearest + //! input it must *not* refuse. Adding a branch means adding a row, and a row with one side + //! blank is the finding, not a matter of taste. + //! + //! | Function | Branch | Refuses | Accepts | + //! |---|---|---|---| + //! | [`find_jumbf_superbox`] | `.skip(4)`: a type offset below 4 has no `LBox` in front of it | [`a_jumb_three_bytes_in_is_too_early_to_carry_an_lbox`] | [`a_superbox_whose_lbox_opens_the_buffer_is_found_at_offset_zero`] | + //! | [`find_jumbf_superbox`] | `.ok_or`: no `jumb` in the buffer at all | [`a_buffer_carrying_no_jumb_at_all_is_an_absent_superbox`] | [`the_search_continues_past_a_jumb_too_early_to_carry_an_lbox`] | + //! | [`declared_store_len`] | `get(..4)`: the `LBox` field is truncated | [`a_buffer_too_short_for_an_lbox_field_is_refused_as_a_truncated_field`] | [`an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused`] | + //! | [`declared_store_len`] | `LBox == 0` in a buffer below the 8-byte header | [`an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused`] | [`an_lbox_of_zero_in_a_buffer_of_exactly_the_header_size_is_a_length`] | + //! | [`declared_store_len`] | `LBox == 1`: `get(8..16)`, the `XLBox` field is truncated | [`an_lbox_of_one_without_room_for_an_xlbox_is_refused`] | [`an_lbox_of_one_in_a_buffer_of_exactly_the_sixteen_byte_header_is_a_length`] | + //! | [`declared_store_len`] | `XLBox` below the 16-byte header it counts | [`an_xlbox_below_the_sixteen_byte_header_is_refused_rather_than_resolved`] | [`an_xlbox_of_exactly_the_header_size_is_a_length`] | + //! | [`declared_store_len`] | `LBox` in 2..=7, below the 8-byte header it counts | [`an_lbox_between_two_and_seven_is_refused_rather_than_resolved`] | [`an_lbox_of_exactly_the_header_size_is_a_length`] | + //! | [`jumbf_superbox_span`] | `checked_add`: offset plus declared length leaves `usize` | [`a_declared_length_that_overflows_the_buffer_offset_is_an_unusable_length`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_length`] | + //! | [`jumbf_superbox_span`] | `end <= buffer.len()`: the length runs past the buffer | [`a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_length`] | + //! + //! Two rows share an "accepts" column deliberately: the same input is the nearest non-refused + //! one for both, and splitting it would only give the second row a fixture that differs in a + //! byte neither branch reads. + //! + //! Two arms of [`declared_store_len`] refuse nothing and so appear in no row — the reserved + //! `LBox` values, which resolve to a length rather than rejecting it. They are pinned by + //! [`an_lbox_of_zero_declares_the_rest_of_the_buffer`] and + //! [`an_lbox_of_one_takes_its_length_from_the_xlbox_field`], which are about *not* taking a + //! reserved value literally rather than about a boundary. + //! + //! The refusal outside this layer — `reserve_then_fill` rejecting a slot that is not the + //! signed store's length — needs c2pa-rs and a gamut encoder, so it is pinned where those are + //! in reach: `tests/reserve_then_fill.rs`. + //! + //! Every refusal is asserted by the **message** it carries, never by `is_err()`. Several + //! branches refuse the same input for different reasons — deleting the truncated-`LBox` arm, + //! for instance, sends a 3-byte buffer into the `LBox == 0` arm, which refuses it too — so + //! only the message distinguishes the arm that fired from the one that caught the fall. use super::{OracleError, declared_store_len, find_jumbf_superbox, jumbf_superbox_span}; - /// A buffer with a stray `jumb` at offset 0 — too early to be a superbox type, since there is - /// no room for an `LBox` in front of it — and a genuine `LBox` + `jumb` superbox at offset 12. - fn stray_jumb_then_real_superbox() -> Vec { + /// A buffer with a decoy `jumb` at offset **3** — the last offset too early to be a superbox + /// type, because an `LBox` needs the four bytes in front of it — and a genuine `LBox` + `jumb` + /// superbox at offset 12. + /// + /// The decoy sits *at* the boundary on purpose. A search that considers one window too early + /// lands on exactly this one, where the offset arithmetic underflows; a decoy placed further + /// back would leave that off-by-one unobserved, which is what an earlier version of this + /// fixture did. + fn decoy_jumb_at_the_boundary_then_a_real_superbox() -> Vec { + let mut buffer = Vec::new(); + buffer.extend_from_slice(&[0xAA; 3]); // offsets 0..3 + buffer.extend_from_slice(b"jumb"); // offset 3: the last offset with no room for an `LBox` + buffer.extend_from_slice(&[0xAA; 5]); // filler, to offset 12 + buffer.extend_from_slice(&24u32.to_be_bytes()); // offset 12: the real `LBox` + buffer.extend_from_slice(b"jumb"); // offset 16: the real `TBox` + buffer.extend_from_slice(&[0x11; 16]); // the store's body, to `LBox`'s 24 bytes + buffer + } + + /// A buffer whose **only** `jumb` sits at offset 3 — one byte too early to be a superbox + /// type. Nothing here is a store, and the search must say so rather than report an offset it + /// had to compute by subtracting four from three. + fn only_a_decoy_jumb_at_the_boundary() -> Vec { + let mut buffer = vec![0xAAu8; 3]; + buffer.extend_from_slice(b"jumb"); // offset 3 + buffer.extend_from_slice(&[0xAA; 25]); + buffer + } + + /// A minimal superbox that opens the buffer: `LBox` at offset 0, so its `TBox` is at offset 4 + /// — the *first* offset a superbox type can occupy. + fn superbox_at_the_start_of_the_buffer() -> Vec { let mut buffer = Vec::new(); - buffer.extend_from_slice(b"jumb"); // offset 0: too early to be a superbox type - buffer.extend_from_slice(&[0xAA; 8]); // filler - buffer.extend_from_slice(&24u32.to_be_bytes()); // offset 12: the real LBox - buffer.extend_from_slice(b"jumb"); // offset 16: the real TBox - buffer.extend_from_slice(&[0x11; 16]); // the store's body, to LBox's 24 bytes + buffer.extend_from_slice(&24u32.to_be_bytes()); + buffer.extend_from_slice(b"jumb"); + buffer.extend_from_slice(&[0x11; 16]); buffer } + /// A `store`-shaped buffer of `len` bytes whose `LBox` is `lbox` and whose `TBox` is `jumb`. + fn store_with_lbox(lbox: u32, len: usize) -> Vec { + let mut store = vec![0u8; len]; + store[..4].copy_from_slice(&lbox.to_be_bytes()); + if len >= 8 { + store[4..8].copy_from_slice(b"jumb"); + } + store + } + + /// A `store`-shaped buffer of `len` bytes declaring `LBox == 1` and the given `XLBox`. + fn store_with_xlbox(xlbox: u64, len: usize) -> Vec { + let mut store = store_with_lbox(1, len); + store[8..16].copy_from_slice(&xlbox.to_be_bytes()); + store + } + #[test] fn the_search_continues_past_a_jumb_too_early_to_carry_an_lbox() { assert_eq!( - find_jumbf_superbox(&stray_jumb_then_real_superbox()).expect("the real superbox"), + find_jumbf_superbox(&decoy_jumb_at_the_boundary_then_a_real_superbox()) + .expect("the real superbox"), 12, - "a `jumb` in the first four bytes has no room for an `LBox` in front of it, so it must \ - be skipped and the search continued, not treated as the end of it" + "a `jumb` with no room for an `LBox` in front of it must be skipped and the search \ + continued, not treated as the end of it" + ); + } + + #[test] + fn a_jumb_three_bytes_in_is_too_early_to_carry_an_lbox() { + // Offset 3 is the last offset a superbox type cannot occupy: its `LBox` would begin one + // byte before the buffer. A search that considers one window too early lands on exactly + // this one and computes `3 - 4`, so this input is where an off-by-one is visible at all. + let buffer = only_a_decoy_jumb_at_the_boundary(); + + let error = find_jumbf_superbox(&buffer) + .expect_err("offset 3 leaves no room for the `LBox` in front of a superbox type"); + assert!( + matches!(error, OracleError::NoJumbfSuperbox), + "a `jumb` too early to carry an `LBox` is not a superbox, so a buffer holding only \ + that one holds no store; got {error}" ); } #[test] - fn a_span_is_still_found_when_a_stray_jumb_precedes_the_superbox() { + fn a_superbox_whose_lbox_opens_the_buffer_is_found_at_offset_zero() { + // The other side of the same boundary: offset 4 is the *first* offset a superbox type can + // occupy, and a search that skips one window too many walks straight past this store. assert_eq!( - jumbf_superbox_span(&stray_jumb_then_real_superbox()).expect("the real superbox"), + find_jumbf_superbox(&superbox_at_the_start_of_the_buffer()) + .expect("a superbox whose `LBox` opens the buffer"), + 0, + "a store at the very start of the buffer has its `TBox` at offset 4, which is the \ + first offset with room for an `LBox`, so it must be found" + ); + } + + #[test] + fn a_buffer_carrying_no_jumb_at_all_is_an_absent_superbox() { + let buffer = vec![0xAAu8; 64]; + + let error = find_jumbf_superbox(&buffer).expect_err("there is no superbox to find"); + assert!( + matches!(error, OracleError::NoJumbfSuperbox), + "a buffer with no `jumb` in it carries no store, and the refusal must say so rather \ + than name a length or hand back an offset; got {error}" + ); + } + + #[test] + fn a_span_is_still_found_when_a_decoy_jumb_precedes_the_superbox() { + assert_eq!( + jumbf_superbox_span(&decoy_jumb_at_the_boundary_then_a_real_superbox()) + .expect("the real superbox"), 12..36, ); } #[test] - fn an_lbox_of_zero_declares_the_rest_of_the_buffer() { - let mut store = vec![0u8; 76]; - store[..4].copy_from_slice(&0u32.to_be_bytes()); - store[4..8].copy_from_slice(b"jumb"); + fn a_span_ending_exactly_at_the_end_of_the_buffer_is_a_length() { + // The accepted side of both of `jumbf_superbox_span`'s refusals: the sum stays inside + // `usize` and the end lands on the last byte. A bound that refused one byte too early + // would refuse this store, which is the shape every real store has. + let buffer = superbox_at_the_start_of_the_buffer(); + assert_eq!(buffer.len(), 24, "the declared length is the whole buffer"); assert_eq!( - declared_store_len(&store).expect("LBox 0 is a length, not a literal zero"), + jumbf_superbox_span(&buffer).expect("a store that ends where the buffer does"), + 0..24, + ); + } + + #[test] + fn an_lbox_of_zero_declares_the_rest_of_the_buffer() { + assert_eq!( + declared_store_len(&store_with_lbox(0, 76)) + .expect("LBox 0 is a length, not a literal zero"), 76, "ISO box syntax reads `LBox = 0` as \"to the end of the file\"; taken literally it \ would make the store zero bytes long" @@ -530,44 +668,60 @@ mod tests { #[test] fn an_lbox_of_one_takes_its_length_from_the_xlbox_field() { - let mut store = vec![0u8; 40]; - store[..4].copy_from_slice(&1u32.to_be_bytes()); - store[4..8].copy_from_slice(b"jumb"); - store[8..16].copy_from_slice(&40u64.to_be_bytes()); - assert_eq!( - declared_store_len(&store).expect("LBox 1 defers to XLBox"), + declared_store_len(&store_with_xlbox(40, 40)).expect("LBox 1 defers to XLBox"), 40, "ISO box syntax reads `LBox = 1` as \"the 8-byte XLBox after TBox holds the length\"; \ taken literally it would make the store one byte long" ); } + #[test] + fn a_buffer_too_short_for_an_lbox_field_is_refused_as_a_truncated_field() { + for len in 0usize..4 { + let store = vec![0u8; len]; + + let error = declared_store_len(&store).expect_err("there is no LBox to read"); + assert!( + error.to_string().contains("the LBox field is truncated"), + "a buffer with no room for the `LBox` has no declared length at all, and the \ + refusal must name the truncated field rather than fall through to an arm reading \ + a length that was never there; a {len}-byte buffer gave {error}" + ); + } + } + #[test] fn an_lbox_of_one_without_room_for_an_xlbox_is_refused() { - let mut store = vec![0u8; 12]; - store[..4].copy_from_slice(&1u32.to_be_bytes()); - store[4..8].copy_from_slice(b"jumb"); + for len in [8usize, 12, 15] { + let store = store_with_lbox(1, len); - let error = declared_store_len(&store).expect_err("there is no XLBox to read"); - assert!( - error - .to_string() - .contains("XLBox field that carries the length is truncated"), - "the refusal must name the truncated XLBox rather than any other unusable length; got \ - {error}" + let error = declared_store_len(&store).expect_err("there is no XLBox to read"); + assert!( + error + .to_string() + .contains("XLBox field that carries the length is truncated"), + "the refusal must name the truncated XLBox rather than any other unusable length; \ + a {len}-byte buffer gave {error}" + ); + } + } + + #[test] + fn an_lbox_of_one_in_a_buffer_of_exactly_the_sixteen_byte_header_is_a_length() { + assert_eq!( + declared_store_len(&store_with_xlbox(16, 16)) + .expect("16 bytes is the LBox+TBox+XLBox header itself"), + 16, + "the XLBox field ends at byte 16, so a 16-byte buffer holds all of it; refusing this \ + one would refuse the smallest legal extended box" ); } #[test] fn an_xlbox_below_the_sixteen_byte_header_is_refused_rather_than_resolved() { for xlbox in [0u64, 1, 8, 15] { - let mut store = vec![0u8; 40]; - store[..4].copy_from_slice(&1u32.to_be_bytes()); - store[4..8].copy_from_slice(b"jumb"); - store[8..16].copy_from_slice(&xlbox.to_be_bytes()); - - let error = declared_store_len(&store) + let error = declared_store_len(&store_with_xlbox(xlbox, 40)) .expect_err("an XLBox below 16 is shorter than the header it counts"); assert!( error @@ -581,13 +735,9 @@ mod tests { #[test] fn an_xlbox_of_exactly_the_header_size_is_a_length() { - let mut store = vec![0u8; 40]; - store[..4].copy_from_slice(&1u32.to_be_bytes()); - store[4..8].copy_from_slice(b"jumb"); - store[8..16].copy_from_slice(&16u64.to_be_bytes()); - assert_eq!( - declared_store_len(&store).expect("16 is the header itself, the smallest legal box"), + declared_store_len(&store_with_xlbox(16, 40)) + .expect("16 is the header itself, the smallest legal box"), 16, "the refusal must stop exactly at the header size, not swallow the first legal length" ); @@ -596,11 +746,7 @@ mod tests { #[test] fn an_lbox_between_two_and_seven_is_refused_rather_than_resolved() { for lbox in [2u32, 3, 4, 5, 6, 7] { - let mut store = vec![0u8; 32]; - store[..4].copy_from_slice(&lbox.to_be_bytes()); - store[4..8].copy_from_slice(b"jumb"); - - let error = declared_store_len(&store) + let error = declared_store_len(&store_with_lbox(lbox, 32)) .expect_err("an LBox below 8 is shorter than the header it counts"); assert!( error @@ -614,12 +760,9 @@ mod tests { #[test] fn an_lbox_of_exactly_the_header_size_is_a_length() { - let mut store = vec![0u8; 32]; - store[..4].copy_from_slice(&8u32.to_be_bytes()); - store[4..8].copy_from_slice(b"jumb"); - assert_eq!( - declared_store_len(&store).expect("8 is the header itself, the smallest legal box"), + declared_store_len(&store_with_lbox(8, 32)) + .expect("8 is the header itself, the smallest legal box"), 8, "the refusal must stop exactly at the header size, not swallow the first legal length" ); @@ -637,18 +780,16 @@ mod tests { .to_string() .contains("the buffer ends inside the LBox+TBox header"), "the to-end-of-buffer length obeys the same header minimum as the other arms, and \ - the refusal must name it; a {len}-byte buffer gave {error}" + the refusal must name it — which also says the `LBox` field itself was read, \ + since four bytes is enough for it; a {len}-byte buffer gave {error}" ); } } #[test] fn an_lbox_of_zero_in_a_buffer_of_exactly_the_header_size_is_a_length() { - let mut store = vec![0u8; 8]; - store[4..8].copy_from_slice(b"jumb"); - assert_eq!( - declared_store_len(&store) + declared_store_len(&store_with_lbox(0, 8)) .expect("8 bytes is the header itself, the smallest legal box"), 8, "the refusal must stop exactly at the header size, not swallow the first legal length" @@ -670,4 +811,26 @@ mod tests { rather than report the store as absent; got {error}" ); } + + #[test] + fn a_declared_length_that_overflows_the_buffer_offset_is_an_unusable_length() { + // The `XLBox` arm is the only one that can return a length near `usize::MAX`, and the + // superbox starts 12 bytes in, so the sum leaves `usize` entirely. Adding without the + // overflow check would wrap to an offset *inside* the buffer and hand back a backwards + // range — a span that passes the bound check by arithmetic accident. + let mut buffer = vec![0xAAu8; 12]; + buffer.extend_from_slice(&1u32.to_be_bytes()); + buffer.extend_from_slice(b"jumb"); + buffer.extend_from_slice(&u64::MAX.to_be_bytes()); + buffer.resize(64, 0); + + let error = jumbf_superbox_span(&buffer) + .expect_err("a length that cannot be added to the offset bounds nothing"); + assert!( + matches!(&error, OracleError::UnusableSuperboxLength(what) + if what.contains("runs past the end of the buffer")), + "a declared length that overflows the offset it is added to runs past every end there \ + is, and must be refused as the unusable length it is; got {error}" + ); + } } From 6d8e7331e912ce1237269e60c38fa3476fe88d47 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:53:26 -0400 Subject: [PATCH 19/36] test(c2pa-oracle): drive the reserved-slot length guard instead of restating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three assertions in the slot-filling test could not fail. `reserve_then_fill` refuses a store that is not the slot's length before it returns, so restating that equality asserted the oracle's own guard had run; and it writes the slot with `copy_from_slice`, so "the slot's bytes are the store's bytes" was true by construction. Only the third — that the signed store is the length the placeholder asked for — says anything about c2pa-rs, so the test keeps that one and is renamed for it. The guard itself is now exercised rather than assumed: reserving one byte more than the placeholder asked for makes the signed store no longer fill the slot, and the refusal has to name both lengths. Deleting the guard or inverting it both die. A slot larger than the store is also the one configuration nothing here signs successfully, and it is where a box-bounded and an LBox-bounded locator legitimately diverge. Filed as #598 and cited at the site. --- .../c2pa-oracle/tests/reserve_then_fill.rs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/tooling/c2pa-oracle/tests/reserve_then_fill.rs b/tooling/c2pa-oracle/tests/reserve_then_fill.rs index e990ab20..83a0c77a 100644 --- a/tooling/c2pa-oracle/tests/reserve_then_fill.rs +++ b/tooling/c2pa-oracle/tests/reserve_then_fill.rs @@ -49,26 +49,45 @@ fn a_store_signed_over_the_reserved_file_validates_once_patched_into_the_reporte } #[test] -fn the_signed_store_exactly_fills_the_slot_that_was_reserved() { +fn the_signed_store_is_exactly_the_length_the_placeholder_asked_the_host_to_reserve() { let filled = reserve_then_fill(AVIF_MIME, reserve_avif).expect("reserve, sign and patch"); // `Builder::placeholder` pins the JUMBF length and `sign_embeddable` zero-pads back to it, so // the store is the size the caller was told to reserve. Nothing in the file after the slot can // move, which is the encoder-side criterion the epic states. + // + // This is the one claim here that is about c2pa-rs. `slot.len() == store.len()` and "the + // slot's bytes are the store's bytes" were asserted alongside it and could not fail: + // `reserve_then_fill` refuses the first before it returns — see + // `a_slot_that_is_not_the_signed_stores_length_is_refused_rather_than_patched` below, which + // drives that refusal instead of restating it — and produces the second with + // `copy_from_slice`. Both said only that the oracle's own plumbing ran. assert_eq!( filled.store.len(), filled.placeholder_store_len, "the signed store must be exactly the length the placeholder asked the host to reserve" ); - assert_eq!( - filled.slot.len(), - filled.store.len(), - "the reserved slot must be exactly the store's length" - ); - assert_eq!( - &filled.asset[filled.slot.clone()], - filled.store.as_slice(), - "the slot's bytes must be the store's bytes" +} + +#[test] +fn a_slot_that_is_not_the_signed_stores_length_is_refused_rather_than_patched() { + // The refusal `reserve_then_fill` carries, exercised rather than assumed: reserve one byte + // more than the placeholder asked for and the signed store no longer fills the slot. Patching + // it in anyway would leave a trailing byte of whatever the encoder wrote inside the store's + // own `LBox` bound, and every "the range is identical" claim downstream would be measured + // against a store the oracle had quietly mis-sized. + // + // A slot *larger* than the store is also the one configuration nothing here signs + // successfully — the case where a box-bounded and an `LBox`-bounded locator legitimately + // diverge. Making the oracle pad rather than refuse is issue #598. + let error = reserve_then_fill(AVIF_MIME, |len| reserve_avif(len + 1)) + .expect_err("a slot one byte too long is not a slot the signed store fits"); + + let message = error.to_string(); + assert!( + message.contains("signed store is") && message.contains("the reserved slot is"), + "the refusal must name both lengths, so a caller sees which side got it wrong rather \ + than only that something did; got {error}" ); } From 67a5a253edf67dd22dd691947bca3aee49c3d830 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:53:26 -0400 Subject: [PATCH 20/36] test(c2pa-oracle): compare the carried store without assuming no padding The model's C2PA block is bounded by the enclosing box, so it carries any padding a writer leaves after the store; the span it was compared against is bounded by the store's own LBox. Asserting the two equal makes a padding release of c2pa-rs fail here, where it reads as the model carrying the wrong bytes, rather than at the test that pins the no-padding observation by name. Compare by containment instead, the way `locate_embedded.rs` states the same relation, and say at the site which test owns the padding fact. --- tooling/c2pa-oracle/tests/no_copy_forward.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tooling/c2pa-oracle/tests/no_copy_forward.rs b/tooling/c2pa-oracle/tests/no_copy_forward.rs index e22f9f83..7a63bef9 100644 --- a/tooling/c2pa-oracle/tests/no_copy_forward.rs +++ b/tooling/c2pa-oracle/tests/no_copy_forward.rs @@ -81,11 +81,21 @@ fn derivative_through(embedder: MetadataEmbedder, parent: &[u8]) -> Vec { // agree however wrong `store_of` is. `meta.c2pa.is_some()` says even less, because // `MetadataExtractor` carries a C2PA block through whatever its length, so an empty or // truncated store satisfies it and the policy is then asked to drop nothing. + // + // Containment, not equality, for the same reason `locate_embedded.rs` states it that way: + // `store_of` bounds by the enclosing box, so it would carry any padding a writer left after + // the store, while `jumbf_superbox_span` bounds by the store's own `LBox`. That c2pa-rs pads + // nothing today is pinned by name, once, in + // `locate_embedded.rs::c2pa_rs_leaves_no_padding_between_the_store_and_the_end_of_its_box`, + // so a future padding release fails *that* test instead of being misread here as the model + // carrying the wrong bytes. let expected = jumbf_superbox_span(parent).expect("the signed parent carries a JUMBF store"); assert_eq!( - meta.c2pa.as_deref(), + meta.c2pa + .as_deref() + .and_then(|carried| carried.get(..expected.len())), Some(&parent[expected]), - "the model handed to the embedder must carry the parent's store byte for byte, or the \ + "the model handed to the embedder must open with the parent's store byte for byte, or the \ derivative carries no store for a reason that is not the policy" ); let blocks = embedder.embed(&meta).expect("embedding the parent's model"); From 805f10b7243708956a6a8eb452a6dc30606dbac7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:53:38 -0400 Subject: [PATCH 21/36] build(c2pa-oracle): commit the lockfile the resolved-graph check reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact `=` version pin was chosen instead of a lockfile, on the grounds that it was the smaller fix and the one that kept the prose honest. It holds the direct dependency and nothing under it: the three hundred-odd transitive packages re-resolve on every invocation. So the no-OpenSSL assertion, which is an assertion about the resolved graph, could only ever inspect the resolution cargo had written for it moments earlier — which is not an assertion. Commit `Cargo.lock` for this crate as a deliberate exception to the blanket `tooling/*/Cargo.lock` rule, with the exception and its reason recorded beside the rule. The pin stays: it guards a different thing, the line-numbered citations into c2pa-rs's source, and it is the line a human edits on purpose where the lockfile is regenerated by any `cargo update`. The exception is itself a drift guard, because dropping the negation is one edit that no other test here would notice. --- .gitignore | 11 + tooling/c2pa-oracle/Cargo.lock | 3176 +++++++++++++++++ tooling/c2pa-oracle/Cargo.toml | 12 +- .../c2pa-oracle/tests/build_configuration.rs | 52 +- 4 files changed, 3238 insertions(+), 13 deletions(-) create mode 100644 tooling/c2pa-oracle/Cargo.lock diff --git a/.gitignore b/.gitignore index 14b1630c..4d90e24d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,17 @@ target # regenerates a redundant local lockfile that should not be committed. tooling/*/Cargo.lock +# One deliberate exception. `tooling/c2pa-oracle` resolves a real external dependency tree +# (`c2pa`, the C2PA reference implementation) that no other manifest in this repository pins, so +# there is no root lockfile standing behind it: without one of its own, its whole transitive graph +# re-resolves on every invocation. Two things depend on that resolution being held still — +# `README.md` cites `c2pa`'s source **by line number**, and +# `tests/build_configuration.rs` asserts no OpenSSL package reached the graph. The exact `=` +# version pin in its manifest holds only the direct dependency; the assertion about the *graph* +# can otherwise only ever inspect the resolution cargo has just written, which is not an +# assertion. Committing this one lockfile is what makes it one. +!tooling/c2pa-oracle/Cargo.lock + # The fuzz tier's search state (issues #264, #311). The corpus is a search aid, not the regression # record: a saved input is only reproducible while the target's byte-to-input mapping is unchanged, # so a crash is minimised and promoted into a NAMED DETERMINISTIC TEST in the crate's own suite diff --git a/tooling/c2pa-oracle/Cargo.lock b/tooling/c2pa-oracle/Cargo.lock new file mode 100644 index 00000000..72fe36d6 --- /dev/null +++ b/tooling/c2pa-oracle/Cargo.lock @@ -0,0 +1,3176 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abnf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" +dependencies = [ + "abnf-core", + "nom", +] + +[[package]] +name = "abnf-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" +dependencies = [ + "nom", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-generic" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf3728566eefa873833159754f5732fb0951d3649e6e5b891cc70d56dd41673" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atree" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239d25181cb40f1955529367ee495e35d03aab4e578028f41e7abc8b21c367a8" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcder" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b593e5aeaf7992d388c08a9831c921cd703718064b3e50ba8e6d666d6cf86ca7" +dependencies = [ + "bytes", + "smallvec", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bitvec-nom2" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988fcc40055ceaa85edc55875a08f8abd29018582647fd82ad6128dba14a5f0" +dependencies = [ + "bitvec", + "nom", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "btree-range-map" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be5c9672446d3800bcbcaabaeba121fe22f1fb25700c4562b22faf76d377c33" +dependencies = [ + "btree-slab", + "cc-traits", + "range-traits", + "serde", + "slab", +] + +[[package]] +name = "btree-slab" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2b56d3029f075c4fa892428a098425b86cef5c89ae54073137ece416aef13c" +dependencies = [ + "cc-traits", + "slab", + "smallvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteordered" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf2cd9424f5ff404aba1959c835cbc448ee8b689b870a9981c76c0fd46280e6" +dependencies = [ + "byteorder", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "c2pa" +version = "0.90.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d639c3f5f1e6347f8ba51ffe93c07231938f02d98491c7d7db6876354b2369" +dependencies = [ + "asn1-rs", + "async-generic", + "async-trait", + "atree", + "base64 0.22.1", + "bcder", + "brotli", + "byteorder", + "byteordered", + "bytes", + "c2pa_cbor", + "chrono", + "console_log", + "const-hex", + "const-oid 0.9.6", + "coset", + "der", + "ecdsa", + "ed25519-dalek", + "extfmt", + "getrandom 0.2.17", + "getrandom 0.3.4", + "glob", + "hex", + "http", + "id3", + "img-parts", + "iref", + "jfifdump", + "js-sys", + "lazy_static", + "log", + "memchr", + "nom", + "non-empty-string", + "nonempty-collections", + "num-bigint-dig", + "p256", + "p384", + "p521", + "pem", + "pkcs1", + "pkcs8", + "png_pong", + "quick-xml 0.41.0", + "rand 0.8.8", + "rand_chacha 0.9.0", + "range-set", + "rasn", + "rasn-cms", + "rasn-ocsp", + "rasn-pkix", + "regex", + "riff", + "rsa", + "serde", + "serde-transcode", + "serde-wasm-bindgen", + "serde_bytes", + "serde_derive", + "serde_json", + "serde_with", + "sha1", + "sha2 0.11.0", + "spki", + "static-iref", + "tempfile", + "thiserror 2.0.20", + "toml", + "url", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "x509-parser", + "zeroize", + "zip", +] + +[[package]] +name = "c2pa-oracle" +version = "0.0.0" +dependencies = [ + "c2pa", + "gamut-avif", + "gamut-core", + "gamut-heic", + "gamut-metadata", +] + +[[package]] +name = "c2pa_cbor" +version = "0.77.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88380203853f999aa8e5bcdac2a0984d082c42994cc5a99d240aed180b2c2f3b" +dependencies = [ + "half", + "serde", + "serde_bytes", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cc-traits" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "060303ef31ef4a522737e1b1ab68c67916f2a787bb2f4f54f383279adba962b5" +dependencies = [ + "slab", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "console_log" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1" +dependencies = [ + "log", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb98d5e9155e2cf7cd942c8b3033097d4563b6fb0a00b9caecb74669555c058" +dependencies = [ + "ciborium", + "ciborium-io", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "delegate" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "082a24a9967533dc5d743c602157637116fc1b52806d694a5a45e6f32567fcdd" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "sha2 0.10.9", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "extfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a61bffc6f807b136c3efeeac295edde2c65b1e345de7ea777e75f63de7436c6" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gamut-av1" +version = "0.4.1" +dependencies = [ + "gamut-bitstream", + "gamut-color", + "gamut-core", + "gamut-dsp", +] + +[[package]] +name = "gamut-avif" +version = "1.1.0" +dependencies = [ + "gamut-av1", + "gamut-codec-abi", + "gamut-color", + "gamut-core", + "gamut-isobmff", +] + +[[package]] +name = "gamut-bitstream" +version = "0.2.3" +dependencies = [ + "gamut-core", +] + +[[package]] +name = "gamut-codec-abi" +version = "0.1.1" + +[[package]] +name = "gamut-color" +version = "2.0.0" +dependencies = [ + "gamut-core", +] + +[[package]] +name = "gamut-core" +version = "2.0.1" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-dsp" +version = "2.0.0" + +[[package]] +name = "gamut-exif" +version = "1.0.0" +dependencies = [ + "gamut-core", + "gamut-ifd", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-heic" +version = "0.2.2" +dependencies = [ + "gamut-codec-abi", + "gamut-color", + "gamut-core", + "gamut-isobmff", +] + +[[package]] +name = "gamut-icc" +version = "1.0.0" +dependencies = [ + "gamut-core", + "md-5", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-ifd" +version = "2.0.1" +dependencies = [ + "gamut-core", +] + +[[package]] +name = "gamut-iptc" +version = "1.0.0" +dependencies = [ + "gamut-core", + "gamut-xmp", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-isobmff" +version = "2.0.1" +dependencies = [ + "gamut-core", +] + +[[package]] +name = "gamut-metadata" +version = "1.0.0" +dependencies = [ + "gamut-exif", + "gamut-icc", + "gamut-iptc", + "gamut-xmp", + "thiserror 2.0.20", +] + +[[package]] +name = "gamut-xmp" +version = "1.0.0" +dependencies = [ + "gamut-core", + "quick-xml 0.40.1", + "thiserror 2.0.20", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex_fmt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id3" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24993fcabcbc07c8ac076a8e62db8593d1a5c4dbe81d57e531d2b7cb7f737380" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "flate2", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "img-parts" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19734e3c43b2a850f5889c077056e47c874095f2d87e853c7c41214ae67375f0" +dependencies = [ + "bytes", + "crc32fast", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "iref" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374372d9ca7331cec26f307b12552554849143e6b2077be3553576aa9aa8258c" +dependencies = [ + "iref-core", +] + +[[package]] +name = "iref-core" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b10559a0d518effd4f2cee107f40f83acf8583dcd3e6760b9b60293b0d2c2a70" +dependencies = [ + "pct-str", + "serde", + "smallvec", + "static-regular-grammar", + "thiserror 1.0.69", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jfifdump" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68cf72bc7b75b6615ffd06bfe6840f3c57e7e4ea615558a2a452f458ebf5551e" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "non-empty-string" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260010741d90def3ca7c4b5ec0bbe28c26d0c775d3c7d291b38642514136677a" +dependencies = [ + "delegate", + "serde", +] + +[[package]] +name = "nonempty-collections" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "988fb92b275335f315a9bddf7a8881a02c79c6084e9a626f656f12c50776a045" +dependencies = [ + "serde", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "parsenic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c695d2b8bcf1dd62a5173a9c43e6ebbe9261701c002f9b462fc9690c144bb61" +dependencies = [ + "traitful", +] + +[[package]] +name = "pct-str" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf1bdcc492c285a50bed60860dfa00b50baf1f60c73c7d6b435b01a2a11fd6ff" +dependencies = [ + "thiserror 1.0.69", + "utf8-decode", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pix" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a054a84d1ff0dc456386e5fc081e099e6855ddcc8913dc9349c294d47d76bd4" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "png_pong" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb58df2a992d49a178fd69f8c9ead5d4c70014652cd4cca9608a1cb46097aff6" +dependencies = [ + "miniz_oxide 0.9.1", + "parsenic", + "pix", + "simd-adler32", + "traitful", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quick-xml" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "range-set" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "393b6d845a7f7b955dba8621ad2998590eaa470c1d9a4ee6df0f65354e0ffc31" +dependencies = [ + "num-traits", + "smallvec", +] + +[[package]] +name = "range-traits" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab" + +[[package]] +name = "rasn" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d1b71dd951343df0fe30b11bca09518f3aba8e5bd0ece4564adbc2dabd875fa" +dependencies = [ + "bitvec", + "bitvec-nom2", + "bytes", + "cfg-if", + "chrono", + "either", + "nom", + "num-bigint", + "num-integer", + "num-traits", + "once_cell", + "rasn-derive", + "serde_json", + "snafu", + "xml-no-std", +] + +[[package]] +name = "rasn-cms" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4abf4c2fe5537b99e2bf3099a7ad26e40bd9cf51bc003600ed58dbbff69a1c" +dependencies = [ + "rasn", + "rasn-pkix", +] + +[[package]] +name = "rasn-derive" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "979eafa601d351f7f6490f1d2f4decc168e8e51cb6517c6c6ff80111951129d5" +dependencies = [ + "proc-macro2", + "rasn-derive-impl", + "syn 2.0.119", +] + +[[package]] +name = "rasn-derive-impl" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee3136c97d6f2553c0d9f84d2c2555bb87ae9b365c5c9274e8bc5c366174431" +dependencies = [ + "either", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "rasn-ocsp" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bf6709bc94437d12614fd2d34463c3478d981bf913286812cc50bb093bc657" +dependencies = [ + "rasn", + "rasn-pkix", +] + +[[package]] +name = "rasn-pkix" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c029da7ed3b94dd93b75299431984081c1eaf88ce79771c21b056f0ebf6fb964" +dependencies = [ + "rasn", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "riff" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c601484456988d75017d86700d3743b949c21cdc7399f940c75e34680d185c5" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-transcode" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590c0e25c2a5bb6e85bf5c1bce768ceb86b316e7a01bdf07d2cb4ec2271990e2" +dependencies = [ + "serde", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static-iref" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cc4068497ae43896d41174586dcdc2153a1af2c82856fb308bfaaddc28e5549" +dependencies = [ + "iref", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "static-regular-grammar" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4a6c40247579acfbb138c3cd7de3dab113ab4ac6227f1b7de7d626ee667957" +dependencies = [ + "abnf", + "btree-range-map", + "ciborium", + "hex_fmt", + "indoc", + "proc-macro-error", + "proc-macro2", + "quote", + "serde", + "sha2 0.10.9", + "syn 2.0.119", + "thiserror 1.0.69", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "traitful" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d856e22ead1fb79b9fc3cec63300086f680924f2f7b0e2701f6835a28b9c4425" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-decode" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca61eb27fa339aa08826a29f03e87b99b4d8f0fc2255306fd266bb1b6a9de498" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "xml-no-std" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd223bc94c615fc02bf2f4bbc22a4a9bfe489c2add3ec10b1038df3aca44cac7" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "indexmap 2.14.2", + "memchr", + "typed-path", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tooling/c2pa-oracle/Cargo.toml b/tooling/c2pa-oracle/Cargo.toml index 6e265d70..6075c4b6 100644 --- a/tooling/c2pa-oracle/Cargo.toml +++ b/tooling/c2pa-oracle/Cargo.toml @@ -29,11 +29,15 @@ doctest = false # pure-Rust signing/verification backend that replaces it; dropping `default_http` additionally # drops reqwest and ureq, which this oracle never needs because it fetches no remote manifests. # -# Pinned to an exact version, not a caret range. `README.md` cites c2pa-rs's own source **by line +# Pinned to an exact version, not a caret range: `README.md` cites c2pa-rs's own source **by line # number** for two claims it records, and a `^` range lets a patch release move those lines under -# a citation nobody re-checked. There is no committed lockfile to hold them either: `.gitignore` -# excludes `tooling/*/Cargo.lock`, because a workspace-excluded oracle resolves standalone. The -# pin is the smaller of the two fixes and it is the one that makes the citations honest. +# a citation nobody re-checked. +# +# The pin holds this one line and nothing else. The three hundred-odd transitive packages under it +# are held by `Cargo.lock`, which is committed for this crate alone against the blanket +# `tooling/*/Cargo.lock` rule — the exception, and why it is made, are recorded in `.gitignore`. +# Without it the no-OpenSSL assertion in `tests/build_configuration.rs` could only ever inspect +# the resolution cargo had just written for it, which the pin does nothing to constrain. c2pa = { version = "=0.90.21", default-features = false, features = ["rust_native_crypto"] } [dev-dependencies] diff --git a/tooling/c2pa-oracle/tests/build_configuration.rs b/tooling/c2pa-oracle/tests/build_configuration.rs index 9cf584cb..0cf5b6d5 100644 --- a/tooling/c2pa-oracle/tests/build_configuration.rs +++ b/tooling/c2pa-oracle/tests/build_configuration.rs @@ -8,13 +8,19 @@ //! would be silent: everything would still pass, just far more slowly and with a C toolchain //! newly on the critical path. //! -//! Three checks, weakest sufficient technique each. Two are about features: the manifest check is a +//! Four checks, weakest sufficient technique each. Two are about features: the manifest check is a //! drift guard on the line a human would edit, and the lockfile check is a **resolved-graph** //! assertion, the stronger of the two, because it would also catch the feature arriving by -//! unification from somewhere else. The third is about the version, and guards a different thing: -//! `README.md` cites c2pa-rs's own source **by line number**, and only an exact pin holds those -//! citations still. There is no committed lockfile to do it instead — `.gitignore` excludes -//! `tooling/*/Cargo.lock`, because a workspace-excluded oracle resolves standalone. +//! unification from somewhere else. The third is about the version: `README.md` cites c2pa-rs's +//! own source **by line number**, and only an exact pin holds those citations still. +//! +//! The fourth guards what makes the second one an assertion at all. `Cargo.lock` is committed for +//! this crate — an exception to the blanket `tooling/*/Cargo.lock` rule, recorded in +//! `.gitignore` — because the `=` pin holds the direct dependency and nothing under it: with the +//! lockfile ignored, the whole transitive graph re-resolves on every invocation and the +//! resolved-graph check can only inspect a file cargo has just written for it, which is not a +//! check. So the exception itself is pinned, or it can be reverted in one line without a single +//! test going red. use std::path::Path; @@ -58,8 +64,8 @@ fn the_c2pa_dependency_line_disables_default_features_and_asks_only_for_rust_nat #[test] fn the_resolved_dependency_graph_contains_no_openssl_package() { - // Cargo writes this before it builds the test, so it always exists by the time the test runs; - // it is `.gitignore`d because a `tooling/` oracle resolves standalone. + // Read from the tree rather than from `cargo metadata`, because it is the *committed* + // resolution that has to be OpenSSL-free — see the fourth test below. let lock = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock"); let lock = std::fs::read_to_string(&lock) .unwrap_or_else(|error| panic!("reading {}: {error}", lock.display())); @@ -80,11 +86,39 @@ fn the_c2pa_dependency_pins_the_exact_version_the_readmes_citations_were_read_ag // `README.md` quotes `c2pa`'s `src/validation_results.rs:36-41` and names `src/jumbf_io.rs:246` // and `:258`, and the `update`-purpose finding is attributed to a branch in its `bmff_io.rs`. // A caret range lets a patch release move every one of those lines while the citation stays as - // written, and there is no lockfile in the tree to hold the resolution instead. `=` is the - // smaller of the two fixes and it is the one that keeps the prose honest. + // written. The lockfile pins the same version, but it is regenerated by any `cargo update` + // without a word of prose being re-read; the manifest line is the one a human edits on purpose. assert!( line.contains(&format!(r#"version = "={CITED_C2PA_VERSION}""#)), "the `c2pa` dependency must pin `={CITED_C2PA_VERSION}` exactly, because README.md cites \ that release's source by line number and nothing else holds those lines still: {line}" ); } + +#[test] +fn the_lockfile_this_crate_resolves_against_is_committed_rather_than_ignored() { + // The repository ignores `tooling/*/Cargo.lock` — a `tooling/` crate normally resolves through + // the root lockfile, so a local one is redundant. This crate is the exception: nothing else in + // the tree depends on `c2pa`, so nothing else pins the graph it drags in, and the + // resolved-graph assertion above would be reading a file cargo had written moments earlier. + // + // A drift guard on the negation line, the same technique as the manifest check: dropping it + // is one edit, and every other test here would stay green afterwards. + const IGNORE: &str = include_str!("../../../.gitignore"); + + assert!( + IGNORE + .lines() + .map(str::trim) + .any(|line| line == "!tooling/c2pa-oracle/Cargo.lock"), + "`.gitignore` must keep the negation that exempts this crate's lockfile from the blanket \ + `tooling/*/Cargo.lock` rule; without it the graph re-resolves on every run and nothing \ + above asserts anything about what was resolved" + ); + assert!( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("Cargo.lock") + .exists(), + "and the lockfile the negation exempts must be in the tree" + ); +} From a1f6a612f8f69634900ced0c4b3a07dd7211ed6e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:53:38 -0400 Subject: [PATCH 22/36] docs(c2pa-oracle): point the pinned-refusal claim at its enumeration The README asserted that each side of each refusal is pinned by its own test. That is a claim about a set of branches, and it was false: four of them had one side untested. Now that the set is enumerated in the test module, say where the enumeration lives and what a blank side means, name the one refusal that lives outside that layer, and record that the tests assert the refusal's message rather than that an error occurred. Also replaces the passage saying no lockfile holds the resolution, which the committed lockfile reverses, with the reason it was committed. --- tooling/c2pa-oracle/README.md | 37 +++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index c62442f2..367cf4bd 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -183,11 +183,18 @@ manifest line is the whole determinant, which is what makes a drift guard over i The version is pinned with `=`, not a caret range, and that is a separate guarantee from the features. This file cites c2pa-rs's own source **by line number** — `src/validation_results.rs:36-41` below, `src/jumbf_io.rs:246` and `:258` at the end — and a caret range lets any patch release move -those lines while the citation stands as written. There is no committed lockfile to hold the -resolution instead: `.gitignore` excludes `tooling/*/Cargo.lock`, because a workspace-excluded -oracle resolves standalone. The pin is the smaller fix and the one that keeps the prose honest; -`tests/build_configuration.rs` guards it too, so raising the version is a deliberate act that also -means re-reading every citation here. +those lines while the citation stands as written. `tests/build_configuration.rs` guards the pin, so +raising the version is a deliberate act that also means re-reading every citation here. + +`Cargo.lock` is committed as well, for this crate alone. `.gitignore` excludes +`tooling/*/Cargo.lock` — a `tooling/` crate normally resolves through the root lockfile, so a local +one is redundant — and this crate is the one exception, with the negation and its reason recorded +beside the rule. The reason is that the `=` pin holds exactly one line: everything *under* `c2pa`, +three hundred-odd transitive packages, re-resolves on every invocation without a lockfile. The +no-OpenSSL check above is an assertion about the resolved graph, and with the graph re-resolved +moments earlier it could only ever inspect what cargo had just written for it. Committing the +lockfile is what makes it a check; the exception is itself pinned by a drift guard, so it cannot be +reverted in one line without a test going red. ## The signing identity @@ -249,9 +256,23 @@ same 8-byte header. Any of them would otherwise yield a span ending at or before body byte. All three refusals are the typed `OracleError::UnusableSuperboxLength`, and no length is ever guessed. No store this crate has seen uses either reserved value — c2pa-rs writes a plain 32-bit `LBox` — which is precisely why the handling is written rather than assumed, and why the -arms are pinned by unit tests in `src/lib.rs` rather than left to a fixture that cannot reach them: -each side of each refusal is pinned by its own test, so widening or narrowing a range by one is -caught. +arms are pinned by unit tests in `src/lib.rs` rather than left to a fixture that cannot reach them. + +Each side of each refusal is pinned by its own test — the input the branch refuses, and the nearest +input it must *not* refuse — so widening or narrowing a range by one is caught. That is a claim +about a *set* of branches, and three consecutive reviews each found one more member of the set +untested, so the set is now written down rather than argued: the module documentation on +`#[cfg(test)] mod tests` in `src/lib.rs` carries the enumeration as a table, one row per refusing +branch across `find_jumbf_superbox`, `declared_store_len` and `jumbf_superbox_span`, naming both +tests. A branch added without a row, or a row with one side blank, is the finding. The one refusal +outside that layer — `reserve_then_fill` rejecting a slot that is not the signed store's length — +needs c2pa-rs and a gamut encoder in reach, so it is pinned in `tests/reserve_then_fill.rs` +instead, and the table says so. + +Every one of those tests asserts the **message** the refusal carries, never `is_err()`. Several +branches refuse the same input for different reasons — delete the truncated-`LBox` arm and a +three-byte buffer falls into the `LBox == 0` arm, which refuses it too — so only the message +distinguishes the branch that fired from the one that caught the fall. For the same reason `find_jumbf_superbox` **continues** past a `jumb` that appears too early to carry an `LBox` in front of it, instead of concluding the buffer has no superbox. Today's fixtures From 447ab4bdca9b31a58c10cb3f04c92d13a0215fa8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:07:21 -0400 Subject: [PATCH 23/36] test(c2pa-oracle): name the span row for the span it pins Self-review of the enumeration. The test on the accepted side of both of `jumbf_superbox_span`'s refusals was named for a length; it asserts a span. And the table has one test in both columns, which reads as a gap and is not: a 4-to-7-byte buffer is one whose LBox field was read successfully and whose resulting length is then too short, so it is the accepted side of the truncation branch and the refused side of the LBox == 0 branch. Adjacent branches on the same input share a boundary; that is what makes it one. Say so where the table is. --- tooling/c2pa-oracle/src/lib.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index 10e9b1e3..61868c0c 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -500,12 +500,17 @@ mod tests { //! | [`declared_store_len`] | `LBox == 1`: `get(8..16)`, the `XLBox` field is truncated | [`an_lbox_of_one_without_room_for_an_xlbox_is_refused`] | [`an_lbox_of_one_in_a_buffer_of_exactly_the_sixteen_byte_header_is_a_length`] | //! | [`declared_store_len`] | `XLBox` below the 16-byte header it counts | [`an_xlbox_below_the_sixteen_byte_header_is_refused_rather_than_resolved`] | [`an_xlbox_of_exactly_the_header_size_is_a_length`] | //! | [`declared_store_len`] | `LBox` in 2..=7, below the 8-byte header it counts | [`an_lbox_between_two_and_seven_is_refused_rather_than_resolved`] | [`an_lbox_of_exactly_the_header_size_is_a_length`] | - //! | [`jumbf_superbox_span`] | `checked_add`: offset plus declared length leaves `usize` | [`a_declared_length_that_overflows_the_buffer_offset_is_an_unusable_length`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_length`] | - //! | [`jumbf_superbox_span`] | `end <= buffer.len()`: the length runs past the buffer | [`a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_length`] | + //! | [`jumbf_superbox_span`] | `checked_add`: offset plus declared length leaves `usize` | [`a_declared_length_that_overflows_the_buffer_offset_is_an_unusable_length`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_span`] | + //! | [`jumbf_superbox_span`] | `end <= buffer.len()`: the length runs past the buffer | [`a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_span`] | //! - //! Two rows share an "accepts" column deliberately: the same input is the nearest non-refused - //! one for both, and splitting it would only give the second row a fixture that differs in a - //! byte neither branch reads. + //! Two things about the table read as gaps and are not. The last two rows share an "accepts" + //! column: the same input is the nearest non-refused one for both branches, and splitting it + //! would only give the second row a fixture differing in a byte neither branch reads. And one + //! test appears in both columns — [`an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused`] + //! is the *refused* side of the `LBox == 0` branch and the *accepted* side of the truncation + //! branch above it, because a 4-to-7-byte buffer is one whose `LBox` field was read + //! successfully and whose resulting length is then too short. Adjacent branches on the same + //! input share a boundary; that is what makes it a boundary. //! //! Two arms of [`declared_store_len`] refuse nothing and so appear in no row — the reserved //! `LBox` values, which resolve to a length rather than rejecting it. They are pinned by @@ -642,10 +647,10 @@ mod tests { } #[test] - fn a_span_ending_exactly_at_the_end_of_the_buffer_is_a_length() { + fn a_span_ending_exactly_at_the_end_of_the_buffer_is_a_span() { // The accepted side of both of `jumbf_superbox_span`'s refusals: the sum stays inside - // `usize` and the end lands on the last byte. A bound that refused one byte too early - // would refuse this store, which is the shape every real store has. + // `usize` and the end lands on the last byte of the buffer. A bound that refused one byte + // too early would refuse this store, which is the shape every real store has. let buffer = superbox_at_the_start_of_the_buffer(); assert_eq!(buffer.len(), 24, "the declared length is the whole buffer"); From 3d47fb67645183b1b6c3ce43bd3acccfd946aca1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:18:40 -0400 Subject: [PATCH 24/36] docs(c2pa-oracle): say what the refusal tests assert, exactly Both the enumeration and the README claimed every test asserts the refusal's message. Three of them assert a variant instead, because `NoJumbfSuperbox` is fieldless and has no message to name. The claim that matters is that each test says *which* refusal fired rather than that one did, so state it that way. --- tooling/c2pa-oracle/README.md | 9 +++++---- tooling/c2pa-oracle/src/lib.rs | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 367cf4bd..412b5715 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -269,10 +269,11 @@ outside that layer — `reserve_then_fill` rejecting a slot that is not the sign needs c2pa-rs and a gamut encoder in reach, so it is pinned in `tests/reserve_then_fill.rs` instead, and the table says so. -Every one of those tests asserts the **message** the refusal carries, never `is_err()`. Several -branches refuse the same input for different reasons — delete the truncated-`LBox` arm and a -three-byte buffer falls into the `LBox == 0` arm, which refuses it too — so only the message -distinguishes the branch that fired from the one that caught the fall. +Every one of those tests asserts **which** refusal fired — the message where the variant carries +one, the variant itself where it does not — never merely that an error occurred. Several branches +refuse the same input for different reasons: delete the truncated-`LBox` arm and a three-byte +buffer falls into the `LBox == 0` arm, which refuses it too, so only the message distinguishes the +branch that fired from the one that caught the fall. For the same reason `find_jumbf_superbox` **continues** past a `jumb` that appears too early to carry an `LBox` in front of it, instead of concluding the buffer has no superbox. Today's fixtures diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index 61868c0c..8161ebb6 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -522,10 +522,11 @@ mod tests { //! signed store's length — needs c2pa-rs and a gamut encoder, so it is pinned where those are //! in reach: `tests/reserve_then_fill.rs`. //! - //! Every refusal is asserted by the **message** it carries, never by `is_err()`. Several - //! branches refuse the same input for different reasons — deleting the truncated-`LBox` arm, - //! for instance, sends a 3-byte buffer into the `LBox == 0` arm, which refuses it too — so - //! only the message distinguishes the arm that fired from the one that caught the fall. + //! Every one of these tests asserts **which** refusal fired — the message where the variant + //! carries one, the variant itself where it does not — never merely that an error occurred. + //! Several branches refuse the same input for different reasons: deleting the + //! truncated-`LBox` arm sends a 3-byte buffer into the `LBox == 0` arm, which refuses it too, + //! so only the message distinguishes the arm that fired from the one that caught the fall. use super::{OracleError, declared_store_len, find_jumbf_superbox, jumbf_superbox_span}; From 124eea9308ac02dde457f1d44314fd2038369dfe Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:14:05 -0400 Subject: [PATCH 25/36] build(c2pa-oracle): pass --locked so the committed lockfile is the one used The lockfile committed for this crate held nothing: neither `test-c2pa` nor `check-c2pa` passed `--locked`, so deleting it left all four build-configuration tests green while cargo wrote a fresh resolution over it. Verified: with the file removed the suite passed and the regenerated graph differed from the committed one (bitflags 2.13.1 -> 2.13.2, ryu 1.26.0 -> 1.26.1), which is exactly the resolution the no-OpenSSL assertion then read. Both tasks now pass `--locked`, so a missing or stale lockfile fails the task instead of being regenerated. That also makes the `.exists()` assertion unfalsifiable -- cargo creates the file while building the test binary, and under `--locked` it cannot be absent at all -- so it is deleted rather than left as decoration. The `.gitignore` negation drift guard, the falsifiable half, stays. The rationale on the record is corrected while it is being read: the exception rests on the 325 transitive packages the `=` pin does not hold, not on nothing else in the tree depending on `c2pa`. That second fact is true but bears on the feature line -- no other dependent can unify `openssl` back on -- and says nothing about which versions those packages resolve to. --- mise.toml | 10 +++++-- tooling/c2pa-oracle/Cargo.toml | 14 +++++---- .../c2pa-oracle/tests/build_configuration.rs | 30 ++++++++++++------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/mise.toml b/mise.toml index 24aa9d73..dabea958 100644 --- a/mise.toml +++ b/mise.toml @@ -216,9 +216,15 @@ run = "cargo check --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml # `--no-default-features --features rust_native_crypto`, which is what keeps its vendored OpenSSL # out of this repository entirely. It is still kept off `mise run test` because a shipped crate # must never gain an edge to it, and the excluded manifest is what enforces that. +# +# `--locked` is what makes the committed `tooling/c2pa-oracle/Cargo.lock` load-bearing. Without it +# cargo silently re-resolves the 325 transitive packages under the pinned `c2pa` and writes the +# result over the committed file, so the crate's own resolved-graph assertion would be reading a +# resolution cargo had produced moments earlier. With it, a lockfile that is missing or out of date +# fails the task instead of being regenerated. [tasks.test-c2pa] description = "Cross-check gamut's C2PA carriage against c2pa-rs, both directions (issue #447)" -run = "cargo test --manifest-path tooling/c2pa-oracle/Cargo.toml" +run = "cargo test --locked --manifest-path tooling/c2pa-oracle/Cargo.toml" # The compile half, shaped after `check-dng-real`: a `gamut-avif` or `gamut-heic` API change can # break this crate while every per-PR gate stays green, and `check` catches that in seconds without @@ -230,7 +236,7 @@ run = "cargo test --manifest-path tooling/c2pa-oracle/Cargo.toml" # Wiring them up — `check-c2pa` in the per-PR lint lane, `test-c2pa` in extended — is issue #541. [tasks.check-c2pa] description = "Compile the C2PA differential oracle (no signing, no corpus)" -run = "cargo check --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets" +run = "cargo check --locked --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets" # Doctests only. On stable, `cargo llvm-cov` cannot instrument doctests (that needs nightly), so # the coverage gate — which CI uses as its green-test gate — silently skips them. This task is the diff --git a/tooling/c2pa-oracle/Cargo.toml b/tooling/c2pa-oracle/Cargo.toml index 6075c4b6..08e04700 100644 --- a/tooling/c2pa-oracle/Cargo.toml +++ b/tooling/c2pa-oracle/Cargo.toml @@ -33,11 +33,15 @@ doctest = false # number** for two claims it records, and a `^` range lets a patch release move those lines under # a citation nobody re-checked. # -# The pin holds this one line and nothing else. The three hundred-odd transitive packages under it -# are held by `Cargo.lock`, which is committed for this crate alone against the blanket -# `tooling/*/Cargo.lock` rule — the exception, and why it is made, are recorded in `.gitignore`. -# Without it the no-OpenSSL assertion in `tests/build_configuration.rs` could only ever inspect -# the resolution cargo had just written for it, which the pin does nothing to constrain. +# The pin holds this one line and nothing else. The 325 transitive packages under it are held by +# `Cargo.lock`, which is committed for this crate alone against the blanket `tooling/*/Cargo.lock` +# rule — the exception, and why it is made, are recorded in `.gitignore`. That, and not the fact +# that nothing else in the tree depends on `c2pa`, is what the exception rests on: the +# single-dependent fact bears on the feature line above (no other dependent can unify `openssl` +# back on) and says nothing about which versions those 325 packages resolve to. `mise run +# test-c2pa` and `check-c2pa` pass `--locked`, so the committed resolution is the one that is +# used; without both, the no-OpenSSL assertion in `tests/build_configuration.rs` could only ever +# inspect a resolution cargo had just written for it, which the pin does nothing to constrain. c2pa = { version = "=0.90.21", default-features = false, features = ["rust_native_crypto"] } [dev-dependencies] diff --git a/tooling/c2pa-oracle/tests/build_configuration.rs b/tooling/c2pa-oracle/tests/build_configuration.rs index 0cf5b6d5..5a4e1248 100644 --- a/tooling/c2pa-oracle/tests/build_configuration.rs +++ b/tooling/c2pa-oracle/tests/build_configuration.rs @@ -17,10 +17,16 @@ //! The fourth guards what makes the second one an assertion at all. `Cargo.lock` is committed for //! this crate — an exception to the blanket `tooling/*/Cargo.lock` rule, recorded in //! `.gitignore` — because the `=` pin holds the direct dependency and nothing under it: with the -//! lockfile ignored, the whole transitive graph re-resolves on every invocation and the +//! lockfile ignored, the 325 transitive packages re-resolve on every invocation and the //! resolved-graph check can only inspect a file cargo has just written for it, which is not a //! check. So the exception itself is pinned, or it can be reverted in one line without a single //! test going red. +//! +//! The committed file is only half of that. Cargo regenerates a missing or stale lockfile without +//! complaint, so `mise run test-c2pa` and `mise run check-c2pa` pass **`--locked`**: the resolution +//! in the tree is the resolution that was used, or the task fails. Nothing here asserts the file +//! merely *exists* — under `--locked` it cannot be absent, and an assertion that cannot fail is +//! not one. use std::path::Path; @@ -98,9 +104,19 @@ fn the_c2pa_dependency_pins_the_exact_version_the_readmes_citations_were_read_ag #[test] fn the_lockfile_this_crate_resolves_against_is_committed_rather_than_ignored() { // The repository ignores `tooling/*/Cargo.lock` — a `tooling/` crate normally resolves through - // the root lockfile, so a local one is redundant. This crate is the exception: nothing else in - // the tree depends on `c2pa`, so nothing else pins the graph it drags in, and the - // resolved-graph assertion above would be reading a file cargo had written moments earlier. + // the root lockfile, so a local one is redundant. This crate is the exception, and the reason + // is the 325 packages *under* `c2pa`: the `=` pin holds one direct dependency and nothing + // beneath it, so without a committed lockfile the whole transitive graph re-resolves on every + // invocation and the resolved-graph assertion above can only inspect the resolution cargo just + // wrote for it. `mise run test-c2pa` and `check-c2pa` pass `--locked`, which is what turns + // "the file exists" into "this is the resolution that was used". + // + // A different argument used to stand here — that nothing else in the tree depends on `c2pa`, + // so nothing else pins its graph. That one is true but it bears on the *feature* line above, + // not on this test: it says no other dependent can unify `openssl` back on. It says nothing + // about which versions those 325 packages resolve to, which is the only thing committing this + // file actually holds still. The two are different claims and only the second justifies the + // exception. // // A drift guard on the negation line, the same technique as the manifest check: dropping it // is one edit, and every other test here would stay green afterwards. @@ -115,10 +131,4 @@ fn the_lockfile_this_crate_resolves_against_is_committed_rather_than_ignored() { `tooling/*/Cargo.lock` rule; without it the graph re-resolves on every run and nothing \ above asserts anything about what was resolved" ); - assert!( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("Cargo.lock") - .exists(), - "and the lockfile the negation exempts must be in the tree" - ); } From 09de2a4fa496e8f3b6f48465a236a82afa0a90ba Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:14:14 -0400 Subject: [PATCH 26/36] docs(c2pa-oracle): name the panic ComposedBox::store can raise `store()` slices `composed` from `store_offset` and panics if that offset is past the end. `split_composed_box` cannot produce such a value -- it derives the offset by finding a superbox type inside the buffer -- but both fields are `pub`, so a hand-built value reaches it. Documented rather than defended: narrowing the field visibility is a wider API change than the omission needs. --- tooling/c2pa-oracle/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index 8161ebb6..ac02ab50 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -174,6 +174,13 @@ pub struct ComposedBox { impl ComposedBox { /// The raw JUMBF manifest store: what a host embeds in its own framing, and what gamut's /// locators report. + /// + /// # Panics + /// + /// If [`store_offset`](Self::store_offset) is past the end of [`composed`](Self::composed). + /// [`split_composed_box`] cannot produce such a value — it derives the offset by finding a + /// superbox type *inside* the buffer — so this is reachable only by building the struct + /// literally, which both fields being `pub` permits. #[must_use] pub fn store(&self) -> &[u8] { &self.composed[self.store_offset..] From f4ff9aefeb030458c2bb1ce744a9b09f37fca855 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:14:22 -0400 Subject: [PATCH 27/36] test(c2pa-oracle): refuse the nearest length that runs past the buffer The fixture declared `LBox = 4096` in a 32-byte buffer -- 4064 bytes past the bound, so any bound off by any amount still refuses it. Verified: with the bound mutated to `*end <= buffer.len() + 1` the whole suite stayed green. Declaring 33 bytes instead is the nearest input the bound must refuse, and the same mutant now fails the test with the span it wrongly returned (`0..33`). The accepting side was already at the boundary; this is the rule the same enumeration applies elsewhere, applied here. --- tooling/c2pa-oracle/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index ac02ab50..bc7d5f02 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -811,8 +811,12 @@ mod tests { #[test] fn a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox() { + // 33 bytes declared in a 32-byte buffer: the *nearest* length the bound must refuse, one + // byte past the end. A far-past length — 4096, say — is refused by a bound off by any + // amount, so it says nothing about where the bound sits; this one is refused only by a + // bound that stops exactly at `buffer.len()`. let mut buffer = vec![0u8; 32]; - buffer[..4].copy_from_slice(&4096u32.to_be_bytes()); + buffer[..4].copy_from_slice(&33u32.to_be_bytes()); buffer[4..8].copy_from_slice(b"jumb"); let error = jumbf_superbox_span(&buffer) From d28fd70e9179035a26984ecf7212916092a57a54 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:14:44 -0400 Subject: [PATCH 28/36] test(c2pa-oracle): enumerate every discriminating branch, not only refusals The enumeration's scope was branches that can refuse an input. That boundary excluded exactly the predicates whose whole job is telling two cases apart, and one of them had a blank side: `is_jumbf_not_found` mutated to `-> true` survived all 40 tests, because no test ever observed it answering `false`. Only the `-> false` direction was pinned, by the differential in `tests/no_copy_forward.rs`. The scope is now every discriminating branch on the crate's own parsing surface, and the columns are `Taken` / `Not taken` rather than `Refuses` / `Accepts`, which generalises without losing the refusal reading. Three rows join: the two reserved `LBox` values, which resolve a length rather than rejecting one and were relegated to prose, and `is_jumbf_not_found`. Its `false` side is pinned on `c2pa::Error::JumbfBoxNotFound` -- the nearest neighbour there is, raised only once a store has been found and something inside it is missing, which is precisely the case the no-copy-forward claim must not confuse with an absent manifest. Verified: the `-> true` mutant now fails that test. Its `true` side is pinned inline as well as differentially, because the inline test fails for one reason where the differential fails for anything wrong in a re-encode. --- tooling/c2pa-oracle/src/lib.rs | 90 +++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index bc7d5f02..70716d78 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -489,45 +489,63 @@ mod tests { //! itself; these are the arms an oracle has to get right before it is pointed at a container //! whose store follows arbitrary bytes. //! - //! # Every arm, enumerated once + //! # Every branch, enumerated once //! //! Three rounds of review each found one more untested arm here, because each round looked at //! the arm the last one had missed rather than at the set. So the set is written down. Every - //! branch that can refuse an input across the three header-reading functions is listed below - //! with the test that pins each of its two directions — the input it refuses, and the nearest - //! input it must *not* refuse. Adding a branch means adding a row, and a row with one side - //! blank is the finding, not a matter of taste. + //! **discriminating** branch on this crate's own parsing surface — every point where the code + //! chooses between two answers about a buffer — is listed below with the test that pins each + //! of its two directions. Adding a branch means adding a row, and a row with one side blank is + //! the finding, not a matter of taste. //! - //! | Function | Branch | Refuses | Accepts | + //! The scope was narrower once: only branches that could *refuse* an input. That boundary + //! excluded exactly the predicates whose whole job is telling two cases apart — + //! [`is_jumbf_not_found`] sat outside it and had one direction unpinned, which is the same + //! blank-side shape the table exists to make visible. Discrimination, not refusal, is the + //! property that earns a row. + //! + //! *Taken* is the input that reaches the branch; *not taken* is the nearest input that does + //! not. For a branch that refuses, those are the input it refuses and the nearest input it + //! must **not** refuse. + //! + //! | Function | Branch | Taken | Not taken | //! |---|---|---|---| //! | [`find_jumbf_superbox`] | `.skip(4)`: a type offset below 4 has no `LBox` in front of it | [`a_jumb_three_bytes_in_is_too_early_to_carry_an_lbox`] | [`a_superbox_whose_lbox_opens_the_buffer_is_found_at_offset_zero`] | //! | [`find_jumbf_superbox`] | `.ok_or`: no `jumb` in the buffer at all | [`a_buffer_carrying_no_jumb_at_all_is_an_absent_superbox`] | [`the_search_continues_past_a_jumb_too_early_to_carry_an_lbox`] | //! | [`declared_store_len`] | `get(..4)`: the `LBox` field is truncated | [`a_buffer_too_short_for_an_lbox_field_is_refused_as_a_truncated_field`] | [`an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused`] | + //! | [`declared_store_len`] | `LBox == 0`: the reserved to-end-of-buffer value, not a literal zero | [`an_lbox_of_zero_declares_the_rest_of_the_buffer`] | [`an_lbox_of_exactly_the_header_size_is_a_length`] | //! | [`declared_store_len`] | `LBox == 0` in a buffer below the 8-byte header | [`an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused`] | [`an_lbox_of_zero_in_a_buffer_of_exactly_the_header_size_is_a_length`] | + //! | [`declared_store_len`] | `LBox == 1`: the reserved defer-to-`XLBox` value, not a literal one | [`an_lbox_of_one_takes_its_length_from_the_xlbox_field`] | [`an_lbox_of_exactly_the_header_size_is_a_length`] | //! | [`declared_store_len`] | `LBox == 1`: `get(8..16)`, the `XLBox` field is truncated | [`an_lbox_of_one_without_room_for_an_xlbox_is_refused`] | [`an_lbox_of_one_in_a_buffer_of_exactly_the_sixteen_byte_header_is_a_length`] | //! | [`declared_store_len`] | `XLBox` below the 16-byte header it counts | [`an_xlbox_below_the_sixteen_byte_header_is_refused_rather_than_resolved`] | [`an_xlbox_of_exactly_the_header_size_is_a_length`] | //! | [`declared_store_len`] | `LBox` in 2..=7, below the 8-byte header it counts | [`an_lbox_between_two_and_seven_is_refused_rather_than_resolved`] | [`an_lbox_of_exactly_the_header_size_is_a_length`] | //! | [`jumbf_superbox_span`] | `checked_add`: offset plus declared length leaves `usize` | [`a_declared_length_that_overflows_the_buffer_offset_is_an_unusable_length`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_span`] | //! | [`jumbf_superbox_span`] | `end <= buffer.len()`: the length runs past the buffer | [`a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_span`] | + //! | [`is_jumbf_not_found`] | `C2pa(JumbfNotFound)`: the asset carries no manifest at all | [`c2pa_rss_jumbf_not_found_is_an_absent_manifest`] | [`a_c2pa_error_other_than_jumbf_not_found_is_not_an_absent_manifest`] | //! - //! Two things about the table read as gaps and are not. The last two rows share an "accepts" - //! column: the same input is the nearest non-refused one for both branches, and splitting it - //! would only give the second row a fixture differing in a byte neither branch reads. And one - //! test appears in both columns — [`an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused`] - //! is the *refused* side of the `LBox == 0` branch and the *accepted* side of the truncation - //! branch above it, because a 4-to-7-byte buffer is one whose `LBox` field was read - //! successfully and whose resulting length is then too short. Adjacent branches on the same - //! input share a boundary; that is what makes it a boundary. + //! Two things about the table read as gaps and are not. Two rows share a "not taken" column — + //! [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_span`] is the nearest non-refused + //! input for both of [`jumbf_superbox_span`]'s refusals, and splitting it would only give the + //! second row a fixture differing in a byte neither branch reads; so do the three + //! [`declared_store_len`] rows whose neighbouring arm is the ordinary 32-bit `LBox`, which + //! [`an_lbox_of_exactly_the_header_size_is_a_length`] is. And one test appears in both columns + //! — [`an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused`] is the *taken* side of + //! the `LBox == 0` header-minimum branch and the *not taken* side of the truncation branch + //! above it, because a 4-to-7-byte buffer is one whose `LBox` field was read successfully and + //! whose resulting length is then too short. Adjacent branches on the same input share a + //! boundary; that is what makes it a boundary. //! - //! Two arms of [`declared_store_len`] refuse nothing and so appear in no row — the reserved - //! `LBox` values, which resolve to a length rather than rejecting it. They are pinned by - //! [`an_lbox_of_zero_declares_the_rest_of_the_buffer`] and - //! [`an_lbox_of_one_takes_its_length_from_the_xlbox_field`], which are about *not* taking a - //! reserved value literally rather than about a boundary. + //! One test here is in no row: [`a_span_is_still_found_when_a_decoy_jumb_precedes_the_superbox`] + //! pins the *composition* of the search and the length reading, not a branch of either. + //! + //! [`is_jumbf_not_found`]'s taken side is also observed end to end, on an error c2pa-rs itself + //! raised, in `tests/no_copy_forward.rs`. The row points at the inline test rather than that + //! one because the inline test fails for exactly one reason — the predicate misread an error — + //! while the differential fails for anything wrong anywhere in a re-encode. //! //! The refusal outside this layer — `reserve_then_fill` rejecting a slot that is not the - //! signed store's length — needs c2pa-rs and a gamut encoder, so it is pinned where those are - //! in reach: `tests/reserve_then_fill.rs`. + //! signed store's length — is not parsing and needs c2pa-rs and a gamut encoder, so it is + //! pinned where those are in reach: `tests/reserve_then_fill.rs`. //! //! Every one of these tests asserts **which** refusal fired — the message where the variant //! carries one, the variant itself where it does not — never merely that an error occurred. @@ -535,7 +553,10 @@ mod tests { //! truncated-`LBox` arm sends a 3-byte buffer into the `LBox == 0` arm, which refuses it too, //! so only the message distinguishes the arm that fired from the one that caught the fall. - use super::{OracleError, declared_store_len, find_jumbf_superbox, jumbf_superbox_span}; + use super::{ + OracleError, declared_store_len, find_jumbf_superbox, is_jumbf_not_found, + jumbf_superbox_span, + }; /// A buffer with a decoy `jumb` at offset **3** — the last offset too early to be a superbox /// type, because an `LBox` needs the four bytes in front of it — and a genuine `LBox` + `jumb` @@ -850,4 +871,29 @@ mod tests { is, and must be refused as the unusable length it is; got {error}" ); } + + #[test] + fn c2pa_rss_jumbf_not_found_is_an_absent_manifest() { + assert!( + is_jumbf_not_found(&OracleError::C2pa(c2pa::Error::JumbfNotFound)), + "`JumbfNotFound` is c2pa-rs saying the asset carries no manifest at all, which is the \ + one verdict this predicate exists to recognise" + ); + } + + #[test] + fn a_c2pa_error_other_than_jumbf_not_found_is_not_an_absent_manifest() { + // `JumbfBoxNotFound` is the nearest neighbour there is: same crate, same phrasing, and it + // is raised only once a store *has* been found and something inside it is missing. Reading + // it as an absent manifest would turn "the derivative carries a broken copy of its + // parent's store" into "the derivative carries nothing", which is precisely the confusion + // the no-copy-forward claim turns on. + let error = OracleError::C2pa(c2pa::Error::JumbfBoxNotFound); + + assert!( + !is_jumbf_not_found(&error), + "an error raised about a manifest that is present is not the absence of one; got \ + {error} reported as an absent manifest" + ); + } } From aa6340db1ec217309e5275e52e3a7f98d31af27e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:14:52 -0400 Subject: [PATCH 29/36] test(c2pa-oracle): check the enumeration against the functions it names Nothing enforced the table. Its 21 intra-doc links look like references but rustdoc never compiles a `cfg(test)` module, so `broken_intra_doc_links` never resolves them: renaming a pinned test and leaving its row stale kept the whole suite and clippy green. The table is now parsed out of the module doc at compile time -- the file reads its own source -- and three tests check it, one reason each: the shape (four cells, none blank, each of the three name columns carrying a link), that every name it links is a function this file defines, and that every `tests/` file it points at exists. Verified by falsifying all three: renaming `an_xlbox_of_exactly_the_header_size_is_a_length` in its definition only, blanking a `Not taken` cell, and misspelling `tests/no_copy_forward.rs` each fail exactly the corresponding test. This is what makes "a row with one side blank is the finding" enforced rather than aspirational. --- tooling/c2pa-oracle/src/lib.rs | 125 +++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index 70716d78..d7910b70 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -523,6 +523,14 @@ mod tests { //! | [`jumbf_superbox_span`] | `end <= buffer.len()`: the length runs past the buffer | [`a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox`] | [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_span`] | //! | [`is_jumbf_not_found`] | `C2pa(JumbfNotFound)`: the asset carries no manifest at all | [`c2pa_rss_jumbf_not_found_is_an_absent_manifest`] | [`a_c2pa_error_other_than_jumbf_not_found_is_not_an_absent_manifest`] | //! + //! The table is machine-checked. [`the_enumeration_is_a_table_with_no_blank_cell`] and + //! [`every_test_the_enumeration_names_exists_in_this_file`] parse it out of this very doc + //! comment and fail on a blank cell or on a name nothing defines, so renaming a pinned test + //! cannot leave a stale row behind a green suite; rustdoc would not catch it, because it never + //! compiles a `cfg(test)` module and never resolves these links. + //! [`every_test_file_the_enumeration_names_exists`] does the same for the `tests/` files the + //! prose points at. + //! //! Two things about the table read as gaps and are not. Two rows share a "not taken" column — //! [`a_span_ending_exactly_at_the_end_of_the_buffer_is_a_span`] is the nearest non-refused //! input for both of [`jumbf_superbox_span`]'s refusals, and splitting it would only give the @@ -896,4 +904,121 @@ mod tests { {error} reported as an absent manifest" ); } + + /// This file's own source, so the enumeration in the module doc above can be checked against + /// the definitions below it. + const SOURCE: &str = include_str!("lib.rs"); + + /// The lines of this module's doc comment, `//!` and surrounding space stripped. + fn module_doc_lines() -> impl Iterator { + SOURCE + .lines() + .skip_while(|line| !line.starts_with("mod tests {")) + .filter_map(|line| line.trim_start().strip_prefix("//!")) + .map(str::trim) + } + + /// The enumeration's rows, header and separator included, each split into its cells. + fn enumeration_rows() -> Vec> { + module_doc_lines() + .filter(|line| line.starts_with('|')) + .map(|line| line.trim_matches('|').split('|').map(str::trim).collect()) + .collect() + } + + /// The `` [`name`] `` intra-doc links in `text`, in order. + fn intra_doc_links(text: &str) -> Vec<&str> { + text.match_indices("[`") + .filter_map(|(at, _)| { + let rest = &text[at + 2..]; + rest.find("`]").map(|end| &rest[..end]) + }) + .collect() + } + + #[test] + fn the_enumeration_is_a_table_with_no_blank_cell() { + const HEADER: [&str; 4] = ["Function", "Branch", "Taken", "Not taken"]; + + let rows = enumeration_rows(); + assert!( + rows.len() > 2, + "the module doc must carry the enumeration table: header, separator and at least one \ + branch" + ); + assert_eq!( + rows[0], HEADER, + "the columns this test reads by position must be the ones the table declares" + ); + + for row in &rows[2..] { + assert_eq!( + row.len(), + HEADER.len(), + "every row names a function, a branch and both of its directions: {row:?}" + ); + for (column, cell) in HEADER.iter().zip(row) { + assert!( + !cell.is_empty(), + "a row with a blank `{column}` is the finding, not a formatting slip: {row:?}" + ); + } + for column in [0usize, 2, 3] { + assert!( + !intra_doc_links(row[column]).is_empty(), + "`{}` must name its function or test as an intra-doc link, so this file can \ + check it resolves: {row:?}", + HEADER[column] + ); + } + } + } + + #[test] + fn every_test_the_enumeration_names_exists_in_this_file() { + let mut checked = 0usize; + + for line in module_doc_lines() { + for name in intra_doc_links(line) { + checked += 1; + assert!( + SOURCE.contains(&format!("fn {name}(")), + "the enumeration names `{name}`, and this file defines no function by that \ + name; a renamed test has to be renamed in its row too" + ); + } + } + + assert!( + checked > 0, + "the enumeration names its functions with intra-doc links; finding none means this \ + test read the wrong lines and was checking nothing" + ); + } + + #[test] + fn every_test_file_the_enumeration_names_exists() { + let mut checked = 0usize; + + for line in module_doc_lines() { + for span in line.split('`').skip(1).step_by(2) { + if !span.starts_with("tests/") || !span.ends_with(".rs") { + continue; + } + checked += 1; + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(span); + assert!( + path.exists(), + "the enumeration points at `{span}` for a claim it does not pin itself, and \ + there is no such file" + ); + } + } + + assert!( + checked > 0, + "the enumeration points at `tests/` files for the two claims outside this layer; \ + finding none means this test read the wrong lines and was checking nothing" + ); + } } From 0eb7b1bb2f5862b601b0713348fa86e68a27e1c4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:38:47 -0400 Subject: [PATCH 30/36] docs(c2pa-oracle): keep the README's claims level with the code Two statements went stale in this round. The enumeration is no longer one row per refusing branch across three functions -- it is one row per discriminating branch across four, and three tests in the same module now parse the table and enforce it. And the lockfile paragraph described committing the file without `--locked`, which is the half that makes the committed resolution the one used; it also repeated the single-dependent argument, which bears on the feature line rather than on the graph. --- tooling/c2pa-oracle/README.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 412b5715..14272d29 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -190,11 +190,17 @@ raising the version is a deliberate act that also means re-reading every citatio `tooling/*/Cargo.lock` — a `tooling/` crate normally resolves through the root lockfile, so a local one is redundant — and this crate is the one exception, with the negation and its reason recorded beside the rule. The reason is that the `=` pin holds exactly one line: everything *under* `c2pa`, -three hundred-odd transitive packages, re-resolves on every invocation without a lockfile. The -no-OpenSSL check above is an assertion about the resolved graph, and with the graph re-resolved -moments earlier it could only ever inspect what cargo had just written for it. Committing the -lockfile is what makes it a check; the exception is itself pinned by a drift guard, so it cannot be -reverted in one line without a test going red. +325 transitive packages, re-resolves on every invocation without a lockfile. The no-OpenSSL check +above is an assertion about the resolved graph, and with the graph re-resolved moments earlier it +could only ever inspect what cargo had just written for it. Committing the lockfile is what makes +it a check; the exception is itself pinned by a drift guard, so it cannot be reverted in one line +without a test going red. + +Committing it is only half. Cargo regenerates a missing or stale lockfile without complaint, so +`mise run test-c2pa` and `mise run check-c2pa` pass **`--locked`**: the resolution in the tree is +the resolution that was used, or the task fails. That is *not* the same claim as "nothing else in +the tree depends on `c2pa`" — true, but it bears on the feature line, saying no other dependent can +unify `openssl` back on, and says nothing about which versions those 325 packages resolve to. ## The signing identity @@ -262,10 +268,14 @@ Each side of each refusal is pinned by its own test — the input the branch ref input it must *not* refuse — so widening or narrowing a range by one is caught. That is a claim about a *set* of branches, and three consecutive reviews each found one more member of the set untested, so the set is now written down rather than argued: the module documentation on -`#[cfg(test)] mod tests` in `src/lib.rs` carries the enumeration as a table, one row per refusing -branch across `find_jumbf_superbox`, `declared_store_len` and `jumbf_superbox_span`, naming both -tests. A branch added without a row, or a row with one side blank, is the finding. The one refusal -outside that layer — `reserve_then_fill` rejecting a slot that is not the signed store's length — +`#[cfg(test)] mod tests` in `src/lib.rs` carries the enumeration as a table, one row per +**discriminating** branch — every point where the parsing surface chooses between two answers about +a buffer, refusing or not — across `find_jumbf_superbox`, `declared_store_len`, +`jumbf_superbox_span` and `is_jumbf_not_found`, naming the test on each side. A branch added +without a row, or a row with one side blank, is the finding, and three tests in that same module +parse the table and enforce it: rustdoc never compiles a `cfg(test)` module, so its intra-doc links +would otherwise go stale behind a green suite. The one refusal outside that layer — +`reserve_then_fill` rejecting a slot that is not the signed store's length — is not parsing and needs c2pa-rs and a gamut encoder in reach, so it is pinned in `tests/reserve_then_fill.rs` instead, and the table says so. From fa83a8a15e91df39bc4e857b1a75ed91cf86a5d0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:52:42 -0400 Subject: [PATCH 31/36] docs(c2pa-oracle): derive the transitive-package count, and say what it counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five places said "325 transitive packages under `c2pa`". No command produces that number for that population: the committed lockfile holds 326 entries, of which 307 are the version-aware closure beneath `c2pa` — the packages whose versions the `=` pin does not hold, and the population every one of those five sentences is arguing about. The remainder are `c2pa` itself, this crate, and 17 reachable only through its four `gamut-*` dev-dependencies. So state 307, and put the derivation beside its first use in `README.md`: the walk over `Cargo.lock`, the count of the whole file, and the reason the walk has to carry versions (21 names occur at two versions, so a name-keyed closure is a different set). Note there too that only 240 packages are ever compiled with default features off, and why the larger figure is the one the argument needs: a missing lockfile redoes resolution, not compilation. --- mise.toml | 3 +- tooling/c2pa-oracle/Cargo.toml | 5 ++- tooling/c2pa-oracle/README.md | 45 ++++++++++++++++++- .../c2pa-oracle/tests/build_configuration.rs | 7 +-- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/mise.toml b/mise.toml index dabea958..3d271e91 100644 --- a/mise.toml +++ b/mise.toml @@ -218,7 +218,8 @@ run = "cargo check --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml # must never gain an edge to it, and the excluded manifest is what enforces that. # # `--locked` is what makes the committed `tooling/c2pa-oracle/Cargo.lock` load-bearing. Without it -# cargo silently re-resolves the 325 transitive packages under the pinned `c2pa` and writes the +# cargo silently re-resolves the 307 transitive packages under the pinned `c2pa` (the figure is +# derived in `tooling/c2pa-oracle/README.md`) and writes the # result over the committed file, so the crate's own resolved-graph assertion would be reading a # resolution cargo had produced moments earlier. With it, a lockfile that is missing or out of date # fails the task instead of being regenerated. diff --git a/tooling/c2pa-oracle/Cargo.toml b/tooling/c2pa-oracle/Cargo.toml index 08e04700..24af3ca6 100644 --- a/tooling/c2pa-oracle/Cargo.toml +++ b/tooling/c2pa-oracle/Cargo.toml @@ -33,12 +33,13 @@ doctest = false # number** for two claims it records, and a `^` range lets a patch release move those lines under # a citation nobody re-checked. # -# The pin holds this one line and nothing else. The 325 transitive packages under it are held by +# The pin holds this one line and nothing else. The 307 transitive packages under it — the +# version-aware closure of its lockfile entries, derived in `README.md` — are held by # `Cargo.lock`, which is committed for this crate alone against the blanket `tooling/*/Cargo.lock` # rule — the exception, and why it is made, are recorded in `.gitignore`. That, and not the fact # that nothing else in the tree depends on `c2pa`, is what the exception rests on: the # single-dependent fact bears on the feature line above (no other dependent can unify `openssl` -# back on) and says nothing about which versions those 325 packages resolve to. `mise run +# back on) and says nothing about which versions those 307 packages resolve to. `mise run # test-c2pa` and `check-c2pa` pass `--locked`, so the committed resolution is the one that is # used; without both, the no-OpenSSL assertion in `tests/build_configuration.rs` could only ever # inspect a resolution cargo had just written for it, which the pin does nothing to constrain. diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 14272d29..82fae32f 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -190,17 +190,58 @@ raising the version is a deliberate act that also means re-reading every citatio `tooling/*/Cargo.lock` — a `tooling/` crate normally resolves through the root lockfile, so a local one is redundant — and this crate is the one exception, with the negation and its reason recorded beside the rule. The reason is that the `=` pin holds exactly one line: everything *under* `c2pa`, -325 transitive packages, re-resolves on every invocation without a lockfile. The no-OpenSSL check +307 transitive packages, re-resolves on every invocation without a lockfile. The no-OpenSSL check above is an assertion about the resolved graph, and with the graph re-resolved moments earlier it could only ever inspect what cargo had just written for it. Committing the lockfile is what makes it a check; the exception is itself pinned by a drift guard, so it cannot be reverted in one line without a test going red. +307 is the version-aware closure of `c2pa`'s entries in the committed lockfile, `c2pa` itself +excluded: the packages whose versions the `=` pin does **not** hold, which is the population this +argument is about — an `openssl` package can only arrive beneath `c2pa`, and it is *resolution*, not +compilation, that a missing lockfile redoes. Re-derive it from the lockfile: + +```bash +python3 - <<'PY' +import tomllib + +package = tomllib.load(open("tooling/c2pa-oracle/Cargo.lock", "rb"))["package"] +by_key = {(p["name"], p["version"]): p for p in package} +versions = {} +for p in package: + versions.setdefault(p["name"], []).append(p["version"]) + +def key(dependency): + field = dependency.split() + return (field[0], field[1] if len(field) > 1 else versions[field[0]][0]) + +seen, todo = set(), [("c2pa", "0.90.21")] +while todo: + node = todo.pop() + if node in seen: + continue + seen.add(node) + todo += [key(d) for d in by_key[node].get("dependencies", [])] + +print(len(seen) - 1) +PY +``` + +The version has to be carried through the walk: 21 package names occur at two versions in this +lockfile, so a name-keyed closure is not the same set. The whole file holds 326 entries +(`grep -c '^name = ' tooling/c2pa-oracle/Cargo.lock`) — those 307, `c2pa` itself, this crate, and 17 +reachable only through its four `gamut-*` dev-dependencies, which the root workspace lockfile +resolves. Fewer are ever compiled: 240 with default features off +(`cargo tree --manifest-path tooling/c2pa-oracle/Cargo.toml --locked -e normal -p c2pa +--prefix none | sed 's/ (\*)$//' | sort -u | wc -l`, `c2pa` included), because a lockfile pins +optional dependencies this build never turns on. That smaller figure is not the one the argument +needs: re-resolution moves all 307. + Committing it is only half. Cargo regenerates a missing or stale lockfile without complaint, so `mise run test-c2pa` and `mise run check-c2pa` pass **`--locked`**: the resolution in the tree is the resolution that was used, or the task fails. That is *not* the same claim as "nothing else in the tree depends on `c2pa`" — true, but it bears on the feature line, saying no other dependent can -unify `openssl` back on, and says nothing about which versions those 325 packages resolve to. +unify `openssl` back on, and says nothing about which versions those 307 packages resolve to. ## The signing identity diff --git a/tooling/c2pa-oracle/tests/build_configuration.rs b/tooling/c2pa-oracle/tests/build_configuration.rs index 5a4e1248..479a7925 100644 --- a/tooling/c2pa-oracle/tests/build_configuration.rs +++ b/tooling/c2pa-oracle/tests/build_configuration.rs @@ -17,7 +17,7 @@ //! The fourth guards what makes the second one an assertion at all. `Cargo.lock` is committed for //! this crate — an exception to the blanket `tooling/*/Cargo.lock` rule, recorded in //! `.gitignore` — because the `=` pin holds the direct dependency and nothing under it: with the -//! lockfile ignored, the 325 transitive packages re-resolve on every invocation and the +//! lockfile ignored, the 307 transitive packages beneath it re-resolve on every invocation and the //! resolved-graph check can only inspect a file cargo has just written for it, which is not a //! check. So the exception itself is pinned, or it can be reverted in one line without a single //! test going red. @@ -105,7 +105,8 @@ fn the_c2pa_dependency_pins_the_exact_version_the_readmes_citations_were_read_ag fn the_lockfile_this_crate_resolves_against_is_committed_rather_than_ignored() { // The repository ignores `tooling/*/Cargo.lock` — a `tooling/` crate normally resolves through // the root lockfile, so a local one is redundant. This crate is the exception, and the reason - // is the 325 packages *under* `c2pa`: the `=` pin holds one direct dependency and nothing + // is the 307 packages *under* `c2pa` — the version-aware closure of its lockfile entries, + // derived in `README.md`: the `=` pin holds one direct dependency and nothing // beneath it, so without a committed lockfile the whole transitive graph re-resolves on every // invocation and the resolved-graph assertion above can only inspect the resolution cargo just // wrote for it. `mise run test-c2pa` and `check-c2pa` pass `--locked`, which is what turns @@ -114,7 +115,7 @@ fn the_lockfile_this_crate_resolves_against_is_committed_rather_than_ignored() { // A different argument used to stand here — that nothing else in the tree depends on `c2pa`, // so nothing else pins its graph. That one is true but it bears on the *feature* line above, // not on this test: it says no other dependent can unify `openssl` back on. It says nothing - // about which versions those 325 packages resolve to, which is the only thing committing this + // about which versions those 307 packages resolve to, which is the only thing committing this // file actually holds still. The two are different claims and only the second justifies the // exception. // From 82bd447ce82f73813485d82bc6c8d4975e58d52d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:52:54 -0400 Subject: [PATCH 32/36] docs(c2pa-oracle): name the enumerated functions instead of glossing a rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `README.md` said a branch added without a row "is the finding, and three tests parse the table and enforce it". They do not: adding a refusing arm to `declared_store_len` with no row leaves all 24 tests green, because all three checks read rows -> tests. Narrow the sentence to what they hold — a blank cell, or a name nothing in the file defines — and say plainly that completeness is held by review; the guard that would close the other direction is issue #616. The membership rule was not applicable either. "Every point where the code chooses between two answers about a buffer" excludes the `is_jumbf_not_found` row, which chooses between two answers about an error, and a boundary that needs an exception is the wrong boundary. So stop deriving membership: the table is a named list over four named functions, and the two discriminating branches left out — `reserve_then_fill`'s slot-length guard, which differs by reach rather than by subject, and `Display`'s match over the variants, which picks wording for an outcome already decided — are named beside it. The count of unrowed tests was stale at one; it is four of 24, the other three being the checks over the table itself. Derive it from two greps, and state the asymmetry that let it go stale: rows -> tests never looks the other way. --- tooling/c2pa-oracle/README.md | 24 +++++++++------ tooling/c2pa-oracle/src/lib.rs | 53 +++++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 82fae32f..caf9f8c2 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -310,15 +310,21 @@ input it must *not* refuse — so widening or narrowing a range by one is caught about a *set* of branches, and three consecutive reviews each found one more member of the set untested, so the set is now written down rather than argued: the module documentation on `#[cfg(test)] mod tests` in `src/lib.rs` carries the enumeration as a table, one row per -**discriminating** branch — every point where the parsing surface chooses between two answers about -a buffer, refusing or not — across `find_jumbf_superbox`, `declared_store_len`, -`jumbf_superbox_span` and `is_jumbf_not_found`, naming the test on each side. A branch added -without a row, or a row with one side blank, is the finding, and three tests in that same module -parse the table and enforce it: rustdoc never compiles a `cfg(test)` module, so its intra-doc links -would otherwise go stale behind a green suite. The one refusal outside that layer — -`reserve_then_fill` rejecting a slot that is not the signed store's length — is not parsing and -needs c2pa-rs and a gamut encoder in reach, so it is pinned in `tests/reserve_then_fill.rs` -instead, and the table says so. +discriminating branch in four **named** functions — `find_jumbf_superbox`, `declared_store_len`, +`jumbf_superbox_span` and `is_jumbf_not_found` — naming the test on each side. It is a list, not a +rule membership can be derived from: a table that claimed to cover "every branch that chooses +between two answers about a buffer" would exclude the `is_jumbf_not_found` row, which chooses +between two answers about an *error*. The two discriminating branches deliberately left out, and +why, are named in that same doc comment. + +Three tests in that module parse the table out of the doc comment and fail on a blank cell, or on a +name nothing in the file defines — rustdoc never compiles a `cfg(test)` module and never resolves +these links, so a renamed test would otherwise leave a stale row behind a green suite. That is the +whole of what they enforce. They read **rows → tests**: a branch added to one of those four +functions *without* a row leaves all of them green, as does a test added without one, so the +table's completeness is held by review and not by the suite. The per-function branch-count guard +that would close that direction is +[issue #616](https://github.com/visualcommons/gamut/issues/616). Every one of those tests asserts **which** refusal fired — the message where the variant carries one, the variant itself where it does not — never merely that an error occurred. Several branches diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index d7910b70..3206d002 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -492,17 +492,34 @@ mod tests { //! # Every branch, enumerated once //! //! Three rounds of review each found one more untested arm here, because each round looked at - //! the arm the last one had missed rather than at the set. So the set is written down. Every - //! **discriminating** branch on this crate's own parsing surface — every point where the code - //! chooses between two answers about a buffer — is listed below with the test that pins each - //! of its two directions. Adding a branch means adding a row, and a row with one side blank is - //! the finding, not a matter of taste. + //! the arm the last one had missed rather than at the set. So the set is written down — as a + //! **named list**, not as a rule to derive membership from. The table covers every + //! discriminating branch, every point where the code chooses between two answers, in exactly + //! these four functions: [`find_jumbf_superbox`], [`declared_store_len`], + //! [`jumbf_superbox_span`] and [`is_jumbf_not_found`]. Each row names the test pinning each + //! of its two directions. Adding a branch to one of those four means adding a row, and a row + //! with one side blank is the finding, not a matter of taste. //! //! The scope was narrower once: only branches that could *refuse* an input. That boundary //! excluded exactly the predicates whose whole job is telling two cases apart — //! [`is_jumbf_not_found`] sat outside it and had one direction unpinned, which is the same - //! blank-side shape the table exists to make visible. Discrimination, not refusal, is the - //! property that earns a row. + //! blank-side shape the table exists to make visible. Widening it to discrimination is what + //! made the boundary undrawable in prose, though: [`is_jumbf_not_found`] discriminates + //! between two answers about an *error*, not about a buffer, so no wording about buffers + //! covers the row the widening was made for. Hence the list. It claims to be complete over + //! four named functions and nothing wider. + //! + //! Two discriminating branches in this file sit outside it, named here so their absence is a + //! decision and not an oversight: + //! + //! * [`reserve_then_fill`]'s `store.len() != slot.len()` guard. It discriminates exactly as a + //! row does; what separates it is *reach*, not subject — pinning it needs c2pa-rs and a + //! gamut encoder, so it lives in `tests/reserve_then_fill.rs`, which the last column of no + //! row can name because these rows name functions in this file. + //! * `Display for OracleError`'s match over the variants. It chooses the *wording* for an + //! outcome some other branch already decided, so it discriminates nothing about an input. + //! Every refusal test below asserts on the message it renders, so it is exercised + //! throughout without a row of its own. //! //! *Taken* is the input that reaches the branch; *not taken* is the nearest input that does //! not. For a branch that refuses, those are the input it refuses and the nearest input it @@ -543,18 +560,28 @@ mod tests { //! whose resulting length is then too short. Adjacent branches on the same input share a //! boundary; that is what makes it a boundary. //! - //! One test here is in no row: [`a_span_is_still_found_when_a_decoy_jumb_precedes_the_superbox`] - //! pins the *composition* of the search and the length reading, not a branch of either. + //! Four of this module's tests are in no row, and the count is derived rather than + //! remembered: `grep -c '^ #\[test\]$' src/lib.rs` gives 24 tests, and + //! `grep -c '^ //! | \[' src/lib.rs` gives the table's 12 rows, which name 20 distinct + //! tests between their two columns. The four are + //! [`a_span_is_still_found_when_a_decoy_jumb_precedes_the_superbox`], which pins the + //! *composition* of the search and the length reading rather than a branch of either, and the + //! three checks whose subject is this doc comment rather than a buffer — + //! [`the_enumeration_is_a_table_with_no_blank_cell`], + //! [`every_test_the_enumeration_names_exists_in_this_file`] and + //! [`every_test_file_the_enumeration_names_exists`]. + //! + //! Nothing checks that count, and the reason is worth stating: those three run rows → tests + //! and never tests → rows. They catch a row naming a test that is gone; they cannot catch a + //! test that is here and in no row — nor this sentence going stale — because in that + //! direction they ask nothing. That asymmetry is why the number above carries the command + //! that re-derives it. //! //! [`is_jumbf_not_found`]'s taken side is also observed end to end, on an error c2pa-rs itself //! raised, in `tests/no_copy_forward.rs`. The row points at the inline test rather than that //! one because the inline test fails for exactly one reason — the predicate misread an error — //! while the differential fails for anything wrong anywhere in a re-encode. //! - //! The refusal outside this layer — `reserve_then_fill` rejecting a slot that is not the - //! signed store's length — is not parsing and needs c2pa-rs and a gamut encoder, so it is - //! pinned where those are in reach: `tests/reserve_then_fill.rs`. - //! //! Every one of these tests asserts **which** refusal fired — the message where the variant //! carries one, the variant itself where it does not — never merely that an error occurred. //! Several branches refuse the same input for different reasons: deleting the From c542bcb902b87981418d561b5e4ed0f8131fb234 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:57:24 -0400 Subject: [PATCH 33/36] docs(c2pa-oracle): say why no row can point at an integration test The exclusion note said `reserve_then_fill`'s guard is pinned in `tests/` and then explained it with a clause about "the last column of no row", which reads as a riddle. The reason is mechanical: `every_test_the_enumeration_names_exists_in_this_file` resolves every name a row links against this file's own definitions, so a row pointing at an integration test would fail it. Say that instead. --- tooling/c2pa-oracle/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tooling/c2pa-oracle/src/lib.rs b/tooling/c2pa-oracle/src/lib.rs index 3206d002..2bec5ecd 100644 --- a/tooling/c2pa-oracle/src/lib.rs +++ b/tooling/c2pa-oracle/src/lib.rs @@ -514,8 +514,9 @@ mod tests { //! //! * [`reserve_then_fill`]'s `store.len() != slot.len()` guard. It discriminates exactly as a //! row does; what separates it is *reach*, not subject — pinning it needs c2pa-rs and a - //! gamut encoder, so it lives in `tests/reserve_then_fill.rs`, which the last column of no - //! row can name because these rows name functions in this file. + //! gamut encoder in scope, so its test lives in `tests/reserve_then_fill.rs`. A row could + //! not point there anyway: [`every_test_the_enumeration_names_exists_in_this_file`] resolves + //! every name a row links against *this* file's definitions. //! * `Display for OracleError`'s match over the variants. It chooses the *wording* for an //! outcome some other branch already decided, so it discriminates nothing about an input. //! Every refusal test below asserts on the message it renders, so it is exercised From 93b8b542880d98922be73aa521c33d9de01e7d67 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:15:32 -0400 Subject: [PATCH 34/36] docs(c2pa-oracle): count the duplicated package names correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the 21 names that occur more than once in the lockfile — `syn` and `getrandom` — occur three times, so "at two versions" is wrong. Say "more than one", name the two, and give the figure a name-keyed walk actually lands on (285 names, against 307 packages) so the reason the walk carries versions is checkable rather than asserted. --- tooling/c2pa-oracle/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index caf9f8c2..660de3ad 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -227,8 +227,9 @@ print(len(seen) - 1) PY ``` -The version has to be carried through the walk: 21 package names occur at two versions in this -lockfile, so a name-keyed closure is not the same set. The whole file holds 326 entries +The version has to be carried through the walk: 21 package names occur at more than one version +here (`syn` and `getrandom` at three), so a name-keyed walk counts names rather than packages and +lands on 285. The whole file holds 326 entries (`grep -c '^name = ' tooling/c2pa-oracle/Cargo.lock`) — those 307, `c2pa` itself, this crate, and 17 reachable only through its four `gamut-*` dev-dependencies, which the root workspace lockfile resolves. Fewer are ever compiled: 240 with default features off From 722c5ba1152b1632abab9ab62a16bce098ada8c0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:16:43 -0400 Subject: [PATCH 35/36] style(c2pa-oracle): re-wrap the three comments the figure change left ragged Replacing 325 with a longer clause left a short line mid-paragraph in `mise.toml`, `Cargo.toml` and `tests/build_configuration.rs`. Reflow to the column the surrounding prose uses. No wording changes. --- mise.toml | 8 ++++---- tooling/c2pa-oracle/Cargo.toml | 12 ++++++------ tooling/c2pa-oracle/tests/build_configuration.rs | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mise.toml b/mise.toml index 3d271e91..e68ac954 100644 --- a/mise.toml +++ b/mise.toml @@ -219,10 +219,10 @@ run = "cargo check --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml # # `--locked` is what makes the committed `tooling/c2pa-oracle/Cargo.lock` load-bearing. Without it # cargo silently re-resolves the 307 transitive packages under the pinned `c2pa` (the figure is -# derived in `tooling/c2pa-oracle/README.md`) and writes the -# result over the committed file, so the crate's own resolved-graph assertion would be reading a -# resolution cargo had produced moments earlier. With it, a lockfile that is missing or out of date -# fails the task instead of being regenerated. +# derived in `tooling/c2pa-oracle/README.md`) and writes the result over the committed file, so the +# crate's own resolved-graph assertion would be reading a resolution cargo had produced moments +# earlier. With it, a lockfile that is missing or out of date fails the task instead of being +# regenerated. [tasks.test-c2pa] description = "Cross-check gamut's C2PA carriage against c2pa-rs, both directions (issue #447)" run = "cargo test --locked --manifest-path tooling/c2pa-oracle/Cargo.toml" diff --git a/tooling/c2pa-oracle/Cargo.toml b/tooling/c2pa-oracle/Cargo.toml index 24af3ca6..be929f02 100644 --- a/tooling/c2pa-oracle/Cargo.toml +++ b/tooling/c2pa-oracle/Cargo.toml @@ -34,12 +34,12 @@ doctest = false # a citation nobody re-checked. # # The pin holds this one line and nothing else. The 307 transitive packages under it — the -# version-aware closure of its lockfile entries, derived in `README.md` — are held by -# `Cargo.lock`, which is committed for this crate alone against the blanket `tooling/*/Cargo.lock` -# rule — the exception, and why it is made, are recorded in `.gitignore`. That, and not the fact -# that nothing else in the tree depends on `c2pa`, is what the exception rests on: the -# single-dependent fact bears on the feature line above (no other dependent can unify `openssl` -# back on) and says nothing about which versions those 307 packages resolve to. `mise run +# version-aware closure of its lockfile entries, derived in `README.md` — are held by `Cargo.lock`, +# which is committed for this crate alone against the blanket `tooling/*/Cargo.lock` rule — the +# exception, and why it is made, are recorded in `.gitignore`. That, and not the fact that nothing +# else in the tree depends on `c2pa`, is what the exception rests on: the single-dependent fact +# bears on the feature line above (no other dependent can unify `openssl` back on) and says nothing +# about which versions those 307 packages resolve to. `mise run # test-c2pa` and `check-c2pa` pass `--locked`, so the committed resolution is the one that is # used; without both, the no-OpenSSL assertion in `tests/build_configuration.rs` could only ever # inspect a resolution cargo had just written for it, which the pin does nothing to constrain. diff --git a/tooling/c2pa-oracle/tests/build_configuration.rs b/tooling/c2pa-oracle/tests/build_configuration.rs index 479a7925..8d5bc20d 100644 --- a/tooling/c2pa-oracle/tests/build_configuration.rs +++ b/tooling/c2pa-oracle/tests/build_configuration.rs @@ -106,10 +106,10 @@ fn the_lockfile_this_crate_resolves_against_is_committed_rather_than_ignored() { // The repository ignores `tooling/*/Cargo.lock` — a `tooling/` crate normally resolves through // the root lockfile, so a local one is redundant. This crate is the exception, and the reason // is the 307 packages *under* `c2pa` — the version-aware closure of its lockfile entries, - // derived in `README.md`: the `=` pin holds one direct dependency and nothing - // beneath it, so without a committed lockfile the whole transitive graph re-resolves on every - // invocation and the resolved-graph assertion above can only inspect the resolution cargo just - // wrote for it. `mise run test-c2pa` and `check-c2pa` pass `--locked`, which is what turns + // derived in `README.md`. The `=` pin holds one direct dependency and nothing beneath it, so + // without a committed lockfile the whole transitive graph re-resolves on every invocation and + // the resolved-graph assertion above can only inspect the resolution cargo just wrote for it. + // `mise run test-c2pa` and `check-c2pa` pass `--locked`, which is what turns // "the file exists" into "this is the resolution that was used". // // A different argument used to stand here — that nothing else in the tree depends on `c2pa`, From c5b48e22e45ce1d967ab2ba3de2d8e2305bece60 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 24 Sep 2026 22:39:21 -0400 Subject: [PATCH 36/36] docs(c2pa-oracle): document the path-crate lockfile hazard and its refresh step The committed tooling/c2pa-oracle/Cargo.lock records the 15 gamut-* crates the oracle reaches by path, so a release-plz bump or a dependency change in any of them fails test-c2pa/check-c2pa under --locked. State that, give the cargo update --workspace refresh that leaves the registry graph under c2pa in place, and correct the README's claim that the root workspace lockfile resolves those entries. --- mise.toml | 8 ++++++++ tooling/c2pa-oracle/README.md | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/mise.toml b/mise.toml index e68ac954..9f45264f 100644 --- a/mise.toml +++ b/mise.toml @@ -223,6 +223,14 @@ run = "cargo check --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml # crate's own resolved-graph assertion would be reading a resolution cargo had produced moments # earlier. With it, a lockfile that is missing or out of date fails the task instead of being # regenerated. +# +# That lockfile also records the 15 `gamut-*` crates this one reaches by path (it is workspace- +# `exclude`d, so the root lockfile does not stand in for it). A release-plz version bump or a +# dependency change in any of them therefore fails both tasks with "cannot update the lock file … +# --locked was passed" even though nothing here changed. Refresh with +# `cargo update --workspace --manifest-path tooling/c2pa-oracle/Cargo.toml` (path entries only; the +# registry graph under `c2pa` stays put) and commit it — `tooling/c2pa-oracle/README.md`, +# "Refreshing the lockfile after a gamut crate changes". [tasks.test-c2pa] description = "Cross-check gamut's C2PA carriage against c2pa-rs, both directions (issue #447)" run = "cargo test --locked --manifest-path tooling/c2pa-oracle/Cargo.toml" diff --git a/tooling/c2pa-oracle/README.md b/tooling/c2pa-oracle/README.md index 660de3ad..6e805b2c 100644 --- a/tooling/c2pa-oracle/README.md +++ b/tooling/c2pa-oracle/README.md @@ -231,8 +231,11 @@ The version has to be carried through the walk: 21 package names occur at more t here (`syn` and `getrandom` at three), so a name-keyed walk counts names rather than packages and lands on 285. The whole file holds 326 entries (`grep -c '^name = ' tooling/c2pa-oracle/Cargo.lock`) — those 307, `c2pa` itself, this crate, and 17 -reachable only through its four `gamut-*` dev-dependencies, which the root workspace lockfile -resolves. Fewer are ever compiled: 240 with default features off +reachable only through its four `gamut-*` dev-dependencies. Those 17 are resolved **here**, not by +the root workspace lockfile: this crate is `exclude`d from the workspace, so its lockfile records +every package it reaches, and 15 of the 17 are the `gamut-*` crates it reaches by `path` (see +[Refreshing the lockfile](#refreshing-the-lockfile-after-a-gamut-crate-changes) for what that +costs). Fewer are ever compiled: 240 with default features off (`cargo tree --manifest-path tooling/c2pa-oracle/Cargo.toml --locked -e normal -p c2pa --prefix none | sed 's/ (\*)$//' | sort -u | wc -l`, `c2pa` included), because a lockfile pins optional dependencies this build never turns on. That smaller figure is not the one the argument @@ -244,6 +247,31 @@ the resolution that was used, or the task fails. That is *not* the same claim as the tree depends on `c2pa`" — true, but it bears on the feature line, saying no other dependent can unify `openssl` back on, and says nothing about which versions those 307 packages resolve to. +### Refreshing the lockfile after a gamut crate changes + +`--locked` holds the path-reached `gamut-*` entries exactly as it holds the 307: the lockfile +records each one's version and its dependency list. So a change that touches none of this crate's +files still fails both tasks with `cannot update the lock file … because --locked was passed` when +it moves any of those 15 crates' lock entries — a release-plz version bump (`gamut-core` 2.0.1 → +2.0.2 is enough), or a dependency added to or removed from one of them. That failure says the +lockfile is stale, not that the oracle found anything. Refresh it, and commit the result with the +change that caused it: + +```bash +cargo update --workspace --manifest-path tooling/c2pa-oracle/Cargo.toml +``` + +`--workspace` rewrites only the local path crates' entries and adds a package only when one of them +needs one the file does not yet hold; it leaves every locked registry version, and so the 307 under +`c2pa`, where it was. Check that with `git diff tooling/c2pa-oracle/Cargo.lock` before committing: +for a version bump the diff is that one `version =` line. A bare `cargo update` or +`cargo generate-lockfile` re-resolves the whole graph instead, which is exactly what the committed +file exists to prevent. + +Nothing refreshes it automatically — no release workflow touches this crate — and until #541 wires +the two tasks into CI nothing notices a stale one either, so the first person to run `test-c2pa` +after a release is the one who meets this. + ## The signing identity `c2pa::EphemeralSigner` mints a self-signed CA and an end-entity certificate in memory, Ed25519,