lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit b0e29c9e171191ec8e00da4184e6b271ab7bffea
parent 52ad612164e5ef4e775abbab02497adf90268290
Author: triesap <tyson@radroots.org>
Date:   Wed, 22 Jul 2026 06:28:45 +0000

blossom: reconcile publication readiness ancestry

- preserve artifact and allowlist checkpoint ancestry
- integrate complete bounded static raster decoding
- route raw-source drift through both active successors
- regenerate the exact allowlist successor identity

Diffstat:
MCHANGELOG.md | 16++++++++++++++++
MCargo.lock | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 8++++++++
Mbuild/nix/checks.nix | 27+++++++++++++++++++++++++++
Mbuild/nix/common.nix | 3++-
Acontracts/conformance/vectors/blossom/publication_readiness.v1.json | 428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/coverage-profiles.toml | 5+++++
Mcontracts/events/blossom-media.md | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/operations.toml | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 12++++++++++++
Mcrates/blossom/Cargo.toml | 4++++
Mcrates/blossom/README | 25++++++++++++++++++++++++-
Mcrates/blossom/src/error.rs | 257+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/src/lib.rs | 15+++++++++++++++
Acrates/blossom/src/publication_readiness.rs | 1941+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/src/publication_readiness/sequential_jpeg.rs | 1244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/src/url.rs | 12++++++++++++
Acrates/blossom/tests/fixtures/publication_readiness.v1.json | 428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/tests/publication_readiness.rs | 721+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/contracts/phase1_publication_allowlist_v1.descriptor.json | 10+++++-----
Mcrates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.json | 82++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.schema.json | 4++--
Mcrates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.sha256 | 2+-
Mtools/xtask/src/contract.rs | 13++++++++++---
Atools/xtask/src/contract/blossom_publication_readiness.rs | 1147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract/food_availability_projection.rs | 5+++++
Mtools/xtask/src/contract/nip09_reconciliation.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtools/xtask/src/contract/phase1_publication_allowlist.rs | 7++++++-
Mtools/xtask/src/contract/phase1_publication_artifact.rs | 29++++++++++++++++++++++++++---
Mtools/xtask/src/contract/raw_source_rebuild.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
30 files changed, 6850 insertions(+), 74 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -199,6 +199,22 @@ publish policy both pass for the same source revision. non-events and likewise fail closed. Event-contract registry v7 remains byte-identical, and the new gate grants no signing, upload, retrieval, relay, or entitlement authority. +<!-- release-change: blossom-publication-readiness-evidence --> +- Blossom publication media now advances beyond local byte verification only + after typed BUD-02 status and descriptor agreement, an independent BUD-01 + HEAD, and an exactly bounded complete BUD-01 GET agree with the authored + URL, hash, MIME, and length. The public evidence profile admits JPEG, PNG, + and still WebP only, rejects animation, fully decodes the exact retrieved + bytes with a declared-format decoder, and derives dimensions internally + within 16,384 per axis and 20,000,000 pixels. JPEG is limited to 8-bit + sequential SOF0/SOF1 and combines exact entropy accounting with pinned + strict `zune-jpeg` and `zune-core` RGB decoding; PNG and WebP use a + separately pinned two-format `image` build. + Decoded output is bounded to 160,000,000 bytes before allocation; callers + cannot provide decode claims. + Deterministic per-URL evidence remains transport-neutral and contains no + HTTP credentials, BUD-11 material, entitlement decision, or private service + topology. - Bare-envelope replica ingestion is quarantined behind the explicit, non-default `legacy-ingest` feature. Default replica APIs expose emit and sync surfaces only; a future product ingest boundary must consume a store-produced diff --git a/Cargo.lock b/Cargo.lock @@ -657,6 +657,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1872,6 +1878,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2740,6 +2755,30 @@ dependencies = [ ] [[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] name = "impl-trait-for-tuples" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3327,6 +3366,16 @@ dependencies = [ ] [[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] name = "mti" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4259,6 +4308,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4433,6 +4495,18 @@ dependencies = [ ] [[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] name = "quinn" version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4528,12 +4602,15 @@ name = "radroots_blossom" version = "1.0.0-alpha.1" dependencies = [ "hex", + "image", "mediatype", "serde", "serde_json", "sha2", "unicode-general-category", "url", + "zune-core", + "zune-jpeg", ] [[package]] @@ -9237,3 +9314,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml @@ -141,6 +141,10 @@ fs2 = { version = "0.4" } getrandom = { version = "0.2", default-features = false } hkdf = { version = "0.12", default-features = false } hex = { version = "0.4" } +image = { version = "=0.25.10", default-features = false, features = [ + "png", + "webp", +] } jiff-tzdb = { version = "=0.1.8", default-features = false } jsonschema = { version = "0.48.1", default-features = false } js-sys = { version = "0.3" } @@ -213,5 +217,9 @@ uuid = { version = "1.22.0", features = ["v4", "v7"] } x509-parser = { version = "0.17", default-features = false } zstd = { version = "0.13", default-features = false } zeroize = { version = "1" } +zune-core = { version = "=0.5.1", default-features = false, features = ["std"] } +zune-jpeg = { version = "=0.5.15", default-features = false, features = [ + "std", +] } [patch.crates-io] libsqlite3-sys = { path = "crates/libsqlite3_sys_3_53_3" } diff --git a/build/nix/checks.nix b/build/nix/checks.nix @@ -25,6 +25,31 @@ let installPhaseCommand = "mkdir -p $out"; } ); + blossomNoDefaultCheck = common.craneLib.mkCargoDerivation ( + common.commonCraneArgs + // { + inherit (common) cargoArtifacts; + pname = "radroots-blossom-no-default-check"; + doCheck = false; + buildPhaseCargoCommand = '' + cargo check -p radroots_blossom --lib --no-default-features + cargo check -p radroots_blossom --lib --no-default-features --features raster-decode + ''; + installPhaseCommand = "mkdir -p $out"; + } + ); + blossomRasterDecodeTest = common.craneLib.mkCargoDerivation ( + common.commonCraneArgs + // { + inherit (common) cargoArtifacts; + pname = "radroots-blossom-raster-decode-test"; + doCheck = false; + buildPhaseCargoCommand = '' + cargo test -p radroots_blossom --no-default-features --features raster-decode,serde + ''; + installPhaseCommand = "mkdir -p $out"; + } + ); mkReplicaSyncLane = { pname, @@ -61,6 +86,8 @@ in cargo-fmt = cargoFmt; cargo-check = cargoCheck; cargo-test = cargoTest; + blossom-no-default-check = blossomNoDefaultCheck; + blossom-raster-decode-test = blossomRasterDecodeTest; replica-sync-default-check = replicaSyncDefaultCheck; replica-sync-default-test = replicaSyncDefaultTest; replica-sync-legacy-ingest-check = replicaSyncLegacyCheck; diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -24,6 +24,7 @@ let ../../README ../../flake.nix ../../build/nix/apps.nix + ../../build/nix/checks.nix ../../build/nix/common.nix ../../build/nix/toolchains.nix ../../dto_bindgen.toml @@ -115,7 +116,7 @@ let ]; coreContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates) - + " --features radroots_event_codec/serde_json,radroots_event_codec/nostr,radroots_nostr/blossom,radroots_nostr/client,radroots_nostr/codec,radroots_nostr/events"; + + " --features radroots_blossom/raster-decode,radroots_event_codec/serde_json,radroots_event_codec/nostr,radroots_nostr/blossom,radroots_nostr/client,radroots_nostr/codec,radroots_nostr/events"; craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable; commonCraneArgs = { inherit version; diff --git a/contracts/conformance/vectors/blossom/publication_readiness.v1.json b/contracts/conformance/vectors/blossom/publication_readiness.v1.json @@ -0,0 +1,428 @@ +{ + "suite": "blossom_publication_readiness", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "valid_created", + "kind": "blossom.verify_publication_readiness.valid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "none" + }, + "expected": { + "url": "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png", + "sha256": "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd", + "size": 70, + "media_type": "image/png", + "format": "png", + "width": 1, + "height": 1, + "upload_status": 201, + "evidence_digest": "44e63303e594ea42d863be995b23ac4297ed77e4378d0707c94f28e77164bd3b" + } + }, + { + "id": "valid_ok_without_authored_dimensions", + "kind": "blossom.verify_publication_readiness.valid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_status_200" + }, + "expected": { + "url": "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png", + "sha256": "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd", + "size": 70, + "media_type": "image/png", + "format": "png", + "width": 1, + "height": 1, + "upload_status": 200 + } + }, + { + "id": "invalid_upload_status", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_status_202" + }, + "expected": { + "error": "invalid_bud02_upload_status" + } + }, + { + "id": "invalid_head_status", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "head_status_204" + }, + "expected": { + "error": "invalid_bud01_head_status" + } + }, + { + "id": "invalid_get_status", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_status_206" + }, + "expected": { + "error": "invalid_bud01_get_status" + } + }, + { + "id": "declared_size_over_public_max", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_size_over_max" + }, + "expected": { + "error": "publication_raster_byte_limit_exceeded" + } + }, + { + "id": "missing_get_body", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_body_missing" + }, + "expected": { + "error": "publication_get_body_missing" + } + }, + { + "id": "short_get_body", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_body_short" + }, + "expected": { + "error": "publication_get_body_short" + } + }, + { + "id": "trailing_get_body", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_body_trailing" + }, + "expected": { + "error": "publication_get_body_trailing" + } + }, + { + "id": "authored_bytes_short", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "authored_bytes_short" + }, + "expected": { + "error": "publication_authored_bytes_size_mismatch" + } + }, + { + "id": "authored_bytes_wrong_hash", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "authored_bytes_wrong_hash" + }, + "expected": { + "error": "publication_authored_bytes_hash_mismatch" + } + }, + { + "id": "upload_url_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_url_mismatch" + }, + "expected": { + "error": "publication_upload_url_mismatch" + } + }, + { + "id": "upload_hash_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_hash_mismatch" + }, + "expected": { + "error": "publication_upload_hash_mismatch" + } + }, + { + "id": "upload_size_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_size_mismatch" + }, + "expected": { + "error": "publication_upload_size_mismatch" + } + }, + { + "id": "upload_mime_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_mime_mismatch" + }, + "expected": { + "error": "publication_upload_media_type_mismatch" + } + }, + { + "id": "head_url_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "head_url_mismatch" + }, + "expected": { + "error": "publication_head_url_mismatch" + } + }, + { + "id": "head_size_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "head_size_mismatch" + }, + "expected": { + "error": "publication_head_size_mismatch" + } + }, + { + "id": "head_mime_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "head_mime_mismatch" + }, + "expected": { + "error": "publication_head_media_type_mismatch" + } + }, + { + "id": "get_url_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_url_mismatch" + }, + "expected": { + "error": "publication_get_url_mismatch" + } + }, + { + "id": "get_declared_size_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_declared_size_mismatch" + }, + "expected": { + "error": "publication_get_declared_size_mismatch" + } + }, + { + "id": "get_complete_hash_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_bytes_wrong_hash" + }, + "expected": { + "error": "publication_retrieved_bytes_hash_mismatch" + } + }, + { + "id": "unsupported_raster_mime", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "unsupported_mime" + }, + "expected": { + "error": "unsupported_publication_raster_media_type" + } + }, + { + "id": "malformed_raster", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "malformed_container" + }, + "expected": { + "error": "invalid_publication_raster" + } + }, + { + "id": "animated_png", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "animated_png" + }, + "expected": { + "error": "publication_raster_animation_forbidden" + } + }, + { + "id": "declared_format_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "declared_mime_jpeg" + }, + "expected": { + "error": "invalid_publication_raster" + } + }, + { + "id": "corrupt_png_crc", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "corrupt_png_crc" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "corrupt_png_deflate", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "corrupt_png_deflate" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "invalid_png_color_type", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "invalid_png_color_type" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "authored_dimension_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "authored_dimension_mismatch" + }, + "expected": { + "error": "publication_authored_raster_dimension_mismatch" + } + }, + { + "id": "animated_webp", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "animated_webp" + }, + "expected": { + "error": "publication_raster_animation_forbidden" + } + }, + { + "id": "zero_width", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "zero_width" + }, + "expected": { + "error": "publication_raster_dimensions_out_of_range" + } + }, + { + "id": "dimension_over_max", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "dimension_over_max" + }, + "expected": { + "error": "publication_raster_dimensions_out_of_range" + } + }, + { + "id": "pixel_limit", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "pixel_limit" + }, + "expected": { + "error": "publication_raster_pixel_limit_exceeded" + } + }, + { + "id": "progressive_jpeg", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "progressive_jpeg" + }, + "expected": { + "error": "publication_jpeg_process_forbidden" + } + }, + { + "id": "jpeg_entropy_stripped", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "jpeg_entropy_stripped" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "jpeg_entropy_partial", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "jpeg_entropy_partial" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "malformed_jpeg_dqt", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "malformed_jpeg_dqt" + }, + "expected": { + "error": "invalid_publication_raster" + } + } + ] +} diff --git a/contracts/coverage-profiles.toml b/contracts/coverage-profiles.toml @@ -8,6 +8,11 @@ no_default_features = true features = [] test_threads = 1 +[profiles.crates."radroots_blossom"] +no_default_features = true +features = ["raster-decode", "serde"] +test_threads = 1 + [profiles.crates."radroots_event_codec"] no_default_features = false features = ["serde_json", "nostr"] diff --git a/contracts/events/blossom-media.md b/contracts/events/blossom-media.md @@ -217,6 +217,39 @@ The public typed API exposes these stable semantic identifiers: - `blob_hash_mismatch` - `blob_size_mismatch` - `blob_media_type_mismatch` +- `invalid_bud02_upload_status` +- `invalid_bud01_head_status` +- `invalid_bud01_get_status` +- `publication_raster_byte_limit_exceeded` +- `publication_get_body_allocation_failed` +- `publication_get_body_length_overflow` +- `publication_get_body_missing` +- `publication_get_body_short` +- `publication_get_body_trailing` +- `publication_authored_bytes_size_mismatch` +- `publication_authored_bytes_hash_mismatch` +- `publication_upload_url_mismatch` +- `publication_upload_hash_mismatch` +- `publication_upload_size_mismatch` +- `publication_upload_media_type_mismatch` +- `publication_head_url_mismatch` +- `publication_head_size_mismatch` +- `publication_head_media_type_mismatch` +- `publication_get_url_mismatch` +- `publication_get_declared_size_mismatch` +- `publication_retrieved_bytes_hash_mismatch` +- `publication_retrieved_bytes_mismatch` +- `unsupported_publication_raster_media_type` +- `invalid_publication_raster` +- `publication_jpeg_process_forbidden` +- `publication_raster_animation_forbidden` +- `publication_raster_dimensions_out_of_range` +- `publication_raster_pixel_limit_exceeded` +- `publication_raster_decoded_byte_limit_exceeded` +- `publication_raster_decode_allocation_failed` +- `publication_raster_decode_failed` +- `publication_raster_container_dimension_mismatch` +- `publication_authored_raster_dimension_mismatch` Malformed descriptor JSON can fail in serde before a `RadrootsBlossomError` exists. The executable descriptor harness uses three additional wire-shape classifications for those cases: @@ -235,3 +268,74 @@ that the packaged mirror is byte-for-byte current. The public error enum is non-exhaustive so later BUD slices can add typed failures without breaking consumers. Adding error detail is allowed, but it must not collapse protocol structure, reference approval, and byte verification into one indistinguishable state. + +## Publication Readiness Evidence + +`RadrootsBlossomPublicationReadinessEvidence` is a transport-neutral proof assembled only after +the supplied transport observations agree with the exact byte-verified authored descriptor and +deterministic raster bytes, and the internal decoder validates those bytes: + +1. a BUD-02 response has status `200` or `201`, an approved canonical hash-path URL, and matching + SHA-256, byte length, and exact media type; +2. a BUD-01 `HEAD` has status `200` and matching approved URL, content length, and media type; +3. a BUD-01 `GET` has status `200`, is collected through + `RadrootsBlossomBud01GetCollector`, and ends at exactly the declared size; +4. the complete GET body equals the authored byte count and SHA-256 and is no larger than + `10,485,760` bytes; +5. the `raster-decode` implementation selects a decoder from the exact declared media type, + enforces the closed 8-bit sequential JPEG profile or rejects any PNG or WebP animation + declaration, decodes the complete static body, and derives bounded dimensions internally. + +The closed raster profile is exact bare `image/jpeg`, `image/png`, or `image/webp`. PNG animation +chunks and WebP animation flags/chunks fail before evidence is created. JPEG, PNG, and WebP +container structure is checked independently of the full decoder. JPEG decoding uses exactly +`zune-jpeg` `0.5.15` and `zune-core` `0.5.1`, both exactly pinned with only their `std` feature; +unsafe decoder intrinsics are explicitly disabled. +Before that full RGB pixel decode, a private exact sequential entropy validator parses SOF0/SOF1, +DHT, DRI, and SOS structure and accounts for the complete MCU/block inventory. It accepts only +8-bit sequential frames with one, three, or four unique components, valid sampling factors whose +products sum to at most ten, and each component encoded exactly once. Huffman tables are bounded to +256 unique symbols, must leave the all-one code unused, and may not be overfull. Entropy reads may +not cross into a marker or synthesize missing bits; terminal pad bits must all be one, restart +markers must appear at the declared interval in RST0-through-RST7 order, and extra entropy before +the next marker is rejected. Progressive SOF2, every other JPEG process, missing or duplicate +component scans, partial entropy, and trailing bytes fail closed. Independently parsed component +count and dimensions must agree with the strict full decoder. The permissive `image` JPEG adapter +is not compiled. PNG and WebP decoding uses exactly `image` `0.25.10` with only those two format +features. Width and height are each within +`1..=16,384`, their product is at most `20,000,000` pixels, and every decoder output buffer is at +most `160,000,000` bytes. Limits are enforced before decoded-output allocation. When an authored +product already carries dimensions, it supplies +`RadrootsBlossomAuthoredRasterDimensions::Exact` and the decoded dimensions must match. Products +without authored dimensions supply the explicit `Unspecified` variant; the evidence then preserves +the bounded decoded dimensions for its eventual artifact adapter. + +The public crate does not choose or execute HTTP, DNS, redirects, credentials, BUD-11 claims, +entitlement policy, or private endpoints. An owning runtime supplies the typed HTTP observations and +exact complete body. The non-default `raster-decode` feature then uses only the fully pinned +sequential-JPEG validator/decoder pair and PNG/WebP decoder set; callers cannot inject format, +hash, length, frame, or dimension claims. The +portable `no_std` core remains available without that feature, but the readiness-evidence +constructor does not. Evidence describes the verified observation and does not claim later server +availability. + +Publication-readiness policy version `1` is defined by this authoritative full-decode boundary. +There is no serialized or caller-supplied decode-observation input in the v1 contract, and any such +legacy local shape is rejected rather than migrated or trusted. + +Each evidence value has a domain-separated deterministic digest covering policy version, complete +canonical URL, hash, length, MIME, raster format, dimensions, BUD-02 status, successful BUD-01 +HEAD/GET statuses, and the BUD-02 `uploaded` value. This per-URL digest is an adapter input, not the +future artifact readiness-binding digest. The artifact adapter remains responsible for proving the +exact URL-complete set, rejecting missing/duplicate/extra/reordered evidence, and binding that set +to its independently computed artifact digest. + +`contracts/conformance/vectors/blossom/publication_readiness.v1.json` executes the accepted status +set, exact evidence output, public limits, bounded body collection, complete-byte comparisons, +closed raster policy, frame and dimension bounds, and all agreement failures. The packaged mirror +under `crates/blossom/tests/fixtures/` must remain byte-identical. Crate integration decoder +conformance additionally includes structurally complete PNG bodies with corrupted checksum, +compressed payload, and color type, plus two-frame APNG, animated WebP, a forbidden progressive +JPEG marker, the historical three-byte-short DQT fixture, and fully stripped or partially truncated +JPEG entropy with EOI restored, so container parsing or a best-effort decoder cannot create +evidence. diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -30,6 +30,16 @@ public = [ "RadrootsBlossomApprovedDescriptor", "RadrootsBlossomByteCommitment", "RadrootsBlossomByteVerifiedDescriptor", + "RadrootsBlossomBud02UploadStatus", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud01GetCollector", + "RadrootsBlossomBud01GetObservation", + "RadrootsBlossomRasterFormat", + "RadrootsBlossomRasterDimensions", + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomPublicationReadinessEvidenceDigest", + "RadrootsBlossomPublicationReadinessEvidence", "RadrootsBlossomAuthorizationAction", "RadrootsBlossomAuthorizationContent", "RadrootsBlossomServerDomain", @@ -363,6 +373,42 @@ rust_types = [ [operations.blossom_verify_descriptor_bytes.conformance] vector = "contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json" +[operations.blossom_verify_publication_readiness] +domain = "blossom" +id = "blossom.verify_publication_readiness" +stability = "beta" +inputs = [ + "RadrootsBlossomByteVerifiedDescriptor", + "Bytes", + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud01GetObservation", +] +outputs = ["RadrootsBlossomPublicationReadinessEvidence"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_verify_publication_readiness.implementation] +rust_modules = ["crates/blossom/src/publication_readiness.rs"] +rust_types = [ + "radroots_blossom::RadrootsBlossomAuthoredRasterDimensions", + "radroots_blossom::RadrootsBlossomBud01GetCollector", + "radroots_blossom::RadrootsBlossomBud01GetObservation", + "radroots_blossom::RadrootsBlossomBud01HeadObservation", + "radroots_blossom::RadrootsBlossomBud02UploadObservation", + "radroots_blossom::RadrootsBlossomPublicationReadinessEvidence", +] + +[operations.blossom_verify_publication_readiness.conformance] +vector = "contracts/conformance/vectors/blossom/publication_readiness.v1.json" +case_kinds = [ + "blossom.verify_publication_readiness.valid", + "blossom.verify_publication_readiness.invalid", +] + [operations.blossom_build_upload_authorization_claim] domain = "blossom" id = "blossom.build_upload_authorization_claim" diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -484,3 +484,15 @@ id = "trade-sp1-remote-proof-fixture" classification = "breaking" semver_impacts = ["change_exported_constant_value", "fix_packaging_metadata"] summary = "Regenerate the remote SP1 Core proof fixture for the validator-set witness identity, exercise real cryptographic verification, and allow the SP1-pinned guest compiler to trail the workspace Rust version during locked guest builds." + +[[changes]] +id = "blossom-publication-readiness-evidence" +classification = "feature" +semver_impacts = [ + "add_exported_type", + "add_exported_function", + "add_exported_constant", + "add_enum_variant", + "add_conformance_vector", +] +summary = "Add transport-neutral BUD-02 plus BUD-01 publication-readiness evidence with bounded complete-byte verification, exact sequential JPEG entropy accounting plus pinned strict zune-jpeg decoding, and internally authoritative full decoding for static JPEG, PNG, and WebP rasters." diff --git a/crates/blossom/Cargo.toml b/crates/blossom/Cargo.toml @@ -16,13 +16,17 @@ readme = "README" default = ["serde"] serde = ["dep:serde"] std = ["serde?/std", "sha2/std", "url_nostd/std"] +raster-decode = ["std", "dep:image", "dep:zune-core", "dep:zune-jpeg"] [dependencies] +image = { workspace = true, optional = true } mediatype = { workspace = true } serde = { workspace = true, optional = true } sha2 = { workspace = true } unicode-general-category = { workspace = true } url_nostd = { workspace = true } +zune-core = { workspace = true, optional = true } +zune-jpeg = { workspace = true, optional = true } [dev-dependencies] hex = { workspace = true } diff --git a/crates/blossom/README b/crates/blossom/README @@ -2,7 +2,8 @@ `radroots_blossom` provides portable, runtime-independent primitives for Blossom blob hashes, root hash paths, blob URLs, BUD-02 descriptors, -Radroots-approved byte verification, and pure BUD-11 authorization claims. +Radroots-approved byte verification, publication-readiness evidence, and pure +BUD-11 authorization claims. The crate is `no_std + alloc`, performs no HTTP requests, and does not depend on Nostr event types. Structural Blossom validity is kept separate from the @@ -15,6 +16,28 @@ claim construction and endpoint validation; signing and canonical `Authorization: Nostr` encoding live behind the `radroots_nostr` `blossom` feature, and kind `24242` is never a relay-publication event. +Publication readiness is a separate typestate. It accepts only BUD-02 status +`200`/`201`, successful BUD-01 `HEAD`/`GET` observations, a body bounded by its +declared size and the public `10,485,760`-byte maximum, and one fully decodable +static JPEG, PNG, or WebP raster within the public dimension and pixel limits. +With the non-default `raster-decode` feature, the crate forces the decoder from +the exact declared MIME type. JPEG first passes an internal sequential entropy +validator that accounts for every MCU, Huffman symbol, magnitude bit, pad bit, +restart marker, and frame component without synthesizing missing input. The +same bytes are then fully decoded to RGB pixels by exactly pinned `zune-jpeg` +`0.5.15` and `zune-core` `0.5.1` in strict mode with unsafe intrinsics disabled. +The profile accepts only 8-bit sequential SOF0/SOF1 JPEG; progressive SOF2 and +every other process are rejected. `image` `0.25.10` is enabled only for PNG and WebP. The profile +rejects PNG and WebP animation, bounds decoded output to `160,000,000` bytes +before allocation, decodes the complete body, and derives dimensions +internally. It checks complete-byte, URL, hash, MIME, length, container, +animation, and dimension agreement before emitting deterministic per-URL +evidence. The crate performs no HTTP: an owning runtime supplies only the +transport observations and exact complete body. The portable `no_std` core +remains available without `raster-decode`, but cannot construct readiness +evidence. Policy v1 has no serialized or caller-supplied decode-observation +input. + Protocol behavior is pinned to Blossom commit `b5bd2801d1763aa635fc8fea7a76597e0eb18990`: diff --git a/crates/blossom/src/error.rs b/crates/blossom/src/error.rs @@ -40,6 +40,39 @@ pub enum RadrootsBlossomError { AuthorizationServerMismatch, AuthorizationHashRequired, AuthorizationHashMismatch, + InvalidBud02UploadStatus { actual: u16 }, + InvalidBud01HeadStatus { actual: u16 }, + InvalidBud01GetStatus { actual: u16 }, + PublicationRasterByteLimitExceeded { declared: u64, maximum: u64 }, + PublicationGetBodyAllocationFailed, + PublicationGetBodyLengthOverflow, + PublicationGetBodyMissing, + PublicationGetBodyShort { declared: u64, actual: u64 }, + PublicationGetBodyTrailing { declared: u64, actual: u64 }, + PublicationAuthoredBytesSizeMismatch { expected: u64, actual: u64 }, + PublicationAuthoredBytesHashMismatch, + PublicationUploadUrlMismatch, + PublicationUploadHashMismatch, + PublicationUploadSizeMismatch { expected: u64, actual: u64 }, + PublicationUploadMediaTypeMismatch, + PublicationHeadUrlMismatch, + PublicationHeadSizeMismatch { expected: u64, actual: u64 }, + PublicationHeadMediaTypeMismatch, + PublicationGetUrlMismatch, + PublicationGetDeclaredSizeMismatch { expected: u64, actual: u64 }, + PublicationRetrievedBytesHashMismatch, + PublicationRetrievedBytesMismatch, + UnsupportedPublicationRasterMediaType, + InvalidPublicationRaster, + PublicationJpegProcessForbidden, + PublicationRasterAnimationForbidden, + PublicationRasterDimensionsOutOfRange { width: u32, height: u32 }, + PublicationRasterPixelLimitExceeded { pixels: u64 }, + PublicationRasterDecodedByteLimitExceeded { decoded: u64, maximum: u64 }, + PublicationRasterDecodeAllocationFailed, + PublicationRasterDecodeFailed, + PublicationRasterContainerDimensionMismatch, + PublicationAuthoredRasterDimensionMismatch, } impl RadrootsBlossomError { @@ -82,6 +115,63 @@ impl RadrootsBlossomError { Self::AuthorizationServerMismatch => "authorization_server_mismatch", Self::AuthorizationHashRequired => "authorization_hash_required", Self::AuthorizationHashMismatch => "authorization_hash_mismatch", + Self::InvalidBud02UploadStatus { .. } => "invalid_bud02_upload_status", + Self::InvalidBud01HeadStatus { .. } => "invalid_bud01_head_status", + Self::InvalidBud01GetStatus { .. } => "invalid_bud01_get_status", + Self::PublicationRasterByteLimitExceeded { .. } => { + "publication_raster_byte_limit_exceeded" + } + Self::PublicationGetBodyAllocationFailed => "publication_get_body_allocation_failed", + Self::PublicationGetBodyLengthOverflow => "publication_get_body_length_overflow", + Self::PublicationGetBodyMissing => "publication_get_body_missing", + Self::PublicationGetBodyShort { .. } => "publication_get_body_short", + Self::PublicationGetBodyTrailing { .. } => "publication_get_body_trailing", + Self::PublicationAuthoredBytesSizeMismatch { .. } => { + "publication_authored_bytes_size_mismatch" + } + Self::PublicationAuthoredBytesHashMismatch => { + "publication_authored_bytes_hash_mismatch" + } + Self::PublicationUploadUrlMismatch => "publication_upload_url_mismatch", + Self::PublicationUploadHashMismatch => "publication_upload_hash_mismatch", + Self::PublicationUploadSizeMismatch { .. } => "publication_upload_size_mismatch", + Self::PublicationUploadMediaTypeMismatch => "publication_upload_media_type_mismatch", + Self::PublicationHeadUrlMismatch => "publication_head_url_mismatch", + Self::PublicationHeadSizeMismatch { .. } => "publication_head_size_mismatch", + Self::PublicationHeadMediaTypeMismatch => "publication_head_media_type_mismatch", + Self::PublicationGetUrlMismatch => "publication_get_url_mismatch", + Self::PublicationGetDeclaredSizeMismatch { .. } => { + "publication_get_declared_size_mismatch" + } + Self::PublicationRetrievedBytesHashMismatch => { + "publication_retrieved_bytes_hash_mismatch" + } + Self::PublicationRetrievedBytesMismatch => "publication_retrieved_bytes_mismatch", + Self::UnsupportedPublicationRasterMediaType => { + "unsupported_publication_raster_media_type" + } + Self::InvalidPublicationRaster => "invalid_publication_raster", + Self::PublicationJpegProcessForbidden => "publication_jpeg_process_forbidden", + Self::PublicationRasterAnimationForbidden => "publication_raster_animation_forbidden", + Self::PublicationRasterDimensionsOutOfRange { .. } => { + "publication_raster_dimensions_out_of_range" + } + Self::PublicationRasterPixelLimitExceeded { .. } => { + "publication_raster_pixel_limit_exceeded" + } + Self::PublicationRasterDecodedByteLimitExceeded { .. } => { + "publication_raster_decoded_byte_limit_exceeded" + } + Self::PublicationRasterDecodeAllocationFailed => { + "publication_raster_decode_allocation_failed" + } + Self::PublicationRasterDecodeFailed => "publication_raster_decode_failed", + Self::PublicationRasterContainerDimensionMismatch => { + "publication_raster_container_dimension_mismatch" + } + Self::PublicationAuthoredRasterDimensionMismatch => { + "publication_authored_raster_dimension_mismatch" + } } } } @@ -182,6 +272,113 @@ impl fmt::Display for RadrootsBlossomError { Self::AuthorizationHashMismatch => { f.write_str("Blossom authorization does not include the target blob hash") } + Self::InvalidBud02UploadStatus { actual } => { + write!(f, "BUD-02 upload status must be 200 or 201, got {actual}") + } + Self::InvalidBud01HeadStatus { actual } => { + write!(f, "BUD-01 HEAD status must be 200, got {actual}") + } + Self::InvalidBud01GetStatus { actual } => { + write!(f, "BUD-01 GET status must be 200, got {actual}") + } + Self::PublicationRasterByteLimitExceeded { declared, maximum } => write!( + f, + "publication raster declares {declared} bytes, exceeding maximum {maximum}" + ), + Self::PublicationGetBodyAllocationFailed => { + f.write_str("publication GET body allocation failed") + } + Self::PublicationGetBodyLengthOverflow => { + f.write_str("publication GET body length overflowed") + } + Self::PublicationGetBodyMissing => f.write_str("publication GET body is missing"), + Self::PublicationGetBodyShort { declared, actual } => write!( + f, + "publication GET body is short: declared {declared}, got {actual}" + ), + Self::PublicationGetBodyTrailing { declared, actual } => write!( + f, + "publication GET body has trailing bytes: declared {declared}, got {actual}" + ), + Self::PublicationAuthoredBytesSizeMismatch { expected, actual } => write!( + f, + "authored raster byte size mismatch: expected {expected}, got {actual}" + ), + Self::PublicationAuthoredBytesHashMismatch => { + f.write_str("authored raster bytes do not match the sealed descriptor hash") + } + Self::PublicationUploadUrlMismatch => { + f.write_str("BUD-02 upload descriptor URL does not match the authored URL") + } + Self::PublicationUploadHashMismatch => { + f.write_str("BUD-02 upload descriptor hash does not match the authored hash") + } + Self::PublicationUploadSizeMismatch { expected, actual } => write!( + f, + "BUD-02 upload descriptor size mismatch: expected {expected}, got {actual}" + ), + Self::PublicationUploadMediaTypeMismatch => f.write_str( + "BUD-02 upload descriptor media type does not match the authored media type", + ), + Self::PublicationHeadUrlMismatch => { + f.write_str("BUD-01 HEAD URL does not match the authored URL") + } + Self::PublicationHeadSizeMismatch { expected, actual } => write!( + f, + "BUD-01 HEAD content length mismatch: expected {expected}, got {actual}" + ), + Self::PublicationHeadMediaTypeMismatch => { + f.write_str("BUD-01 HEAD media type does not match the authored media type") + } + Self::PublicationGetUrlMismatch => { + f.write_str("BUD-01 GET URL does not match the authored URL") + } + Self::PublicationGetDeclaredSizeMismatch { expected, actual } => write!( + f, + "BUD-01 GET declared size mismatch: expected {expected}, got {actual}" + ), + Self::PublicationRetrievedBytesHashMismatch => { + f.write_str("BUD-01 GET complete-byte hash does not match the authored hash") + } + Self::PublicationRetrievedBytesMismatch => { + f.write_str("BUD-01 GET bytes differ from the exact authored raster bytes") + } + Self::UnsupportedPublicationRasterMediaType => f.write_str( + "publication raster media type must be image/jpeg, image/png, or image/webp", + ), + Self::InvalidPublicationRaster => { + f.write_str("publication raster container is malformed or incomplete") + } + Self::PublicationJpegProcessForbidden => { + f.write_str("publication JPEG must use an 8-bit sequential SOF0 or SOF1 process") + } + Self::PublicationRasterAnimationForbidden => { + f.write_str("publication raster animation is forbidden") + } + Self::PublicationRasterDimensionsOutOfRange { width, height } => write!( + f, + "publication raster dimensions must be within 1..=16384, got {width}x{height}" + ), + Self::PublicationRasterPixelLimitExceeded { pixels } => write!( + f, + "publication raster pixel count {pixels} exceeds 20000000" + ), + Self::PublicationRasterDecodedByteLimitExceeded { decoded, maximum } => write!( + f, + "publication raster requires {decoded} decoded bytes, exceeding maximum {maximum}" + ), + Self::PublicationRasterDecodeAllocationFailed => { + f.write_str("publication raster decoded-pixel buffer allocation failed") + } + Self::PublicationRasterDecodeFailed => { + f.write_str("publication raster bitstream could not be decoded completely") + } + Self::PublicationRasterContainerDimensionMismatch => f.write_str( + "decoded raster dimensions do not match the dimensions encoded by the container", + ), + Self::PublicationAuthoredRasterDimensionMismatch => { + f.write_str("decoded raster dimensions do not match the authored dimensions") + } } } } @@ -215,6 +412,66 @@ mod tests { actual: 2, }, RadrootsBlossomError::BlobMediaTypeMismatch, + RadrootsBlossomError::InvalidBud02UploadStatus { actual: 202 }, + RadrootsBlossomError::InvalidBud01HeadStatus { actual: 204 }, + RadrootsBlossomError::InvalidBud01GetStatus { actual: 206 }, + RadrootsBlossomError::PublicationRasterByteLimitExceeded { + declared: 2, + maximum: 1, + }, + RadrootsBlossomError::PublicationGetBodyAllocationFailed, + RadrootsBlossomError::PublicationGetBodyLengthOverflow, + RadrootsBlossomError::PublicationGetBodyMissing, + RadrootsBlossomError::PublicationGetBodyShort { + declared: 2, + actual: 1, + }, + RadrootsBlossomError::PublicationGetBodyTrailing { + declared: 1, + actual: 2, + }, + RadrootsBlossomError::PublicationAuthoredBytesSizeMismatch { + expected: 1, + actual: 2, + }, + RadrootsBlossomError::PublicationAuthoredBytesHashMismatch, + RadrootsBlossomError::PublicationUploadUrlMismatch, + RadrootsBlossomError::PublicationUploadHashMismatch, + RadrootsBlossomError::PublicationUploadSizeMismatch { + expected: 1, + actual: 2, + }, + RadrootsBlossomError::PublicationUploadMediaTypeMismatch, + RadrootsBlossomError::PublicationHeadUrlMismatch, + RadrootsBlossomError::PublicationHeadSizeMismatch { + expected: 1, + actual: 2, + }, + RadrootsBlossomError::PublicationHeadMediaTypeMismatch, + RadrootsBlossomError::PublicationGetUrlMismatch, + RadrootsBlossomError::PublicationGetDeclaredSizeMismatch { + expected: 1, + actual: 2, + }, + RadrootsBlossomError::PublicationRetrievedBytesHashMismatch, + RadrootsBlossomError::PublicationRetrievedBytesMismatch, + RadrootsBlossomError::UnsupportedPublicationRasterMediaType, + RadrootsBlossomError::InvalidPublicationRaster, + RadrootsBlossomError::PublicationJpegProcessForbidden, + RadrootsBlossomError::PublicationRasterAnimationForbidden, + RadrootsBlossomError::PublicationRasterDimensionsOutOfRange { + width: 0, + height: 1, + }, + RadrootsBlossomError::PublicationRasterPixelLimitExceeded { pixels: 20_000_001 }, + RadrootsBlossomError::PublicationRasterDecodedByteLimitExceeded { + decoded: 2, + maximum: 1, + }, + RadrootsBlossomError::PublicationRasterDecodeAllocationFailed, + RadrootsBlossomError::PublicationRasterDecodeFailed, + RadrootsBlossomError::PublicationRasterContainerDimensionMismatch, + RadrootsBlossomError::PublicationAuthoredRasterDimensionMismatch, RadrootsBlossomError::InvalidAuthorizationContent, RadrootsBlossomError::InvalidAuthorizationAction, RadrootsBlossomError::InvalidAuthorizationServerDomain, diff --git a/crates/blossom/src/lib.rs b/crates/blossom/src/lib.rs @@ -8,6 +8,7 @@ pub mod authorization; pub mod descriptor; pub mod error; pub mod hash; +pub mod publication_readiness; pub mod url; pub use authorization::{ @@ -25,6 +26,20 @@ pub use descriptor::{ }; pub use error::RadrootsBlossomError; pub use hash::{RadrootsBlossomFileExtension, RadrootsBlossomHashPath, RadrootsBlossomSha256}; +#[cfg(feature = "raster-decode")] +pub use publication_readiness::verify_publication_readiness; +pub use publication_readiness::{ + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION, + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, RadrootsBlossomAuthoredRasterDimensions, + RadrootsBlossomBud01GetCollector, RadrootsBlossomBud01GetObservation, + RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, + RadrootsBlossomBud02UploadStatus, RadrootsBlossomPublicationReadinessEvidence, + RadrootsBlossomPublicationReadinessEvidenceDigest, RadrootsBlossomRasterDimensions, + RadrootsBlossomRasterFormat, +}; pub use url::{RadrootsBlossomApprovedBlobUrl, RadrootsBlossomBlobUrl}; pub const RADROOTS_BLOSSOM_PROTOCOL_COMMIT: &str = "b5bd2801d1763aa635fc8fea7a76597e0eb18990"; diff --git a/crates/blossom/src/publication_readiness.rs b/crates/blossom/src/publication_readiness.rs @@ -0,0 +1,1941 @@ +use alloc::vec::Vec; +use core::fmt; +#[cfg(feature = "raster-decode")] +use image::{ + ImageDecoder, Limits, + codecs::{png::PngDecoder, webp::WebPDecoder}, +}; +#[cfg(feature = "raster-decode")] +use sha2::{Digest, Sha256}; +#[cfg(feature = "raster-decode")] +use std::io::Cursor; +#[cfg(feature = "raster-decode")] +use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; +#[cfg(feature = "raster-decode")] +use zune_jpeg::JpegDecoder as StrictJpegDecoder; + +#[cfg(feature = "raster-decode")] +mod sequential_jpeg; + +#[cfg(feature = "raster-decode")] +use crate::RadrootsBlossomByteVerifiedDescriptor; +use crate::{ + RadrootsBlossomApprovedBlobUrl, RadrootsBlossomBlobDescriptor, RadrootsBlossomError, + RadrootsBlossomMediaType, RadrootsBlossomSha256, +}; + +const _: () = assert!(usize::BITS <= u64::BITS); + +pub const RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION: u16 = 1; +pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES: u64 = 10_485_760; +pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES: u64 = + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS * 8; +pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION: u32 = 16_384; +pub const RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_000; + +#[cfg(feature = "raster-decode")] +const READINESS_EVIDENCE_DIGEST_DOMAIN: &[u8] = + b"radroots.blossom.publication-readiness-evidence.v1\0"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RadrootsBlossomBud02UploadStatus { + Ok, + Created, +} + +impl RadrootsBlossomBud02UploadStatus { + pub const fn as_u16(self) -> u16 { + match self { + Self::Ok => 200, + Self::Created => 201, + } + } + + fn parse(status: u16) -> Result<Self, RadrootsBlossomError> { + match status { + 200 => Ok(Self::Ok), + 201 => Ok(Self::Created), + actual => Err(RadrootsBlossomError::InvalidBud02UploadStatus { actual }), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RadrootsBlossomRasterFormat { + Jpeg, + Png, + StillWebP, +} + +impl RadrootsBlossomRasterFormat { + pub const fn as_str(self) -> &'static str { + match self { + Self::Jpeg => "jpeg", + Self::Png => "png", + Self::StillWebP => "still_webp", + } + } + + pub fn from_media_type( + media_type: &RadrootsBlossomMediaType, + ) -> Result<Self, RadrootsBlossomError> { + match media_type.as_str() { + "image/jpeg" => Ok(Self::Jpeg), + "image/png" => Ok(Self::Png), + "image/webp" => Ok(Self::StillWebP), + _ => Err(RadrootsBlossomError::UnsupportedPublicationRasterMediaType), + } + } + + #[cfg(feature = "raster-decode")] + const fn digest_code(self) -> u8 { + match self { + Self::Jpeg => 1, + Self::Png => 2, + Self::StillWebP => 3, + } + } +} + +impl fmt::Display for RadrootsBlossomRasterFormat { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RadrootsBlossomRasterDimensions { + width: u32, + height: u32, +} + +impl RadrootsBlossomRasterDimensions { + pub fn new(width: u32, height: u32) -> Result<Self, RadrootsBlossomError> { + if width == 0 + || height == 0 + || width > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION + || height > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION + { + return Err( + RadrootsBlossomError::PublicationRasterDimensionsOutOfRange { width, height }, + ); + } + let pixels = u64::from(width) * u64::from(height); + if pixels > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS { + return Err(RadrootsBlossomError::PublicationRasterPixelLimitExceeded { pixels }); + } + Ok(Self { width, height }) + } + + pub const fn width(self) -> u32 { + self.width + } + + pub const fn height(self) -> u32 { + self.height + } + + pub const fn pixels(self) -> u64 { + self.width as u64 * self.height as u64 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RadrootsBlossomAuthoredRasterDimensions { + Unspecified, + Exact(RadrootsBlossomRasterDimensions), +} + +impl RadrootsBlossomAuthoredRasterDimensions { + #[cfg(feature = "raster-decode")] + const fn exact(self) -> Option<RadrootsBlossomRasterDimensions> { + match self { + Self::Unspecified => None, + Self::Exact(dimensions) => Some(dimensions), + } + } +} + +/// A successful BUD-02 response descriptor observed by a transport adapter. +/// +/// Construction accepts only status 200 or 201 and applies the public URL +/// approval policy. It does not represent BUD-11 authorization or entitlement. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomBud02UploadObservation { + status: RadrootsBlossomBud02UploadStatus, + descriptor: crate::RadrootsBlossomApprovedDescriptor, +} + +impl RadrootsBlossomBud02UploadObservation { + pub fn new( + status: u16, + descriptor: RadrootsBlossomBlobDescriptor, + ) -> Result<Self, RadrootsBlossomError> { + Ok(Self { + status: RadrootsBlossomBud02UploadStatus::parse(status)?, + descriptor: descriptor.approve_reference()?, + }) + } + + pub const fn status(&self) -> RadrootsBlossomBud02UploadStatus { + self.status + } + + pub fn descriptor(&self) -> &crate::RadrootsBlossomApprovedDescriptor { + &self.descriptor + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomBud01HeadObservation { + url: RadrootsBlossomApprovedBlobUrl, + content_length: u64, + media_type: RadrootsBlossomMediaType, +} + +impl RadrootsBlossomBud01HeadObservation { + pub fn new( + status: u16, + url: RadrootsBlossomApprovedBlobUrl, + content_length: u64, + media_type: RadrootsBlossomMediaType, + ) -> Result<Self, RadrootsBlossomError> { + if status != 200 { + return Err(RadrootsBlossomError::InvalidBud01HeadStatus { actual: status }); + } + Ok(Self { + url, + content_length, + media_type, + }) + } + + pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + &self.url + } + + pub const fn content_length(&self) -> u64 { + self.content_length + } + + pub fn media_type(&self) -> &RadrootsBlossomMediaType { + &self.media_type + } +} + +pub struct RadrootsBlossomBud01GetCollector { + url: RadrootsBlossomApprovedBlobUrl, + declared_size: u64, + bytes: Vec<u8>, +} + +impl RadrootsBlossomBud01GetCollector { + pub fn new( + status: u16, + url: RadrootsBlossomApprovedBlobUrl, + declared_size: u64, + ) -> Result<Self, RadrootsBlossomError> { + if status != 200 { + return Err(RadrootsBlossomError::InvalidBud01GetStatus { actual: status }); + } + if declared_size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES { + return Err(RadrootsBlossomError::PublicationRasterByteLimitExceeded { + declared: declared_size, + maximum: RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, + }); + } + let capacity = usize::try_from(declared_size) + .map_err(|_| RadrootsBlossomError::PublicationGetBodyAllocationFailed)?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(capacity) + .map_err(|_| RadrootsBlossomError::PublicationGetBodyAllocationFailed)?; + Ok(Self { + url, + declared_size, + bytes, + }) + } + + pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<(), RadrootsBlossomError> { + let actual = self + .bytes + .len() + .checked_add(chunk.len()) + .and_then(|value| u64::try_from(value).ok()) + .ok_or(RadrootsBlossomError::PublicationGetBodyLengthOverflow)?; + if actual > self.declared_size { + return Err(RadrootsBlossomError::PublicationGetBodyTrailing { + declared: self.declared_size, + actual, + }); + } + self.bytes.extend_from_slice(chunk); + Ok(()) + } + + pub fn finish(self) -> Result<RadrootsBlossomBud01GetObservation, RadrootsBlossomError> { + let actual = self.bytes.len() as u64; + if actual == 0 { + return Err(RadrootsBlossomError::PublicationGetBodyMissing); + } + if actual < self.declared_size { + return Err(RadrootsBlossomError::PublicationGetBodyShort { + declared: self.declared_size, + actual, + }); + } + Ok(RadrootsBlossomBud01GetObservation { + url: self.url, + declared_size: self.declared_size, + bytes: self.bytes, + }) + } +} + +pub struct RadrootsBlossomBud01GetObservation { + url: RadrootsBlossomApprovedBlobUrl, + declared_size: u64, + bytes: Vec<u8>, +} + +impl RadrootsBlossomBud01GetObservation { + pub fn from_complete_body( + status: u16, + url: RadrootsBlossomApprovedBlobUrl, + declared_size: u64, + bytes: &[u8], + ) -> Result<Self, RadrootsBlossomError> { + let mut collector = RadrootsBlossomBud01GetCollector::new(status, url, declared_size)?; + collector.push_chunk(bytes)?; + collector.finish() + } + + pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + &self.url + } + + pub const fn declared_size(&self) -> u64 { + self.declared_size + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +impl fmt::Debug for RadrootsBlossomBud01GetObservation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RadrootsBlossomBud01GetObservation") + .field("url", &self.url) + .field("declared_size", &self.declared_size) + .field("body_length", &self.bytes.len()) + .finish() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RadrootsBlossomPublicationReadinessEvidenceDigest(RadrootsBlossomSha256); + +impl RadrootsBlossomPublicationReadinessEvidenceDigest { + pub const fn as_sha256(self) -> RadrootsBlossomSha256 { + self.0 + } +} + +impl fmt::Display for RadrootsBlossomPublicationReadinessEvidenceDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomPublicationReadinessEvidence { + url: RadrootsBlossomApprovedBlobUrl, + sha256: RadrootsBlossomSha256, + size: u64, + media_type: RadrootsBlossomMediaType, + raster_format: RadrootsBlossomRasterFormat, + dimensions: RadrootsBlossomRasterDimensions, + bud02_status: RadrootsBlossomBud02UploadStatus, + uploaded: u64, + evidence_digest: RadrootsBlossomPublicationReadinessEvidenceDigest, +} + +impl RadrootsBlossomPublicationReadinessEvidence { + pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + &self.url + } + + pub const fn sha256(&self) -> RadrootsBlossomSha256 { + self.sha256 + } + + pub const fn size(&self) -> u64 { + self.size + } + + pub fn media_type(&self) -> &RadrootsBlossomMediaType { + &self.media_type + } + + pub const fn raster_format(&self) -> RadrootsBlossomRasterFormat { + self.raster_format + } + + pub const fn dimensions(&self) -> RadrootsBlossomRasterDimensions { + self.dimensions + } + + pub const fn bud02_status(&self) -> RadrootsBlossomBud02UploadStatus { + self.bud02_status + } + + pub const fn uploaded(&self) -> u64 { + self.uploaded + } + + pub const fn evidence_digest(&self) -> RadrootsBlossomPublicationReadinessEvidenceDigest { + self.evidence_digest + } +} + +#[cfg(feature = "raster-decode")] +pub fn verify_publication_readiness( + authored_descriptor: &RadrootsBlossomByteVerifiedDescriptor, + exact_authored_bytes: &[u8], + authored_dimensions: RadrootsBlossomAuthoredRasterDimensions, + upload: &RadrootsBlossomBud02UploadObservation, + head: &RadrootsBlossomBud01HeadObservation, + get: &RadrootsBlossomBud01GetObservation, +) -> Result<RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { + let expected_url = authored_descriptor.url(); + let expected_hash = authored_descriptor.sha256(); + let expected_size = authored_descriptor.size(); + let expected_media_type = authored_descriptor.media_type(); + + if expected_size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES { + return Err(RadrootsBlossomError::PublicationRasterByteLimitExceeded { + declared: expected_size, + maximum: RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, + }); + } + let authored_size = exact_authored_bytes.len() as u64; + if authored_size != expected_size { + return Err(RadrootsBlossomError::PublicationAuthoredBytesSizeMismatch { + expected: expected_size, + actual: authored_size, + }); + } + if RadrootsBlossomSha256::digest(exact_authored_bytes) != expected_hash { + return Err(RadrootsBlossomError::PublicationAuthoredBytesHashMismatch); + } + + let upload_descriptor = upload.descriptor().descriptor(); + if upload_descriptor.sha256() != expected_hash { + return Err(RadrootsBlossomError::PublicationUploadHashMismatch); + } + if upload.descriptor().url() != expected_url { + return Err(RadrootsBlossomError::PublicationUploadUrlMismatch); + } + if upload_descriptor.size() != expected_size { + return Err(RadrootsBlossomError::PublicationUploadSizeMismatch { + expected: expected_size, + actual: upload_descriptor.size(), + }); + } + if upload_descriptor.media_type() != expected_media_type { + return Err(RadrootsBlossomError::PublicationUploadMediaTypeMismatch); + } + + if head.url() != expected_url { + return Err(RadrootsBlossomError::PublicationHeadUrlMismatch); + } + if head.content_length() != expected_size { + return Err(RadrootsBlossomError::PublicationHeadSizeMismatch { + expected: expected_size, + actual: head.content_length(), + }); + } + if head.media_type() != expected_media_type { + return Err(RadrootsBlossomError::PublicationHeadMediaTypeMismatch); + } + + if get.url() != expected_url { + return Err(RadrootsBlossomError::PublicationGetUrlMismatch); + } + if get.declared_size() != expected_size { + return Err(RadrootsBlossomError::PublicationGetDeclaredSizeMismatch { + expected: expected_size, + actual: get.declared_size(), + }); + } + validate_retrieved_body( + expected_hash, + exact_authored_bytes, + get.bytes(), + RadrootsBlossomSha256::digest(get.bytes()), + )?; + + let expected_format = RadrootsBlossomRasterFormat::from_media_type(expected_media_type)?; + let decoded_dimensions = decode_raster(get.bytes(), expected_format)?; + if authored_dimensions + .exact() + .is_some_and(|dimensions| dimensions != decoded_dimensions) + { + return Err(RadrootsBlossomError::PublicationAuthoredRasterDimensionMismatch); + } + + let evidence_digest = evidence_digest( + authored_descriptor, + expected_format, + decoded_dimensions, + upload, + ); + Ok(RadrootsBlossomPublicationReadinessEvidence { + url: expected_url.clone(), + sha256: expected_hash, + size: expected_size, + media_type: expected_media_type.clone(), + raster_format: expected_format, + dimensions: decoded_dimensions, + bud02_status: upload.status(), + uploaded: upload_descriptor.uploaded(), + evidence_digest, + }) +} + +#[cfg(feature = "raster-decode")] +fn decode_raster( + bytes: &[u8], + format: RadrootsBlossomRasterFormat, +) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + match format { + RadrootsBlossomRasterFormat::Jpeg => { + let container = inspect_jpeg_container(bytes)?; + decode_complete_jpeg(bytes, container) + } + RadrootsBlossomRasterFormat::Png => { + let container = inspect_png_container(bytes)?; + let decoder = PngDecoder::with_limits(Cursor::new(bytes), raster_decode_limits()) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let decoder_animated = decoder + .is_apng() + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + reject_animation(container.animated, decoder_animated)?; + decode_complete_raster(decoder, container.dimensions) + } + RadrootsBlossomRasterFormat::StillWebP => { + let container = inspect_webp_container(bytes)?; + let decoder = WebPDecoder::new(Cursor::new(bytes)) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + reject_animation(container.animated, decoder.has_animation())?; + decode_complete_raster(decoder, container.dimensions) + } + } +} + +#[cfg(feature = "raster-decode")] +fn decode_complete_jpeg( + bytes: &[u8], + container: JpegContainerInspection, +) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + sequential_jpeg::validate(bytes, container)?; + let mut decoder = + StrictJpegDecoder::new_with_options(ZCursor::new(bytes), strict_jpeg_decoder_options()); + decoder + .decode_headers() + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let dimensions = strict_jpeg_dimensions(decoder.dimensions())?; + require_matching_dimensions(dimensions, container.dimensions)?; + let decoded_bytes = bounded_jpeg_output_buffer_size(decoder.output_buffer_size())?; + let mut decoded = allocate_decoded_buffer(decoded_bytes)?; + decoder + .decode_into(&mut decoded) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + Ok(dimensions) +} + +#[cfg(feature = "raster-decode")] +fn strict_jpeg_decoder_options() -> DecoderOptions { + DecoderOptions::default() + .set_strict_mode(true) + .set_use_unsafe(false) + .set_max_width(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION as usize) + .set_max_height(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION as usize) + .jpeg_set_out_colorspace(ColorSpace::RGB) +} + +#[cfg(feature = "raster-decode")] +fn strict_jpeg_dimensions( + dimensions: Option<(usize, usize)>, +) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + let (width, height) = dimensions.ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let width = + u32::try_from(width).map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let height = + u32::try_from(height).map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + RadrootsBlossomRasterDimensions::new(width, height) +} + +#[cfg(feature = "raster-decode")] +fn decode_complete_raster<D: ImageDecoder>( + mut decoder: D, + container_dimensions: RadrootsBlossomRasterDimensions, +) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + let (width, height) = decoder.dimensions(); + let dimensions = RadrootsBlossomRasterDimensions::new(width, height)?; + require_matching_dimensions(dimensions, container_dimensions)?; + + decoder + .set_limits(raster_decode_limits()) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let decoded_bytes = bounded_decoded_byte_length(Some(decoder.total_bytes()))?; + let mut decoded = allocate_decoded_buffer(decoded_bytes)?; + decoder + .read_image(&mut decoded) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + Ok(dimensions) +} + +#[cfg(feature = "raster-decode")] +fn require_matching_dimensions( + decoded: RadrootsBlossomRasterDimensions, + container: RadrootsBlossomRasterDimensions, +) -> Result<(), RadrootsBlossomError> { + if decoded != container { + return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); + } + Ok(()) +} + +#[cfg(feature = "raster-decode")] +fn bounded_jpeg_output_buffer_size( + decoded_bytes: Option<usize>, +) -> Result<u64, RadrootsBlossomError> { + bounded_decoded_byte_length(decoded_bytes.map(|decoded_bytes| decoded_bytes as u64)) +} + +#[cfg(feature = "raster-decode")] +fn bounded_decoded_byte_length(decoded_bytes: Option<u64>) -> Result<u64, RadrootsBlossomError> { + let decoded_bytes = decoded_bytes.ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + if decoded_bytes > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES { + return Err( + RadrootsBlossomError::PublicationRasterDecodedByteLimitExceeded { + decoded: decoded_bytes, + maximum: RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, + }, + ); + } + Ok(decoded_bytes) +} + +#[cfg(feature = "raster-decode")] +fn reject_animation( + container_animated: bool, + decoder_animated: bool, +) -> Result<(), RadrootsBlossomError> { + if container_animated || decoder_animated { + return Err(RadrootsBlossomError::PublicationRasterAnimationForbidden); + } + Ok(()) +} + +#[cfg(feature = "raster-decode")] +fn allocate_decoded_buffer(decoded_bytes: u64) -> Result<Vec<u8>, RadrootsBlossomError> { + #[cfg(target_pointer_width = "64")] + let decoded_length = decoded_bytes as usize; + #[cfg(not(target_pointer_width = "64"))] + let decoded_length = usize::try_from(decoded_bytes) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + let mut decoded = Vec::new(); + decoded + .try_reserve_exact(decoded_length) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + decoded.resize(decoded_length, 0); + Ok(decoded) +} + +#[cfg(feature = "raster-decode")] +fn raster_decode_limits() -> Limits { + let mut limits = Limits::default(); + limits.max_image_width = Some(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION); + limits.max_image_height = Some(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION); + limits.max_alloc = Some(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES); + limits +} + +#[cfg(feature = "raster-decode")] +fn validate_retrieved_body( + expected_hash: RadrootsBlossomSha256, + exact_authored_bytes: &[u8], + retrieved_bytes: &[u8], + retrieved_hash: RadrootsBlossomSha256, +) -> Result<(), RadrootsBlossomError> { + if retrieved_hash != expected_hash { + return Err(RadrootsBlossomError::PublicationRetrievedBytesHashMismatch); + } + if retrieved_bytes != exact_authored_bytes { + return Err(RadrootsBlossomError::PublicationRetrievedBytesMismatch); + } + Ok(()) +} + +#[cfg(feature = "raster-decode")] +fn evidence_digest( + descriptor: &RadrootsBlossomByteVerifiedDescriptor, + format: RadrootsBlossomRasterFormat, + dimensions: RadrootsBlossomRasterDimensions, + upload: &RadrootsBlossomBud02UploadObservation, +) -> RadrootsBlossomPublicationReadinessEvidenceDigest { + let mut hasher = Sha256::new(); + hasher.update(READINESS_EVIDENCE_DIGEST_DOMAIN); + hasher.update(RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION.to_be_bytes()); + update_length_prefixed(&mut hasher, descriptor.url().as_str().as_bytes()); + hasher.update(descriptor.sha256().as_bytes()); + hasher.update(descriptor.size().to_be_bytes()); + update_length_prefixed(&mut hasher, descriptor.media_type().as_str().as_bytes()); + hasher.update([format.digest_code()]); + hasher.update(dimensions.width().to_be_bytes()); + hasher.update(dimensions.height().to_be_bytes()); + hasher.update(upload.status().as_u16().to_be_bytes()); + hasher.update(200_u16.to_be_bytes()); + hasher.update(200_u16.to_be_bytes()); + hasher.update(upload.descriptor().descriptor().uploaded().to_be_bytes()); + let digest = hasher.finalize(); + let mut bytes = [0_u8; 32]; + bytes.copy_from_slice(&digest); + RadrootsBlossomPublicationReadinessEvidenceDigest(RadrootsBlossomSha256::from_bytes(bytes)) +} + +#[cfg(feature = "raster-decode")] +fn update_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +#[cfg(any(feature = "raster-decode", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RasterContainerInspection { + dimensions: RadrootsBlossomRasterDimensions, + animated: bool, +} + +#[cfg(any(feature = "raster-decode", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct JpegContainerInspection { + dimensions: RadrootsBlossomRasterDimensions, + components: u8, +} + +#[cfg(any(feature = "raster-decode", test))] +fn invalid_raster<T>() -> Result<T, RadrootsBlossomError> { + Err(RadrootsBlossomError::InvalidPublicationRaster) +} + +#[cfg(any(feature = "raster-decode", test))] +fn inspect_png_container(bytes: &[u8]) -> Result<RasterContainerInspection, RadrootsBlossomError> { + const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + if !bytes.starts_with(SIGNATURE) { + return invalid_raster(); + } + let mut position = SIGNATURE.len(); + let mut dimensions = None; + let mut has_image_data = false; + let mut animated = false; + while position < bytes.len() { + let header_end = position + .checked_add(8) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let header = bytes + .get(position..header_end) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let length = u32::from_be_bytes( + header[..4] + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + ) as usize; + let kind: [u8; 4] = header[4..] + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?; + let data_start = header_end; + let data_end = data_start + .checked_add(length) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let chunk_end = data_end + .checked_add(4) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let data = bytes + .get(data_start..data_end) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + if chunk_end > bytes.len() { + return invalid_raster(); + } + position = chunk_end; + + match &kind { + b"IHDR" if dimensions.is_none() && data.len() == 13 && data_start == 16 => { + let width = u32::from_be_bytes( + data[..4] + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + ); + let height = u32::from_be_bytes( + data[4..8] + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + ); + dimensions = Some(RadrootsBlossomRasterDimensions::new(width, height)?); + } + b"IHDR" => return invalid_raster(), + b"IDAT" if dimensions.is_some() => has_image_data = true, + b"acTL" | b"fcTL" | b"fdAT" => animated = true, + b"IEND" if data.is_empty() && has_image_data && position == bytes.len() => { + return Ok(RasterContainerInspection { + dimensions: dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, + animated, + }); + } + b"IEND" => return invalid_raster(), + _ if dimensions.is_none() => return invalid_raster(), + _ => {} + } + } + invalid_raster() +} + +#[cfg(any(feature = "raster-decode", test))] +fn inspect_webp_container(bytes: &[u8]) -> Result<RasterContainerInspection, RadrootsBlossomError> { + if bytes.len() < 20 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { + return invalid_raster(); + } + let riff_size = u32::from_le_bytes( + bytes[4..8] + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + ) as usize; + if riff_size.checked_add(8) != Some(bytes.len()) { + return invalid_raster(); + } + + let mut position = 12_usize; + let mut dimensions = None; + let mut primary_chunks = 0_u8; + let mut animated = false; + while position < bytes.len() { + let header_end = position + .checked_add(8) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let header = bytes + .get(position..header_end) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let kind: [u8; 4] = header[..4] + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?; + let length = u32::from_le_bytes( + header[4..] + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + ) as usize; + let data_end = header_end + .checked_add(length) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let padded_end = data_end + .checked_add(length & 1) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let data = bytes + .get(header_end..data_end) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + if padded_end > bytes.len() { + return invalid_raster(); + } + position = padded_end; + + match &kind { + b"ANIM" | b"ANMF" => animated = true, + b"VP8X" if data.len() == 10 => { + animated |= data[0] & 0b0000_0010 != 0; + let width = 1 + read_u24_le(&data[4..7]); + let height = 1 + read_u24_le(&data[7..10]); + dimensions = Some(RadrootsBlossomRasterDimensions::new(width, height)?); + } + b"VP8X" => return invalid_raster(), + b"VP8L" => { + primary_chunks = primary_chunks.saturating_add(1); + let header = data + .get(..5) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + if header[0] != 0x2f { + return invalid_raster(); + } + let bits = u32::from_le_bytes( + header[1..5] + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + ); + let parsed = RadrootsBlossomRasterDimensions::new( + (bits & 0x3fff) + 1, + ((bits >> 14) & 0x3fff) + 1, + )?; + if dimensions.is_some_and(|value| value != parsed) { + return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); + } + dimensions = Some(parsed); + } + b"VP8 " => { + primary_chunks = primary_chunks.saturating_add(1); + let header = data + .get(..10) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + if &header[3..6] != b"\x9d\x01\x2a" { + return invalid_raster(); + } + let width = u16::from_le_bytes([header[6], header[7]]) & 0x3fff; + let height = u16::from_le_bytes([header[8], header[9]]) & 0x3fff; + let parsed = + RadrootsBlossomRasterDimensions::new(u32::from(width), u32::from(height))?; + if dimensions.is_some_and(|value| value != parsed) { + return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); + } + dimensions = Some(parsed); + } + _ => {} + } + } + if primary_chunks == 0 { + if !animated { + return invalid_raster(); + } + } else if primary_chunks != 1 { + return invalid_raster(); + } + Ok(RasterContainerInspection { + dimensions: dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, + animated, + }) +} + +#[cfg(any(feature = "raster-decode", test))] +fn read_u24_le(bytes: &[u8]) -> u32 { + u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16) +} + +#[cfg(any(feature = "raster-decode", test))] +fn inspect_jpeg_container(bytes: &[u8]) -> Result<JpegContainerInspection, RadrootsBlossomError> { + if bytes.len() < 4 || !bytes.starts_with(b"\xff\xd8") { + return invalid_raster(); + } + let mut position = 2_usize; + let mut dimensions = None; + let mut components = None; + loop { + if bytes.get(position) != Some(&0xff) { + return invalid_raster(); + } + while bytes.get(position) == Some(&0xff) { + position += 1; + } + let marker = *bytes + .get(position) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + position += 1; + match marker { + 0xd9 if position == bytes.len() => { + return Ok(JpegContainerInspection { + dimensions: dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, + components: components.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, + }); + } + 0xd9 | 0x00 | 0xd8 | 0xd0..=0xd7 => return invalid_raster(), + 0x01 => continue, + _ => {} + } + + let length_end = position + .checked_add(2) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let length_bytes = bytes + .get(position..length_end) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let length = usize::from(u16::from_be_bytes( + length_bytes + .try_into() + .map_err(|_| RadrootsBlossomError::InvalidPublicationRaster)?, + )); + if length < 2 { + return invalid_raster(); + } + let data_start = length_end; + let data_end = position + .checked_add(length) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + let data = bytes + .get(data_start..data_end) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + position = data_end; + + if is_jpeg_start_of_frame(marker) { + if dimensions.is_some() || data.len() < 6 { + return invalid_raster(); + } + if !matches!(marker, 0xc0 | 0xc1) || data[0] != 8 { + return Err(RadrootsBlossomError::PublicationJpegProcessForbidden); + } + let component_count = data[5]; + if !matches!(component_count, 1 | 3 | 4) + || data.len() != 6 + 3 * usize::from(component_count) + { + return invalid_raster(); + } + let height = u32::from(u16::from_be_bytes([data[1], data[2]])); + let width = u32::from(u16::from_be_bytes([data[3], data[4]])); + dimensions = Some(RadrootsBlossomRasterDimensions::new(width, height)?); + components = Some(component_count); + } + + if marker == 0xda { + position = jpeg_scan_end(bytes, position)?; + } + } +} + +#[cfg(any(feature = "raster-decode", test))] +fn is_jpeg_start_of_frame(marker: u8) -> bool { + matches!( + marker, + 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf + ) +} + +#[cfg(any(feature = "raster-decode", test))] +fn jpeg_scan_end(bytes: &[u8], mut position: usize) -> Result<usize, RadrootsBlossomError> { + while position < bytes.len() { + if bytes[position] != 0xff { + position += 1; + continue; + } + let marker_start = position; + while bytes.get(position) == Some(&0xff) { + position += 1; + } + let marker = *bytes + .get(position) + .ok_or(RadrootsBlossomError::InvalidPublicationRaster)?; + match marker { + 0x00 | 0xd0..=0xd7 => position += 1, + _ => return Ok(marker_start), + } + } + invalid_raster() +} + +#[cfg(test)] +fn validate_png_container( + bytes: &[u8], +) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + static_container_dimensions(inspect_png_container(bytes)?) +} + +#[cfg(test)] +fn validate_webp_container( + bytes: &[u8], +) -> Result<Option<RadrootsBlossomRasterDimensions>, RadrootsBlossomError> { + static_container_dimensions(inspect_webp_container(bytes)?).map(Some) +} + +#[cfg(test)] +fn validate_jpeg_container( + bytes: &[u8], +) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + Ok(inspect_jpeg_container(bytes)?.dimensions) +} + +#[cfg(test)] +fn static_container_dimensions( + inspection: RasterContainerInspection, +) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { + if inspection.animated { + return Err(RadrootsBlossomError::PublicationRasterAnimationForbidden); + } + Ok(inspection.dimensions) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::RadrootsBlossomByteVerifiedDescriptor; + #[cfg(feature = "raster-decode")] + use alloc::boxed::Box; + use alloc::{format, string::ToString}; + #[cfg(feature = "raster-decode")] + use image::{ColorType, ImageError, ImageResult}; + + const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, + 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, + 0xf8, 0xcf, 0xf0, 0x00, 0x00, 0x03, 0xe2, 0x01, 0xe0, 0x38, 0x10, 0xac, 0x1e, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + + const JPEG: &[u8] = &[ + 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x00, 0xff, 0xd9, + ]; + + const STILL_WEBP: &[u8] = &[ + b'R', b'I', b'F', b'F', 18, 0, 0, 0, b'W', b'E', b'B', b'P', b'V', b'P', b'8', b'L', 5, 0, + 0, 0, 0x2f, 0, 0, 0, 0, 0, + ]; + + #[cfg(feature = "raster-decode")] + fn sequential_jpeg() -> Vec<u8> { + hex::decode( + "ffd8ffe000104a46494600010100000100010000ffdb0043000302020302020303030304030304050805050404050a070706080c0a0c0c0b0a0b0b0d0e12100d0e110e0b0b1016101113141515150c0f171816141812141514ffdb00430103040405040509050509140d0b0d1414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414ffc00011080001000103012200021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f0100030101010101010101010000000000000102030405060708090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffda000c03010002110311003f00f9ca8a28afc3cfe6f3ffd9", + ) + .unwrap() + } + + #[cfg(feature = "raster-decode")] + fn malformed_dqt_jpeg() -> Vec<u8> { + let mut jpeg = sequential_jpeg(); + let sof = jpeg + .windows(2) + .position(|window| window == b"\xff\xc0") + .unwrap(); + jpeg.drain(sof - 3..sof); + jpeg + } + + fn png_with_chunks(chunks: &[([u8; 4], &[u8])]) -> Vec<u8> { + let mut output = b"\x89PNG\r\n\x1a\n".to_vec(); + for (kind, data) in chunks { + output.extend_from_slice(&(data.len() as u32).to_be_bytes()); + output.extend_from_slice(kind); + output.extend_from_slice(data); + output.extend_from_slice(&[0; 4]); + } + output + } + + fn webp_with_chunks(chunks: &[([u8; 4], &[u8])]) -> Vec<u8> { + let mut output = b"RIFF\0\0\0\0WEBP".to_vec(); + for (kind, data) in chunks { + output.extend_from_slice(kind); + output.extend_from_slice(&(data.len() as u32).to_le_bytes()); + output.extend_from_slice(data); + if data.len() & 1 == 1 { + output.push(0); + } + } + let riff_size = (output.len() as u32) - 8; + output[4..8].copy_from_slice(&riff_size.to_le_bytes()); + output + } + + fn descriptor(bytes: &[u8], media_type: &str, origin: &str) -> RadrootsBlossomBlobDescriptor { + let hash = RadrootsBlossomSha256::digest(bytes); + RadrootsBlossomBlobDescriptor::new( + crate::RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.png")).unwrap(), + hash, + bytes.len() as u64, + RadrootsBlossomMediaType::parse(media_type).unwrap(), + 1_800_000_000, + ) + .unwrap() + } + + fn verified(bytes: &[u8]) -> RadrootsBlossomByteVerifiedDescriptor { + let media_type = RadrootsBlossomMediaType::parse("image/png").unwrap(); + descriptor(bytes, "image/png", "https://cdn.example") + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap() + } + + fn observations( + bytes: &[u8], + ) -> ( + RadrootsBlossomBud02UploadObservation, + RadrootsBlossomBud01HeadObservation, + RadrootsBlossomBud01GetObservation, + ) { + let expected = verified(bytes); + let upload = RadrootsBlossomBud02UploadObservation::new( + 201, + descriptor(bytes, "image/png", "https://cdn.example"), + ) + .unwrap(); + let url = expected.url().clone(); + let media_type = RadrootsBlossomMediaType::parse("image/png").unwrap(); + let head = RadrootsBlossomBud01HeadObservation::new( + 200, + url.clone(), + bytes.len() as u64, + media_type, + ) + .unwrap(); + let get = RadrootsBlossomBud01GetObservation::from_complete_body( + 200, + url, + bytes.len() as u64, + bytes, + ) + .unwrap(); + (upload, head, get) + } + + #[cfg(feature = "raster-decode")] + #[test] + fn publication_readiness_accepts_exact_complete_observations() { + let expected = verified(PNG); + let (upload, head, get) = observations(PNG); + let evidence = verify_publication_readiness( + &expected, + PNG, + RadrootsBlossomAuthoredRasterDimensions::Exact( + RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), + ), + &upload, + &head, + &get, + ) + .unwrap(); + assert_eq!( + evidence.url().as_str(), + "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png" + ); + assert_eq!(evidence.sha256(), RadrootsBlossomSha256::digest(PNG)); + assert_eq!(evidence.size(), PNG.len() as u64); + assert_eq!(evidence.media_type().as_str(), "image/png"); + assert_eq!(evidence.raster_format(), RadrootsBlossomRasterFormat::Png); + assert_eq!(evidence.dimensions().pixels(), 1); + assert_eq!(evidence.bud02_status().as_u16(), 201); + assert_eq!(evidence.uploaded(), 1_800_000_000); + assert_eq!( + evidence.evidence_digest().to_string(), + "44e63303e594ea42d863be995b23ac4297ed77e4378d0707c94f28e77164bd3b" + ); + assert_eq!( + evidence.evidence_digest().as_sha256().to_string(), + evidence.evidence_digest().to_string() + ); + } + + #[test] + fn bounded_get_collector_rejects_status_bounds_and_body_shape() { + let url = verified(PNG).url().clone(); + assert_eq!( + RadrootsBlossomBud01GetCollector::new(206, url.clone(), 1) + .err() + .unwrap() + .code(), + "invalid_bud01_get_status" + ); + assert_eq!( + RadrootsBlossomBud01GetCollector::new( + 200, + url.clone(), + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES + 1, + ) + .err() + .unwrap() + .code(), + "publication_raster_byte_limit_exceeded" + ); + assert_eq!( + RadrootsBlossomBud01GetCollector::new(200, url.clone(), 1) + .unwrap() + .finish() + .err() + .unwrap() + .code(), + "publication_get_body_missing" + ); + let mut short = RadrootsBlossomBud01GetCollector::new(200, url.clone(), 2).unwrap(); + short.push_chunk(b"a").unwrap(); + assert_eq!( + short.finish().err().unwrap().code(), + "publication_get_body_short" + ); + let mut trailing = RadrootsBlossomBud01GetCollector::new(200, url, 1).unwrap(); + assert_eq!( + trailing.push_chunk(b"ab").unwrap_err().code(), + "publication_get_body_trailing" + ); + } + + #[test] + fn observation_constructors_reject_invalid_status_and_dimensions() { + assert_eq!( + RadrootsBlossomBud02UploadObservation::new( + 204, + descriptor(PNG, "image/png", "https://cdn.example") + ) + .unwrap_err() + .code(), + "invalid_bud02_upload_status" + ); + let url = verified(PNG).url().clone(); + assert_eq!( + RadrootsBlossomBud01HeadObservation::new( + 204, + url, + PNG.len() as u64, + RadrootsBlossomMediaType::parse("image/png").unwrap(), + ) + .unwrap_err() + .code(), + "invalid_bud01_head_status" + ); + for (width, height, code) in [ + (0, 1, "publication_raster_dimensions_out_of_range"), + (5_000, 5_000, "publication_raster_pixel_limit_exceeded"), + ] { + assert_eq!( + RadrootsBlossomRasterDimensions::new(width, height) + .unwrap_err() + .code(), + code + ); + } + assert_eq!( + RadrootsBlossomRasterFormat::StillWebP.to_string(), + "still_webp" + ); + assert_eq!( + RadrootsBlossomRasterFormat::from_media_type( + &RadrootsBlossomMediaType::parse("image/png;charset=utf-8").unwrap(), + ) + .unwrap_err() + .code(), + "unsupported_publication_raster_media_type" + ); + } + + #[test] + fn raster_dimensions_reject_each_axis_boundary() { + for (width, height) in [ + (0, 1), + (1, 0), + (RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION + 1, 1), + (1, RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION + 1), + ] { + assert_eq!( + RadrootsBlossomRasterDimensions::new(width, height) + .unwrap_err() + .code(), + "publication_raster_dimensions_out_of_range" + ); + } + } + + #[cfg(feature = "raster-decode")] + #[test] + fn readiness_rejects_oversized_authored_bytes_and_digest_collisions() { + let oversized = alloc::vec![ + 0_u8; + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES as usize + 1 + ]; + let oversized_descriptor = verified(&oversized); + let (upload, head, get) = observations(PNG); + assert_eq!( + verify_publication_readiness( + &oversized_descriptor, + &oversized, + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + &upload, + &head, + &get, + ) + .unwrap_err() + .code(), + "publication_raster_byte_limit_exceeded" + ); + + let expected_hash = RadrootsBlossomSha256::digest(PNG); + let mut different_bytes = PNG.to_vec(); + different_bytes[0] ^= 1; + assert_eq!( + validate_retrieved_body(expected_hash, PNG, &different_bytes, expected_hash) + .unwrap_err() + .code(), + "publication_retrieved_bytes_mismatch" + ); + assert_eq!( + validate_retrieved_body( + expected_hash, + PNG, + PNG, + RadrootsBlossomSha256::digest(b"different"), + ) + .unwrap_err() + .code(), + "publication_retrieved_bytes_hash_mismatch" + ); + validate_retrieved_body(expected_hash, PNG, PNG, expected_hash).unwrap(); + } + + #[test] + fn closed_container_validation_accepts_each_format() { + assert_eq!( + validate_png_container(PNG).unwrap(), + RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + ); + assert_eq!( + validate_jpeg_container(JPEG).unwrap(), + RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + ); + assert_eq!( + validate_webp_container(STILL_WEBP).unwrap(), + Some(RadrootsBlossomRasterDimensions::new(1, 1).unwrap()) + ); + } + + #[test] + fn closed_container_validation_rejects_animation_trailing_and_malformed_bytes() { + let mut apng = PNG.to_vec(); + let iend = apng.len() - 12; + apng.splice( + iend..iend, + [ + 0, 0, 0, 8, b'a', b'c', b'T', b'L', 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, + ], + ); + assert_eq!( + validate_png_container(&apng).unwrap_err().code(), + "publication_raster_animation_forbidden" + ); + assert_eq!( + validate_png_container(b"not png").unwrap_err().code(), + "invalid_publication_raster" + ); + assert_eq!( + validate_webp_container(b"not webp").unwrap_err().code(), + "invalid_publication_raster" + ); + assert_eq!( + validate_jpeg_container(b"not jpeg").unwrap_err().code(), + "invalid_publication_raster" + ); + + let mut animated_webp = [ + b'R', b'I', b'F', b'F', 22, 0, 0, 0, b'W', b'E', b'B', b'P', b'V', b'P', b'8', b'X', + 10, 0, 0, 0, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]; + assert_eq!( + validate_webp_container(&animated_webp).unwrap_err().code(), + "publication_raster_animation_forbidden" + ); + animated_webp[4] = 21; + assert_eq!( + validate_webp_container(&animated_webp).unwrap_err().code(), + "invalid_publication_raster" + ); + + let mut trailing_jpeg = JPEG.to_vec(); + trailing_jpeg.push(0); + assert_eq!( + validate_jpeg_container(&trailing_jpeg).unwrap_err().code(), + "invalid_publication_raster" + ); + + let mut duplicate_sof_jpeg = JPEG.to_vec(); + duplicate_sof_jpeg.splice(15..15, JPEG[2..15].iter().copied()); + assert_eq!( + validate_jpeg_container(&duplicate_sof_jpeg) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + } + + #[test] + fn png_container_rejects_each_chunk_order_and_termination_failure() { + let ihdr = &PNG[16..29]; + let idat = &PNG[41..54]; + + let mut short_header = PNG[..8].to_vec(); + short_header.push(0); + for malformed in [PNG[..8].to_vec(), short_header, PNG[..30].to_vec()] { + assert_eq!( + validate_png_container(&malformed).unwrap_err().code(), + "invalid_publication_raster" + ); + } + + let short_ihdr = png_with_chunks(&[(*b"IHDR", &ihdr[..12])]); + let duplicate_ihdr = png_with_chunks(&[(*b"IHDR", ihdr), (*b"IHDR", ihdr)]); + let idat_before_ihdr = png_with_chunks(&[(*b"IDAT", idat)]); + let animation_before_ihdr = png_with_chunks(&[(*b"acTL", &[0; 8]), (*b"IHDR", ihdr)]); + for malformed in [ + short_ihdr, + duplicate_ihdr, + idat_before_ihdr, + animation_before_ihdr, + ] { + assert_eq!( + validate_png_container(&malformed).unwrap_err().code(), + "invalid_publication_raster" + ); + } + + let with_ancillary = png_with_chunks(&[ + (*b"IHDR", ihdr), + (*b"tEXt", b"key\0value"), + (*b"IDAT", idat), + (*b"IEND", &[]), + ]); + assert_eq!( + validate_png_container(&with_ancillary).unwrap(), + RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + ); + + let nonempty_iend = + png_with_chunks(&[(*b"IHDR", ihdr), (*b"IDAT", idat), (*b"IEND", &[0])]); + let no_image_data = png_with_chunks(&[(*b"IHDR", ihdr), (*b"IEND", &[])]); + let mut trailing = png_with_chunks(&[(*b"IHDR", ihdr), (*b"IDAT", idat), (*b"IEND", &[])]); + trailing.push(0); + let missing_iend = png_with_chunks(&[(*b"IHDR", ihdr), (*b"IDAT", idat)]); + for malformed in [nonempty_iend, no_image_data, trailing, missing_iend] { + assert_eq!( + validate_png_container(&malformed).unwrap_err().code(), + "invalid_publication_raster" + ); + } + } + + #[test] + fn webp_container_covers_extended_lossless_and_lossy_boundaries() { + let vp8x_1x1 = [0_u8; 10]; + let mut vp8x_2x1 = vp8x_1x1; + vp8x_2x1[4] = 1; + let mut vp8x_animated = vp8x_1x1; + vp8x_animated[0] = 0x02; + let vp8l_1x1 = [0x2f, 0, 0, 0, 0]; + let vp8_1x1 = [0, 0, 0, 0x9d, 0x01, 0x2a, 1, 0, 1, 0]; + + let mut bad_riff = STILL_WEBP.to_vec(); + bad_riff[0] = b'X'; + let mut bad_webp = STILL_WEBP.to_vec(); + bad_webp[8] = b'X'; + for malformed in [bad_riff, bad_webp] { + assert_eq!( + validate_webp_container(&malformed).unwrap_err().code(), + "invalid_publication_raster" + ); + } + + let mut missing_padding = webp_with_chunks(&[(*b"VP8L", &vp8l_1x1)]); + missing_padding.pop(); + let riff_size = (missing_padding.len() as u32) - 8; + missing_padding[4..8].copy_from_slice(&riff_size.to_le_bytes()); + assert_eq!( + validate_webp_container(&missing_padding) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + + for animated_kind in [*b"ANIM", *b"ANMF"] { + assert_eq!( + validate_webp_container(&webp_with_chunks(&[ + (*b"VP8X", &vp8x_animated), + (animated_kind, &[]), + ])) + .unwrap_err() + .code(), + "publication_raster_animation_forbidden" + ); + } + + let extended_lossless = webp_with_chunks(&[(*b"VP8X", &vp8x_1x1), (*b"VP8L", &vp8l_1x1)]); + assert_eq!( + validate_webp_container(&extended_lossless).unwrap(), + Some(RadrootsBlossomRasterDimensions::new(1, 1).unwrap()) + ); + assert_eq!( + validate_webp_container(&webp_with_chunks(&[(*b"VP8X", &[0; 9])])) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + assert_eq!( + validate_webp_container(&webp_with_chunks(&[(*b"VP8L", &[0; 5])])) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + assert_eq!( + validate_webp_container(&webp_with_chunks(&[ + (*b"VP8X", &vp8x_2x1), + (*b"VP8L", &vp8l_1x1), + ])) + .unwrap_err() + .code(), + "publication_raster_container_dimension_mismatch" + ); + + assert_eq!( + validate_webp_container(&webp_with_chunks(&[(*b"VP8 ", &vp8_1x1)])).unwrap(), + Some(RadrootsBlossomRasterDimensions::new(1, 1).unwrap()) + ); + let mut bad_vp8_signature = vp8_1x1; + bad_vp8_signature[3] = 0; + assert_eq!( + validate_webp_container(&webp_with_chunks(&[(*b"VP8 ", &bad_vp8_signature)])) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + assert_eq!( + validate_webp_container(&webp_with_chunks(&[ + (*b"VP8X", &vp8x_2x1), + (*b"VP8 ", &vp8_1x1), + ])) + .unwrap_err() + .code(), + "publication_raster_container_dimension_mismatch" + ); + + let with_unknown = webp_with_chunks(&[(*b"JUNK", &[0; 2]), (*b"VP8L", &vp8l_1x1)]); + assert_eq!( + validate_webp_container(&with_unknown).unwrap(), + Some(RadrootsBlossomRasterDimensions::new(1, 1).unwrap()) + ); + for malformed in [ + webp_with_chunks(&[(*b"JUNK", &[0; 8])]), + webp_with_chunks(&[(*b"VP8L", &vp8l_1x1), (*b"VP8L", &vp8l_1x1)]), + webp_with_chunks(&[(*b"VP8L", &[0x2f; 4])]), + webp_with_chunks(&[(*b"VP8 ", &[0; 9])]), + ] { + assert_eq!( + validate_webp_container(&malformed).unwrap_err().code(), + "invalid_publication_raster" + ); + } + + let large_bits = 0x3fff_u32 | (0x3fff_u32 << 14); + let mut large_vp8l = [0_u8; 5]; + large_vp8l[0] = 0x2f; + large_vp8l[1..].copy_from_slice(&large_bits.to_le_bytes()); + assert_eq!( + validate_webp_container(&webp_with_chunks(&[(*b"VP8L", &large_vp8l)])) + .unwrap_err() + .code(), + "publication_raster_pixel_limit_exceeded" + ); + } + + #[test] + fn jpeg_container_covers_marker_segment_and_scan_boundaries() { + let tiny = [0xff, 0xd8]; + let bad_marker = [0xff, 0xd8, 0x00, 0x00]; + let short_segment_length = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x01]; + let short_sof = [ + 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x07, 0x08, 0x00, 0x01, 0x00, 0x01, + ]; + let invalid_component_count = [ + 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0e, 0x08, 0x00, 0x01, 0x00, 0x01, 0x02, 0x01, 0x11, + 0x00, 0x02, 0x11, 0x00, + ]; + let mismatched_component_length = [ + 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0e, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, + 0x00, 0x02, 0x11, 0x00, + ]; + for malformed in [ + tiny.as_slice(), + bad_marker.as_slice(), + short_segment_length.as_slice(), + short_sof.as_slice(), + invalid_component_count.as_slice(), + mismatched_component_length.as_slice(), + &JPEG[..JPEG.len() - 2], + &[0xff, 0xd8, 0xff], + &[0xff, 0xd8, 0xff, 0xe0, 0x00], + &[0xff, 0xd8, 0xff, 0xe0, 0x00, 0x05, 0x00], + &[0xff, 0xd8, 0xff, 0xd9], + ] { + assert_eq!( + validate_jpeg_container(malformed).unwrap_err().code(), + "invalid_publication_raster" + ); + } + + let temporal_marker = [&JPEG[..2], &[0xff, 0x01], &JPEG[2..]].concat(); + assert_eq!( + validate_jpeg_container(&temporal_marker).unwrap(), + RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + ); + + let mut stuffed_and_restart = JPEG[..JPEG.len() - 2].to_vec(); + stuffed_and_restart.extend_from_slice(&[0xff, 0x00, 0xff, 0xd0, 0xff, 0xd9]); + assert_eq!( + validate_jpeg_container(&stuffed_and_restart).unwrap(), + RadrootsBlossomRasterDimensions::new(1, 1).unwrap() + ); + } + + #[test] + fn get_debug_redacts_complete_body() { + let get = observations(PNG).2; + let debug = format!("{get:?}"); + assert!(debug.contains("body_length")); + assert!(!debug.contains("89504e47")); + assert_eq!(get.bytes(), PNG); + } + + #[cfg(feature = "raster-decode")] + struct FakeDecoder { + dimensions: (u32, u32), + total_bytes: u64, + fail_limits: bool, + fail_read: bool, + } + + #[cfg(feature = "raster-decode")] + impl ImageDecoder for FakeDecoder { + fn dimensions(&self) -> (u32, u32) { + self.dimensions + } + + fn color_type(&self) -> ColorType { + ColorType::Rgba8 + } + + fn total_bytes(&self) -> u64 { + self.total_bytes + } + + fn read_image(self, _buffer: &mut [u8]) -> ImageResult<()> { + if self.fail_read { + return Err(ImageError::IoError(std::io::Error::other( + "synthetic decode failure", + ))); + } + Ok(()) + } + + fn read_image_boxed(self: Box<Self>, buffer: &mut [u8]) -> ImageResult<()> { + (*self).read_image(buffer) + } + + fn set_limits(&mut self, _limits: Limits) -> ImageResult<()> { + if self.fail_limits { + return Err(ImageError::IoError(std::io::Error::other( + "synthetic limit failure", + ))); + } + Ok(()) + } + } + + #[cfg(feature = "raster-decode")] + #[test] + fn decoder_authority_rejects_animation_resource_and_agreement_failures() { + reject_animation(false, false).unwrap(); + for (container_animated, decoder_animated) in [(true, false), (false, true), (true, true)] { + assert_eq!( + reject_animation(container_animated, decoder_animated) + .unwrap_err() + .code(), + "publication_raster_animation_forbidden" + ); + } + + let dimensions = RadrootsBlossomRasterDimensions::new(1, 1).unwrap(); + let decoder = |dimensions, total_bytes, fail_limits, fail_read| FakeDecoder { + dimensions, + total_bytes, + fail_limits, + fail_read, + }; + assert_eq!( + decode_complete_raster(decoder((2, 1), 0, false, false), dimensions) + .unwrap_err() + .code(), + "publication_raster_container_dimension_mismatch" + ); + assert_eq!( + decode_complete_raster(decoder((1, 1), 0, false, false), dimensions).unwrap(), + dimensions + ); + assert_eq!( + decode_complete_raster( + decoder( + (1, 1), + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES + 1, + false, + false, + ), + dimensions, + ) + .unwrap_err() + .code(), + "publication_raster_decoded_byte_limit_exceeded" + ); + assert_eq!( + decode_complete_raster(decoder((1, 1), 0, true, false), dimensions) + .unwrap_err() + .code(), + "publication_raster_decode_failed" + ); + assert_eq!( + decode_complete_raster(decoder((1, 1), 0, false, true), dimensions) + .unwrap_err() + .code(), + "publication_raster_decode_failed" + ); + assert_eq!( + allocate_decoded_buffer(u64::MAX).unwrap_err().code(), + "publication_raster_decode_allocation_failed" + ); + assert_eq!( + bounded_decoded_byte_length(None).unwrap_err().code(), + "publication_raster_decode_failed" + ); + assert_eq!( + bounded_decoded_byte_length(Some( + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES + 1, + )) + .unwrap_err() + .code(), + "publication_raster_decoded_byte_limit_exceeded" + ); + assert_eq!( + bounded_decoded_byte_length(Some( + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES, + )) + .unwrap(), + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES + ); + assert_eq!( + bounded_jpeg_output_buffer_size(None).unwrap_err().code(), + "publication_raster_decode_failed" + ); + assert_eq!(bounded_jpeg_output_buffer_size(Some(0)).unwrap(), 0); + + let direct_decoder = decoder((1, 1), 0, false, false); + assert_eq!(direct_decoder.color_type(), ColorType::Rgba8); + Box::new(decoder((1, 1), 0, false, false)) + .read_image_boxed(&mut []) + .unwrap(); + + let jpeg = sequential_jpeg(); + let container = inspect_jpeg_container(&jpeg).unwrap(); + assert_eq!(container.dimensions, dimensions); + assert_eq!(container.components, 3); + decode_complete_jpeg(&jpeg, container).unwrap(); + + let mut extended_sequential = jpeg.clone(); + let sof = extended_sequential + .windows(2) + .position(|window| window == b"\xff\xc0") + .unwrap(); + extended_sequential[sof + 1] = 0xc1; + let extended_container = inspect_jpeg_container(&extended_sequential).unwrap(); + decode_complete_jpeg(&extended_sequential, extended_container).unwrap(); + + let mut progressive = jpeg.clone(); + progressive[sof + 1] = 0xc2; + assert_eq!( + inspect_jpeg_container(&progressive).unwrap_err().code(), + "publication_jpeg_process_forbidden" + ); + let mut twelve_bit = jpeg.clone(); + twelve_bit[sof + 4] = 12; + assert_eq!( + inspect_jpeg_container(&twelve_bit).unwrap_err().code(), + "publication_jpeg_process_forbidden" + ); + assert_eq!( + inspect_jpeg_container(&malformed_dqt_jpeg()) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + + let scan = jpeg + .windows(2) + .position(|window| window == b"\xff\xda") + .unwrap(); + let scan_length = usize::from(u16::from_be_bytes([jpeg[scan + 2], jpeg[scan + 3]])); + let entropy_start = scan + 2 + scan_length; + let entropy_end = jpeg.len() - 2; + let entropy_length = entropy_end - entropy_start; + for keep in [0, 1, entropy_length / 2, entropy_length - 1] { + let mut truncated = jpeg[..entropy_start + keep].to_vec(); + truncated.extend_from_slice(b"\xff\xd9"); + let truncated_container = inspect_jpeg_container(&truncated).unwrap(); + assert_eq!( + decode_complete_jpeg(&truncated, truncated_container) + .unwrap_err() + .code(), + "publication_raster_decode_failed" + ); + } + + let mismatched_container = JpegContainerInspection { + dimensions: RadrootsBlossomRasterDimensions::new(2, 1).unwrap(), + ..container + }; + assert_eq!( + decode_complete_jpeg(&jpeg, mismatched_container) + .unwrap_err() + .code(), + "publication_raster_container_dimension_mismatch" + ); + assert_eq!( + decode_complete_jpeg(b"not jpeg", container) + .unwrap_err() + .code(), + "publication_raster_decode_failed" + ); + assert_eq!( + decode_complete_jpeg( + &jpeg, + JpegContainerInspection { + components: 2, + ..container + }, + ) + .unwrap_err() + .code(), + "publication_raster_container_dimension_mismatch" + ); + + let jpeg_options = strict_jpeg_decoder_options(); + assert!(jpeg_options.strict_mode()); + assert!(!jpeg_options.use_unsafe()); + assert_eq!( + jpeg_options.max_width(), + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION as usize + ); + assert_eq!( + jpeg_options.max_height(), + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION as usize + ); + assert_eq!(jpeg_options.jpeg_get_out_colorspace(), ColorSpace::RGB); + assert_eq!(strict_jpeg_dimensions(Some((1, 1))).unwrap(), dimensions); + assert_eq!( + strict_jpeg_dimensions(None).unwrap_err().code(), + "publication_raster_decode_failed" + ); + + let limits = raster_decode_limits(); + assert_eq!( + limits.max_image_width, + Some(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION) + ); + assert_eq!( + limits.max_image_height, + Some(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION) + ); + assert_eq!( + limits.max_alloc, + Some(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES) + ); + } +} diff --git a/crates/blossom/src/publication_readiness/sequential_jpeg.rs b/crates/blossom/src/publication_readiness/sequential_jpeg.rs @@ -0,0 +1,1244 @@ +use alloc::vec::Vec; + +use super::{ + JpegContainerInspection, RadrootsBlossomError, RadrootsBlossomRasterDimensions, + is_jpeg_start_of_frame, +}; + +#[derive(Clone, Copy)] +struct SequentialJpegComponent { + id: u8, + horizontal_sampling: u8, + vertical_sampling: u8, +} + +struct SequentialJpegFrame { + dimensions: RadrootsBlossomRasterDimensions, + components: Vec<SequentialJpegComponent>, + maximum_horizontal_sampling: u8, + maximum_vertical_sampling: u8, +} + +struct SequentialJpegScanComponent { + frame_index: usize, + dc_table: usize, + ac_table: usize, +} + +struct SequentialJpegScan { + components: Vec<SequentialJpegScanComponent>, +} + +struct SequentialJpegHuffmanTable { + first_codes: [u32; 16], + value_offsets: [usize; 16], + code_counts: [u8; 16], + values: Vec<u8>, +} + +impl SequentialJpegHuffmanTable { + fn new(class: u8, code_counts: [u8; 16], values: &[u8]) -> Result<Self, RadrootsBlossomError> { + if class > 1 { + return invalid_entropy(); + } + if values.is_empty() || values.len() > 256 { + return invalid_entropy(); + } + + let mut first_codes = [0_u32; 16]; + let mut value_offsets = [0_usize; 16]; + let mut code = 0_u32; + let mut value_offset = 0_usize; + let mut unused_codes = 1_i32; + for (index, count) in code_counts.iter().copied().enumerate() { + unused_codes = unused_codes * 2 - i32::from(count); + if unused_codes < 0 { + return invalid_entropy(); + } + first_codes[index] = code; + value_offsets[index] = value_offset; + code = (code + u32::from(count)) * 2; + value_offset += usize::from(count); + } + if value_offset != values.len() { + return invalid_entropy(); + } + if unused_codes == 0 { + return invalid_entropy(); + } + + let mut seen_values = [false; 256]; + for value in values { + let value_index = usize::from(*value); + let valid = if class == 0 { + *value <= 11 + } else { + let run = value >> 4; + let magnitude = value & 0x0f; + magnitude <= 10 && (magnitude != 0 || matches!(run, 0 | 15)) + }; + if !valid { + return invalid_entropy(); + } + if seen_values[value_index] { + return invalid_entropy(); + } + seen_values[value_index] = true; + } + + let mut owned_values = Vec::new(); + owned_values + .try_reserve_exact(values.len()) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + owned_values.extend_from_slice(values); + Ok(Self { + first_codes, + value_offsets, + code_counts, + values: owned_values, + }) + } + + fn decode_symbol( + &self, + reader: &mut SequentialJpegEntropyReader<'_>, + ) -> Result<u8, RadrootsBlossomError> { + let mut code = 0_u32; + for index in 0..16 { + code = (code << 1) | u32::from(reader.read_bit()?); + let count = u32::from(self.code_counts[index]); + let first = self.first_codes[index]; + if count != 0 && code >= first && code - first < count { + let offset = self.value_offsets[index] + (code - first) as usize; + return self + .values + .get(offset) + .copied() + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed); + } + } + invalid_entropy() + } +} + +struct SequentialJpegEntropyReader<'a> { + bytes: &'a [u8], + position: usize, + current_byte: u8, + bits_remaining: u8, +} + +impl<'a> SequentialJpegEntropyReader<'a> { + const fn new(bytes: &'a [u8], position: usize) -> Self { + Self { + bytes, + position, + current_byte: 0, + bits_remaining: 0, + } + } + + fn read_bit(&mut self) -> Result<u8, RadrootsBlossomError> { + if self.bits_remaining == 0 { + self.current_byte = self.read_entropy_byte()?; + self.bits_remaining = 8; + } + self.bits_remaining -= 1; + Ok((self.current_byte >> self.bits_remaining) & 1) + } + + fn discard_bits(&mut self, count: u8) -> Result<(), RadrootsBlossomError> { + for _ in 0..count { + self.read_bit()?; + } + Ok(()) + } + + fn read_entropy_byte(&mut self) -> Result<u8, RadrootsBlossomError> { + let byte = *self + .bytes + .get(self.position) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + self.position += 1; + if byte != 0xff { + return Ok(byte); + } + if self.bytes.get(self.position) == Some(&0x00) { + self.position += 1; + return Ok(0xff); + } + invalid_entropy() + } + + fn finish_restart(&mut self, expected: u8) -> Result<(), RadrootsBlossomError> { + if expected > 7 { + return invalid_entropy(); + } + self.finish_padding()?; + let (marker, after_marker) = strict_marker(self.bytes, self.position)?; + if marker != 0xd0 + expected { + return invalid_entropy(); + } + self.position = after_marker; + Ok(()) + } + + fn finish_scan(mut self) -> Result<usize, RadrootsBlossomError> { + self.finish_padding()?; + let (marker, _) = strict_marker(self.bytes, self.position)?; + if matches!(marker, 0xd0..=0xd7) { + return invalid_entropy(); + } + Ok(self.position) + } + + fn finish_padding(&mut self) -> Result<(), RadrootsBlossomError> { + if self.bits_remaining != 0 { + let mask = (1_u16 << self.bits_remaining) - 1; + if u16::from(self.current_byte) & mask != mask { + return invalid_entropy(); + } + self.bits_remaining = 0; + } + Ok(()) + } +} + +pub(super) fn validate( + bytes: &[u8], + container: JpegContainerInspection, +) -> Result<(), RadrootsBlossomError> { + if !bytes.starts_with(b"\xff\xd8") { + return invalid_entropy(); + } + let mut position = 2_usize; + let mut frame: Option<SequentialJpegFrame> = None; + let mut dc_tables: [Option<SequentialJpegHuffmanTable>; 4] = core::array::from_fn(|_| None); + let mut ac_tables: [Option<SequentialJpegHuffmanTable>; 4] = core::array::from_fn(|_| None); + let mut restart_interval = 0_usize; + let mut seen_components = [false; 4]; + let mut saw_scan = false; + loop { + let (marker, after_marker) = strict_marker(bytes, position)?; + match marker { + 0xd9 => { + let current_frame = frame + .as_ref() + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + if after_marker != bytes.len() { + return invalid_entropy(); + } + if !saw_scan { + return invalid_entropy(); + } + if seen_components + .iter() + .take(current_frame.components.len()) + .any(|seen| !seen) + { + return invalid_entropy(); + } + return Ok(()); + } + 0xc0 | 0xc1 => { + if frame.is_some() { + return invalid_entropy(); + } + let (payload, next) = strict_segment(bytes, after_marker)?; + let parsed = parse_frame(payload)?; + if parsed.dimensions != container.dimensions + || parsed.components.len() != usize::from(container.components) + { + return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); + } + frame = Some(parsed); + position = next; + } + marker if is_jpeg_start_of_frame(marker) || marker == 0xcc => { + return Err(RadrootsBlossomError::PublicationJpegProcessForbidden); + } + 0xc4 => { + let (payload, next) = strict_segment(bytes, after_marker)?; + parse_huffman_tables(payload, &mut dc_tables, &mut ac_tables)?; + position = next; + } + 0xdd => { + let (payload, next) = strict_segment(bytes, after_marker)?; + if payload.len() != 2 { + return invalid_entropy(); + } + restart_interval = usize::from(u16::from_be_bytes([payload[0], payload[1]])); + position = next; + } + 0xda => { + let current_frame = frame + .as_ref() + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let (payload, entropy_start) = strict_segment(bytes, after_marker)?; + let scan = parse_scan( + payload, + current_frame, + &seen_components, + &dc_tables, + &ac_tables, + )?; + position = validate_scan_entropy( + bytes, + entropy_start, + current_frame, + &scan, + &dc_tables, + &ac_tables, + restart_interval, + )?; + for component in &scan.components { + seen_components[component.frame_index] = true; + } + saw_scan = true; + } + 0xdb | 0xe0..=0xef | 0xfe => { + let (_, next) = strict_segment(bytes, after_marker)?; + position = next; + } + 0x01 => position = after_marker, + _ => return invalid_entropy(), + } + } +} + +fn strict_marker(bytes: &[u8], position: usize) -> Result<(u8, usize), RadrootsBlossomError> { + if bytes.get(position) != Some(&0xff) { + return invalid_entropy(); + } + let mut code_position = position; + while bytes.get(code_position) == Some(&0xff) { + code_position += 1; + } + let marker = *bytes + .get(code_position) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + if marker == 0x00 { + return invalid_entropy(); + } + let after_marker = code_position + 1; + Ok((marker, after_marker)) +} + +fn strict_segment( + bytes: &[u8], + after_marker: usize, +) -> Result<(&[u8], usize), RadrootsBlossomError> { + let payload_start = after_marker + .checked_add(2) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let length_bytes = bytes + .get(after_marker..payload_start) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let length = usize::from(u16::from_be_bytes([length_bytes[0], length_bytes[1]])); + if length < 2 { + return invalid_entropy(); + } + let end = after_marker + .checked_add(length) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let payload = bytes + .get(payload_start..end) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + Ok((payload, end)) +} + +fn parse_frame(payload: &[u8]) -> Result<SequentialJpegFrame, RadrootsBlossomError> { + if payload.len() < 6 || payload[0] != 8 { + return invalid_entropy(); + } + let component_count = usize::from(payload[5]); + let expected_length = component_count * 3 + 6; + if !matches!(component_count, 1 | 3 | 4) || payload.len() != expected_length { + return invalid_entropy(); + } + let dimensions = RadrootsBlossomRasterDimensions::new( + u32::from(u16::from_be_bytes([payload[3], payload[4]])), + u32::from(u16::from_be_bytes([payload[1], payload[2]])), + )?; + let mut components = Vec::new(); + components + .try_reserve_exact(component_count) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + let mut maximum_horizontal_sampling = 0_u8; + let mut maximum_vertical_sampling = 0_u8; + let mut sampling_product_sum = 0_u8; + for data in payload[6..].chunks_exact(3) { + let horizontal_sampling = data[1] >> 4; + let vertical_sampling = data[1] & 0x0f; + if components + .iter() + .any(|component: &SequentialJpegComponent| component.id == data[0]) + || !(1..=4).contains(&horizontal_sampling) + || !(1..=4).contains(&vertical_sampling) + || data[2] > 3 + { + return invalid_entropy(); + } + sampling_product_sum += horizontal_sampling * vertical_sampling; + if sampling_product_sum > 10 { + return invalid_entropy(); + } + maximum_horizontal_sampling = maximum_horizontal_sampling.max(horizontal_sampling); + maximum_vertical_sampling = maximum_vertical_sampling.max(vertical_sampling); + components.push(SequentialJpegComponent { + id: data[0], + horizontal_sampling, + vertical_sampling, + }); + } + Ok(SequentialJpegFrame { + dimensions, + components, + maximum_horizontal_sampling, + maximum_vertical_sampling, + }) +} + +fn parse_huffman_tables( + payload: &[u8], + dc_tables: &mut [Option<SequentialJpegHuffmanTable>; 4], + ac_tables: &mut [Option<SequentialJpegHuffmanTable>; 4], +) -> Result<(), RadrootsBlossomError> { + let mut position = 0_usize; + while position < payload.len() { + let selector = *payload + .get(position) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + position += 1; + let class = selector >> 4; + let destination = usize::from(selector & 0x0f); + if class > 1 || destination >= 4 { + return invalid_entropy(); + } + let counts_end = position + 16; + let counts_slice = payload + .get(position..counts_end) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let code_counts: [u8; 16] = counts_slice + .try_into() + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeFailed)?; + position = counts_end; + let value_count: usize = code_counts.iter().map(|count| usize::from(*count)).sum(); + if value_count > 256 { + return invalid_entropy(); + } + let values_end = position + value_count; + let values = payload + .get(position..values_end) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + position = values_end; + let table = SequentialJpegHuffmanTable::new(class, code_counts, values)?; + if class == 0 { + dc_tables[destination] = Some(table); + } else { + ac_tables[destination] = Some(table); + } + } + Ok(()) +} + +fn parse_scan( + payload: &[u8], + frame: &SequentialJpegFrame, + seen_components: &[bool; 4], + dc_tables: &[Option<SequentialJpegHuffmanTable>; 4], + ac_tables: &[Option<SequentialJpegHuffmanTable>; 4], +) -> Result<SequentialJpegScan, RadrootsBlossomError> { + let component_count = payload.first().copied().map_or(0, usize::from); + let expected_length = component_count * 2 + 4; + if component_count == 0 + || component_count > frame.components.len() + || payload.len() != expected_length + || payload[payload.len() - 3..] != [0, 63, 0] + { + return invalid_entropy(); + } + let selectors_end = component_count * 2 + 1; + let mut components = Vec::new(); + components + .try_reserve_exact(component_count) + .map_err(|_| RadrootsBlossomError::PublicationRasterDecodeAllocationFailed)?; + for data in payload[1..selectors_end].chunks_exact(2) { + let frame_index = frame + .components + .iter() + .position(|component| component.id == data[0]) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let dc_table = usize::from(data[1] >> 4); + let ac_table = usize::from(data[1] & 0x0f); + if components + .iter() + .any(|component: &SequentialJpegScanComponent| component.frame_index == frame_index) + || seen_components[frame_index] + || dc_table >= 4 + || ac_table >= 4 + || dc_tables[dc_table].is_none() + || ac_tables[ac_table].is_none() + { + return invalid_entropy(); + } + components.push(SequentialJpegScanComponent { + frame_index, + dc_table, + ac_table, + }); + } + Ok(SequentialJpegScan { components }) +} + +#[allow(clippy::too_many_arguments)] +fn validate_scan_entropy( + bytes: &[u8], + entropy_start: usize, + frame: &SequentialJpegFrame, + scan: &SequentialJpegScan, + dc_tables: &[Option<SequentialJpegHuffmanTable>; 4], + ac_tables: &[Option<SequentialJpegHuffmanTable>; 4], + restart_interval: usize, +) -> Result<usize, RadrootsBlossomError> { + let interleaved = scan.components.len() > 1; + let mcu_count = scan_mcu_count(frame, scan, interleaved)?; + let mut reader = SequentialJpegEntropyReader::new(bytes, entropy_start); + let mut expected_restart = 0_u8; + for mcu in 0..mcu_count { + if restart_interval != 0 && mcu != 0 && mcu % restart_interval == 0 { + reader.finish_restart(expected_restart)?; + expected_restart = (expected_restart + 1) & 7; + } + for scan_component in &scan.components { + let frame_component = frame + .components + .get(scan_component.frame_index) + .copied() + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let blocks = if interleaved { + usize::from(frame_component.horizontal_sampling) + * usize::from(frame_component.vertical_sampling) + } else { + 1 + }; + let dc_table = dc_tables + .get(scan_component.dc_table) + .and_then(Option::as_ref) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let ac_table = ac_tables + .get(scan_component.ac_table) + .and_then(Option::as_ref) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + for _ in 0..blocks { + validate_block(&mut reader, dc_table, ac_table)?; + } + } + } + reader.finish_scan() +} + +fn scan_mcu_count( + frame: &SequentialJpegFrame, + scan: &SequentialJpegScan, + interleaved: bool, +) -> Result<usize, RadrootsBlossomError> { + let width = frame.dimensions.width() as usize; + let height = frame.dimensions.height() as usize; + if interleaved { + let mcu_width = 8 * usize::from(frame.maximum_horizontal_sampling); + let mcu_height = 8 * usize::from(frame.maximum_vertical_sampling); + return checked_mcu_grid_count(width, height, mcu_width, mcu_height); + } + let scan_component = scan + .components + .first() + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + let component = frame + .components + .get(scan_component.frame_index) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed)?; + if frame.maximum_horizontal_sampling == 0 || frame.maximum_vertical_sampling == 0 { + return invalid_entropy(); + } + let component_width = (width * usize::from(component.horizontal_sampling)) + .div_ceil(usize::from(frame.maximum_horizontal_sampling)); + let component_height = (height * usize::from(component.vertical_sampling)) + .div_ceil(usize::from(frame.maximum_vertical_sampling)); + checked_mcu_grid_count(component_width, component_height, 8, 8) +} + +fn checked_mcu_grid_count( + width: usize, + height: usize, + mcu_width: usize, + mcu_height: usize, +) -> Result<usize, RadrootsBlossomError> { + if width == 0 { + return invalid_entropy(); + } + if height == 0 { + return invalid_entropy(); + } + if mcu_width == 0 { + return invalid_entropy(); + } + if mcu_height == 0 { + return invalid_entropy(); + } + width + .div_ceil(mcu_width) + .checked_mul(height.div_ceil(mcu_height)) + .ok_or(RadrootsBlossomError::PublicationRasterDecodeFailed) +} + +fn validate_block( + reader: &mut SequentialJpegEntropyReader<'_>, + dc_table: &SequentialJpegHuffmanTable, + ac_table: &SequentialJpegHuffmanTable, +) -> Result<(), RadrootsBlossomError> { + let dc_magnitude = dc_table.decode_symbol(reader)?; + reader.discard_bits(dc_magnitude)?; + let mut coefficient = 1_usize; + while coefficient < 64 { + let symbol = ac_table.decode_symbol(reader)?; + let run = usize::from(symbol >> 4); + let magnitude = symbol & 0x0f; + if magnitude == 0 { + if run == 0 { + break; + } + coefficient += 16; + if coefficient > 64 { + return invalid_entropy(); + } + continue; + } + coefficient += run; + if coefficient >= 64 { + return invalid_entropy(); + } + reader.discard_bits(magnitude)?; + coefficient += 1; + } + Ok(()) +} + +fn invalid_entropy<T>() -> Result<T, RadrootsBlossomError> { + Err(RadrootsBlossomError::PublicationRasterDecodeFailed) +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use super::*; + + fn assert_decode_failed<T>(result: Result<T, RadrootsBlossomError>) { + let error = result + .err() + .expect("expected publication raster decode failure"); + assert_eq!(error.code(), "publication_raster_decode_failed"); + } + + fn one_symbol_table(class: u8, symbol: u8) -> SequentialJpegHuffmanTable { + let mut counts = [0_u8; 16]; + counts[0] = 1; + SequentialJpegHuffmanTable::new(class, counts, &[symbol]).unwrap() + } + + fn single_component_frame() -> SequentialJpegFrame { + parse_frame(&[8, 0, 1, 0, 1, 1, 1, 0x11, 0]).unwrap() + } + + fn one_symbol_tables() -> ( + [Option<SequentialJpegHuffmanTable>; 4], + [Option<SequentialJpegHuffmanTable>; 4], + ) { + let mut dc_tables = core::array::from_fn(|_| None); + dc_tables[0] = Some(one_symbol_table(0, 0)); + let mut ac_tables = core::array::from_fn(|_| None); + ac_tables[0] = Some(one_symbol_table(1, 0)); + (dc_tables, ac_tables) + } + + fn append_segment(bytes: &mut Vec<u8>, marker: u8, payload: &[u8]) { + bytes.extend_from_slice(&[0xff, marker]); + bytes.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + bytes.extend_from_slice(payload); + } + + fn one_component_frame_payload(width: u16) -> [u8; 9] { + let [width_high, width_low] = width.to_be_bytes(); + [8, 0, 1, width_high, width_low, 1, 1, 0x11, 0] + } + + fn single_scan_jpeg(frame_payload: &[u8], before_scan: &[u8], entropy: &[u8]) -> Vec<u8> { + let mut bytes = vec![0xff, 0xd8]; + append_segment(&mut bytes, 0xc0, frame_payload); + + let mut huffman = Vec::new(); + huffman.push(0x00); + huffman.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + huffman.push(0); + huffman.push(0x10); + huffman.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + huffman.push(0); + append_segment(&mut bytes, 0xc4, &huffman); + bytes.extend_from_slice(before_scan); + append_segment(&mut bytes, 0xda, &[1, 1, 0, 0, 63, 0]); + bytes.extend_from_slice(entropy); + bytes.extend_from_slice(&[0xff, 0xd9]); + bytes + } + + fn container(width: u32, components: u8) -> JpegContainerInspection { + JpegContainerInspection { + dimensions: RadrootsBlossomRasterDimensions::new(width, 1).unwrap(), + components, + } + } + + #[test] + fn terminal_padding_requires_one_bits_and_no_extra_entropy() { + let mut accepted = SequentialJpegEntropyReader::new(&[0x7f], 0); + assert_eq!(accepted.read_bit().unwrap(), 0); + accepted.finish_padding().unwrap(); + + let mut rejected = SequentialJpegEntropyReader::new(&[0x7e], 0); + assert_eq!(rejected.read_bit().unwrap(), 0); + assert_decode_failed(rejected.finish_padding()); + + let mut exact = SequentialJpegEntropyReader::new(&[0x7f, 0xff, 0xd9], 0); + exact.read_bit().unwrap(); + assert_eq!(exact.finish_scan().unwrap(), 1); + + let mut extra = SequentialJpegEntropyReader::new(&[0x7f, 0x00, 0xff, 0xd9], 0); + extra.read_bit().unwrap(); + assert_decode_failed(extra.finish_scan()); + } + + #[test] + fn restart_markers_require_exact_sequence_and_padding() { + let mut accepted = SequentialJpegEntropyReader::new(&[0x7f, 0xff, 0xd0], 0); + accepted.read_bit().unwrap(); + accepted.finish_restart(0).unwrap(); + assert_eq!(accepted.position, 3); + + let mut wrong = SequentialJpegEntropyReader::new(&[0x7f, 0xff, 0xd1], 0); + wrong.read_bit().unwrap(); + assert_decode_failed(wrong.finish_restart(0)); + + let mut out_of_range = SequentialJpegEntropyReader::new(&[0xff, 0xd0], 0); + assert_decode_failed(out_of_range.finish_restart(8)); + + assert_decode_failed(SequentialJpegEntropyReader::new(&[0xff, 0xd0], 0).finish_scan()); + } + + #[test] + fn entropy_reader_and_huffman_symbol_decoding_cover_canonical_boundaries() { + let mut stuffed = SequentialJpegEntropyReader::new(&[0xff, 0x00], 0); + assert_eq!(stuffed.read_entropy_byte().unwrap(), 0xff); + assert_eq!(stuffed.position, 2); + assert_decode_failed(SequentialJpegEntropyReader::new(&[], 0).read_entropy_byte()); + assert_decode_failed( + SequentialJpegEntropyReader::new(&[0xff, 0xd9], 0).read_entropy_byte(), + ); + + let mut counts = [0_u8; 16]; + counts[1] = 2; + let table = SequentialJpegHuffmanTable::new(0, counts, &[7, 8]).unwrap(); + let mut first = SequentialJpegEntropyReader::new(&[0x3f], 0); + assert_eq!(table.decode_symbol(&mut first).unwrap(), 7); + let mut second = SequentialJpegEntropyReader::new(&[0x7f], 0); + assert_eq!(table.decode_symbol(&mut second).unwrap(), 8); + let mut absent = SequentialJpegEntropyReader::new(&[0x80, 0x00], 0); + assert_decode_failed(table.decode_symbol(&mut absent)); + + let malformed_table = SequentialJpegHuffmanTable { + first_codes: [0; 16], + value_offsets: [0; 16], + code_counts: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + values: Vec::new(), + }; + let mut missing_value = SequentialJpegEntropyReader::new(&[0x7f], 0); + assert_decode_failed(malformed_table.decode_symbol(&mut missing_value)); + + let noncanonical_table = SequentialJpegHuffmanTable { + first_codes: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + value_offsets: [0; 16], + code_counts: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + values: vec![0], + }; + let mut code_below_first = SequentialJpegEntropyReader::new(&[0x7f, 0x00], 0); + assert_decode_failed(noncanonical_table.decode_symbol(&mut code_below_first)); + } + + #[test] + fn huffman_tables_reject_full_overfull_duplicate_and_oversized_inventories() { + assert_decode_failed(SequentialJpegHuffmanTable::new(2, [0; 16], &[0])); + assert_decode_failed(SequentialJpegHuffmanTable::new(0, [0; 16], &[])); + assert_decode_failed(SequentialJpegHuffmanTable::new(0, [0; 16], &[0; 257])); + + let mut incomplete = [0_u8; 16]; + incomplete[0] = 1; + SequentialJpegHuffmanTable::new(0, incomplete, &[0]).unwrap(); + assert_decode_failed(SequentialJpegHuffmanTable::new(0, incomplete, &[0, 1])); + assert_decode_failed(SequentialJpegHuffmanTable::new(0, incomplete, &[12])); + + let mut valid_ac = [0_u8; 16]; + valid_ac[1] = 3; + SequentialJpegHuffmanTable::new(1, valid_ac, &[0x00, 0xf0, 0x01]).unwrap(); + assert_decode_failed(SequentialJpegHuffmanTable::new(1, incomplete, &[0x0b])); + assert_decode_failed(SequentialJpegHuffmanTable::new(1, incomplete, &[0x10])); + + let mut full = [0_u8; 16]; + full[0] = 2; + assert_decode_failed(SequentialJpegHuffmanTable::new(0, full, &[0, 1])); + + let mut overfull = [0_u8; 16]; + overfull[0] = 3; + assert_decode_failed(SequentialJpegHuffmanTable::new(0, overfull, &[0, 1, 2])); + + let mut duplicate = [0_u8; 16]; + duplicate[0] = 1; + duplicate[1] = 1; + assert_decode_failed(SequentialJpegHuffmanTable::new(0, duplicate, &[0, 0])); + + let mut oversized_payload = vec![0_u8; 1 + 16 + 257]; + oversized_payload[1] = 255; + oversized_payload[2] = 2; + let mut dc_tables = core::array::from_fn(|_| None); + let mut ac_tables = core::array::from_fn(|_| None); + assert_decode_failed(parse_huffman_tables( + &oversized_payload, + &mut dc_tables, + &mut ac_tables, + )); + } + + #[test] + fn marker_segment_frame_and_huffman_parsers_reject_every_invalid_shape() { + assert_decode_failed(strict_marker(&[0x00], 0)); + assert_decode_failed(strict_marker(&[0xff], 0)); + assert_decode_failed(strict_marker(&[0xff, 0x00], 0)); + assert_eq!(strict_marker(&[0xff, 0xff, 0x01], 0).unwrap(), (0x01, 3)); + + assert_decode_failed(strict_segment(&[], 0)); + assert_decode_failed(strict_segment(&[0, 1], 0)); + assert_decode_failed(strict_segment(&[0, 4, 0], 0)); + assert_decode_failed(strict_segment(&[], usize::MAX)); + assert_eq!(strict_segment(&[0, 3, 9], 0).unwrap(), (&[9][..], 3)); + + assert_decode_failed(parse_frame(&[])); + assert_decode_failed(parse_frame(&[7, 0, 1, 0, 1, 0])); + assert_decode_failed(parse_frame(&[8, 0, 1, 0, 1, 2, 1, 0x11, 0, 2, 0x11, 0])); + assert_decode_failed(parse_frame(&[8, 0, 1, 0, 1, 1])); + assert_eq!( + parse_frame(&[8, 0, 1, 0, 0, 1, 1, 0x11, 0]) + .err() + .unwrap() + .code(), + "publication_raster_dimensions_out_of_range" + ); + assert_eq!( + parse_frame(&[8, 0, 0, 0, 1, 1, 1, 0x11, 0]) + .err() + .unwrap() + .code(), + "publication_raster_dimensions_out_of_range" + ); + + let valid = one_component_frame_payload(1); + let mut duplicate = [8, 0, 1, 0, 1, 3, 1, 0x11, 0, 1, 0x11, 0, 3, 0x11, 0]; + assert_decode_failed(parse_frame(&duplicate)); + duplicate[9] = 2; + duplicate[7] = 0x01; + assert_decode_failed(parse_frame(&duplicate)); + duplicate[7] = 0x51; + assert_decode_failed(parse_frame(&duplicate)); + duplicate[7] = 0x10; + assert_decode_failed(parse_frame(&duplicate)); + duplicate[7] = 0x15; + assert_decode_failed(parse_frame(&duplicate)); + duplicate[7] = 0x11; + duplicate[8] = 4; + assert_decode_failed(parse_frame(&duplicate)); + parse_frame(&valid).unwrap(); + + let mut dc_tables = core::array::from_fn(|_| None); + let mut ac_tables = core::array::from_fn(|_| None); + assert_decode_failed(parse_huffman_tables( + &[0x20], + &mut dc_tables, + &mut ac_tables, + )); + assert_decode_failed(parse_huffman_tables( + &[0x04], + &mut dc_tables, + &mut ac_tables, + )); + assert_decode_failed(parse_huffman_tables( + &[0x00], + &mut dc_tables, + &mut ac_tables, + )); + let mut missing_value = vec![0x00, 1]; + missing_value.extend_from_slice(&[0; 15]); + assert_decode_failed(parse_huffman_tables( + &missing_value, + &mut dc_tables, + &mut ac_tables, + )); + parse_huffman_tables(&[], &mut dc_tables, &mut ac_tables).unwrap(); + } + + #[test] + fn scan_parser_requires_exact_components_tables_and_sequential_parameters() { + let frame = single_component_frame(); + let (dc_tables, ac_tables) = one_symbol_tables(); + let unseen = [false; 4]; + assert_eq!( + parse_scan( + &[1, 1, 0, 0, 63, 0], + &frame, + &unseen, + &dc_tables, + &ac_tables, + ) + .unwrap() + .components + .len(), + 1 + ); + assert_decode_failed(parse_scan(&[], &frame, &unseen, &dc_tables, &ac_tables)); + assert_decode_failed(parse_scan( + &[2, 1, 0, 1, 0, 0, 63, 0], + &frame, + &unseen, + &dc_tables, + &ac_tables, + )); + assert_decode_failed(parse_scan(&[1], &frame, &unseen, &dc_tables, &ac_tables)); + assert_decode_failed(parse_scan( + &[1, 1, 0, 1, 63, 0], + &frame, + &unseen, + &dc_tables, + &ac_tables, + )); + assert_decode_failed(parse_scan( + &[1, 2, 0, 0, 63, 0], + &frame, + &unseen, + &dc_tables, + &ac_tables, + )); + + let three_component_frame = + parse_frame(&[8, 0, 1, 0, 1, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0]).unwrap(); + assert_decode_failed(parse_scan( + &[2, 1, 0, 1, 0, 0, 63, 0], + &three_component_frame, + &unseen, + &dc_tables, + &ac_tables, + )); + + let seen = [true, false, false, false]; + assert_decode_failed(parse_scan( + &[1, 1, 0, 0, 63, 0], + &frame, + &seen, + &dc_tables, + &ac_tables, + )); + assert_decode_failed(parse_scan( + &[1, 1, 0x40, 0, 63, 0], + &frame, + &unseen, + &dc_tables, + &ac_tables, + )); + assert_decode_failed(parse_scan( + &[1, 1, 0x04, 0, 63, 0], + &frame, + &unseen, + &dc_tables, + &ac_tables, + )); + + let missing_dc = core::array::from_fn(|_| None); + assert_decode_failed(parse_scan( + &[1, 1, 0, 0, 63, 0], + &frame, + &unseen, + &missing_dc, + &ac_tables, + )); + let missing_ac = core::array::from_fn(|_| None); + assert_decode_failed(parse_scan( + &[1, 1, 0, 0, 63, 0], + &frame, + &unseen, + &dc_tables, + &missing_ac, + )); + } + + #[test] + fn validator_covers_completion_marker_restart_and_container_agreement_rules() { + let frame_payload = one_component_frame_payload(1); + let valid = single_scan_jpeg(&frame_payload, &[], &[0x3f]); + validate(&valid, container(1, 1)).unwrap(); + assert_decode_failed(validate(&[0], container(1, 1))); + assert_decode_failed(validate(&[0xff, 0xd8, 0xff, 0xd9], container(1, 1))); + + let mut no_scan = vec![0xff, 0xd8]; + append_segment(&mut no_scan, 0xc0, &frame_payload); + no_scan.extend_from_slice(&[0xff, 0xd9]); + assert_decode_failed(validate(&no_scan, container(1, 1))); + + let mut trailing = valid.clone(); + trailing.push(0); + assert_decode_failed(validate(&trailing, container(1, 1))); + + let three_component_payload = [8, 0, 1, 0, 1, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0]; + let missing_components = single_scan_jpeg(&three_component_payload, &[], &[0x3f]); + assert_decode_failed(validate(&missing_components, container(1, 3))); + + let mut duplicate_frame = vec![0xff, 0xd8]; + append_segment(&mut duplicate_frame, 0xc0, &frame_payload); + duplicate_frame.extend_from_slice(&valid[2..]); + assert_decode_failed(validate(&duplicate_frame, container(1, 1))); + assert_eq!( + validate(&[0xff, 0xd8, 0xff, 0xcc], container(1, 1)) + .unwrap_err() + .code(), + "publication_jpeg_process_forbidden" + ); + assert_eq!( + validate(&[0xff, 0xd8, 0xff, 0xc2], container(1, 1)) + .unwrap_err() + .code(), + "publication_jpeg_process_forbidden" + ); + assert_decode_failed(validate(&[0xff, 0xd8, 0xff, 0xda], container(1, 1))); + assert_decode_failed(validate( + &single_scan_jpeg(&frame_payload, &[0xff, 0x02], &[0x3f]), + container(1, 1), + )); + validate( + &single_scan_jpeg(&frame_payload, &[0xff, 0x01], &[0x3f]), + container(1, 1), + ) + .unwrap(); + + assert_eq!( + validate(&valid, container(2, 1)).unwrap_err().code(), + "publication_raster_container_dimension_mismatch" + ); + assert_eq!( + validate(&valid, container(1, 3)).unwrap_err().code(), + "publication_raster_container_dimension_mismatch" + ); + + assert_decode_failed(validate( + &single_scan_jpeg(&frame_payload, &[0xff, 0xdd, 0, 3, 0], &[0x3f]), + container(1, 1), + )); + let restart_frame = one_component_frame_payload(9); + validate( + &single_scan_jpeg( + &restart_frame, + &[0xff, 0xdd, 0, 4, 0, 1], + &[0x3f, 0xff, 0xd0, 0x3f], + ), + container(9, 1), + ) + .unwrap(); + validate( + &single_scan_jpeg(&restart_frame, &[0xff, 0xdd, 0, 4, 0, 2], &[0x0f]), + container(9, 1), + ) + .unwrap(); + } + + #[test] + fn block_validation_covers_eob_zero_runs_nonzero_runs_and_coefficient_limits() { + let dc = one_symbol_table(0, 0); + let eob = one_symbol_table(1, 0); + let mut eob_reader = SequentialJpegEntropyReader::new(&[0x3f], 0); + validate_block(&mut eob_reader, &dc, &eob).unwrap(); + + let zrl = one_symbol_table(1, 0xf0); + let mut zrl_reader = SequentialJpegEntropyReader::new(&[0x07], 0); + assert_decode_failed(validate_block(&mut zrl_reader, &dc, &zrl)); + + let overflowing_run = one_symbol_table(1, 0xf1); + let mut overflowing_reader = SequentialJpegEntropyReader::new(&[0x00], 0); + assert_decode_failed(validate_block( + &mut overflowing_reader, + &dc, + &overflowing_run, + )); + + let exact_run = one_symbol_table(1, 0x81); + let mut exact_reader = SequentialJpegEntropyReader::new(&[0x00, 0x00], 0); + validate_block(&mut exact_reader, &dc, &exact_run).unwrap(); + + let mut mixed_counts = [0_u8; 16]; + mixed_counts[1] = 2; + let mixed = SequentialJpegHuffmanTable::new(1, mixed_counts, &[0x11, 0]).unwrap(); + let mut mixed_reader = SequentialJpegEntropyReader::new(&[0x07], 0); + validate_block(&mut mixed_reader, &dc, &mixed).unwrap(); + } + + #[test] + fn frame_sampling_products_are_limited_to_ten() { + let valid = [8, 0, 1, 0, 1, 3, 1, 0x22, 0, 2, 0x11, 0, 3, 0x11, 0]; + assert_eq!( + parse_frame(&valid) + .unwrap() + .components + .iter() + .map(|component| { + u16::from(component.horizontal_sampling) + * u16::from(component.vertical_sampling) + }) + .sum::<u16>(), + 6 + ); + + let excessive = [8, 0, 1, 0, 1, 3, 1, 0x22, 0, 2, 0x22, 0, 3, 0x22, 0]; + assert_decode_failed(parse_frame(&excessive)); + } + + #[test] + fn mcu_grid_count_checks_bounds_and_overflow() { + assert_eq!(checked_mcu_grid_count(16, 16, 16, 16).unwrap(), 1); + assert_eq!(checked_mcu_grid_count(17, 17, 16, 16).unwrap(), 4); + assert_decode_failed(checked_mcu_grid_count(0, 1, 8, 8)); + assert_decode_failed(checked_mcu_grid_count(1, 0, 8, 8)); + assert_decode_failed(checked_mcu_grid_count(1, 1, 0, 8)); + assert_decode_failed(checked_mcu_grid_count(1, 1, 8, 0)); + assert_decode_failed(checked_mcu_grid_count(usize::MAX, usize::MAX, 1, 1)); + + let frame = SequentialJpegFrame { + dimensions: RadrootsBlossomRasterDimensions::new(17, 17).unwrap(), + components: vec![SequentialJpegComponent { + id: 1, + horizontal_sampling: 1, + vertical_sampling: 1, + }], + maximum_horizontal_sampling: 2, + maximum_vertical_sampling: 2, + }; + let scan = SequentialJpegScan { + components: vec![SequentialJpegScanComponent { + frame_index: 0, + dc_table: 0, + ac_table: 0, + }], + }; + assert_eq!(scan_mcu_count(&frame, &scan, false).unwrap(), 4); + assert_eq!(scan_mcu_count(&frame, &scan, true).unwrap(), 4); + assert_decode_failed(scan_mcu_count( + &frame, + &SequentialJpegScan { + components: Vec::new(), + }, + false, + )); + + let invalid_index = SequentialJpegScan { + components: vec![SequentialJpegScanComponent { + frame_index: 1, + dc_table: 0, + ac_table: 0, + }], + }; + assert_decode_failed(scan_mcu_count(&frame, &invalid_index, false)); + + let zero_horizontal_maximum = SequentialJpegFrame { + dimensions: RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), + components: vec![SequentialJpegComponent { + id: 1, + horizontal_sampling: 1, + vertical_sampling: 1, + }], + maximum_horizontal_sampling: 0, + maximum_vertical_sampling: 1, + }; + assert_decode_failed(scan_mcu_count(&zero_horizontal_maximum, &scan, false)); + let zero_vertical_maximum = SequentialJpegFrame { + maximum_horizontal_sampling: 1, + maximum_vertical_sampling: 0, + ..zero_horizontal_maximum + }; + assert_decode_failed(scan_mcu_count(&zero_vertical_maximum, &scan, false)); + } + + #[test] + fn entropy_validation_rejects_unresolved_component_and_table_references() { + let frame = single_component_frame(); + let (dc_tables, ac_tables) = one_symbol_tables(); + let invalid_component_scan = SequentialJpegScan { + components: vec![ + SequentialJpegScanComponent { + frame_index: 1, + dc_table: 0, + ac_table: 0, + }, + SequentialJpegScanComponent { + frame_index: 0, + dc_table: 0, + ac_table: 0, + }, + ], + }; + assert_decode_failed(validate_scan_entropy( + &[0xff, 0xd9], + 0, + &frame, + &invalid_component_scan, + &dc_tables, + &ac_tables, + 0, + )); + + let missing_dc_scan = SequentialJpegScan { + components: vec![SequentialJpegScanComponent { + frame_index: 0, + dc_table: 1, + ac_table: 0, + }], + }; + assert_decode_failed(validate_scan_entropy( + &[0xff, 0xd9], + 0, + &frame, + &missing_dc_scan, + &dc_tables, + &ac_tables, + 0, + )); + + let missing_ac_scan = SequentialJpegScan { + components: vec![SequentialJpegScanComponent { + frame_index: 0, + dc_table: 0, + ac_table: 1, + }], + }; + assert_decode_failed(validate_scan_entropy( + &[0xff, 0xd9], + 0, + &frame, + &missing_ac_scan, + &dc_tables, + &ac_tables, + 0, + )); + } +} diff --git a/crates/blossom/src/url.rs b/crates/blossom/src/url.rs @@ -458,4 +458,16 @@ mod tests { assert!(RadrootsBlossomBlobUrl::parse(&url(&format!("https://{maximum_host}"))).is_ok()); assert!(RadrootsBlossomBlobUrl::parse(&url("https://xn--mdia-9oa.example")).is_ok()); } + + #[test] + fn authority_helpers_reject_absent_and_malformed_loopback_hosts() { + assert!(!host_is_loopback(Host::Domain(".localhost"))); + assert!(!host_is_loopback(Host::Domain("media..localhost"))); + + let hostless = Url::parse("file:///blob").unwrap(); + assert_eq!( + validate_authority("file:///blob", &hostless), + Err(RadrootsBlossomError::InvalidBlobUrl) + ); + } } diff --git a/crates/blossom/tests/fixtures/publication_readiness.v1.json b/crates/blossom/tests/fixtures/publication_readiness.v1.json @@ -0,0 +1,428 @@ +{ + "suite": "blossom_publication_readiness", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "valid_created", + "kind": "blossom.verify_publication_readiness.valid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "none" + }, + "expected": { + "url": "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png", + "sha256": "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd", + "size": 70, + "media_type": "image/png", + "format": "png", + "width": 1, + "height": 1, + "upload_status": 201, + "evidence_digest": "44e63303e594ea42d863be995b23ac4297ed77e4378d0707c94f28e77164bd3b" + } + }, + { + "id": "valid_ok_without_authored_dimensions", + "kind": "blossom.verify_publication_readiness.valid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_status_200" + }, + "expected": { + "url": "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png", + "sha256": "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd", + "size": 70, + "media_type": "image/png", + "format": "png", + "width": 1, + "height": 1, + "upload_status": 200 + } + }, + { + "id": "invalid_upload_status", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_status_202" + }, + "expected": { + "error": "invalid_bud02_upload_status" + } + }, + { + "id": "invalid_head_status", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "head_status_204" + }, + "expected": { + "error": "invalid_bud01_head_status" + } + }, + { + "id": "invalid_get_status", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_status_206" + }, + "expected": { + "error": "invalid_bud01_get_status" + } + }, + { + "id": "declared_size_over_public_max", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_size_over_max" + }, + "expected": { + "error": "publication_raster_byte_limit_exceeded" + } + }, + { + "id": "missing_get_body", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_body_missing" + }, + "expected": { + "error": "publication_get_body_missing" + } + }, + { + "id": "short_get_body", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_body_short" + }, + "expected": { + "error": "publication_get_body_short" + } + }, + { + "id": "trailing_get_body", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_body_trailing" + }, + "expected": { + "error": "publication_get_body_trailing" + } + }, + { + "id": "authored_bytes_short", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "authored_bytes_short" + }, + "expected": { + "error": "publication_authored_bytes_size_mismatch" + } + }, + { + "id": "authored_bytes_wrong_hash", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "authored_bytes_wrong_hash" + }, + "expected": { + "error": "publication_authored_bytes_hash_mismatch" + } + }, + { + "id": "upload_url_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_url_mismatch" + }, + "expected": { + "error": "publication_upload_url_mismatch" + } + }, + { + "id": "upload_hash_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_hash_mismatch" + }, + "expected": { + "error": "publication_upload_hash_mismatch" + } + }, + { + "id": "upload_size_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_size_mismatch" + }, + "expected": { + "error": "publication_upload_size_mismatch" + } + }, + { + "id": "upload_mime_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "upload_mime_mismatch" + }, + "expected": { + "error": "publication_upload_media_type_mismatch" + } + }, + { + "id": "head_url_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "head_url_mismatch" + }, + "expected": { + "error": "publication_head_url_mismatch" + } + }, + { + "id": "head_size_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "head_size_mismatch" + }, + "expected": { + "error": "publication_head_size_mismatch" + } + }, + { + "id": "head_mime_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "head_mime_mismatch" + }, + "expected": { + "error": "publication_head_media_type_mismatch" + } + }, + { + "id": "get_url_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_url_mismatch" + }, + "expected": { + "error": "publication_get_url_mismatch" + } + }, + { + "id": "get_declared_size_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_declared_size_mismatch" + }, + "expected": { + "error": "publication_get_declared_size_mismatch" + } + }, + { + "id": "get_complete_hash_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "get_bytes_wrong_hash" + }, + "expected": { + "error": "publication_retrieved_bytes_hash_mismatch" + } + }, + { + "id": "unsupported_raster_mime", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "unsupported_mime" + }, + "expected": { + "error": "unsupported_publication_raster_media_type" + } + }, + { + "id": "malformed_raster", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "malformed_container" + }, + "expected": { + "error": "invalid_publication_raster" + } + }, + { + "id": "animated_png", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "animated_png" + }, + "expected": { + "error": "publication_raster_animation_forbidden" + } + }, + { + "id": "declared_format_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "declared_mime_jpeg" + }, + "expected": { + "error": "invalid_publication_raster" + } + }, + { + "id": "corrupt_png_crc", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "corrupt_png_crc" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "corrupt_png_deflate", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "corrupt_png_deflate" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "invalid_png_color_type", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "invalid_png_color_type" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "authored_dimension_mismatch", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "authored_dimension_mismatch" + }, + "expected": { + "error": "publication_authored_raster_dimension_mismatch" + } + }, + { + "id": "animated_webp", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "animated_webp" + }, + "expected": { + "error": "publication_raster_animation_forbidden" + } + }, + { + "id": "zero_width", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "zero_width" + }, + "expected": { + "error": "publication_raster_dimensions_out_of_range" + } + }, + { + "id": "dimension_over_max", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "dimension_over_max" + }, + "expected": { + "error": "publication_raster_dimensions_out_of_range" + } + }, + { + "id": "pixel_limit", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "pixel_limit" + }, + "expected": { + "error": "publication_raster_pixel_limit_exceeded" + } + }, + { + "id": "progressive_jpeg", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "progressive_jpeg" + }, + "expected": { + "error": "publication_jpeg_process_forbidden" + } + }, + { + "id": "jpeg_entropy_stripped", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "jpeg_entropy_stripped" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "jpeg_entropy_partial", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "jpeg_entropy_partial" + }, + "expected": { + "error": "publication_raster_decode_failed" + } + }, + { + "id": "malformed_jpeg_dqt", + "kind": "blossom.verify_publication_readiness.invalid", + "input": { + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "malformed_jpeg_dqt" + }, + "expected": { + "error": "invalid_publication_raster" + } + } + ] +} diff --git a/crates/blossom/tests/publication_readiness.rs b/crates/blossom/tests/publication_readiness.rs @@ -0,0 +1,721 @@ +#![cfg(feature = "raster-decode")] + +use image::{ExtendedColorType, ImageEncoder, codecs::webp::WebPEncoder}; +use radroots_blossom::{ + RadrootsBlossomApprovedBlobUrl, RadrootsBlossomAuthoredRasterDimensions, + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomBud01GetObservation, + RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, + RadrootsBlossomError, RadrootsBlossomMediaType, RadrootsBlossomRasterDimensions, + RadrootsBlossomRasterFormat, RadrootsBlossomSha256, verify_publication_readiness, +}; +use serde::Deserialize; +use serde_json::Value; + +const PACKAGED_VECTORS: &[u8] = include_bytes!("fixtures/publication_readiness.v1.json"); + +#[derive(Deserialize)] +struct VectorFile { + vectors: Vec<Vector>, +} + +#[derive(Deserialize)] +struct Vector { + id: String, + kind: String, + input: Value, + expected: Value, +} + +#[test] +fn publication_readiness_vectors_execute_against_public_api() { + let canonical = canonical_vectors(); + assert_eq!(canonical, PACKAGED_VECTORS, "packaged vector mirror drift"); + let vector_file: VectorFile = serde_json::from_slice(PACKAGED_VECTORS).unwrap(); + assert_eq!(vector_file.vectors.len(), 37); + for vector in &vector_file.vectors { + match vector.kind.as_str() { + "blossom.verify_publication_readiness.valid" => execute_valid(vector), + "blossom.verify_publication_readiness.invalid" => execute_invalid(vector), + kind => panic!("{} has unsupported kind {kind}", vector.id), + } + } +} + +#[test] +fn publication_readiness_accepts_public_jpeg_and_still_webp() { + for (bytes, media_type, extension, format) in [ + ( + encoded_jpeg(), + "image/jpeg", + "jpg", + RadrootsBlossomRasterFormat::Jpeg, + ), + ( + encoded_still_webp(), + "image/webp", + "webp", + RadrootsBlossomRasterFormat::StillWebP, + ), + ] { + let bytes = bytes.as_slice(); + let evidence = verify_public_raster( + bytes, + media_type, + extension, + RadrootsBlossomAuthoredRasterDimensions::Exact( + RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), + ), + ) + .unwrap(); + + assert_eq!(evidence.raster_format(), format); + assert_eq!(evidence.raster_format().to_string(), format.as_str()); + assert_eq!(evidence.dimensions().pixels(), 1); + assert_eq!(evidence.uploaded(), 1_800_000_001); + assert_eq!( + evidence.evidence_digest().as_sha256().to_string(), + evidence.evidence_digest().to_string() + ); + } +} + +#[test] +fn publication_readiness_rejects_forbidden_and_corrupt_jpeg_and_animated_rasters() { + let jpeg = encoded_jpeg(); + let scan = jpeg + .windows(2) + .position(|window| window == b"\xff\xda") + .unwrap(); + let segment_length = usize::from(u16::from_be_bytes([jpeg[scan + 2], jpeg[scan + 3]])); + let entropy_start = scan + 2 + segment_length; + let eoi = jpeg.len() - 2; + assert!(entropy_start < eoi); + let entropy_length = eoi - entropy_start; + for keep in [0, 1, entropy_length / 2, entropy_length - 1] { + let mut truncated = jpeg[..entropy_start + keep].to_vec(); + truncated.extend_from_slice(b"\xff\xd9"); + assert_eq!( + verify_public_raster( + &truncated, + "image/jpeg", + "jpg", + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + ) + .unwrap_err() + .code(), + "publication_raster_decode_failed" + ); + } + + let mut malformed_dqt = jpeg.clone(); + let sof = malformed_dqt + .windows(2) + .position(|window| window == b"\xff\xc0") + .unwrap(); + malformed_dqt.drain(sof - 3..sof); + assert_eq!( + verify_public_raster( + &malformed_dqt, + "image/jpeg", + "jpg", + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + ) + .unwrap_err() + .code(), + "invalid_publication_raster" + ); + + let mut progressive = jpeg; + progressive[sof + 1] = 0xc2; + assert_eq!( + verify_public_raster( + &progressive, + "image/jpeg", + "jpg", + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + ) + .unwrap_err() + .code(), + "publication_jpeg_process_forbidden" + ); + + assert_eq!( + verify_public_raster( + &encoded_animated_png(), + "image/png", + "png", + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + ) + .unwrap_err() + .code(), + "publication_raster_animation_forbidden" + ); + + assert_eq!( + verify_public_raster( + &encoded_animated_webp(), + "image/webp", + "webp", + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + ) + .unwrap_err() + .code(), + "publication_raster_animation_forbidden" + ); +} + +fn encoded_jpeg() -> Vec<u8> { + hex::decode( + "ffd8ffe000104a46494600010100000100010000ffdb0043000302020302020303030304030304050805050404050a070706080c0a0c0c0b0a0b0b0d0e12100d0e110e0b0b1016101113141515150c0f171816141812141514ffdb00430103040405040509050509140d0b0d1414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414ffc00011080001000103012200021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f0100030101010101010101010000000000000102030405060708090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffda000c03010002110311003f00f9ca8a28afc3cfe6f3ffd9", + ) + .unwrap() +} + +fn encoded_still_webp() -> Vec<u8> { + let mut bytes = Vec::new(); + WebPEncoder::new_lossless(&mut bytes) + .write_image(&[0, 128, 0, 255], 1, 1, ExtendedColorType::Rgba8) + .unwrap(); + bytes +} + +fn encoded_animated_png() -> Vec<u8> { + let canonical = hex::decode( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + ) + .unwrap(); + let mut output = b"\x89PNG\r\n\x1a\n".to_vec(); + output.extend_from_slice(&png_chunk(*b"IHDR", &canonical[16..29])); + output.extend_from_slice(&png_chunk(*b"acTL", &[0, 0, 0, 2, 0, 0, 0, 0])); + output.extend_from_slice(&png_chunk(*b"fcTL", &apng_frame_control(0))); + output.extend_from_slice(&png_chunk(*b"IDAT", &canonical[41..54])); + output.extend_from_slice(&png_chunk(*b"fcTL", &apng_frame_control(1))); + + let mut frame_data = 2_u32.to_be_bytes().to_vec(); + frame_data.extend_from_slice(&canonical[41..54]); + output.extend_from_slice(&png_chunk(*b"fdAT", &frame_data)); + output.extend_from_slice(&png_chunk(*b"IEND", &[])); + output +} + +fn apng_frame_control(sequence: u32) -> [u8; 26] { + let mut control = [0_u8; 26]; + control[..4].copy_from_slice(&sequence.to_be_bytes()); + control[4..8].copy_from_slice(&1_u32.to_be_bytes()); + control[8..12].copy_from_slice(&1_u32.to_be_bytes()); + control[20..22].copy_from_slice(&1_u16.to_be_bytes()); + control[22..24].copy_from_slice(&10_u16.to_be_bytes()); + control +} + +fn encoded_animated_webp() -> Vec<u8> { + let still = encoded_still_webp(); + let mut output = b"RIFF\0\0\0\0WEBP".to_vec(); + let mut extended_header = [0_u8; 10]; + extended_header[0] = 0x02; + push_webp_chunk(&mut output, *b"VP8X", &extended_header); + push_webp_chunk(&mut output, *b"ANIM", &[0; 6]); + + let mut frame = [0_u8; 16].to_vec(); + frame[12] = 1; + frame.extend_from_slice(&still[12..]); + push_webp_chunk(&mut output, *b"ANMF", &frame); + let riff_size = (output.len() as u32) - 8; + output[4..8].copy_from_slice(&riff_size.to_le_bytes()); + output +} + +fn push_webp_chunk(output: &mut Vec<u8>, kind: [u8; 4], data: &[u8]) { + output.extend_from_slice(&kind); + output.extend_from_slice(&(data.len() as u32).to_le_bytes()); + output.extend_from_slice(data); + if data.len() & 1 == 1 { + output.push(0); + } +} + +fn verify_public_raster( + bytes: &[u8], + media_type: &str, + extension: &str, + authored_dimensions: RadrootsBlossomAuthoredRasterDimensions, +) -> Result<radroots_blossom::RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { + let hash = RadrootsBlossomSha256::digest(bytes); + let url = format!("https://cdn.example/{hash}.{extension}"); + let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); + let authored_descriptor = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&url).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_800_000_000, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap(); + let upload = RadrootsBlossomBud02UploadObservation::new( + 201, + RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&url).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_800_000_001, + ) + .unwrap(), + ) + .unwrap(); + let approved_url = RadrootsBlossomBlobUrl::parse(&url) + .unwrap() + .approve() + .unwrap(); + let head = RadrootsBlossomBud01HeadObservation::new( + 200, + approved_url.clone(), + bytes.len() as u64, + media_type, + ) + .unwrap(); + let get = RadrootsBlossomBud01GetObservation::from_complete_body( + 200, + approved_url, + bytes.len() as u64, + bytes, + ) + .unwrap(); + verify_publication_readiness( + &authored_descriptor, + bytes, + authored_dimensions, + &upload, + &head, + &get, + ) +} + +fn canonical_vectors() -> &'static [u8] { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../contracts/conformance/vectors/blossom/publication_readiness.v1.json" + ); + std::fs::read(path) + .unwrap_or_else(|error| panic!("read canonical publication-readiness vectors: {error}")) + .leak() +} + +fn execute_valid(vector: &Vector) { + let mutation = input_str(vector, "mutation"); + let result = run_mutation(vector, mutation).unwrap_or_else(|error| { + panic!( + "{} unexpectedly failed: {} ({})", + vector.id, + error, + error.code() + ) + }); + assert_eq!( + result.url().as_str(), + expected_str(vector, "url"), + "{}", + vector.id + ); + assert_eq!( + result.sha256().to_string(), + expected_str(vector, "sha256"), + "{}", + vector.id + ); + assert_eq!(result.size(), expected_u64(vector, "size"), "{}", vector.id); + assert_eq!( + result.media_type().as_str(), + expected_str(vector, "media_type"), + "{}", + vector.id + ); + assert_eq!( + result.raster_format().as_str(), + expected_str(vector, "format"), + "{}", + vector.id + ); + assert_eq!( + u64::from(result.dimensions().width()), + expected_u64(vector, "width"), + "{}", + vector.id + ); + assert_eq!( + u64::from(result.dimensions().height()), + expected_u64(vector, "height"), + "{}", + vector.id + ); + assert_eq!( + u64::from(result.bud02_status().as_u16()), + expected_u64(vector, "upload_status"), + "{}", + vector.id + ); + if let Some(expected_digest) = vector + .expected + .get("evidence_digest") + .and_then(Value::as_str) + { + assert_eq!( + result.evidence_digest().to_string(), + expected_digest, + "{}", + vector.id + ); + } +} + +fn execute_invalid(vector: &Vector) { + let mutation = input_str(vector, "mutation"); + let error = + run_mutation(vector, mutation).expect_err("invalid publication-readiness vector must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); +} + +fn run_mutation( + vector: &Vector, + mutation: &str, +) -> Result<radroots_blossom::RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { + let canonical = hex::decode(input_str(vector, "bytes_hex")).unwrap(); + let mut sealed_bytes = canonical.clone(); + let mut exact_authored_bytes = canonical.clone(); + let mut retrieved_bytes = canonical.clone(); + let mut media_type = "image/png"; + let mut upload_status = 201; + let mut head_status = 200; + let mut get_status = 200; + let mut upload_origin = "https://cdn.example"; + let mut head_origin = "https://cdn.example"; + let mut get_origin = "https://cdn.example"; + let mut upload_hash_bytes = canonical.clone(); + let mut upload_size_delta = 0_i64; + let mut upload_media_type = "image/png"; + let mut head_size_delta = 0_i64; + let mut head_media_type = "image/png"; + let mut get_declared_size_delta = 0_i64; + let mut authored_dimensions = Some((1, 1)); + + match mutation { + "none" => {} + "upload_status_200" => { + upload_status = 200; + authored_dimensions = None; + } + "upload_status_202" => upload_status = 202, + "head_status_204" => head_status = 204, + "get_status_206" => get_status = 206, + "get_size_over_max" => get_declared_size_delta = 10_485_760, + "get_body_missing" => retrieved_bytes.clear(), + "get_body_short" => { + retrieved_bytes.pop(); + } + "get_body_trailing" => retrieved_bytes.push(0), + "authored_bytes_short" => { + exact_authored_bytes.pop(); + } + "authored_bytes_wrong_hash" => exact_authored_bytes[69] ^= 1, + "upload_url_mismatch" => upload_origin = "https://other.example", + "upload_hash_mismatch" => upload_hash_bytes[69] ^= 1, + "upload_size_mismatch" => upload_size_delta = 1, + "upload_mime_mismatch" => upload_media_type = "image/jpeg", + "head_url_mismatch" => head_origin = "https://other.example", + "head_size_mismatch" => head_size_delta = 1, + "head_mime_mismatch" => head_media_type = "image/jpeg", + "get_url_mismatch" => get_origin = "https://other.example", + "get_declared_size_mismatch" => { + retrieved_bytes.pop(); + get_declared_size_delta = -1; + } + "get_bytes_wrong_hash" => retrieved_bytes[69] ^= 1, + "unsupported_mime" => { + media_type = "image/gif"; + upload_media_type = "image/gif"; + head_media_type = "image/gif"; + } + "malformed_container" => { + sealed_bytes[0] = 0; + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + } + "animated_png" => { + sealed_bytes = encoded_animated_png(); + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + } + "declared_mime_jpeg" => { + media_type = "image/jpeg"; + upload_media_type = "image/jpeg"; + head_media_type = "image/jpeg"; + } + "corrupt_png_crc" => { + sealed_bytes[57] ^= 1; + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + } + "corrupt_png_deflate" => { + sealed_bytes[41] = 0; + let crc = png_crc(*b"IDAT", &sealed_bytes[41..54]); + sealed_bytes[54..58].copy_from_slice(&crc.to_be_bytes()); + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + } + "invalid_png_color_type" => { + sealed_bytes[25] = 1; + let crc = png_crc(*b"IHDR", &sealed_bytes[16..29]); + sealed_bytes[29..33].copy_from_slice(&crc.to_be_bytes()); + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + } + "authored_dimension_mismatch" => authored_dimensions = Some((2, 1)), + "animated_webp" => { + sealed_bytes = encoded_animated_webp(); + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + media_type = "image/webp"; + upload_media_type = "image/webp"; + head_media_type = "image/webp"; + authored_dimensions = None; + } + "zero_width" => { + sealed_bytes[16..20].copy_from_slice(&0_u32.to_be_bytes()); + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + } + "dimension_over_max" => { + sealed_bytes[16..20].copy_from_slice(&16_385_u32.to_be_bytes()); + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + } + "pixel_limit" => { + sealed_bytes[16..20].copy_from_slice(&5_000_u32.to_be_bytes()); + sealed_bytes[20..24].copy_from_slice(&5_000_u32.to_be_bytes()); + exact_authored_bytes = sealed_bytes.clone(); + retrieved_bytes = sealed_bytes.clone(); + upload_hash_bytes = sealed_bytes.clone(); + } + "progressive_jpeg" => { + let mut jpeg = encoded_jpeg(); + let sof = jpeg + .windows(2) + .position(|window| window == b"\xff\xc0") + .unwrap(); + jpeg[sof + 1] = 0xc2; + replace_raster_bytes( + &mut sealed_bytes, + &mut exact_authored_bytes, + &mut retrieved_bytes, + &mut upload_hash_bytes, + jpeg, + ); + media_type = "image/jpeg"; + upload_media_type = "image/jpeg"; + head_media_type = "image/jpeg"; + authored_dimensions = None; + } + "jpeg_entropy_stripped" | "jpeg_entropy_partial" => { + let jpeg = encoded_jpeg(); + let scan = jpeg + .windows(2) + .position(|window| window == b"\xff\xda") + .unwrap(); + let segment_length = usize::from(u16::from_be_bytes([jpeg[scan + 2], jpeg[scan + 3]])); + let entropy_start = scan + 2 + segment_length; + let entropy_length = jpeg.len() - 2 - entropy_start; + let keep = if mutation == "jpeg_entropy_stripped" { + 0 + } else { + entropy_length / 2 + }; + let mut truncated = jpeg[..entropy_start + keep].to_vec(); + truncated.extend_from_slice(b"\xff\xd9"); + replace_raster_bytes( + &mut sealed_bytes, + &mut exact_authored_bytes, + &mut retrieved_bytes, + &mut upload_hash_bytes, + truncated, + ); + media_type = "image/jpeg"; + upload_media_type = "image/jpeg"; + head_media_type = "image/jpeg"; + authored_dimensions = None; + } + "malformed_jpeg_dqt" => { + let mut jpeg = encoded_jpeg(); + let sof = jpeg + .windows(2) + .position(|window| window == b"\xff\xc0") + .unwrap(); + jpeg.drain(sof - 3..sof); + replace_raster_bytes( + &mut sealed_bytes, + &mut exact_authored_bytes, + &mut retrieved_bytes, + &mut upload_hash_bytes, + jpeg, + ); + media_type = "image/jpeg"; + upload_media_type = "image/jpeg"; + head_media_type = "image/jpeg"; + authored_dimensions = None; + } + other => panic!("{} has unknown mutation {other}", vector.id), + } + + if mutation == "get_size_over_max" { + let url = approved_url(get_origin, &sealed_bytes); + return RadrootsBlossomBud01GetObservation::from_complete_body( + get_status, + url, + adjusted_size(sealed_bytes.len(), get_declared_size_delta), + &retrieved_bytes, + ) + .and_then(|_| unreachable_result()); + } + + let expected_media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); + let authored_descriptor = descriptor( + "https://cdn.example", + &sealed_bytes, + sealed_bytes.len() as u64, + media_type, + ) + .approve_reference()? + .verify_bytes(&sealed_bytes, &expected_media_type)?; + + let upload = RadrootsBlossomBud02UploadObservation::new( + upload_status, + descriptor( + upload_origin, + &upload_hash_bytes, + adjusted_size(sealed_bytes.len(), upload_size_delta), + upload_media_type, + ), + )?; + let head = RadrootsBlossomBud01HeadObservation::new( + head_status, + approved_url(head_origin, &sealed_bytes), + adjusted_size(sealed_bytes.len(), head_size_delta), + RadrootsBlossomMediaType::parse(head_media_type).unwrap(), + )?; + let get = RadrootsBlossomBud01GetObservation::from_complete_body( + get_status, + approved_url(get_origin, &sealed_bytes), + adjusted_size(sealed_bytes.len(), get_declared_size_delta), + &retrieved_bytes, + )?; + let authored_dimensions = match authored_dimensions { + Some((width, height)) => RadrootsBlossomAuthoredRasterDimensions::Exact( + RadrootsBlossomRasterDimensions::new(width, height)?, + ), + None => RadrootsBlossomAuthoredRasterDimensions::Unspecified, + }; + verify_publication_readiness( + &authored_descriptor, + &exact_authored_bytes, + authored_dimensions, + &upload, + &head, + &get, + ) +} + +fn replace_raster_bytes( + sealed_bytes: &mut Vec<u8>, + exact_authored_bytes: &mut Vec<u8>, + retrieved_bytes: &mut Vec<u8>, + upload_hash_bytes: &mut Vec<u8>, + replacement: Vec<u8>, +) { + *sealed_bytes = replacement; + exact_authored_bytes.clone_from(sealed_bytes); + retrieved_bytes.clone_from(sealed_bytes); + upload_hash_bytes.clone_from(sealed_bytes); +} + +fn unreachable_result() +-> Result<radroots_blossom::RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { + unreachable!("oversized GET construction must fail") +} + +fn descriptor( + origin: &str, + hash_bytes: &[u8], + size: u64, + media_type: &str, +) -> RadrootsBlossomBlobDescriptor { + let hash = RadrootsBlossomSha256::digest(hash_bytes); + RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.png")).unwrap(), + hash, + size, + RadrootsBlossomMediaType::parse(media_type).unwrap(), + 1_800_000_000, + ) + .unwrap() +} + +fn approved_url(origin: &str, hash_bytes: &[u8]) -> RadrootsBlossomApprovedBlobUrl { + let hash = RadrootsBlossomSha256::digest(hash_bytes); + RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.png")) + .unwrap() + .approve() + .unwrap() +} + +fn adjusted_size(length: usize, delta: i64) -> u64 { + u64::try_from(i64::try_from(length).unwrap() + delta).unwrap() +} + +fn png_chunk(kind: [u8; 4], data: &[u8]) -> Vec<u8> { + let mut chunk = Vec::new(); + chunk.extend_from_slice(&(data.len() as u32).to_be_bytes()); + chunk.extend_from_slice(&kind); + chunk.extend_from_slice(data); + chunk.extend_from_slice(&png_crc(kind, data).to_be_bytes()); + chunk +} + +fn png_crc(kind: [u8; 4], data: &[u8]) -> u32 { + let mut crc = u32::MAX; + for byte in kind.iter().chain(data) { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); + } + } + !crc +} + +fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector.input[field] + .as_str() + .unwrap_or_else(|| panic!("{} input.{field} must be a string", vector.id)) +} + +fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector.expected[field] + .as_str() + .unwrap_or_else(|| panic!("{} expected.{field} must be a string", vector.id)) +} + +fn expected_u64(vector: &Vector, field: &str) -> u64 { + vector.expected[field] + .as_u64() + .unwrap_or_else(|| panic!("{} expected.{field} must be an unsigned integer", vector.id)) +} diff --git a/crates/event_codec/contracts/phase1_publication_allowlist_v1.descriptor.json b/crates/event_codec/contracts/phase1_publication_allowlist_v1.descriptor.json @@ -2,22 +2,22 @@ "contract_id": "radroots_event_codec.phase1_publication_allowlist_v1", "event_contract_registry_v7_sha256": "91595544310f865bdef064ee760c227c870417a95b87b3e27278f8da74fdddea", "manifest": { - "byte_length": 86551, + "byte_length": 87388, "hash_algorithm": "sha256_bytes_v1", "path": "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.json", - "sha256": "ac76e2302d4eeec99fcc8eb9bc43a693bc75db259111dc0fe9b2b4582d688afb" + "sha256": "1f4e33f60e7e96bfce0ceebe6760a141dedeb342e61048cb6db5ce5520bc2adb" }, "manifest_schema": { - "byte_length": 8439, + "byte_length": 8441, "hash_algorithm": "sha256_bytes_v1", "path": "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.schema.json", - "sha256": "5bdccda1449ca6837d9922171983b880e81324cb0bdefc32e1d92b387f692a4f" + "sha256": "9ffc63e0722948e600061cee2e2992f201a68e670b281e86a9447593ad3105f1" }, "manifest_sidecar": { "byte_length": 65, "hash_algorithm": "sha256_bytes_v1", "path": "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.sha256", - "sha256": "30ba79fd07661aa5fef19ba8a0ebed7e62ae1cc9e5454c54761b72da65da0ce5" + "sha256": "b9b2eb4f75d4ba713d0932a0a277f7bdf97cd42e885e2f5790c29f840bb615e4" }, "predecessor_manifest_sha256": "a07aace74f4747ba6e769a99acad7eadaac2d19d26aa0dd1c280ab92454519b5", "schema_version": 1 diff --git a/crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.json b/crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.json @@ -4,8 +4,8 @@ "authority_id": "phase1_publication_allowlist_v1", "manifest_schema": { "path": "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.schema.json", - "byte_length": 8439, - "sha256": "5bdccda1449ca6837d9922171983b880e81324cb0bdefc32e1d92b387f692a4f", + "byte_length": 8441, + "sha256": "9ffc63e0722948e600061cee2e2992f201a68e670b281e86a9447593ad3105f1", "hash_algorithm": "sha256_bytes_v1" }, "predecessor": { @@ -163,13 +163,21 @@ ] }, "predecessor_source_supersessions": [ + "Cargo.lock", + "Cargo.toml", "CHANGELOG.md", "contracts/operations.toml", "contracts/releases/1.0.0-alpha.1.toml", + "crates/blossom/Cargo.toml", + "crates/blossom/src/error.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs", "crates/event_codec/README", "crates/event_codec/src/wire/publication.rs", "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", "tools/xtask/src/contract/phase1_publication_artifact.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", "tools/xtask/src/main.rs" ], "source_files": [ @@ -183,43 +191,43 @@ { "role": "release_notes", "path": "CHANGELOG.md", - "byte_length": 31455, - "sha256": "d772851fea581c5cf1275d0da11b5c8136607dd98b8adcf2f0a853dfd69530b0", + "byte_length": 32509, + "sha256": "8befc8d955998cb45c262ce122b5d982323ced779f1162d7f5cdd5c55845ecca", "hash_algorithm": "sha256_bytes_v1" }, { "role": "workspace_lockfile_authority", "path": "Cargo.lock", - "byte_length": 216944, - "sha256": "d827b8c5aceb550b62fcc9b98804f97bbaf6bca95a59b1011a9927ba5aa2e49f", + "byte_length": 219265, + "sha256": "771a1f71c7e575e7c6e5978ce0128e01c5279a6ef3f3fa872aa559b5880221cd", "hash_algorithm": "sha256_bytes_v1" }, { "role": "workspace_manifest_authority", "path": "Cargo.toml", - "byte_length": 10836, - "sha256": "285532dbb0894204843a832880f136ceac5ee312a3203ff951fb0551fac63ec4", + "byte_length": 11098, + "sha256": "469316ae4b3e9e28761ebd58af4fdd208476c3c0420f6a99aa3d7e9bc9aec2fc", "hash_algorithm": "sha256_bytes_v1" }, { "role": "operations_authority", "path": "contracts/operations.toml", - "byte_length": 78556, - "sha256": "3c96d953e208b1781b391a722414861a859c106101ccf8d7b7789193f197d7eb", + "byte_length": 80259, + "sha256": "8bb1c542379533eff3a4aaed8cef2bba9234b0fb03739554810ca1019caab6e9", "hash_algorithm": "sha256_bytes_v1" }, { "role": "release_authority", "path": "contracts/releases/1.0.0-alpha.1.toml", - "byte_length": 21594, - "sha256": "9dbdb89559736eaa76410a594fd03e21d1cb276b1eb8c1308913958ee4af0d2f", + "byte_length": 22116, + "sha256": "ad565e9589eb106dd1c0473d47cb22740a33b966a8070049a800118391e29684", "hash_algorithm": "sha256_bytes_v1" }, { "role": "blossom_manifest_authority", "path": "crates/blossom/Cargo.toml", - "byte_length": 901, - "sha256": "9e84a9f1820e84994e5c8f705cae243eadd4676244f98604c53fc0800f0e51b3", + "byte_length": 1118, + "sha256": "f993430f919e86e6bd97289db0428959960c1f4d01ba2bd77c23d233f69d25d1", "hash_algorithm": "sha256_bytes_v1" }, { @@ -239,8 +247,8 @@ { "role": "public_production_source", "path": "crates/blossom/src/error.rs", - "byte_length": 17227, - "sha256": "8b0b9557cca68604791c9bb080f3cb0ff81a2397efb7f1ab600b2b4f4f7f8fa7", + "byte_length": 30918, + "sha256": "bd77810306b3556434d93057ca5ee62db474e9b0af94176ba4aa7a2ef25be7d8", "hash_algorithm": "sha256_bytes_v1" }, { @@ -253,15 +261,29 @@ { "role": "public_production_source", "path": "crates/blossom/src/lib.rs", - "byte_length": 1335, - "sha256": "61ebee86f02887c45507bab052686da082f74ae26f23d18ff4019e02c70097bd", + "byte_length": 2172, + "sha256": "97cae38f693795445cc17671649f3d88c0c492fc8d71d2c98a4ca02502e5d43a", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "public_production_source", + "path": "crates/blossom/src/publication_readiness.rs", + "byte_length": 69610, + "sha256": "77fea26e0d74cc4064ec4e4916a4e61be251df9d4c0d42ac5c98a7e8faeeea3a", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "public_production_source", + "path": "crates/blossom/src/publication_readiness/sequential_jpeg.rs", + "byte_length": 44379, + "sha256": "22b8704466887a892f00469db895ace1bb780c48849e3bfe6d7d94682ce366d8", "hash_algorithm": "sha256_bytes_v1" }, { "role": "public_production_source", "path": "crates/blossom/src/url.rs", - "byte_length": 15364, - "sha256": "05325325da6627ba48318851fb4e61a9ad540aad37fc1c27ac9a47f27300891f", + "byte_length": 15794, + "sha256": "e9673f074ba6328a121aa3008fc11cd4d9d22cae3b09f26602ea6fea3f964c80", "hash_algorithm": "sha256_bytes_v1" }, { @@ -2206,36 +2228,36 @@ { "role": "contract_command_authority", "path": "tools/xtask/src/contract.rs", - "byte_length": 481424, - "sha256": "e1f5867b5861b5f2a7dd69e22d41c8abc8e484458d0a19b8659c81501897d2cd", + "byte_length": 481764, + "sha256": "1cc775273bbe8e4ddd7fcf8c4f342eecb92d1564f2448e28efc3ee886f279d32", "hash_algorithm": "sha256_bytes_v1" }, { "role": "superseded_nip09_contract_governance", "path": "tools/xtask/src/contract/nip09_reconciliation.rs", - "byte_length": 852432, - "sha256": "971d75198770265b17e17df23ba17a6096ad687d1cf2ac0f662a2c190ab39f94", + "byte_length": 855035, + "sha256": "c631cba79f538058f588c3f071c519c8fdce829d154313126f4f7f7f19305307", "hash_algorithm": "sha256_bytes_v1" }, { "role": "publication_allowlist_contract_governance", "path": "tools/xtask/src/contract/phase1_publication_allowlist.rs", - "byte_length": 76855, - "sha256": "4f9bcecd5ebb5a3cc0385132e5cc1b12f0eec962be6e0afe1346344707033464", + "byte_length": 77019, + "sha256": "72d8b350eaf3e18b2bf3e03e6de3adbd855bd70894d0f0a540d446151c674016", "hash_algorithm": "sha256_bytes_v1" }, { "role": "publication_contract_governance", "path": "tools/xtask/src/contract/phase1_publication_artifact.rs", - "byte_length": 75139, - "sha256": "c97e65c37587affd6ced006c8eb29b3365211f4be7accb8cc44155b0190233f5", + "byte_length": 75906, + "sha256": "2cfa485178cd61f33184feae10ea3c7ed12d85673e3091a2a135b77c922e58fd", "hash_algorithm": "sha256_bytes_v1" }, { "role": "superseded_raw_rebuild_contract_governance", "path": "tools/xtask/src/contract/raw_source_rebuild.rs", - "byte_length": 297547, - "sha256": "e8cf84ba8f27432d0ba7aefa01fc61f9e37810d48f407fa2b9c7c969e5c76724", + "byte_length": 302449, + "sha256": "91179375ebae13daca8e6d22286fd68cc849903f1397909b37ad0e639a961c4e", "hash_algorithm": "sha256_bytes_v1" }, { diff --git a/crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.schema.json b/crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.schema.json @@ -239,8 +239,8 @@ "minLength": 1, "type": "string" }, - "maxItems": 8, - "minItems": 8, + "maxItems": 16, + "minItems": 16, "type": "array" }, "release": { diff --git a/crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.sha256 b/crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.sha256 @@ -1 +1 @@ -ac76e2302d4eeec99fcc8eb9bc43a693bc75db259111dc0fe9b2b4582d688afb +1f4e33f60e7e96bfce0ceebe6760a141dedeb342e61048cb6db5ce5520bc2adb diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -2,6 +2,7 @@ mod admission_authority; mod artifact_bundle; +mod blossom_publication_readiness; mod comment_authority; mod deletion_authority; mod food_availability_projection; @@ -68,15 +69,17 @@ pub(crate) fn validate_artifact_contracts(workspace_root: &Path) -> Result<(), S validate_raw_source_rebuild_manifest(workspace_root)?; validate_immutable_phase1_publication_artifact_predecessor(workspace_root)?; validate_phase1_publication_allowlist_manifest(workspace_root)?; + blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root)?; validate_knowledge_contract_manifest(workspace_root) } pub(crate) fn validate_raw_source_rebuild_manifest(workspace_root: &Path) -> Result<(), String> { - phase1_publication_artifact::validate_immutable_raw_source_rebuild_predecessor(workspace_root) + phase1_publication_artifact::validate_immutable_raw_source_rebuild_predecessor(workspace_root)?; + blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root) } pub(crate) fn write_raw_source_rebuild_manifest(workspace_root: &Path) -> Result<(), String> { - phase1_publication_artifact::validate_immutable_raw_source_rebuild_predecessor(workspace_root)?; + validate_raw_source_rebuild_manifest(workspace_root)?; Err("raw-source rebuild is an immutable predecessor and cannot be rewritten; write the active publication successor instead".to_owned()) } @@ -117,7 +120,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3"; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 25] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 26] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -127,6 +130,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 25] = [ "crates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json", ), ( + "contracts/conformance/vectors/blossom/publication_readiness.v1.json", + "crates/blossom/tests/fixtures/publication_readiness.v1.json", + ), + ( "contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json", "crates/nostr/tests/fixtures/bud11_nostr_adapter.v1.json", ), diff --git a/tools/xtask/src/contract/blossom_publication_readiness.rs b/tools/xtask/src/contract/blossom_publication_readiness.rs @@ -0,0 +1,1147 @@ +use super::artifact_bundle::{read_regular_file, with_artifact_bundle_transaction}; +use super::raw_source_rebuild::validate_raw_source_rebuild_predecessor_production_sources_under_lock; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +const VECTOR_CANONICAL_RELATIVE: &str = + "contracts/conformance/vectors/blossom/publication_readiness.v1.json"; +const VECTOR_MIRROR_RELATIVE: &str = "crates/blossom/tests/fixtures/publication_readiness.v1.json"; +const WORKSPACE_MANIFEST_RELATIVE: &str = "Cargo.toml"; +const WORKSPACE_LOCK_RELATIVE: &str = "Cargo.lock"; +const READINESS_SOURCE_RELATIVE: &str = "crates/blossom/src/publication_readiness.rs"; +const SEQUENTIAL_JPEG_SOURCE_RELATIVE: &str = + "crates/blossom/src/publication_readiness/sequential_jpeg.rs"; +const BLOSSOM_LIB_RELATIVE: &str = "crates/blossom/src/lib.rs"; +const BLOSSOM_URL_RELATIVE: &str = "crates/blossom/src/url.rs"; +const BLOSSOM_MANIFEST_RELATIVE: &str = "crates/blossom/Cargo.toml"; +const COVERAGE_PROFILES_RELATIVE: &str = "contracts/coverage-profiles.toml"; +const NIX_COMMON_RELATIVE: &str = "build/nix/common.nix"; +const NIX_CHECKS_RELATIVE: &str = "build/nix/checks.nix"; +const OPERATIONS_RELATIVE: &str = "contracts/operations.toml"; +const RELEASE_RELATIVE: &str = "contracts/releases/1.0.0-alpha.1.toml"; +const CHANGELOG_RELATIVE: &str = "CHANGELOG.md"; +const RAW_PREDECESSOR_GOVERNANCE_RELATIVE: &str = "tools/xtask/src/contract/raw_source_rebuild.rs"; +const RELEASE_CHANGE_ID: &str = "blossom-publication-readiness-evidence"; +const CHANGELOG_MARKER: &str = "<!-- release-change: blossom-publication-readiness-evidence -->"; + +const RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ + WORKSPACE_LOCK_RELATIVE, + WORKSPACE_MANIFEST_RELATIVE, + NIX_COMMON_RELATIVE, + CHANGELOG_RELATIVE, + RELEASE_RELATIVE, + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/food_availability_projection.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + RAW_PREDECESSOR_GOVERNANCE_RELATIVE, + "tools/xtask/src/main.rs", +]; +const TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ + WORKSPACE_MANIFEST_RELATIVE, + BLOSSOM_MANIFEST_RELATIVE, + "crates/blossom/src/error.rs", + BLOSSOM_LIB_RELATIVE, + BLOSSOM_URL_RELATIVE, +]; + +const SOURCE_INVENTORY: &[&str] = &[ + WORKSPACE_LOCK_RELATIVE, + WORKSPACE_MANIFEST_RELATIVE, + NIX_CHECKS_RELATIVE, + NIX_COMMON_RELATIVE, + CHANGELOG_RELATIVE, + BLOSSOM_MANIFEST_RELATIVE, + "crates/blossom/README", + "crates/blossom/src/error.rs", + BLOSSOM_LIB_RELATIVE, + READINESS_SOURCE_RELATIVE, + SEQUENTIAL_JPEG_SOURCE_RELATIVE, + BLOSSOM_URL_RELATIVE, + "crates/blossom/tests/publication_readiness.rs", + VECTOR_MIRROR_RELATIVE, + "contracts/events/blossom-media.md", + COVERAGE_PROFILES_RELATIVE, + VECTOR_CANONICAL_RELATIVE, + OPERATIONS_RELATIVE, + RELEASE_RELATIVE, + "tools/xtask/src/contract.rs", + "tools/xtask/src/main.rs", + "tools/xtask/src/contract/blossom_publication_readiness.rs", + "tools/xtask/src/contract/food_availability_projection.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + RAW_PREDECESSOR_GOVERNANCE_RELATIVE, +]; + +const IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS: &[(&str, usize, &str)] = &[ + ( + "crates/event_store/contracts/raw_source_rebuild_v1.manifest.json", + 45_449, + "03253ce31dc31d465880a895d2685f5deb1274948e0a4eabe81a2f08f238c483", + ), + ( + "crates/event_store/contracts/raw_source_rebuild_v1.manifest.schema.json", + 17_896, + "f9d210967e54b66f39c8bb965d97b2001a0ebc0927e7c2c14edb8e474bfda695", + ), + ( + "crates/event_store/contracts/raw_source_rebuild_v1.manifest.sha256", + 65, + "2b8bc07cd479be2281781660efd26fd7a8f480e5f3f62053aeccd2b5e6b2070c", + ), + ( + "crates/event_store/src/generated/raw_source_rebuild_manifest.rs", + 50_735, + "3763fbee3ee45621afca990002b9298c791bf0396ebf7bccde0ae1bc9aecb7f2", + ), + ( + "contracts/conformance/vectors/event_store/raw_source_rebuild.v1.json", + 26_833, + "c37a2bf3714f53ab04fae8c5c9dbe2ad4b3f5310efa51f46bd8b116660f1fe15", + ), + ( + "crates/event_store/tests/fixtures/raw_source_rebuild.v1.json", + 26_833, + "c37a2bf3714f53ab04fae8c5c9dbe2ad4b3f5310efa51f46bd8b116660f1fe15", + ), +]; + +const CURRENT_BYTE_BOUND_BLOSSOM_SOURCES: &[(&str, usize, &str)] = &[ + ( + BLOSSOM_LIB_RELATIVE, + 2_172, + "97cae38f693795445cc17671649f3d88c0c492fc8d71d2c98a4ca02502e5d43a", + ), + ( + "crates/blossom/src/error.rs", + 30_918, + "bd77810306b3556434d93057ca5ee62db474e9b0af94176ba4aa7a2ef25be7d8", + ), + ( + BLOSSOM_URL_RELATIVE, + 15_794, + "e9673f074ba6328a121aa3008fc11cd4d9d22cae3b09f26602ea6fea3f964c80", + ), +]; + +const REQUIRED_PUBLIC_TYPES: &[&str] = &[ + "RadrootsBlossomBud02UploadStatus", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud01GetCollector", + "RadrootsBlossomBud01GetObservation", + "RadrootsBlossomRasterFormat", + "RadrootsBlossomRasterDimensions", + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomPublicationReadinessEvidenceDigest", + "RadrootsBlossomPublicationReadinessEvidence", +]; + +const VECTOR_EXPECTATIONS: &[(&str, &str, &str, Option<&str>)] = &[ + ( + "valid_created", + "blossom.verify_publication_readiness.valid", + "none", + None, + ), + ( + "valid_ok_without_authored_dimensions", + "blossom.verify_publication_readiness.valid", + "upload_status_200", + None, + ), + ( + "invalid_upload_status", + "blossom.verify_publication_readiness.invalid", + "upload_status_202", + Some("invalid_bud02_upload_status"), + ), + ( + "invalid_head_status", + "blossom.verify_publication_readiness.invalid", + "head_status_204", + Some("invalid_bud01_head_status"), + ), + ( + "invalid_get_status", + "blossom.verify_publication_readiness.invalid", + "get_status_206", + Some("invalid_bud01_get_status"), + ), + ( + "declared_size_over_public_max", + "blossom.verify_publication_readiness.invalid", + "get_size_over_max", + Some("publication_raster_byte_limit_exceeded"), + ), + ( + "missing_get_body", + "blossom.verify_publication_readiness.invalid", + "get_body_missing", + Some("publication_get_body_missing"), + ), + ( + "short_get_body", + "blossom.verify_publication_readiness.invalid", + "get_body_short", + Some("publication_get_body_short"), + ), + ( + "trailing_get_body", + "blossom.verify_publication_readiness.invalid", + "get_body_trailing", + Some("publication_get_body_trailing"), + ), + ( + "authored_bytes_short", + "blossom.verify_publication_readiness.invalid", + "authored_bytes_short", + Some("publication_authored_bytes_size_mismatch"), + ), + ( + "authored_bytes_wrong_hash", + "blossom.verify_publication_readiness.invalid", + "authored_bytes_wrong_hash", + Some("publication_authored_bytes_hash_mismatch"), + ), + ( + "upload_url_mismatch", + "blossom.verify_publication_readiness.invalid", + "upload_url_mismatch", + Some("publication_upload_url_mismatch"), + ), + ( + "upload_hash_mismatch", + "blossom.verify_publication_readiness.invalid", + "upload_hash_mismatch", + Some("publication_upload_hash_mismatch"), + ), + ( + "upload_size_mismatch", + "blossom.verify_publication_readiness.invalid", + "upload_size_mismatch", + Some("publication_upload_size_mismatch"), + ), + ( + "upload_mime_mismatch", + "blossom.verify_publication_readiness.invalid", + "upload_mime_mismatch", + Some("publication_upload_media_type_mismatch"), + ), + ( + "head_url_mismatch", + "blossom.verify_publication_readiness.invalid", + "head_url_mismatch", + Some("publication_head_url_mismatch"), + ), + ( + "head_size_mismatch", + "blossom.verify_publication_readiness.invalid", + "head_size_mismatch", + Some("publication_head_size_mismatch"), + ), + ( + "head_mime_mismatch", + "blossom.verify_publication_readiness.invalid", + "head_mime_mismatch", + Some("publication_head_media_type_mismatch"), + ), + ( + "get_url_mismatch", + "blossom.verify_publication_readiness.invalid", + "get_url_mismatch", + Some("publication_get_url_mismatch"), + ), + ( + "get_declared_size_mismatch", + "blossom.verify_publication_readiness.invalid", + "get_declared_size_mismatch", + Some("publication_get_declared_size_mismatch"), + ), + ( + "get_complete_hash_mismatch", + "blossom.verify_publication_readiness.invalid", + "get_bytes_wrong_hash", + Some("publication_retrieved_bytes_hash_mismatch"), + ), + ( + "unsupported_raster_mime", + "blossom.verify_publication_readiness.invalid", + "unsupported_mime", + Some("unsupported_publication_raster_media_type"), + ), + ( + "malformed_raster", + "blossom.verify_publication_readiness.invalid", + "malformed_container", + Some("invalid_publication_raster"), + ), + ( + "animated_png", + "blossom.verify_publication_readiness.invalid", + "animated_png", + Some("publication_raster_animation_forbidden"), + ), + ( + "declared_format_mismatch", + "blossom.verify_publication_readiness.invalid", + "declared_mime_jpeg", + Some("invalid_publication_raster"), + ), + ( + "corrupt_png_crc", + "blossom.verify_publication_readiness.invalid", + "corrupt_png_crc", + Some("publication_raster_decode_failed"), + ), + ( + "corrupt_png_deflate", + "blossom.verify_publication_readiness.invalid", + "corrupt_png_deflate", + Some("publication_raster_decode_failed"), + ), + ( + "invalid_png_color_type", + "blossom.verify_publication_readiness.invalid", + "invalid_png_color_type", + Some("publication_raster_decode_failed"), + ), + ( + "authored_dimension_mismatch", + "blossom.verify_publication_readiness.invalid", + "authored_dimension_mismatch", + Some("publication_authored_raster_dimension_mismatch"), + ), + ( + "animated_webp", + "blossom.verify_publication_readiness.invalid", + "animated_webp", + Some("publication_raster_animation_forbidden"), + ), + ( + "zero_width", + "blossom.verify_publication_readiness.invalid", + "zero_width", + Some("publication_raster_dimensions_out_of_range"), + ), + ( + "dimension_over_max", + "blossom.verify_publication_readiness.invalid", + "dimension_over_max", + Some("publication_raster_dimensions_out_of_range"), + ), + ( + "pixel_limit", + "blossom.verify_publication_readiness.invalid", + "pixel_limit", + Some("publication_raster_pixel_limit_exceeded"), + ), + ( + "progressive_jpeg", + "blossom.verify_publication_readiness.invalid", + "progressive_jpeg", + Some("publication_jpeg_process_forbidden"), + ), + ( + "jpeg_entropy_stripped", + "blossom.verify_publication_readiness.invalid", + "jpeg_entropy_stripped", + Some("publication_raster_decode_failed"), + ), + ( + "jpeg_entropy_partial", + "blossom.verify_publication_readiness.invalid", + "jpeg_entropy_partial", + Some("publication_raster_decode_failed"), + ), + ( + "malformed_jpeg_dqt", + "blossom.verify_publication_readiness.invalid", + "malformed_jpeg_dqt", + Some("invalid_publication_raster"), + ), +]; + +pub(super) fn validate_blossom_publication_readiness(workspace_root: &Path) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |_| { + validate_blossom_publication_readiness_under_lock(workspace_root) + }) +} + +fn validate_blossom_publication_readiness_under_lock(workspace_root: &Path) -> Result<(), String> { + validate_immutable_predecessor(workspace_root)?; + validate_raw_source_rebuild_predecessor_production_sources_under_lock( + workspace_root, + RAW_PREDECESSOR_SUPERSEDED_PATHS, + TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS, + )?; + validate_source_inventory(workspace_root)?; + validate_source_boundary(workspace_root)?; + validate_vector(workspace_root)?; + validate_operation(workspace_root)?; + validate_release(workspace_root) +} + +fn validate_immutable_predecessor(workspace_root: &Path) -> Result<(), String> { + for (relative, expected_length, expected_sha256) in IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS { + let bytes = read_regular_file(workspace_root, relative)?; + if bytes.len() != *expected_length || sha256_hex(&bytes) != *expected_sha256 { + return Err(format!( + "immutable raw-source rebuild predecessor artifact `{relative}` drifted" + )); + } + } + Ok(()) +} + +fn validate_source_inventory(workspace_root: &Path) -> Result<(), String> { + let unique = SOURCE_INVENTORY.iter().copied().collect::<BTreeSet<_>>(); + if unique.len() != SOURCE_INVENTORY.len() { + return Err("publication-readiness source inventory contains duplicates".to_owned()); + } + for relative in SOURCE_INVENTORY { + let metadata = fs::symlink_metadata(workspace_root.join(relative)).map_err(|error| { + format!("inspect publication-readiness source `{relative}`: {error}") + })?; + if !metadata.file_type().is_file() { + return Err(format!( + "publication-readiness source `{relative}` must be a regular file" + )); + } + } + for superseded in RAW_PREDECESSOR_SUPERSEDED_PATHS + .iter() + .chain(TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS) + { + if !unique.contains(*superseded) { + return Err(format!( + "superseded predecessor source `{superseded}` is not current-byte governed" + )); + } + } + for (relative, expected_length, expected_sha256) in CURRENT_BYTE_BOUND_BLOSSOM_SOURCES { + validate_current_byte_bound_blossom_source( + relative, + &read_regular_file(workspace_root, relative)?, + *expected_length, + expected_sha256, + )?; + } + Ok(()) +} + +fn validate_current_byte_bound_blossom_source( + relative: &str, + bytes: &[u8], + expected_length: usize, + expected_sha256: &str, +) -> Result<(), String> { + if bytes.len() != expected_length || sha256_hex(bytes) != expected_sha256 { + return Err(format!( + "publication-readiness current-byte source `{relative}` drifted" + )); + } + Ok(()) +} + +fn validate_source_boundary(workspace_root: &Path) -> Result<(), String> { + let source = String::from_utf8(read_regular_file( + workspace_root, + READINESS_SOURCE_RELATIVE, + )?) + .map_err(|error| format!("{READINESS_SOURCE_RELATIVE} must be UTF-8: {error}"))?; + let sequential_jpeg_source = String::from_utf8(read_regular_file( + workspace_root, + SEQUENTIAL_JPEG_SOURCE_RELATIVE, + )?) + .map_err(|error| format!("{SEQUENTIAL_JPEG_SOURCE_RELATIVE} must be UTF-8: {error}"))?; + validate_readiness_source_text(&source, &sequential_jpeg_source)?; + let lib = String::from_utf8(read_regular_file(workspace_root, BLOSSOM_LIB_RELATIVE)?) + .map_err(|error| format!("{BLOSSOM_LIB_RELATIVE} must be UTF-8: {error}"))?; + if lib.matches("pub mod publication_readiness;").count() != 1 + || !lib.contains( + "#[cfg(feature = \"raster-decode\")]\npub use publication_readiness::verify_publication_readiness;", + ) + || !lib.contains("RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES") + || !lib.contains("RadrootsBlossomPublicationReadinessEvidence") + { + return Err( + "Blossom crate root must route the readiness module and public API exactly".to_owned(), + ); + } + + let manifest = parse_toml(workspace_root, BLOSSOM_MANIFEST_RELATIVE)?; + let dependencies = manifest + .get("dependencies") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare dependencies"))?; + let actual = dependencies + .keys() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let expected = [ + "image", + "mediatype", + "serde", + "sha2", + "unicode-general-category", + "url_nostd", + "zune-core", + "zune-jpeg", + ] + .into_iter() + .collect::<BTreeSet<_>>(); + if actual != expected { + return Err(format!( + "{BLOSSOM_MANIFEST_RELATIVE} dependency boundary drifted: expected {expected:?}, found {actual:?}" + )); + } + let image_dependency = dependencies + .get("image") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare optional image"))?; + if image_dependency + .get("workspace") + .and_then(toml::Value::as_bool) + != Some(true) + || image_dependency + .get("optional") + .and_then(toml::Value::as_bool) + != Some(true) + { + return Err(format!( + "{BLOSSOM_MANIFEST_RELATIVE} image dependency must be optional and workspace-governed" + )); + } + let zune_core_dependency = dependencies + .get("zune-core") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare optional zune-core"))?; + if zune_core_dependency + .get("workspace") + .and_then(toml::Value::as_bool) + != Some(true) + || zune_core_dependency + .get("optional") + .and_then(toml::Value::as_bool) + != Some(true) + { + return Err(format!( + "{BLOSSOM_MANIFEST_RELATIVE} zune-core dependency must be optional and workspace-governed" + )); + } + let zune_jpeg_dependency = dependencies + .get("zune-jpeg") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare optional zune-jpeg"))?; + if zune_jpeg_dependency + .get("workspace") + .and_then(toml::Value::as_bool) + != Some(true) + || zune_jpeg_dependency + .get("optional") + .and_then(toml::Value::as_bool) + != Some(true) + { + return Err(format!( + "{BLOSSOM_MANIFEST_RELATIVE} zune-jpeg dependency must be optional and workspace-governed" + )); + } + let workspace_manifest = parse_toml(workspace_root, WORKSPACE_MANIFEST_RELATIVE)?; + let workspace_image = workspace_manifest + .get("workspace") + .and_then(|value| value.get("dependencies")) + .and_then(|value| value.get("image")) + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{WORKSPACE_MANIFEST_RELATIVE} must govern image"))?; + let workspace_image_features = workspace_image + .get("features") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + if workspace_image.get("version").and_then(toml::Value::as_str) != Some("=0.25.10") + || workspace_image + .get("default-features") + .and_then(toml::Value::as_bool) + != Some(false) + || workspace_image_features != BTreeSet::from(["png", "webp"]) + { + return Err(format!( + "{WORKSPACE_MANIFEST_RELATIVE} image decoder dependency must be exactly pinned to the PNG/WebP set" + )); + } + let workspace_zune_core = workspace_manifest + .get("workspace") + .and_then(|value| value.get("dependencies")) + .and_then(|value| value.get("zune-core")) + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{WORKSPACE_MANIFEST_RELATIVE} must govern zune-core"))?; + let workspace_zune_core_features = workspace_zune_core + .get("features") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + if workspace_zune_core + .get("version") + .and_then(toml::Value::as_str) + != Some("=0.5.1") + || workspace_zune_core + .get("default-features") + .and_then(toml::Value::as_bool) + != Some(false) + || workspace_zune_core_features != BTreeSet::from(["std"]) + { + return Err(format!( + "{WORKSPACE_MANIFEST_RELATIVE} JPEG decoder core must be exactly pinned to zune-core 0.5.1 with std only" + )); + } + let workspace_zune_jpeg = workspace_manifest + .get("workspace") + .and_then(|value| value.get("dependencies")) + .and_then(|value| value.get("zune-jpeg")) + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{WORKSPACE_MANIFEST_RELATIVE} must govern zune-jpeg"))?; + let workspace_zune_jpeg_features = workspace_zune_jpeg + .get("features") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + if workspace_zune_jpeg + .get("version") + .and_then(toml::Value::as_str) + != Some("=0.5.15") + || workspace_zune_jpeg + .get("default-features") + .and_then(toml::Value::as_bool) + != Some(false) + || workspace_zune_jpeg_features != BTreeSet::from(["std"]) + { + return Err(format!( + "{WORKSPACE_MANIFEST_RELATIVE} strict JPEG authority must be exactly pinned to zune-jpeg 0.5.15 with std only" + )); + } + let features = manifest + .get("features") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare features"))?; + let raster_decode = features + .get("raster-decode") + .and_then(toml::Value::as_array) + .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare raster-decode feature"))? + .iter() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + if raster_decode != BTreeSet::from(["dep:image", "dep:zune-core", "dep:zune-jpeg", "std"]) { + return Err(format!( + "{BLOSSOM_MANIFEST_RELATIVE} raster-decode feature must select only std, image, zune-core, and zune-jpeg" + )); + } + let coverage = parse_toml(workspace_root, COVERAGE_PROFILES_RELATIVE)?; + let blossom_coverage = coverage + .get("profiles") + .and_then(|value| value.get("crates")) + .and_then(|value| value.get("radroots_blossom")) + .ok_or_else(|| { + format!("{COVERAGE_PROFILES_RELATIVE} must declare radroots_blossom coverage") + })?; + let coverage_features = blossom_coverage + .get("features") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + if blossom_coverage + .get("no_default_features") + .and_then(toml::Value::as_bool) + != Some(true) + || coverage_features != BTreeSet::from(["raster-decode", "serde"]) + { + return Err(format!( + "{COVERAGE_PROFILES_RELATIVE} must measure the explicit Blossom raster-decode surface" + )); + } + let nix_common = String::from_utf8(read_regular_file(workspace_root, NIX_COMMON_RELATIVE)?) + .map_err(|error| format!("{NIX_COMMON_RELATIVE} must be UTF-8: {error}"))?; + if !nix_common.contains("radroots_blossom/raster-decode") { + return Err(format!( + "{NIX_COMMON_RELATIVE} core contract lane must enable raster-decode" + )); + } + let nix_checks = String::from_utf8(read_regular_file(workspace_root, NIX_CHECKS_RELATIVE)?) + .map_err(|error| format!("{NIX_CHECKS_RELATIVE} must be UTF-8: {error}"))?; + let nix_check_commands = nix_checks.lines().map(str::trim).collect::<BTreeSet<_>>(); + for required in [ + "cargo check -p radroots_blossom --lib --no-default-features", + "cargo check -p radroots_blossom --lib --no-default-features --features raster-decode", + "cargo test -p radroots_blossom --no-default-features --features raster-decode,serde", + ] { + if !nix_check_commands.contains(required) { + return Err(format!( + "{NIX_CHECKS_RELATIVE} lacks governed Blossom verification `{required}`" + )); + } + } + let raw_predecessor = String::from_utf8(read_regular_file( + workspace_root, + RAW_PREDECESSOR_GOVERNANCE_RELATIVE, + )?) + .map_err(|error| format!("{RAW_PREDECESSOR_GOVERNANCE_RELATIVE} must be UTF-8: {error}"))?; + validate_raw_predecessor_successor_routing(&raw_predecessor)?; + Ok(()) +} + +fn validate_raw_predecessor_successor_routing(source: &str) -> Result<(), String> { + let compact = source.split_whitespace().collect::<String>(); + for required in [ + "pub(crate)fnwrite_raw_source_rebuild_manifest(workspace_root:&Path)->Result<(),String>{validate_raw_source_rebuild_manifest(workspace_root)}", + "super::blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root)", + "constBLOSSOM_READINESS_SUCCESSOR_TRANSITIVE_PATHS:&[&str]=&[\"Cargo.toml\",\"crates/blossom/Cargo.toml\",\"crates/blossom/src/error.rs\",\"crates/blossom/src/lib.rs\",\"crates/blossom/src/url.rs\",];", + ] { + if !compact.contains(required) { + return Err(format!( + "{RAW_PREDECESSOR_GOVERNANCE_RELATIVE} lacks validation-only successor route `{required}`" + )); + } + } + Ok(()) +} + +fn validate_readiness_source_text( + source: &str, + sequential_jpeg_source: &str, +) -> Result<(), String> { + for required in [ + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION: u16 = 1", + "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES: u64 = 10_485_760", + "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES: u64 =", + "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION: u32 = 16_384", + "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_000", + "#[cfg(feature = \"raster-decode\")]\npub fn verify_publication_readiness(", + "use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};", + "use zune_jpeg::JpegDecoder as StrictJpegDecoder;", + "mod sequential_jpeg;", + "sequential_jpeg::validate(bytes, container)?;", + "StrictJpegDecoder::new_with_options(ZCursor::new(bytes), strict_jpeg_decoder_options())", + ".set_strict_mode(true)", + ".set_use_unsafe(false)", + ".jpeg_set_out_colorspace(ColorSpace::RGB)", + ".decode_headers()", + ".output_buffer_size()", + ".decode_into(&mut decoded)", + "if !matches!(marker, 0xc0 | 0xc1) || data[0] != 8", + "PublicationJpegProcessForbidden", + "PngDecoder::with_limits(Cursor::new(bytes), raster_decode_limits())", + "WebPDecoder::new(Cursor::new(bytes))", + ".read_image(&mut decoded)", + "b\"radroots.blossom.publication-readiness-evidence.v1\\0\"", + ] { + if !source.contains(required) { + return Err(format!( + "{READINESS_SOURCE_RELATIVE} is missing governed fragment `{required}`" + )); + } + } + for required in [ + "struct SequentialJpegHuffmanTable", + "struct SequentialJpegEntropyReader", + "sampling_product_sum > 10", + "value_count > 256", + "seen_values[value_index]", + "unused_codes == 0", + "payload[payload.len() - 3..] != [0, 63, 0]", + "reader.finish_restart(expected_restart)?", + "seen_components[component.frame_index] = true", + "checked_mcu_grid_count", + ] { + if !sequential_jpeg_source.contains(required) { + return Err(format!( + "{SEQUENTIAL_JPEG_SOURCE_RELATIVE} is missing governed fragment `{required}`" + )); + } + } + let combined = format!("{source}\n{sequential_jpeg_source}"); + let lowercase = combined.to_ascii_lowercase(); + for forbidden in [ + "reqwest", + "hyper::", + "tokio::", + "axum::", + "std::net", + "std::fs", + "authorization: nostr", + "bearer ", + "cookie", + ] { + if lowercase.contains(forbidden) { + return Err(format!( + "{READINESS_SOURCE_RELATIVE} crosses the transport-neutral boundary with `{forbidden}`" + )); + } + } + if combined.contains("serde::Deserialize") || combined.contains("derive(Deserialize") { + return Err( + "publication readiness typestates must not gain forgeable Deserialize implementations" + .to_owned(), + ); + } + if combined.contains("pub struct RadrootsBlossomRasterDecodeObservation") + || combined.contains("decode: &RadrootsBlossom") + || combined.contains("ImageReader") + || combined.contains("load_from_memory") + || combined.contains("JpegDecoder::new(Cursor::new(bytes))") + || combined.contains("push_backend(") + || combined.contains("gamut_core") + || combined.contains("gamut_jpeg") + || combined.contains("jpeg_decoder::") + || combined.contains("use jpeg_decoder") + || combined.contains("extern crate jpeg_decoder") + || combined.contains("zenjpeg") + { + return Err( + "publication readiness must force declared-format decode authority internally from exact bytes" + .to_owned(), + ); + } + Ok(()) +} + +fn validate_vector(workspace_root: &Path) -> Result<(), String> { + let canonical = read_regular_file(workspace_root, VECTOR_CANONICAL_RELATIVE)?; + let mirror = read_regular_file(workspace_root, VECTOR_MIRROR_RELATIVE)?; + if canonical != mirror { + return Err(format!( + "{VECTOR_MIRROR_RELATIVE} must byte-match {VECTOR_CANONICAL_RELATIVE}" + )); + } + let vector: Value = serde_json::from_slice(&canonical) + .map_err(|error| format!("parse {VECTOR_CANONICAL_RELATIVE}: {error}"))?; + validate_vector_value(&vector) +} + +fn validate_vector_value(vector: &Value) -> Result<(), String> { + if vector.get("suite").and_then(Value::as_str) != Some("blossom_publication_readiness") + || vector.get("contract_version").and_then(Value::as_str) != Some("1.0.0") + { + return Err("publication-readiness vector identity drifted".to_owned()); + } + let cases = vector + .get("vectors") + .and_then(Value::as_array) + .ok_or_else(|| "publication-readiness vectors must be an array".to_owned())?; + if cases.len() != VECTOR_EXPECTATIONS.len() { + return Err(format!( + "publication-readiness vector count drifted: expected {}, found {}", + VECTOR_EXPECTATIONS.len(), + cases.len() + )); + } + for (case, (id, kind, mutation, expected_error)) in cases.iter().zip(VECTOR_EXPECTATIONS) { + let actual_id = case.get("id").and_then(Value::as_str); + let actual_kind = case.get("kind").and_then(Value::as_str); + let actual_mutation = case + .get("input") + .and_then(|input| input.get("mutation")) + .and_then(Value::as_str); + let actual_error = case + .get("expected") + .and_then(|expected| expected.get("error")) + .and_then(Value::as_str); + if actual_id != Some(*id) + || actual_kind != Some(*kind) + || actual_mutation != Some(*mutation) + || actual_error != *expected_error + { + return Err(format!( + "publication-readiness vector `{id}` identity or expected error drifted" + )); + } + let bytes_hex = case + .get("input") + .and_then(|input| input.get("bytes_hex")) + .and_then(Value::as_str) + .ok_or_else(|| format!("publication-readiness vector `{id}` lacks bytes_hex"))?; + if bytes_hex.len() != 140 + || !bytes_hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "publication-readiness vector `{id}` must bind the exact 70-byte lowercase-hex raster" + )); + } + } + Ok(()) +} + +fn validate_operation(workspace_root: &Path) -> Result<(), String> { + let manifest = parse_toml(workspace_root, OPERATIONS_RELATIVE)?; + let operation = manifest + .get("operations") + .and_then(|value| value.get("blossom_verify_publication_readiness")) + .ok_or_else(|| "operations contract lacks Blossom publication readiness".to_owned())?; + if operation.get("domain").and_then(toml::Value::as_str) != Some("blossom") + || operation.get("id").and_then(toml::Value::as_str) + != Some("blossom.verify_publication_readiness") + || operation.get("transport").and_then(toml::Value::as_str) != Some("none") + || operation.get("signing").and_then(toml::Value::as_str) != Some("none") + || operation + .get("deterministic") + .and_then(toml::Value::as_bool) + != Some(true) + { + return Err("Blossom publication-readiness operation authority drifted".to_owned()); + } + let shared = manifest + .get("shared_types") + .and_then(|value| value.get("public")) + .and_then(toml::Value::as_array) + .ok_or_else(|| "operations contract shared public types are missing".to_owned())? + .iter() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + if let Some(missing) = REQUIRED_PUBLIC_TYPES + .iter() + .find(|required| !shared.contains(**required)) + { + return Err(format!( + "operations contract lacks readiness public type `{missing}`" + )); + } + if shared.contains("RadrootsBlossomRasterDecodeObservation") { + return Err( + "operations contract must not expose caller-constructible raster decode authority" + .to_owned(), + ); + } + let inputs = operation + .get("inputs") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + let expected_inputs = BTreeSet::from([ + "Bytes", + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomBud01GetObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomByteVerifiedDescriptor", + ]); + if inputs != expected_inputs { + return Err( + "Blossom publication-readiness inputs must contain transport evidence and exact bytes only" + .to_owned(), + ); + } + let rust_types = operation + .get("implementation") + .and_then(|value| value.get("rust_types")) + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + if rust_types.contains("radroots_blossom::RadrootsBlossomRasterDecodeObservation") { + return Err( + "Blossom publication-readiness implementation must derive decode facts internally" + .to_owned(), + ); + } + Ok(()) +} + +fn validate_release(workspace_root: &Path) -> Result<(), String> { + let release = parse_toml(workspace_root, RELEASE_RELATIVE)?; + let changes = release + .get("changes") + .and_then(toml::Value::as_array) + .ok_or_else(|| format!("{RELEASE_RELATIVE} must declare changes"))?; + let matches = changes + .iter() + .filter(|change| change.get("id").and_then(toml::Value::as_str) == Some(RELEASE_CHANGE_ID)) + .collect::<Vec<_>>(); + if matches.len() != 1 + || matches[0] + .get("classification") + .and_then(toml::Value::as_str) + != Some("feature") + { + return Err(format!( + "{RELEASE_RELATIVE} must contain one feature change `{RELEASE_CHANGE_ID}`" + )); + } + let changelog = String::from_utf8(read_regular_file(workspace_root, CHANGELOG_RELATIVE)?) + .map_err(|error| format!("{CHANGELOG_RELATIVE} must be UTF-8: {error}"))?; + if changelog.matches(CHANGELOG_MARKER).count() != 1 { + return Err(format!( + "{CHANGELOG_RELATIVE} must contain exactly one readiness release marker" + )); + } + Ok(()) +} + +fn parse_toml(workspace_root: &Path, relative: &str) -> Result<toml::Value, String> { + let bytes = read_regular_file(workspace_root, relative)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("{relative} must be UTF-8 TOML: {error}"))?; + toml::from_str(source).map_err(|error| format!("parse {relative}: {error}")) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(64); + for byte in digest { + use std::fmt::Write; + write!(&mut output, "{byte:02x}").expect("String writes cannot fail"); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workspace_root() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("xtask workspace root") + .to_path_buf() + } + + #[test] + fn publication_readiness_vector_inventory_is_exact_and_mutation_sensitive() { + let bytes = read_regular_file(&workspace_root(), VECTOR_CANONICAL_RELATIVE).unwrap(); + let mut vector: Value = serde_json::from_slice(&bytes).unwrap(); + validate_vector_value(&vector).unwrap(); + vector["vectors"][0]["input"]["mutation"] = Value::String("renamed".to_owned()); + assert!( + validate_vector_value(&vector) + .unwrap_err() + .contains("valid_created") + ); + } + + #[test] + fn publication_readiness_source_boundary_rejects_transport_and_contract_drift() { + let source = String::from_utf8( + read_regular_file(&workspace_root(), READINESS_SOURCE_RELATIVE).unwrap(), + ) + .unwrap(); + let sequential_jpeg_source = String::from_utf8( + read_regular_file(&workspace_root(), SEQUENTIAL_JPEG_SOURCE_RELATIVE).unwrap(), + ) + .unwrap(); + validate_readiness_source_text(&source, &sequential_jpeg_source).unwrap(); + let injected = format!("{source}\nfn injected() {{ let _ = reqwest::get; }}\n"); + assert!( + validate_readiness_source_text(&injected, &sequential_jpeg_source) + .unwrap_err() + .contains("transport-neutral") + ); + let removed = source.replace( + "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_000", + "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_001", + ); + assert!( + validate_readiness_source_text(&removed, &sequential_jpeg_source) + .unwrap_err() + .contains("missing governed fragment") + ); + let weakened_jpeg = sequential_jpeg_source + .replace("sampling_product_sum > 10", "sampling_product_sum > 16"); + assert!( + validate_readiness_source_text(&source, &weakened_jpeg) + .unwrap_err() + .contains("missing governed fragment") + ); + let legacy_decoder = format!("{source}\nuse jpeg_decoder::Decoder;\n"); + assert!( + validate_readiness_source_text(&legacy_decoder, &sequential_jpeg_source) + .unwrap_err() + .contains("decode authority") + ); + + for (relative, expected_length, expected_sha256) in CURRENT_BYTE_BOUND_BLOSSOM_SOURCES { + let mut bytes = read_regular_file(&workspace_root(), relative).unwrap(); + validate_current_byte_bound_blossom_source( + relative, + &bytes, + *expected_length, + expected_sha256, + ) + .unwrap(); + bytes.push(b' '); + assert!( + validate_current_byte_bound_blossom_source( + relative, + &bytes, + *expected_length, + expected_sha256, + ) + .unwrap_err() + .contains("current-byte source") + ); + } + } + + #[test] + fn raw_predecessor_supersession_rejects_unknown_paths() { + let error = validate_raw_source_rebuild_predecessor_production_sources_under_lock( + &workspace_root(), + &["not/a/predecessor.rs"], + &[], + ) + .unwrap_err(); + assert!(error.contains("not predecessor-bound"), "{error}"); + } + + #[test] + fn retired_raw_predecessor_write_route_is_validation_only() { + let root = workspace_root(); + let before = IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS + .iter() + .map(|(relative, _, _)| { + ( + *relative, + read_regular_file(&root, relative).expect("immutable raw predecessor artifact"), + ) + }) + .collect::<Vec<_>>(); + super::super::raw_source_rebuild::write_raw_source_rebuild_manifest(&root) + .expect("retired raw predecessor write route validates its active successor"); + for (relative, expected) in before { + assert_eq!( + read_regular_file(&root, relative).expect("raw predecessor after validation"), + expected, + "retired raw predecessor writer mutated {relative}" + ); + } + + let source = String::from_utf8( + read_regular_file(&root, RAW_PREDECESSOR_GOVERNANCE_RELATIVE).unwrap(), + ) + .unwrap(); + validate_raw_predecessor_successor_routing(&source).unwrap(); + let bypass = source.replacen( + "validate_raw_source_rebuild_manifest(workspace_root)", + "Ok(())", + 1, + ); + assert!( + validate_raw_predecessor_successor_routing(&bypass) + .unwrap_err() + .contains("validation-only successor route") + ); + } +} diff --git a/tools/xtask/src/contract/food_availability_projection.rs b/tools/xtask/src/contract/food_availability_projection.rs @@ -3993,6 +3993,11 @@ mod tests { #[test] fn downstream_nip09_only_supersession_is_transitively_validated() { const CURRENT_SUCCESSOR_SUPERSEDED_PATHS: &[&str] = &[ + "Cargo.toml", + "crates/blossom/Cargo.toml", + "crates/blossom/src/error.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs", "crates/event_store/Cargo.toml", "crates/event_store/src/error.rs", "crates/event_store/src/generated.rs", diff --git a/tools/xtask/src/contract/nip09_reconciliation.rs b/tools/xtask/src/contract/nip09_reconciliation.rs @@ -2375,6 +2375,13 @@ fn nip09_predecessor_production_source_paths( .map(|source| source.path.as_str()) .chain( manifest + .cargo_feature_profile + .packages + .iter() + .map(|package| package.manifest_path.as_str()), + ) + .chain( + manifest .source_route_witnesses .iter() .map(|source| source.path.as_str()), @@ -17218,7 +17225,12 @@ mod tests { use super::*; use std::fs; - const RAW_SOURCE_REBUILD_PREDECESSOR_SUPERSEDED_PATHS: [&str; 11] = [ + const RAW_SOURCE_REBUILD_PREDECESSOR_SUPERSEDED_PATHS: [&str; 16] = [ + "Cargo.toml", + "crates/blossom/Cargo.toml", + "crates/blossom/src/error.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs", "crates/event_store/Cargo.toml", "crates/event_store/src/error.rs", "crates/event_store/src/generated.rs", @@ -17359,6 +17371,55 @@ mod tests { .expect("serialize predecessor event-codec Cargo manifest"); fs::write(codec_manifest_path, codec_predecessor) .expect("restore predecessor event-codec compiler manifest"); + + let blossom_path = workspace_root.join(BLOSSOM_CARGO_MANIFEST_RELATIVE); + let blossom_source = fs::read_to_string(&blossom_path).expect("Blossom Cargo manifest"); + let mut blossom_manifest: toml::Value = + toml::from_str(&blossom_source).expect("parse Blossom Cargo manifest"); + let raster_decode = blossom_manifest + .get_mut("features") + .and_then(toml::Value::as_table_mut) + .and_then(|features| features.remove("raster-decode")) + .expect("successor raster-decode feature must be present in the live fixture"); + assert_eq!( + raster_decode + .as_array() + .expect("raster-decode feature array") + .iter() + .map(toml::Value::as_str) + .collect::<Vec<_>>(), + [ + Some("std"), + Some("dep:image"), + Some("dep:zune-core"), + Some("dep:zune-jpeg") + ], + "successor raster-decode feature must retain its exact semantic shape" + ); + let blossom_dependencies = blossom_manifest + .get_mut("dependencies") + .and_then(toml::Value::as_table_mut) + .expect("Blossom dependencies"); + for dependency in ["image", "zune-core", "zune-jpeg"] { + let removed = blossom_dependencies + .remove(dependency) + .unwrap_or_else(|| panic!("successor dependency {dependency} must be present")); + let expected: toml::Value = + toml::from_str("dependency = { workspace = true, optional = true }") + .expect("parse expected successor dependency"); + assert_eq!( + removed, + expected + .get("dependency") + .expect("expected successor dependency") + .clone(), + "successor dependency {dependency} must retain its exact semantic shape" + ); + } + let blossom_predecessor = toml::to_string_pretty(&blossom_manifest) + .expect("serialize predecessor Blossom Cargo manifest"); + fs::write(blossom_path, blossom_predecessor) + .expect("restore predecessor Blossom compiler manifest"); } fn strip_outer_try(statement: &mut syn::Stmt) { @@ -21246,7 +21307,6 @@ version = "0.1.0" for (relative, first_module) in [ ("crates/event/src/lib.rs", "pub mod account;"), ("crates/event_codec/src/lib.rs", "pub mod d_tag;"), - ("crates/blossom/src/lib.rs", "pub mod authorization;"), ] { let spec = *SOURCE_ROUTE_WITNESS_SPECS .iter() diff --git a/tools/xtask/src/contract/phase1_publication_allowlist.rs b/tools/xtask/src/contract/phase1_publication_allowlist.rs @@ -1746,7 +1746,12 @@ fn manifest_schema() -> Value { "excluded_capabilities": {"type": "array", "minItems": 7, "maxItems": 7, "items": {"type": "string", "minLength": 1}} } }, - "predecessor_source_supersessions": {"type": "array", "minItems": 8, "maxItems": 8, "items": {"type": "string", "minLength": 1}}, + "predecessor_source_supersessions": { + "type": "array", + "minItems": PUBLICATION_SUCCESSOR_SUPERSEDED_PATHS.len(), + "maxItems": PUBLICATION_SUCCESSOR_SUPERSEDED_PATHS.len(), + "items": {"type": "string", "minLength": 1} + }, "source_files": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/source"}}, "result_vector": { "type": "object", "additionalProperties": false, diff --git a/tools/xtask/src/contract/phase1_publication_artifact.rs b/tools/xtask/src/contract/phase1_publication_artifact.rs @@ -120,6 +120,12 @@ const RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ "tools/xtask/src/contract/nip09_reconciliation.rs", "tools/xtask/src/contract/raw_source_rebuild.rs", ]; +const BLOSSOM_READINESS_RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ + "Cargo.lock", + "Cargo.toml", + "build/nix/common.nix", + "tools/xtask/src/contract/food_availability_projection.rs", +]; const PUBLIC_TYPES: &[&str] = &[ "RadrootsPhase1PublicationEventVariant", @@ -279,13 +285,21 @@ const PUBLICATION_IMMUTABLE_ARTIFACTS: &[ImmutableArtifactSpec] = &[ ]; pub(super) const PUBLICATION_SUCCESSOR_SUPERSEDED_PATHS: &[&str] = &[ + "Cargo.lock", + "Cargo.toml", CHANGELOG_RELATIVE, OPERATIONS_RELATIVE, RELEASE_RELATIVE, + "crates/blossom/Cargo.toml", + "crates/blossom/src/error.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs", "crates/event_codec/README", "crates/event_codec/src/wire/publication.rs", "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", "tools/xtask/src/contract/phase1_publication_artifact.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", "tools/xtask/src/main.rs", ]; @@ -1389,8 +1403,16 @@ fn validate_immutable_raw_predecessor_under_lock(workspace_root: &Path) -> Resul let superseded = RAW_PREDECESSOR_SUPERSEDED_PATHS .iter() .copied() + .chain( + BLOSSOM_READINESS_RAW_PREDECESSOR_SUPERSEDED_PATHS + .iter() + .copied(), + ) .collect::<BTreeSet<_>>(); - if superseded.len() != RAW_PREDECESSOR_SUPERSEDED_PATHS.len() { + if superseded.len() + != RAW_PREDECESSOR_SUPERSEDED_PATHS.len() + + BLOSSOM_READINESS_RAW_PREDECESSOR_SUPERSEDED_PATHS.len() + { return Err("raw predecessor supersession paths must be unique".to_owned()); } let sources = manifest @@ -1937,9 +1959,10 @@ mod tests { } #[test] - fn publication_compiler_operations_and_vector_authority_are_current() { + fn publication_predecessor_operations_and_vector_authority_are_current() { let root = workspace_root(); - validate_compiler_authority(&root).expect("compiler authority"); + validate_immutable_phase1_publication_artifact_predecessor(&root) + .expect("immutable publication predecessor"); validate_operations_authority(&root).expect("operations authority"); validate_result_vector(&root).expect("result vector"); } diff --git a/tools/xtask/src/contract/raw_source_rebuild.rs b/tools/xtask/src/contract/raw_source_rebuild.rs @@ -1,6 +1,4 @@ -use super::artifact_bundle::{ - GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction, -}; +use super::artifact_bundle::{GeneratedArtifact, read_regular_file}; use super::food_availability_projection::validate_food_availability_projection_predecessor_production_sources_under_lock; use super::nip09_reconciliation::{ governed_regular_file_inventory, validate_current_event_store_successor_authority, @@ -875,6 +873,11 @@ const EXPECTED_SOURCE_MAINTENANCE_DRIFT_PATHS: &[&str] = &[ ]; const TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ + "Cargo.toml", + "crates/blossom/Cargo.toml", + "crates/blossom/src/error.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs", "crates/event_store/Cargo.toml", "crates/event_store/src/error.rs", "crates/event_store/src/generated.rs", @@ -887,6 +890,16 @@ const TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ "crates/event_store/src/store/food_availability_projection_v1.rs", "crates/event_store/src/store/protocol_reconciliation_v1.rs", ]; +const BLOSSOM_READINESS_SUCCESSOR_TRANSITIVE_PATHS: &[&str] = &[ + "Cargo.toml", + "crates/blossom/Cargo.toml", + "crates/blossom/src/error.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs", +]; +#[cfg(test)] +const BLOSSOM_READINESS_SUCCESSOR_DELEGATED_COMPILER_PATHS: &[&str] = + &[CONTRACT_LANE_SOURCE_RELATIVE]; const GENERATED_ARTIFACT_PATHS: &[&str] = &[ MANIFEST_RELATIVE, @@ -1090,16 +1103,71 @@ struct VectorCase { } pub(crate) fn write_raw_source_rebuild_manifest(workspace_root: &Path) -> Result<(), String> { - with_artifact_bundle_transaction(workspace_root, |transaction| { - transaction.write(expected_artifacts(workspace_root)?)?; - validate_raw_source_rebuild_manifest_under_lock(workspace_root) - }) + validate_raw_source_rebuild_manifest(workspace_root) } pub(crate) fn validate_raw_source_rebuild_manifest(workspace_root: &Path) -> Result<(), String> { - with_artifact_bundle_transaction(workspace_root, |_| { - validate_raw_source_rebuild_manifest_under_lock(workspace_root) - }) + // Keep the frozen predecessor validator compiled for its governed mutation suite. + let _immutable_predecessor_validator: fn(&Path) -> Result<(), String> = + validate_raw_source_rebuild_manifest_under_lock; + super::blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root) +} + +pub(super) fn validate_raw_source_rebuild_predecessor_production_sources_under_lock( + workspace_root: &Path, + raw_superseded_paths: &[&str], + transitive_superseded_paths: &[&str], +) -> Result<(), String> { + let manifest_bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; + let manifest: RawSourceRebuildManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; + validate_manifest_shape(&manifest)?; + + let superseded = raw_superseded_paths + .iter() + .copied() + .collect::<BTreeSet<_>>(); + if superseded.len() != raw_superseded_paths.len() { + return Err("raw-source rebuild successor supersession paths must be unique".to_owned()); + } + let predecessor_paths = manifest + .source_files + .iter() + .map(|source| source.path.as_str()) + .collect::<BTreeSet<_>>(); + if let Some(path) = superseded + .iter() + .find(|path| !predecessor_paths.contains(**path)) + { + return Err(format!( + "raw-source rebuild successor supersession path `{path}` is not predecessor-bound" + )); + } + + for source in &manifest.source_files { + if superseded.contains(source.path.as_str()) { + continue; + } + let current = read_regular_file(workspace_root, &source.path)?; + if current.len() as u64 != source.byte_length || sha256_hex(&current) != source.sha256 { + return Err(format!( + "unchanged raw-source rebuild predecessor source `{}` drifted", + source.path + )); + } + } + + let transitive = TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS + .iter() + .copied() + .chain(transitive_superseded_paths.iter().copied()) + .collect::<BTreeSet<_>>() + .into_iter() + .collect::<Vec<_>>(); + validate_food_availability_projection_predecessor_production_sources_under_lock( + workspace_root, + &transitive, + ) } fn validate_raw_source_rebuild_manifest_under_lock(workspace_root: &Path) -> Result<(), String> { @@ -1683,7 +1751,33 @@ fn validate_complete_event_store_source_closure(workspace_root: &Path) -> Result } fn validate_delegated_compiler_source_pins(workspace_root: &Path) -> Result<(), String> { + validate_delegated_compiler_source_pins_with_supersessions(workspace_root, &[]) +} + +fn validate_delegated_compiler_source_pins_with_supersessions( + workspace_root: &Path, + superseded_paths: &[&str], +) -> Result<(), String> { + let superseded = superseded_paths.iter().copied().collect::<BTreeSet<_>>(); + if superseded.len() != superseded_paths.len() { + return Err("delegated compiler source supersession paths must be unique".to_owned()); + } + let pinned = DELEGATED_COMPILER_SOURCE_PINS + .iter() + .map(|(relative, _)| *relative) + .collect::<BTreeSet<_>>(); + if let Some(relative) = superseded + .iter() + .find(|relative| !pinned.contains(**relative)) + { + return Err(format!( + "delegated compiler source supersession path `{relative}` is not predecessor-pinned" + )); + } for (relative, expected_sha256) in DELEGATED_COMPILER_SOURCE_PINS { + if superseded.contains(relative) { + continue; + } let actual_sha256 = sha256_hex(&read_regular_file(workspace_root, relative)?); if actual_sha256 != *expected_sha256 { return Err(format!( @@ -1944,10 +2038,11 @@ fn validate_predecessor_source_supersession( .to_owned(), ); } - if let Some(path) = transitive - .iter() - .find(|path| !successor_paths.contains(**path) && !predecessor_paths.contains(**path)) - { + if let Some(path) = transitive.iter().find(|path| { + !successor_paths.contains(**path) + && !predecessor_paths.contains(**path) + && !BLOSSOM_READINESS_SUCCESSOR_TRANSITIVE_PATHS.contains(path) + }) { return Err(format!( "raw-source rebuild transitive supersession path `{path}` is not bound by the current successor or immutable SourceMaintenance predecessor" )); @@ -4464,7 +4559,7 @@ fn validate_command_reachability(workspace_root: &Path) -> Result<(), String> { )?); for ordered in [ "validate_source_maintenance_manifest(workspace_root)?", - "validate_raw_source_rebuild_manifest(workspace_root)?", + "blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root)?", "validate_knowledge_contract_manifest(workspace_root)", ] { if !aggregate.contains(ordered) { @@ -4476,15 +4571,17 @@ fn validate_command_reachability(workspace_root: &Path) -> Result<(), String> { let source_index = aggregate .find("validate_source_maintenance_manifest(workspace_root)?") .expect("checked above"); - let rebuild_index = aggregate - .find("validate_raw_source_rebuild_manifest(workspace_root)?") + let readiness_index = aggregate + .find( + "blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root)?", + ) .expect("checked above"); let knowledge_index = aggregate .find("validate_knowledge_contract_manifest(workspace_root)") .expect("checked above"); - if !(source_index < rebuild_index && rebuild_index < knowledge_index) { + if !(source_index < readiness_index && readiness_index < knowledge_index) { return Err( - "aggregate contract authority must validate the immutable predecessor before raw-source rebuild and knowledge contracts" + "aggregate contract authority must validate immutable predecessors before the Blossom readiness successor and knowledge contracts" .to_owned(), ); } @@ -5091,7 +5188,7 @@ fn validate_delegated_suite_contract_lane_sources( || cargo_arg_lines[1] != "lib.concatStringsSep \" \" (map (crate: \"-p ${crate}\") coreContractCrates)" || cargo_arg_lines[2] - != "+ \" --features radroots_event_codec/serde_json,radroots_event_codec/nostr,radroots_nostr/blossom,radroots_nostr/client,radroots_nostr/codec,radroots_nostr/events\";" + != "+ \" --features radroots_blossom/raster-decode,radroots_event_codec/serde_json,radroots_event_codec/nostr,radroots_nostr/blossom,radroots_nostr/client,radroots_nostr/codec,radroots_nostr/events\";" { return Err(format!( "{CONTRACT_LANE_SOURCE_RELATIVE} coreContractCargoArgs must map every literal core contract crate to an unfiltered `-p` package selection" @@ -6503,7 +6600,11 @@ mod tests { .expect("complete event-store Rust source closure"); validate_successor_compiler_input_authority(&root) .expect("complete event-store compiler-input authority"); - validate_delegated_compiler_source_pins(&root).expect("delegated compiler source pins"); + validate_delegated_compiler_source_pins_with_supersessions( + &root, + BLOSSOM_READINESS_SUCCESSOR_DELEGATED_COMPILER_PATHS, + ) + .expect("delegated compiler source pins outside the active Blossom successor"); validate_xtask_manifest_authority(&root).expect("xtask compiler authority"); } @@ -6517,12 +6618,26 @@ mod tests { .expect("create compiler pin parent"); fs::copy(root.join(relative), destination).expect("copy compiler pin source"); } - validate_delegated_compiler_source_pins(pinned_workspace.path()) - .expect("current compiler source pins"); + validate_delegated_compiler_source_pins_with_supersessions( + pinned_workspace.path(), + BLOSSOM_READINESS_SUCCESSOR_DELEGATED_COMPILER_PATHS, + ) + .expect("current compiler source pins outside the active Blossom successor"); fs::write(pinned_workspace.path().join(FLAKE_LOCK_RELATIVE), "{}\n") .expect("mutate pinned flake lock"); - validate_delegated_compiler_source_pins(pinned_workspace.path()) - .expect_err("compiler source mutation must fail closed"); + validate_delegated_compiler_source_pins_with_supersessions( + pinned_workspace.path(), + BLOSSOM_READINESS_SUCCESSOR_DELEGATED_COMPILER_PATHS, + ) + .expect_err("compiler source mutation must fail closed"); + + let unknown = ["build/nix/not-predecessor-pinned.nix"]; + let error = validate_delegated_compiler_source_pins_with_supersessions( + pinned_workspace.path(), + &unknown, + ) + .expect_err("unknown compiler source supersession must fail closed"); + assert!(error.contains("not predecessor-pinned"), "{error}"); let manifest_workspace = tempfile::tempdir().expect("xtask manifest workspace"); let manifest_path = manifest_workspace.path().join(XTASK_MANIFEST_RELATIVE); @@ -7279,6 +7394,14 @@ mod tests { &root, ) .expect("second immutable predecessor validation"); + + let checked_in = read_regular_file(&root, MANIFEST_RELATIVE).expect("immutable manifest"); + let manifest: RawSourceRebuildManifest = + serde_json::from_slice(&checked_in).expect("typed immutable manifest"); + let first = canonical_json_bytes(&manifest).expect("first immutable render"); + let second = canonical_json_bytes(&manifest).expect("second immutable render"); + assert_eq!(first, second); + assert_eq!(first, checked_in); } #[test]