feat(c2pa-oracle): differential oracle against c2pa-rs, both directions - #535
Open
justin13888 wants to merge 38 commits into
Open
justin13888 wants to merge 38 commits into
justin13888 wants to merge 38 commits into
Conversation
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.
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.
…earching `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
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
…ts directory 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
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
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
`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.
`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.
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.
…d bound 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.
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.
`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.
…sence `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.
`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.
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.
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.
… feat/447-c2pa-oracle
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.
…stating it 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.
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.
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.
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.
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.
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.
…e 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.
`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.
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.
…fusals 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.
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.
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.
…it counts 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.
…a rule `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.
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.
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.
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #506 (
feat/444-avif-c2pa-reserve), and transitively on #498. Review the commits whosescope is
c2pa-oracle(13 as of91e099f4, the newest six from round-3 repair) plus the two mergecommits that bring #506's head forward; everything else belongs to the base. Merge #506 first.
No human approved this plan. This is an unattended automation run: the decision record below is
the whole basis for what was built and is what a human should read first.
Summary
Issue #447 asks for
tooling/c2pa-oracle, a dev-only differential oracle againstc2pa-rs— theC2PA reference implementation — in both directions the #239 epic names. This is the first thing
in the repository to stand on the far side of the epic's split: gamut locates, bounds, carries
and reserves a manifest store, and validation belongs to
c2pa-rs.New crate
tooling/c2pa-oracle, workspace-excluded and invoked by manifest path (thetooling/gamut-dng-real-conformanceshape), plus amise run test-c2pa/mise run check-c2papair. Nothing under
crates/is touched — no shipped crate gains an edge toc2pa-rs, which iswhat the epic's "no crypto in the shipped graph" criterion requires.
Both directions, on the two containers this branch can reach:
c2pa-rsvalidates.gamut-avifreserves aslot and reports its range before any signer exists;
c2pa-rssigns a store over the finishedfile through its own placeholder workflow; the host patches it in at that range and the file
validates. The store exactly fills the slot, so nothing after it moves.
c2pa-rsembeds → gamut locates the identical byte range. Bothgamut-avifandgamut-heicreport the same span — compared not against gamut's own parse but against aspan derived from the store's own JUMBF header, so the comparison is a differential and not a
tautology. Handing gamut's extraction straight back to
c2pa-rsas an external store is thesharpest form: a span off by a byte at either end does not survive it.
Two assumptions become observations.
crates/gamut-heic/STATUS.mdrecords a deferred rowwhose premise is "no known writer emits either shape". The oracle checks it:
c2pa-rswrites opensLBox+jumb— theTBoxcheck gamut deliberatelydeclines to assert, because the C2PA spec names that constant only in JPEG XL clauses
attributing it to ISO/IEC 18181-2;
BuilderIntent::Update(there is no public way to set the purpose stringdirectly),
c2pa-rswrites the 8-byte merkle offset in front of abox_purpose = updatestoreexactly as it does for
manifestandoriginal. So the one in-spec layout gamut's probecannot discriminate is not one the reference implementation emits, and the probe's offset-0
fallback is dead weight against files in circulation rather than a source of mis-bounding.
Neither observation makes the
LBoxbound self-checking — that is #505 — but both replace anassumption with evidence. They are recorded in the crate's
README.md, which also states whygamut owns the locate/bound step at all, so nobody later deletes gamut's locator as redundant:
c2pa-rshas no parse-only mode, andValidationState::Invalidis documented as also beingwhat you get when verification is disabled (
c2pa0.90.21,src/validation_results.rs:36-41).One extra assertion the record did not ask for, added because it was nearly free and is the
strongest single check in the crate:
gamut-avif's wholeContentProvenanceBoxis byte-identicalto the one
c2pa-rscomposes around the same store (tests/box_framing.rs).Validation
Every command below was run in this worktree, inside the memory-capped scope
(
systemd-run --user --scope --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0,CARGO_BUILD_JOBS=2).mise run fmt-tooling/fmt-tooling-checkcarry the documented__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel)prefix, because in a nestednested worktree cargo otherwise walks past the worktree root to the primary
checkout when loading the other
tooling/*manifests. No manifest was changed to work around it— though
tooling/c2pa-oracle/Cargo.tomlnow carries the empty[workspace]tabletooling/gamut-fuzzalready had, so this crate no longer needs the prefix on its own.Correction. An earlier revision of this section listed nine gates as
_running_and was neverupdated; none of those nine completed. What follows is only what completed in a run, with the
gates that did not run named as such.
mise run check-c2paFinished dev profile in 10.63s, no warningsmise run test-c2pa__CARGO_TEST_ROOT=… mise run fmt-tooling__CARGO_TEST_ROOT=… mise run fmt-tooling-checkmise run check-commitsno errors in 29 commitsconvco check origin/master..HEADno errors in 29 commitsmise run check-release-depsrelease dependency graph has no dev-only workspace edgesmise run check-testsmodule docs, pinned proptest seeds and oracle filenames all conformcargo run --manifest-path tooling/c2pa-oracle/Cargo.toml --example probeThree falsifiers were executed against this revision, each reverted immediately afterwards:
find_jumbf_superbox/declared_store_lento the previous readingLBox = 0over a 76-byte buffer gives 0,LBox = 1, XLBox = 40gives 1, and a strayjumbat offset 0 givesNoJumbfSuperboxEncodedMetadata::c2paby hand (thePreservearmC2paPolicyrefuses to offer)a_derivative_built_from_the_parents_model_reads_back_as_unsigned_not_as_invalidfails: c2pa-rs reportsValidAvifContainer::c2pa()'s reported range by one bytec2pa_rs_validates_the_store_read_out_of_gamut_avifs_reported_rangenow fails (InvalidJumbfHeader); in the previous revision it stayed greenNot run, and not claimed.
mise run lint,mise run test,mise run check-ffi-featuresandmise run coveragedid not complete in any run of this branch. None of them reaches this crate:it is workspace-
excluded with no dependents, soclippy --workspaceandtest --workspaceareblind to it, no C surface changed, and coverage ignores it for the same reason. That gap is the
subject of the issue filed below, not something a run here can close. CI's own
Clippy & Doctestsand
Coverage (test gate)jobs cover the workspace for the stacked base's commits.The mutation gate has nothing in this diff. No file under
crates/changed in these commits(
c6bc972b..HEADtouchestooling/c2pa-oracle/**andmise.tomlonly), so--in-diffselectsno mutants. This is stated rather than run.
Risks and rollout
crates/changed. The only editsoutside the new directory are one entry in the root manifest's
[workspace].excludelist andtwo new
misetasks; neither is reachable fromcargo build/test --workspace, frommise run lint, or fromcheck-release-deps.c2pa-rsis a large dependency tree, which is exactly why it is behind a workspaceexclusion and its own task rather than in any gate. It is pure Rust as configured: nothing here
compiles C.
tests/build_configuration.rsfails if theopensslfeature ever returns, checkingboth the manifest line a human would edit and the resolved lockfile, which would also catch
the feature arriving by unification.
.github/namesthis crate, and being workspace-
excluded with no dependents it is invisible toclippy --workspaceandtest --workspacetoo — its entire automated reach isfmt-tooling-check. Agamut-aviforgamut-heicAPI change can break it, or a differentialcan start failing, with every required check green.
mise.tomland the crateREADME.mdnowsay exactly that instead of implying otherwise, and issue CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 wires
check-c2painto thePR lane and
test-c2painto extended, mirroringcheck-dng-real/test-dng-real. It was notdone here because this pull request's scope excludes
.github/.gamut-heic's locator is measured on an AVIF. C2PA 2.4 Appendix A defines one placement forevery BMFF-based asset and names HEIF and AVIF together;
c2pa-rsserves both from the samehandler and writer, and a test asserts the same bytes read identically under either MIME type.
Hand-building a HEIF around real HEVC would have tested the fixture, not the locator.
tooling/c2pa-oracle/, theexcludeentry and the two tasks.Issue
Refs #447 — the issue is not fully closed: it names every container, and four of them
(
gamut-png#440,gamut-dng#442,gamut-riff/gamut-webp#445,gamut-tiff#446, plusgamut-jpeg#441 behind a procurement decision) were unmerged and still moving when this waswritten, so their branches were deliberately not reached into.
Remainder filed as #534 — tooling/c2pa-oracle: cover PNG, DNG, RIFF/WebP and TIFF once their
container slices land (#447 remainder). The crate is scaffolded so each is a case, not a
mechanism: the drivers take a MIME type as a plain argument. #534 also carries the two things a
later branch should not have to re-derive — that the non-BMFF containers bind by
c2pa.hash.data(byte ranges, not box paths, so the exclusion set a gamut crate reports is itself then under
test), and that DNG/TIFF need a second disjoint exclusion range for the IFD entry's
countfield(§18.5.5).
Also filed from the round-2 repair: #541 — CI: run check-c2pa in the PR lane and test-c2pa in
extended (nothing calls either today).
Decisions taken
Appended during delivery
Appended during round-2 repair
An independent review of this pull request raised eight findings. The decision record for the
repair was fixed before it started; these are the decisions as taken, plus the two forks the
record did not cover.
Two forks the record did not cover, decided here:
Unresolved review notes
Round-2 review findings F1-F8 are all addressed above; each has an executed falsifier or a named
artefact. Two residuals a human should weigh:
mise run lintandmise run teststill do not see this crate, and cannot until CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 lands.Nothing in this pull request changes that; the comments and README now state it rather than
implying the opposite.
c2pa-rsfindings are pinned against=0.90.21only. Raising the pin is deliberately abreaking act that also means re-reading every line-number citation in the crate's
README.md;tests/build_configuration.rsfails until someone does.Round 3 — repair
Six of the round-2 findings closed on review; the round-3 pass raised N1-N6 and N8 against this
branch. All eight decisions the record below carries were executed. Head
91e099f4.XLBoxhad no minimum, so 0/1/4/8/15 all returned verbatim as a length, givingOk(8..8)andOk(8..12)declared_store_lennow refusesXLBox < 16with the sameUnusableSuperboxLength, followingcrates/gamut-isobmff/src/boxes.rs:271(size < header_size) through for both header sizes rather than only the 8-byte onean_xlbox_below_the_sixteen_byte_header_is_refused_rather_than_resolvedsweeps 0/1/8/15 and asserts the message,an_xlbox_of_exactly_the_header_size_is_a_lengthpins 16 as accepted so the refusal cannot creep past the boundaryCargo.toml:33claimedcheck-c2pa"is the compile-only half the per-PR lint lane affords", in the present tense.github/calls either task and points at #541. Widening the manifest by one line was the round-3 decision; recorded as decision 21 belowgrep -rn c2pa .github/returns nothing. Tree-wide sweep for the same class of claim (grep -rn 'check-c2pa|test-c2pa'over the whole tree excludingtarget/,.git/,third_party/): seven other sites, all already honest —mise.toml:230andREADME.md:22-23state the gap explicitly, the rest are bare task invocations. RootCargo.toml:33was the only false onetests/no_copy_forward.rs:104-120duplicatedcrates/gamut-metadata/tests/roundtrip.rs:340at ~5x the reach, with no differential in itextractpopulated the model, without which its sibling passes vacuously — is now a directassert!(meta.c2pa.is_some(), …)insidederivative_through, the helper that needs itcrates/gamut-metadata/tests/was not touched: the assertion already exists there.no_copy_forward.rsnow runs 1 test, not 2, and no longer importsC2paPolicy/MetadataErrorLBoxvaluesREADME.md:221's framing: §8.4.2.3 mentionsLBoxonce (the salt sentence, quoted accurately) andXLBoxnot at all; the grammar is ISO 19566-5:2023, paywalled and not vendored, procurement #441. The arms are stated as the convention read against c2pa-rs's behaviour, not as a citable clausesrc/lib.rs, not only in the READMEupdate.startchecked_sub(15)base with an.expectthat names the condition, the waytests/box_framing.rs:27-35already does; a wrong location is reported, not turned into a subtraction overflow that names no sidetests/update_manifest.rsstill passes 4/4usize"const _: () = assert!(usize::BITS >= u64::BITS);states the assumption where they were — this is dev-only host tooling, never cross-compiled — so it is enforced at compile time instead of defended by an error that cannot firemise run check-c2pacleantooling/c2pa-oracle/README.md, "What this oracle does not check"Base moved under this branch
#506's head advanced twice mid-run and both were brought in by merge commit, never by rebase and
never by amending anything already pushed:
32cdc935→470e6787(merge4daeda8c) and470e6787→55bd77d6(merge91e099f4, the branch head). The first renamesAvifContainer::c2pa→c2pa_slotand
c2pa_manifest_stores→c2pa_slots;gamut-heickeeps the old names, sorefactor(c2pa-oracle): follow gamut-avif's renamed C2PA read accessorsadapts the AVIF call sitesonly. The second needed no adaptation.
mise run check-c2pa,mise run test-c2pa,fmt-tooling-check,convcoandcheck-release-depswere all re-run after the second merge andare the results tabulated below.
Round-3 gates
mise run check-c2paFinished dev profile, no warnings (run after the merge and after every edit)mise run test-c2pa__CARGO_TEST_ROOT=… mise run fmt-tooling-checkconvco check origin/feat/444-avif-c2pa-reserve..HEADno errors in 13 commitsmise run check-release-depsrelease dependency graph has no dev-only workspace edgesmise run lint/mise run test/ the mutation gate were not run in this round and are notclaimed: nothing under
crates/changed in the round-3 commits (Cargo.tomlat the root gained acomment line only), so the mutation gate selects nothing, and the workspace gates cannot reach a
workspace-excluded crate. The
fmt-tooling-checkprefix is still required — not for this crate,which now carries its own
[workspace]table, but because the task loops over everytooling/*manifest and
tooling/aom-oracle/Cargo.tomlhas no such table.Decisions appended in round 3
Residual risk after round 3
XLBoxremains untested against a real store, because c2pa-rs writes a plain 32-bitLBoxand no fixture reaches the arm. The five unit tests are hand-built buffers; that is the whole
reason the arm is written rather than assumed, and it is also the reason nothing differential
covers it.
documented, and harmless on today's fixtures; tooling/c2pa-oracle: cover PNG, DNG, RIFF/WebP and TIFF once their container slices land (#447 remainder) #534 is where it first matters.
c2pa-rsfinding is pinned to
=0.90.21.Round 4 (closing round) —
feat/447-c2pa-oracle, headf9895c41Six Low findings from the round-4 review, all closed on executed evidence. No history was
rewritten; four commits were added on top of
91e099f4. The base,feat/444-avif-c2pa-reserve,did not move during this round (
55bd77d6at start and at push).Correction to the round-3 table (row N4)
Row N4 above records that "the rustdoc now mirrors
README.md:221's framing". That isbackwards, and the row is wrong. The rustdoc was the stricter of the two: it said §8.4.2.3 is
the only place the vendored specification writes any of this down, that it writes down only part,
that it never mentions
XLBoxand that it states no reservedLBoxvalue. The README still saidthe section "spells the syntax out" and then listed the oversized-header field as part of "the
syntax". Round 4 moves the README to the rustdoc's framing, not the other way round. Counted
in the vendored document,
references/c2pa/C2PA_Specification_2.4.html:Findings and what was done
AvifContainer::c2pa, renamed toc2pa_slotby the base mergetests/update_manifest.rs:116andREADME.md:125. The companion reference in that same README sentence, togamut-heic::HeifContainer::c2pa, is correct and untouched — that method still existsgrep -rn 'AvifContainer::c2pa\b' tooling/c2pa-oracle/is empty, andcrates/gamut-avif/src/c2pa.rsdefinesc2pa_slot(230) andc2pa_slots(264), no barec2paLBox == 0armLBox == 0is nowUnusableSuperboxLength; the to-end-of-buffer reading is still a length counting the 8-byte headerOk(4),Ok(5),Ok(6),Ok(7)for buffers of those sizes — then refused after itjumbf_superbox_span), not against the value the model was built from, which would be a tautologystore_ofreturningVec::new()the old guard left the test passing; the new one fails with its own message, and also fails whenstore_ofdrops the store's last byteNoJumbfSuperboxnamed one raising function while two raised itUnusableSuperboxLength, which is the variant that case was introduced to name; the doc now namesfind_jumbf_superboxas the only raiser and says the other two propagate. The pinned test is updated and asserts the messageNoJumbfSuperboxfailsa_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superboxLBox2..=7 arm had one mid-range example, and two mutants of it survived the whole suiteXLBoxsibling has: a sweep over[2, 3, 4, 5, 6, 7]and a boundary test atLBox == 8The surviving mutants, before and after
Each was applied to
src/lib.rs, the crate's unit suite run, and the file restored.2..=7→2..=8(refuses the smallest legalLBox)an_lbox_of_exactly_the_header_size_is_a_length2..=7→3..=7(accepts the reservedLBox2)an_lbox_between_two_and_seven_is_refused_rather_than_resolved0..=7→0..=8(refuses a legal 8-byte buffer)an_lbox_of_zero_in_a_buffer_of_exactly_the_header_size_is_a_length0..=7→0..=6(accepts a 7-byte buffer)an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refusedNoJumbfSuperbox(F5 reverted)a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superboxstore_of→Vec::new()(F4)store_of→ drops the last byte (F4)Each side of each range is pinned by a different test, so widening or narrowing either by one
is caught.
Round-4 gates
mise run check-c2paFinished dev profile, no warningsmise run test-c2pa__CARGO_TEST_ROOT=… mise run fmt-tooling-checkmise run check-commitsno errors in 41 commits;convco check origin/feat/444-avif-c2pa-reserve..HEADalso passes (no errors in 17 commits)mise run check-release-depsrelease dependency graph has no dev-only workspace edgesmise run lint,mise run testand the mutation gate were not run in this round and are notclaimed. Nothing under
crates/changed in the round-4 commits, socargo mutants --in-diffselects nothing — that is an absence of selection, not coverage — and the workspace-wide gates
cannot reach a workspace-excluded crate. The mutation evidence above was produced by hand, applying
each mutant to the source and running the crate's own suite, because no automated gate reaches
this crate. The
__CARGO_TEST_ROOTprefix is still required: the task loops over everytooling/*/Cargo.toml, and 15 of the 17 sibling manifests carry no[workspace]table.Decisions appended in round 4
Residual risk after round 4
check-c2painto the per-PR lint laneand
test-c2painto extended CI. Onlyfmt-tooling-checkruns today, so a compile break or adifferential regression here is caught by hand. This is disclosed in
mise.toml, intooling/c2pa-oracle/README.mdand in the rootCargo.toml, and CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 is the filed remedy.Whether the pull request should merge on that basis is a merge judgement, and the human's —
this round deliberately took no action on it.
expression, run
cargo test --manifest-path tooling/c2pa-oracle/Cargo.toml --lib), but no gatere-checks it, and nothing stops a later edit from re-opening one of these arms.
XLBoxremains untested against a real store, and the span's upper bound is stillunchecked against its enclosing box (decision 22) — both round-3 residuals stand unchanged.
c2pa-rsfinding stays pinned to=0.90.21, and every specification count above tothe vendored C2PA 2.4 HTML.
No human approved this plan or these decisions: this is an unattended run, and the record above is
what a human reads afterwards.
Round 5 (closing round) —
feat/447-c2pa-oracle, head3d47fb67The round-4 review found two more untested refusal arms (F1, F2) on the same two functions the
round-3 review had found one on, and the round-2 review before that. Its diagnosis is what this
round acts on: the pattern does not converge by iteration, it converges by enumerating the arms
once. So F1 and F2 are not fixed on their own — the whole set is written down and closed.
The enumeration
#[cfg(test)] mod testsinsrc/lib.rsnow opens with a table, one row per branch that can refusean input across the three header-reading functions, naming the test that pins each of its two
directions: the input the branch refuses, and the nearest input it must not refuse. A branch
added without a row, or a row with one side blank, is the finding. The one refusal outside that
layer —
reserve_then_fillrejecting a slot that is not the signed store's length — needs c2pa-rsand a gamut encoder in reach, so it is pinned in
tests/reserve_then_fill.rs, and the table saysso.
Every arm was then mutated in both directions by hand and the crate's whole suite run against
each. All 21 mutants die. The four rows marked ▲ were the blank sides.
find_jumbf_superbox.skip(4)▲.skip(3)— consider one window too earlyfind_jumbf_superbox.skip(4).skip(5)— skip one window too manyfind_jumbf_superbox.skip(4)▲.skipentirely,type_offset.saturating_sub(4)find_jumbf_superbox.ok_or(NoJumbfSuperbox)▲.map_or(Ok(0), Ok)— delete the refusalfind_jumbf_superbox.ok_or(NoJumbfSuperbox)▲UnusableSuperboxLengthinsteaddeclared_store_lenget(..4)▲.unwrap_or_default()— delete the refusaldeclared_store_lenget(..4)get(..5)— demand one byte too manydeclared_store_lenLBox == 0,0..=70..=6— accept a 7-byte bufferdeclared_store_lenLBox == 0,0..=70..=8— refuse an 8-byte bufferdeclared_store_lenget(8..16).unwrap_or_default()— delete the refusaldeclared_store_lenget(8..16)▲get(8..17)— demand one byte too manydeclared_store_lenXLBox0..=150..=14declared_store_lenXLBox0..=150..=16declared_store_lenLBox2..=72..=6declared_store_lenLBox2..=72..=8jumbf_superbox_spanchecked_add▲wrapping_add— delete the overflow checkjumbf_superbox_span*end <= buffer.len()*end < buffer.len()jumbf_superbox_span*end <= buffer.len()true— delete the boundreserve_then_fillstore.len() != slot.len()▲if false— delete the guardreserve_then_fillstore.len() != slot.len()▲==— invert the guard.gitignorelockfile negation ▲!lineEvery refusal is asserted by the message it carries, never
is_err(). Several branches refusethe same input for different reasons — deleting the truncated-
LBoxarm sends a three-byte bufferinto the
LBox == 0arm, which refuses it too — so only the message distinguishes the branch thatfired from the one that caught the fall.
Findings and what was done
LBoxrefusal had no test. Reproduced: replacing its.ok_or(…)with
.unwrap_or_default()left all 31 tests green. Fixed bya_buffer_too_short_for_an_lbox_field_is_refused_as_a_truncated_field, sweeping 0–3 bytes andasserting the message, because the deletion is caught by the
LBox == 0arm and only themessage tells the two apart.
Reproduced:
.skip(3)survived all 31 tests, and under it ajumbat offset 3 panics with asubtraction overflow. The decoy sat at offset 0 — far from the boundary. The fixture now places
a decoy at offset 3 exactly, the last offset a superbox type cannot occupy, and a second
fixture puts a superbox's
TBoxat offset 4, the first offset it can. Both directions die.slot.len() == store.len()restated a guard
reserve_then_fillapplies before it returns, and "the slot's bytes are thestore's bytes" was
copy_from_slice's postcondition. Both removed; the test keeps the one claimabout c2pa-rs (the store is the placeholder's length) and is renamed for it. The guard they
restated is now driven instead, by reserving one byte too many.
matching its sibling in
locate_embedded.rs, with a note at the site namingc2pa_rs_leaves_no_padding_between_the_store_and_the_end_of_its_boxas the test that owns theno-padding fact — so a future padding release fails that one rather than being misread here.
while four sides were blank. The claim now stands and points at the enumeration that
discharges it, states what a blank side means, and names the one refusal that lives elsewhere.
The lockfile, and what it guarantees that the pin did not
Decision 23 chose the exact
=version pin instead of a lockfile. That is reversed here, andrecorded as reversed. The pin holds exactly one line. Everything under
c2pa— 325 furtherpackages — re-resolved on every invocation, so
the_resolved_dependency_graph_contains_no_openssl_packagecould only ever read the resolutioncargo had written for it moments earlier. Reading a file you just caused to exist is not an
assertion about anything.
tooling/c2pa-oracle/Cargo.lockis now committed as a deliberate exception to the blankettooling/*/Cargo.lockrule, with the exception and its reason recorded beside the rule in.gitignore. The version pin stays: it guards a different thing (the line-numbered citationsinto c2pa-rs's source) and it is the line a human edits on purpose, where a lockfile is regenerated
by any
cargo update. The exception is itself pinned by a drift guard —the_lockfile_this_crate_resolves_against_is_committed_rather_than_ignored— because dropping thenegation is one edit that nothing else here would notice.
Filed
two locators diverge. The one configuration nothing exercises, and exactly what a signer does
when it reserves generously before the certificate chain's size is known. It is the only shape in
which the box-bounded (
gamut-avif) andLBox-bounded (gamut-heic) locators legitimatelyreport different ranges; today every fixture makes them identical. Beyond this issue's scope, and
cited at the site in
tests/reserve_then_fill.rs.Round-5 gates
Base re-merged first: PR #506's head had moved to
c38e64c7, merged ascbd3e30f; the reviewer'sreport that its only non-comment change is an added test block was checked rather than assumed, and
the whole suite was re-run after.
mise run check-c2paFinished dev profile, no warningsmise run test-c2pa__CARGO_TEST_ROOT=… mise run fmt-tooling-checkmise run check-commitsno errors in 52 commits;convco check origin/feat/444-avif-c2pa-reserve..HEADalso passes (no errors in 24 commits)mise run check-release-depsrelease dependency graph has no dev-only workspace edgesThe mutation gate selects nothing. This round's diff touches no file under
crates/, socargo mutants --in-diffhas nothing to select. That is an absence of selection, not coverage.The 21 results in the table above were produced by hand — apply the expression, run
cargo test --manifest-path tooling/c2pa-oracle/Cargo.toml— because no automated gate reaches aworkspace-excluded crate.
mise run lintandmise run testwere not run this round and are notclaimed; neither can reach this crate. The
__CARGO_TEST_ROOTprefix is still required, and iskept: the task loops over every
tooling/*/Cargo.toml, and the sibling manifests still need it.Decisions appended in round 5
Residual risk after round 5
check-c2painto the per-PR lint laneand
test-c2painto extended CI. Unchanged from round 4, and still a merge judgement for thehuman; this round took no action on it.
re-checks them, and nothing stops a later edit from re-opening an arm. The enumeration table is
the mitigation — a branch without a row is visible — not a gate.
reserve_then_fill's length guard.Refusals that are pure
?propagation of a callee's error are covered at the callee, notrestated per caller.
locate_embedded.rsandno_copy_forward.rsare correct by argument, not by fixture.XLBoxremains untested against a real store, and the span's upper bound is stillunchecked against its enclosing box (decision 22) — round-3 residuals, unchanged.
c2pa-rsfinding stays pinned to=0.90.21— now with the whole resolved graph heldby a committed lockfile as well — and every specification count to the vendored C2PA 2.4 HTML.
No human approved this plan or these decisions: this is an unattended run, and the record above is
what a human reads afterwards.
Self-review, after the round-5 gates
Three defects raised against this round's own diff and fixed in
447ab4bdand3d47fb67, withevery gate re-run after each:
the test on the accepted side of both of
jumbf_superbox_span's refusals was named for alength when it asserts a span; and the enumeration table has one test in both columns, which
reads as a gap and is not — a 4-to-7-byte buffer is one whose
LBoxfield was read successfullyand whose resulting length is then too short, so it is the accepted side of the truncation branch
and the refused side of the
LBox == 0branch. Adjacent branches on the same input share aboundary. The table now says so rather than leaving a reader to work it out. Third, both the
enumeration and the README claimed every refusal test asserts the refusal's message; three of
them assert a variant, because
NoJumbfSuperboxis fieldless and has no message to name. Theclaim that matters — each test says which refusal fired, not that one did — is now stated that
way in both places.
One failure was observed and is not a defect in the tree: after the mutation battery,
box_framingfailed on a clean checkout withsigned store is 13278 bytes but the reserved slot is 13278— the inverted guard from the last mutant, still in the compiled binary. The harnessrestored each file with
mv, which carries the backup's older mtime, so cargo considered therestored source fresh and did not rebuild.
touchplus a re-run is green. Classified artefact ofthe hand-mutation harness, not caused and not pre-existing; each mutant's own compile was
triggered by a newer mtime, so only the tree left behind was stale. The harness was fixed and the
whole 21-mutant battery re-run against the final head
3d47fb67from a clean tree: all 21 die,the tree is clean afterwards, and all 7 test binaries pass.
Round 7 — repair against the round-6 review, head
0eb7b1bbThe round-6 review is treated as evidence to verify, not as instruction. Two of its results are
recorded first because they are findings about the round-5 work rather than against it.
without reading the table, and reached the same nine with the same partition and the same scope
boundary, then grepped every refusal-originating site to check the boundary itself. The
incompleteness class that had recurred for four rounds did not recur.
the failure and showed the message it printed — equal lengths on both sides of a
!=guard — isunreachable from the committed source, so it can only have come from the inverted-guard mutant
still compiled in. All 21 mutants claimed dead are dead, each killed by the test the table names.
What reproduced
Every finding reproduced. Nothing in the review failed to reproduce.
tooling/c2pa-oracle/Cargo.lockdeleted, all fourbuild_configurationtests passed and cargo wrote a resolution that differs from thecommitted one (
bitflags 2.13.1→2.13.2,ryu 1.26.0→1.26.1) — the very file theno-OpenSSL assertion then read. Root cause confirmed: neither
test-c2panorcheck-c2papassed--locked.is_jumbf_not_foundwas pinned in one direction. Mutated to-> bool { true }itsurvived all 40 tests. The rebuild was confirmed from cargo's own
Compiling c2pa-oraclelinebefore the result was believed.
*end <= buffer.len() + 1, the whole suite stayed green:LBox = 4096in a 32-byte buffer is4064 bytes past the bound, so a bound wrong by one still refuses it.
What changed
--lockedon bothtest-c2paandcheck-c2pa; the.exists()assertion deletedmise run test-c2panow fails (cannot create the lock file … because --locked was passed) and does not regenerate it. Restored, greenan_xlbox_of_exactly_the_header_size_is_a_lengthin its definition only, blanking aNot takencell, misspellingtests/no_copy_forward.rs— each fails exactly one of themis_jumbf_not_foundgains a row and both directions-> bool { true }mutant now dies ona_c2pa_error_other_than_jumbf_not_found_is_not_an_absent_manifest+ 1mutant now dies, failing with the span it wrongly returned (0..33)# PanicsonComposedBox::store(); field visibility unchangedThe widened enumeration
The scope was "branches that can refuse an input". That is a defensible boundary and it excluded
exactly the predicates whose whole job is discriminating — F3 landed there, with the same
one-side-blank shape the table exists to make visible. The scope is now every discriminating
branch on the crate's own parsing surface, and the columns are
Taken/Not takenrather thanRefuses/Accepts, which generalises without losing the refusal reading (for a refusing branchthey are the input it refuses and the nearest input it must not).
Three rows join, taking the table from nine to twelve:
declared_store_lenLBox == 0— the reserved to-end-of-buffer value, not a literal zero;declared_store_lenLBox == 1— the reserved defer-to-XLBoxvalue, not a literal one;is_jumbf_not_found—C2pa(JumbfNotFound)against everything else.The first two were previously relegated to prose on the grounds that they refuse nothing. Under the
widened scope that is not a reason: they choose between two answers about a buffer, which is what
earns a row. All three share their
Not takenside with the ordinary 32-bitLBoxarm, whichan_lbox_of_exactly_the_header_size_is_a_lengthis; the table says so.is_jumbf_not_found'sfalseside is pinned onc2pa::Error::JumbfBoxNotFound— the nearestneighbour there is: same crate, same phrasing, raised only once a store has been found and
something inside it is missing. That is precisely the case the no-copy-forward claim must not
confuse with an absent manifest. Its
trueside is pinned inline as well as differentially intests/no_copy_forward.rs, because the inline test fails for exactly one reason where thedifferential fails for anything wrong anywhere in a re-encode.
Correction to the round-5 mutation table
That section says "The four rows marked ▲ were the blank sides." Counting the marks gives ten:
.skip(3), the dropped.skip, both.ok_ormutations, theget(..4)deletion,get(8..17),wrapping_add, bothreserve_then_fillslot-guard mutations, and the.gitignorenegation.The mark was meant to say "this mutation sits on an arm round 5 pinned", and it is applied
inconsistently:
.skip(5)andget(..5)fit the same description and carry no mark. The columnthat is load-bearing is Outcome — exactly two mutations,
.skip(3)and theget(..4)deletion, were live survivors before round 5, and their own cells say so. The ▲ marks are
descriptive and should be read as nothing more.
What the committed lockfile actually holds
Round 5 recorded two arguments for committing
tooling/c2pa-oracle/Cargo.lock. Only one of themcarries the claim, and the record is corrected rather than left to be read either way.
=pin holds one direct dependency and nothing beneath it. The 325transitive packages under
c2pare-resolve on every invocation without a committed lockfile, sothe_resolved_dependency_graph_contains_no_openssl_packagecould only inspect a resolution cargohad just written.
--lockedis the other half: the committed resolution is now the one used, orthe task fails.
c2pa, so nothing else pins the graph itdrags in." True, but it bears on the feature line — it says no other dependent can unify
opensslback on — and says nothing about which versions those 325 packages resolve to. The twoare different claims. The second has been removed from
tests/build_configuration.rsand thedistinction written down at both sites.
An open question for the reader, not resolved here
Should a crate that no CI job reaches merge?
check-c2paandtest-c2paare called by noworkflow in
.github/; the crate's whole automated reach isfmt-tooling-check. Wiring them up is#541, filed and disclosed in the mise task comment, the README and this body. Every result in
this body was produced by hand for that reason. Whether that is an acceptable state to merge in is
a human's judgement and this round deliberately takes no position on it.
Round-7 gates
mise run test-c2pamise run check-c2paFinished dev profile, no warningscargo clippy --locked --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets -- -D warnings__CARGO_TEST_ROOT=… mise run fmt-checkfmt-check+fmt-tooling-check)mise run check-testsmodule docs, pinned proptest seeds and oracle filenames all conformmise run check-commitsno errors in 56 commitsmise run lint,mise run testand the mutation gate were not run this round and are notclaimed. None of them can reach a workspace-excluded crate, and this round's diff touches no file
under
crates/, socargo mutants --in-diffselects nothing — an absence of selection, notcoverage. The five hand-applied mutants above were each confirmed to have actually rebuilt from
cargo's own
Compiling c2pa-oracleline before their result was believed, and each was restored bycopy with the tree verified clean afterwards. The
__CARGO_TEST_ROOTprefix is still required forfmt-check: the task loops over everytooling/*/Cargo.tomland the sibling manifests still needit.
Decisions appended in round 7
Self-review, after the round-7 gates
One defect raised against this round's own diff and fixed in
0eb7b1bb, gates re-run after:README.mdstill described the enumeration as "one row per refusing branch" across threefunctions, and still described the lockfile without
--lockedwhile repeating thesingle-dependent argument this round removed from the test. A prose artefact contradicting the
code it documents is the same class of defect as F5, in the same round that closes F5, so it was
fixed rather than deferred. Both passages now match the module doc and the manifest.
Residual risk after round 7
check-c2painto the per-PR lint laneand
test-c2painto extended CI. Unchanged since round 4, restated above as an open question,and still a merge judgement for the human.
Reproducible from the tables; nothing re-checks them. The enumeration and its machine check are
the mitigation — a branch without a row, or a row naming a function that does not exist, is now
visible — not a gate.
function in this file; it cannot confirm the test is the one that pins that branch, and it
cannot see a branch that has no row at all. Adding a row is still a human act; forgetting one is
still caught only by review.
--lockedpins the resolution, not its freshness. Acargo updatestill moves the graph inone command; what changed is that the move is now a committed diff a reviewer sees.
XLBoxremains untested against a realstore; the span's upper bound is still unchecked against its enclosing box (decision 22).
Round-3 and round-5 residuals, unchanged.
c2pa-rsfinding stays pinned to=0.90.21, with the whole resolved graph now held bya committed lockfile that
--lockedmakes load-bearing, and every specification count to thevendored C2PA 2.4 HTML.
Round-7 verification re-established on the pushed head, and the mutation-gate claim closed
Round 7's evidence was produced while the round was still open, and the round ended before the
gate claim above was closed. Every claim in the round-7 section has since been re-established
first-hand against the pushed head
0eb7b1bb, on a tree verified clean before and after eachstep. No code changed: this section adds evidence, not commits.
The mutation gate. The claim above — that the gate selects nothing from this pull request —
is now executed rather than reasoned. Against this pull request's own base branch,
origin/feat/444-avif-c2pa-reserve, notorigin/master:GAMUT_MUTANTS_BASE=origin/feat/444-avif-c2pa-reserve mise run mutants-diff -- -- --listINFO No mutants to filter, zero mutants listedThat is the whole selection, not a shard of it:
git diff --name-only origin/feat/444-avif-c2pa-reserve...HEADlists fifteen files, none of them under
crates/, andtooling/c2pa-oracleis excluded from theworkspace, so cargo-mutants cannot reach a single line this pull request changes. The four
Incremental (PR diff)shards on this head are green for the same reason — they had nothing tomutate. Read the gate as an absence of selection, not as coverage; the enumeration and its
machine check remain the only thing standing where a mutation gate would.
The four falsifiers, re-executed. Each was applied, the rebuild confirmed from cargo's own
Compiling c2pa-oracleline, then restored by copy withgit statusverified empty:tooling/c2pa-oracle/Cargo.lock, runmise run check-c2pacannot create the lock file … because --locked was passed, and the file is not regeneratedan_xlbox_of_exactly_the_header_size_is_a_lengthat its definition onlyevery_test_the_enumeration_names_exists_in_this_filefails, naming the stale rowis_jumbf_not_found→-> bool { true }a_c2pa_error_other_than_jumbf_not_found_is_not_an_absent_manifest.filter(|end| *end <= buffer.len() + 1)a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox, printing the span it wrongly returned,0..33The gates, re-run on the pushed head.
mise run test-c2pamise run check-c2pacargo clippy --locked --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets -- -D warnings__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-checkmise run check-testsmodule docs, pinned proptest seeds and oracle filenames all conformconvco check origin/feat/444-avif-c2pa-reserve..HEADno errors in 30 commitsmise run check-release-depsrelease dependency graph has no dev-only workspace edgesmise run check-ffi-featuresgamut-ffi features in sync with gamutmise run lintandmise run testare still not claimed: neither reaches a workspace-excludedcrate, and this pull request changes no file under
crates/.No human approved this plan or these decisions: this is an unattended run, and the record above is
what a human reads afterwards.
Round 8 (final repair) —
feat/447-c2pa-oracle, head722c5ba1The round-7 review re-executed all four of that round's falsifiers, added two of its own against
checks this branch had never claimed, and verified the mutation-gate statement link by link. Every
one of its findings is prose or a count; none is a defect in an assertion. This round repairs
exactly those, in five commits — two carrying the repair and three from self-review after the
push: a sentence the second commit had left as a riddle, "two versions" where two names occur at
three, and a re-wrap of the three comments the longer clause left ragged. It adds nothing: no test,
no check and no row.
mise run test-c2paruns the same 45 tests here that it ran on0eb7b1bb, and the enumerationstill has 12 rows.
What reproduced
Every finding reproduced; nothing failed to.
refusing arm added to
declared_store_len(0xFFFF_FFFF => Err(UnusableSuperboxLength(…))) withno row in the enumeration.
mise run test-c2paexits 0 — 24 of 24 unit tests, 45 in all —because all three checks read rows → tests and none looks at the code. Restored by copy; the
arm is not in either commit.
c2pa" over-attributed. Derived below. The lockfilecarries 326 entries; the closure under
c2pais 307.published body: Correction to the round-5 mutation table quotes "The four rows marked ▲ were
the blank sides", and the round-5 section no longer contained that sentence — the round-7 edit
had rewritten it in place. Restored; see below.
membership consequence: the table is complete under the intended reading, and this round adds
and removes no row. What changed is how membership is stated.
B — the transitive-package figure, derived
Correcting five sites that each said 325:
README.md(twice),tooling/c2pa-oracle/Cargo.toml(twice),
mise.toml,tests/build_configuration.rs(three times), and this body (four times, indated entries — see the note below). The four figures below are each re-derivable by the command
beside them, run at the repository root on this head:
c2pa— the version-aware closure of its entries in the committed lockfile,c2paexcludedtomllibwalk now published inREADME.md§ Build configuration is not optional307grep -c '^name = ' tooling/c2pa-oracle/Cargo.lock326c2paincludedcargo tree --manifest-path tooling/c2pa-oracle/Cargo.toml --locked -e normal -p c2pa --prefix none | sed 's/ (\*)$//' | sort -u | wc -l240synandgetrandomat three), which is why the walk must carry versions — a name-keyed walk lands on 285 namesgrep '^name = ' tooling/c2pa-oracle/Cargo.lock | sort | uniq -d | wc -l21326 = 307 +
c2pa+c2pa-oracle+ 17 reachable only through the fourgamut-*dev-dependencies,whose versions the root workspace lockfile resolves.
307 is the population the argument needs. Every one of the five sites is arguing that the
=pin holds one line and a committed lockfile holds the rest, so that
the_resolved_dependency_graph_contains_no_openssl_packagereads a resolution nobody just wrote:an
opensslpackage can only arrive beneathc2pa, and it is resolution, not compilation, thata missing lockfile redoes. So neither 326 (which includes packages the pin was never asked to hold)
nor 240 (which counts what is built, not what is resolved) carries the claim.
C — the round-5 sentence is restored, and the record's own rule with it
Round 7 corrected a round-5 count and, in the same edit, rewrote the sentence it was correcting.
A reader following the correction's pointer reached text that no longer said what the correction
quoted, which makes the correction unverifiable — the opposite of its purpose. The round-5
sentence
now stands again exactly as round 5 wrote it, and Correction to the round-5 mutation table under
Round 7 above — which counts ten marks and explains why Outcome, not the marker, is the
load-bearing column — is left to do the work on its own. That is the only in-place edit this round
makes to a dated entry, and it is a reversal of an in-place edit, not a new one.
The same rule is why the four
325s in the dated round-5 and round-7 entries of this body areleft standing. Correcting them in place would repeat exactly the defect above. This section is
their correction: where those entries say "the 325 transitive packages under
c2pa", read 307,derived above. The five source sites are not a record and were edited directly.
A, D, E — what the enumeration and the README now say
README.mdsaid a branch added without a row "is the finding, and three tests in thatsame module parse the table and enforce it". They enforce a blank cell, and a name nothing in the
file defines — nothing more. The sentence is narrowed to that, and the README now states that the
checks read rows → tests, so a branch added without a row leaves them green and completeness
is held by review. The per-function branch-count guard that would close the other direction is
filed as tooling/c2pa-oracle: the branch enumeration is checked rows → tests only, so a branch added without a row stays green #616 rather than built: the round-7 review judged this loop to be generating its own
findings, and new machinery at its close is how such a loop fails to end.
be applied mechanically — the row the scope widening was made for,
is_jumbf_not_found, choosesbetween two answers about an error. A boundary that needs an exception is the wrong boundary
(decision 35's own words), so membership is no longer derived from prose at all. The table is a
named list over four named functions —
find_jumbf_superbox,declared_store_len,jumbf_superbox_span,is_jumbf_not_found— and the two discriminating branches deliberatelyoutside it are named in the same doc comment:
reserve_then_fill'sstore.len() != slot.len()guard, which differs from a row by reach and not by subject (it needs c2pa-rs and a gamut
encoder, so it is pinned in
tests/reserve_then_fill.rs), andDisplay for OracleError's matchover the variants, which picks the wording for an outcome another branch already decided and is
exercised by every refusal test that asserts on a message. A named list needs no exception,
because it does not claim to be derivable.
re-derive it:
grep -c '^ #\[test\]$' src/lib.rs→ 24,grep -c '^ //! | \[' src/lib.rs→12 rows, which name 20 distinct tests between their two columns. The four unrowed are
a_span_is_still_found_when_a_decoy_jumb_precedes_the_superboxand the three checks over thetable itself. The doc now states the asymmetry that let the count go stale: the checks run rows →
tests and never tests → rows, so neither a test without a row nor a stale sentence about them is
visible to the suite.
Filed
added without a row stays green. Carries the falsifier, the shape a per-function branch-count
guard would take, the hand-maintained-count alternative, and why it was filed rather than built.
An open question for the reader, restated and not answered here
Should a pull request whose crate no workflow reaches merge? Nothing under
.github/namescheck-c2paortest-c2pa(#541); the crate's whole automated reach isfmt-tooling-check.Inside the crate, the enumeration's completeness is likewise held by review and not by a check
(#616). Every result in this body was therefore produced by hand, on the pushed head, with the
command and its output shown. Whether that is an acceptable state to merge in is a human's
judgement; this round takes no position, and neither issue is a blocker this round claims to have
closed.
Round-8 gates
Run in this worktree on the pushed head
722c5ba1, and on each pushed head before it.mise run fmt/fmt-checkcarry thedocumented
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel)prefix.mise run test-c2pamise run check-c2pacargo clippy --locked --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets -- -D warnings__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmtthenmise run fmt-checkmise run check-testsmodule docs, pinned proptest seeds and oracle filenames all conformmise run check-commitsno errors in 62 commitsmise run check-release-depsrelease dependency graph has no dev-only workspace edgesmise run check-ffi-featuresgamut-ffi features in sync with gamutOn the pushed head
722c5ba1every required check is green: Format & Metadata, Clippy &Doctests, Coverage (test gate) and all four Incremental (PR diff) shards pass; Full
workspace is
skipping, as it is on every pull request that touches no shipped crate.mise run lintandmise run testare not claimed, for the same reason as in round 7: neitherreaches a workspace-excluded crate, and this pull request still changes no file under
crates/.The mutation gate selects nothing from this pull request; the round-7 section above establishes
that against this branch's own base, and this round changed no line it could reach.
Decisions appended in round 8
Residual risk after round 8
machine-held (tooling/c2pa-oracle: the branch enumeration is checked rows → tests only, so a branch added without a row stays green #616). Both are now stated where a reader meets them: the README, the module doc
and this body. Unchanged as risks; what changed is that neither is overstated anywhere.
Each carries the command that re-derives it, which is the mitigation available to a crate no
workflow runs.
untested (tooling/c2pa-oracle: exercise a reserved slot larger than the signed store, where the two locators diverge #598),
XLBoxis untested against a real store, the span's upper bound is uncheckedagainst its enclosing box (decision 22), and every
c2pa-rsfinding is pinned to=0.90.21withthe resolved graph held by a committed lockfile that
--lockedmakes load-bearing.dependency moved;
git diff 0eb7b1bb..722c5ba1 -- '*.rs'touches only comment lines.No human approved this plan or these decisions: this is an unattended run, and the record above is
what a human reads afterwards.
Unresolved review notes
None outstanding from the round-6 review: F1–F6 and design questions 1–4 are closed above, and
design question 5 is restated as an open question for the reader rather than resolved, because
whether a crate no CI job reaches should merge is a human's judgement.
From the round-7 review: findings A–E are closed in the round-8 section above — A, B, D and E
by change, C by restoring the sentence the round-7 edit had replaced — and design questions 1, 2
and 3 with them. Design question 4 is restated there as an open question and deliberately left to
the reader: whether a pull request whose crate no workflow reaches (#541), and whose enumeration's
completeness no check holds (#616), should merge. Nothing else is outstanding, and no round-8
review is planned.