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/Cargo.toml b/Cargo.toml index 28eb775e..b6353e07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,14 @@ 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`, 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", "tooling/libpng-oracle", diff --git a/mise.toml b/mise.toml index 50ccb405..9f45264f 100644 --- a/mise.toml +++ b/mise.toml @@ -210,6 +210,43 @@ 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. +# +# `--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. +# +# 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" + +# 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 --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 # missing slice: CI's lint lane runs it, reusing the `--all-targets --all-features` build it just 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 new file mode 100644 index 00000000..be929f02 --- /dev/null +++ b/tooling/c2pa-oracle/Cargo.toml @@ -0,0 +1,64 @@ +# 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. +# +# 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. +# +# 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 +# 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] +# 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" } +# 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/README.md b/tooling/c2pa-oracle/README.md new file mode 100644 index 00000000..6e805b2c --- /dev/null +++ b/tooling/c2pa-oracle/README.md @@ -0,0 +1,367 @@ +# 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, 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 +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` | 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 | + +## 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. + +## 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 + +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 +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_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* — +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. + +**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_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 +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"]`, 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. + +`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 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. `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`, +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 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. 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 +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 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, +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`. + +## 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* — `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 *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, 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 — 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 +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 +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 +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. diff --git a/tooling/c2pa-oracle/examples/probe.rs b/tooling/c2pa-oracle/examples/probe.rs new file mode 100644 index 00000000..a34be2cd --- /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_slot() { + 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_slots() { + 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..2bec5ecd --- /dev/null +++ b/tooling/c2pa-oracle/src/lib.rs @@ -0,0 +1,1052 @@ +//! 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), + /// 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` — 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 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, `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 + /// one; see [`declared_store_len`]. + UnusableSuperboxLength(&'static str), +} + +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") + } + Self::UnusableSuperboxLength(what) => { + write!(f, "JUMBF superbox declares no usable length: {what}") + } + } + } +} + +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. + /// + /// # 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..] + } +} + +/// 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. +/// +/// 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()) + .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) +} + +/// 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 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::UnusableSuperboxLength( + "the declared length runs past the end of the buffer", + ))?; + 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 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. +/// +/// # 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`. 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()`. +/// * **`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. +/// +/// # Lengths shorter than the header they sit in +/// +/// 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. +/// +/// # Errors +/// +/// [`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()) + .ok_or(OracleError::UnusableSuperboxLength( + "the LBox field is truncated", + ))?; + + match u32::from_be_bytes(field) { + 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) + .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", + ))?, + ) { + 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 => Ok(lbox 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)) +} + +#[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. + //! + //! # 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 — 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. 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 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 + //! 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 + //! 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`] | + //! + //! 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 + //! 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. + //! + //! 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. + //! + //! 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, 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` + /// 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(&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(&decoy_jumb_at_the_boundary_then_a_real_superbox()) + .expect("the real superbox"), + 12, + "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_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!( + 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 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 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"); + + assert_eq!( + 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" + ); + } + + #[test] + fn an_lbox_of_one_takes_its_length_from_the_xlbox_field() { + assert_eq!( + 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() { + 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; \ + 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 error = declared_store_len(&store_with_xlbox(xlbox, 40)) + .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() { + assert_eq!( + 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" + ); + } + + #[test] + fn an_lbox_between_two_and_seven_is_refused_rather_than_resolved() { + for lbox in [2u32, 3, 4, 5, 6, 7] { + 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 + .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() { + assert_eq!( + 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" + ); + } + + #[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 — 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() { + assert_eq!( + 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" + ); + } + + #[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(&33u32.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!(&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}" + ); + } + + #[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}" + ); + } + + #[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" + ); + } + + /// 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" + ); + } +} diff --git a/tooling/c2pa-oracle/tests/box_framing.rs b/tooling/c2pa-oracle/tests/box_framing.rs new file mode 100644 index 00000000..bd6135f8 --- /dev/null +++ b/tooling/c2pa-oracle/tests/box_framing.rs @@ -0,0 +1,46 @@ +//! 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. + // 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!( + 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/build_configuration.rs b/tooling/c2pa-oracle/tests/build_configuration.rs new file mode 100644 index 00000000..8d5bc20d --- /dev/null +++ b/tooling/c2pa-oracle/tests/build_configuration.rs @@ -0,0 +1,135 @@ +//! 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. +//! +//! 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: `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 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. +//! +//! 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; + +/// 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"]; + +/// 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") +} + +#[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"), + "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() { + // 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())); + + 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" + ); + } +} + +#[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. 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, 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 + // "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 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. + // + // 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" + ); +} 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..426da94a --- /dev/null +++ b/tooling/c2pa-oracle/tests/locate_embedded.rs @@ -0,0 +1,164 @@ +//! **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_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_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 + // 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.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[..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_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 + // 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(); + 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 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 + // 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"), + 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..7a63bef9 --- /dev/null +++ b/tooling/c2pa-oracle/tests/no_copy_forward.rs @@ -0,0 +1,125 @@ +//! 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. +//! +//! # 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 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 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 +//! 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, jumbf_superbox_span, read}; +use common::{dims, plain_avif, source_rgb}; +use gamut_avif::{AvifContainer, AvifEncoder}; +use gamut_core::{EncodeImage, ImageRef, Rgb8}; +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. +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 a derivative's outcome proves nothing" + ); + parent +} + +/// 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_slot() + .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 +/// 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)]) + .expect("a lone C2PA block extracts"); + + // 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. + // + // 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() + .and_then(|carried| carried.get(..expected.len())), + Some(&parent[expected]), + "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"); + + 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(), &parent); + + 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..83a0c77a --- /dev/null +++ b/tooling/c2pa-oracle/tests/reserve_then_fill.rs @@ -0,0 +1,107 @@ +//! **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_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" + ); +} + +#[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}" + ); +} + +#[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).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 new file mode 100644 index 00000000..838d4e77 --- /dev/null +++ b/tooling/c2pa-oracle/tests/update_manifest.rs @@ -0,0 +1,134 @@ +//! 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_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())) +} + +#[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 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[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[nul], 0, + "and immediately before it, the NUL terminating `box_purpose`" + ); + assert_eq!( + &asset[purpose..nul], + 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()]).expect("the store declares its own length"), + 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_slot` promises only "the first one". + let purposes: Vec<_> = container.c2pa_slots().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" + ); +}