lib

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

commit 90cf01f1654f83166352af3af033081c196d92d8
parent 0a6d6dc44763372fe7da267537fa1f5236b89138
Author: triesap <tyson@radroots.org>
Date:   Wed, 22 Jul 2026 06:00:28 +0000

blossom: enforce strict publication raster decoding

- derive raster observations from bounded complete BUD-01 bytes
- accept only static PNG/WebP and 8-bit sequential JPEG entropy
- pin decoder features and govern exact no-default coverage lanes
- preserve frozen predecessor artifacts through successor validation

Diffstat:
MCHANGELOG.md | 15+++++++++++----
MCargo.lock | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 8++++++++
Mbuild/nix/checks.nix | 27+++++++++++++++++++++++++++
Mbuild/nix/common.nix | 3++-
Mcontracts/conformance/vectors/blossom/publication_readiness.v1.json | 164++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcontracts/coverage-profiles.toml | 5+++++
Mcontracts/events/blossom-media.md | 68++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcontracts/operations.toml | 3---
Mcontracts/releases/1.0.0-alpha.1.toml | 2+-
Mcrates/blossom/Cargo.toml | 4++++
Mcrates/blossom/README | 26+++++++++++++++++++-------
Mcrates/blossom/src/error.rs | 61++++++++++++++++++++++++++++++++-----------------------------
Mcrates/blossom/src/lib.rs | 7+++++--
Mcrates/blossom/src/publication_readiness.rs | 1060+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Acrates/blossom/src/publication_readiness/sequential_jpeg.rs | 1244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/src/url.rs | 12++++++++++++
Mcrates/blossom/tests/fixtures/publication_readiness.v1.json | 164++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/blossom/tests/publication_readiness.rs | 454++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtools/xtask/src/contract/blossom_publication_readiness.rs | 447++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtools/xtask/src/contract/food_availability_projection.rs | 3+++
Mtools/xtask/src/contract/nip09_reconciliation.rs | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtools/xtask/src/contract/raw_source_rebuild.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
23 files changed, 3580 insertions(+), 442 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -176,10 +176,17 @@ publish policy both pass for the same source revision. 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, and binds one decoded frame to - dimensions within 16,384 per axis and 20,000,000 pixels. Deterministic - per-URL evidence remains transport-neutral and contains no HTTP credentials, - BUD-11 material, entitlement decision, or private service topology. + 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]] @@ -9238,3 +9315,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 @@ -6,31 +6,31 @@ "id": "valid_created", "kind": "blossom.verify_publication_readiness.valid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "none" }, "expected": { - "url": "https://cdn.example/0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9.png", - "sha256": "0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9", + "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": "c52edeba688fa36c7963a478a35ff78504d7dd79a637c67f93d5acb635110660" + "evidence_digest": "44e63303e594ea42d863be995b23ac4297ed77e4378d0707c94f28e77164bd3b" } }, { "id": "valid_ok_without_authored_dimensions", "kind": "blossom.verify_publication_readiness.valid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_status_200" }, "expected": { - "url": "https://cdn.example/0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9.png", - "sha256": "0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9", + "url": "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png", + "sha256": "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd", "size": 70, "media_type": "image/png", "format": "png", @@ -43,7 +43,7 @@ "id": "invalid_upload_status", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_status_202" }, "expected": { @@ -54,7 +54,7 @@ "id": "invalid_head_status", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "head_status_204" }, "expected": { @@ -65,7 +65,7 @@ "id": "invalid_get_status", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_status_206" }, "expected": { @@ -76,7 +76,7 @@ "id": "declared_size_over_public_max", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_size_over_max" }, "expected": { @@ -87,7 +87,7 @@ "id": "missing_get_body", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_body_missing" }, "expected": { @@ -98,7 +98,7 @@ "id": "short_get_body", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_body_short" }, "expected": { @@ -109,7 +109,7 @@ "id": "trailing_get_body", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_body_trailing" }, "expected": { @@ -120,7 +120,7 @@ "id": "authored_bytes_short", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "authored_bytes_short" }, "expected": { @@ -131,7 +131,7 @@ "id": "authored_bytes_wrong_hash", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "authored_bytes_wrong_hash" }, "expected": { @@ -142,7 +142,7 @@ "id": "upload_url_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_url_mismatch" }, "expected": { @@ -153,7 +153,7 @@ "id": "upload_hash_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_hash_mismatch" }, "expected": { @@ -164,7 +164,7 @@ "id": "upload_size_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_size_mismatch" }, "expected": { @@ -175,7 +175,7 @@ "id": "upload_mime_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_mime_mismatch" }, "expected": { @@ -186,7 +186,7 @@ "id": "head_url_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "head_url_mismatch" }, "expected": { @@ -197,7 +197,7 @@ "id": "head_size_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "head_size_mismatch" }, "expected": { @@ -208,7 +208,7 @@ "id": "head_mime_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "head_mime_mismatch" }, "expected": { @@ -219,7 +219,7 @@ "id": "get_url_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_url_mismatch" }, "expected": { @@ -230,7 +230,7 @@ "id": "get_declared_size_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_declared_size_mismatch" }, "expected": { @@ -241,7 +241,7 @@ "id": "get_complete_hash_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_bytes_wrong_hash" }, "expected": { @@ -252,7 +252,7 @@ "id": "unsupported_raster_mime", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "unsupported_mime" }, "expected": { @@ -263,7 +263,7 @@ "id": "malformed_raster", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "malformed_container" }, "expected": { @@ -274,62 +274,62 @@ "id": "animated_png", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "animated_png" }, "expected": { - "error": "publication_raster_frame_count_mismatch" + "error": "publication_raster_animation_forbidden" } }, { - "id": "decode_format_mismatch", + "id": "declared_format_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_format_mismatch" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "declared_mime_jpeg" }, "expected": { - "error": "publication_raster_decode_format_mismatch" + "error": "invalid_publication_raster" } }, { - "id": "decode_length_mismatch", + "id": "corrupt_png_crc", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_length_mismatch" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "corrupt_png_crc" }, "expected": { - "error": "publication_raster_decode_length_mismatch" + "error": "publication_raster_decode_failed" } }, { - "id": "decode_hash_mismatch", + "id": "corrupt_png_deflate", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_hash_mismatch" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "corrupt_png_deflate" }, "expected": { - "error": "publication_raster_decode_hash_mismatch" + "error": "publication_raster_decode_failed" } }, { - "id": "decode_container_dimension_mismatch", + "id": "invalid_png_color_type", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_container_dimension_mismatch" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "invalid_png_color_type" }, "expected": { - "error": "publication_raster_container_dimension_mismatch" + "error": "publication_raster_decode_failed" } }, { "id": "authored_dimension_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "authored_dimension_mismatch" }, "expected": { @@ -337,48 +337,92 @@ } }, { - "id": "decode_zero_frames", + "id": "animated_webp", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_zero_frames" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "animated_webp" }, "expected": { - "error": "publication_raster_frame_count_mismatch" + "error": "publication_raster_animation_forbidden" } }, { - "id": "decode_zero_width", + "id": "zero_width", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_zero_width" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "zero_width" }, "expected": { "error": "publication_raster_dimensions_out_of_range" } }, { - "id": "decode_dimension_over_max", + "id": "dimension_over_max", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_dimension_over_max" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "dimension_over_max" }, "expected": { "error": "publication_raster_dimensions_out_of_range" } }, { - "id": "decode_pixel_limit", + "id": "pixel_limit", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_pixel_limit" + "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 @@ -241,12 +241,13 @@ The public typed API exposes these stable semantic identifiers: - `publication_retrieved_bytes_mismatch` - `unsupported_publication_raster_media_type` - `invalid_publication_raster` -- `publication_raster_frame_count_mismatch` +- `publication_jpeg_process_forbidden` +- `publication_raster_animation_forbidden` - `publication_raster_dimensions_out_of_range` - `publication_raster_pixel_limit_exceeded` -- `publication_raster_decode_format_mismatch` -- `publication_raster_decode_length_mismatch` -- `publication_raster_decode_hash_mismatch` +- `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` @@ -271,8 +272,8 @@ approval, and byte verification into one indistinguishable state. ## Publication Readiness Evidence `RadrootsBlossomPublicationReadinessEvidence` is a transport-neutral proof assembled only after -all of these independently supplied observations agree with the exact byte-verified authored -descriptor and deterministic raster bytes: +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; @@ -281,24 +282,46 @@ descriptor and deterministic raster bytes: `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. a decoder observation is bound to those same complete bytes and reports the matching closed - raster format, exactly one frame, and bounded dimensions. +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 decoder observation. Width and height are each -within `1..=16,384`, and their product is at most `20,000,000` pixels. 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. +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, private endpoints, or an image-decoder implementation. The owning runtime must -construct the decode observation from its approved decoder over the exact complete body. The core -then verifies the observation's byte hash, length, format, frame count, container dimensions, and -product dimensions. A decode observation is not independently trustworthy without that runtime -adapter and does not claim server availability after the observation. +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 @@ -310,4 +333,9 @@ 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. +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 @@ -37,7 +37,6 @@ public = [ "RadrootsBlossomRasterFormat", "RadrootsBlossomRasterDimensions", "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomRasterDecodeObservation", "RadrootsBlossomPublicationReadinessEvidenceDigest", "RadrootsBlossomPublicationReadinessEvidence", "RadrootsBlossomAuthorizationAction", @@ -373,7 +372,6 @@ inputs = [ "RadrootsBlossomBud02UploadObservation", "RadrootsBlossomBud01HeadObservation", "RadrootsBlossomBud01GetObservation", - "RadrootsBlossomRasterDecodeObservation", ] outputs = ["RadrootsBlossomPublicationReadinessEvidence"] error_class = "validation_error" @@ -390,7 +388,6 @@ rust_types = [ "radroots_blossom::RadrootsBlossomBud01HeadObservation", "radroots_blossom::RadrootsBlossomBud02UploadObservation", "radroots_blossom::RadrootsBlossomPublicationReadinessEvidence", - "radroots_blossom::RadrootsBlossomRasterDecodeObservation", ] [operations.blossom_verify_publication_readiness.conformance] diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -461,4 +461,4 @@ semver_impacts = [ "add_enum_variant", "add_conformance_vector", ] -summary = "Add transport-neutral BUD-02 plus BUD-01 publication-readiness evidence with bounded complete-byte verification and a closed single-frame JPEG, PNG, and still-WebP raster profile." +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 @@ -18,13 +18,25 @@ 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 an exact decoder -observation for one JPEG, PNG, or still-WebP frame within the public dimension -and pixel limits. The crate checks complete-byte, URL, hash, MIME, length, -container, frame, and dimension agreement and emits deterministic per-URL -evidence. It still performs no HTTP or image decoding: an owning runtime must -supply transport results and an approved decoder observation over the exact -complete body. +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 @@ -64,12 +64,13 @@ pub enum RadrootsBlossomError { PublicationRetrievedBytesMismatch, UnsupportedPublicationRasterMediaType, InvalidPublicationRaster, - PublicationRasterFrameCountMismatch { actual: u32 }, + PublicationJpegProcessForbidden, + PublicationRasterAnimationForbidden, PublicationRasterDimensionsOutOfRange { width: u32, height: u32 }, PublicationRasterPixelLimitExceeded { pixels: u64 }, - PublicationRasterDecodeFormatMismatch, - PublicationRasterDecodeLengthMismatch { expected: u64, actual: u64 }, - PublicationRasterDecodeHashMismatch, + PublicationRasterDecodedByteLimitExceeded { decoded: u64, maximum: u64 }, + PublicationRasterDecodeAllocationFailed, + PublicationRasterDecodeFailed, PublicationRasterContainerDimensionMismatch, PublicationAuthoredRasterDimensionMismatch, } @@ -150,22 +151,21 @@ impl RadrootsBlossomError { "unsupported_publication_raster_media_type" } Self::InvalidPublicationRaster => "invalid_publication_raster", - Self::PublicationRasterFrameCountMismatch { .. } => { - "publication_raster_frame_count_mismatch" - } + 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::PublicationRasterDecodeFormatMismatch => { - "publication_raster_decode_format_mismatch" + Self::PublicationRasterDecodedByteLimitExceeded { .. } => { + "publication_raster_decoded_byte_limit_exceeded" } - Self::PublicationRasterDecodeLengthMismatch { .. } => { - "publication_raster_decode_length_mismatch" + Self::PublicationRasterDecodeAllocationFailed => { + "publication_raster_decode_allocation_failed" } - Self::PublicationRasterDecodeHashMismatch => "publication_raster_decode_hash_mismatch", + Self::PublicationRasterDecodeFailed => "publication_raster_decode_failed", Self::PublicationRasterContainerDimensionMismatch => { "publication_raster_container_dimension_mismatch" } @@ -349,10 +349,12 @@ impl fmt::Display for RadrootsBlossomError { Self::InvalidPublicationRaster => { f.write_str("publication raster container is malformed or incomplete") } - Self::PublicationRasterFrameCountMismatch { actual } => write!( - f, - "publication raster must contain exactly one frame, got {actual}" - ), + 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}" @@ -361,15 +363,15 @@ impl fmt::Display for RadrootsBlossomError { f, "publication raster pixel count {pixels} exceeds 20000000" ), - Self::PublicationRasterDecodeFormatMismatch => { - f.write_str("decoded raster format does not match the exact media type") - } - Self::PublicationRasterDecodeLengthMismatch { expected, actual } => write!( + Self::PublicationRasterDecodedByteLimitExceeded { decoded, maximum } => write!( f, - "decoded raster byte length mismatch: expected {expected}, got {actual}" + "publication raster requires {decoded} decoded bytes, exceeding maximum {maximum}" ), - Self::PublicationRasterDecodeHashMismatch => { - f.write_str("decoded raster complete-byte hash does not match the authored hash") + 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", @@ -455,18 +457,19 @@ mod tests { RadrootsBlossomError::PublicationRetrievedBytesMismatch, RadrootsBlossomError::UnsupportedPublicationRasterMediaType, RadrootsBlossomError::InvalidPublicationRaster, - RadrootsBlossomError::PublicationRasterFrameCountMismatch { actual: 2 }, + RadrootsBlossomError::PublicationJpegProcessForbidden, + RadrootsBlossomError::PublicationRasterAnimationForbidden, RadrootsBlossomError::PublicationRasterDimensionsOutOfRange { width: 0, height: 1, }, RadrootsBlossomError::PublicationRasterPixelLimitExceeded { pixels: 20_000_001 }, - RadrootsBlossomError::PublicationRasterDecodeFormatMismatch, - RadrootsBlossomError::PublicationRasterDecodeLengthMismatch { - expected: 1, - actual: 2, + RadrootsBlossomError::PublicationRasterDecodedByteLimitExceeded { + decoded: 2, + maximum: 1, }, - RadrootsBlossomError::PublicationRasterDecodeHashMismatch, + RadrootsBlossomError::PublicationRasterDecodeAllocationFailed, + RadrootsBlossomError::PublicationRasterDecodeFailed, RadrootsBlossomError::PublicationRasterContainerDimensionMismatch, RadrootsBlossomError::PublicationAuthoredRasterDimensionMismatch, RadrootsBlossomError::InvalidAuthorizationContent, diff --git a/crates/blossom/src/lib.rs b/crates/blossom/src/lib.rs @@ -26,16 +26,19 @@ 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, RadrootsBlossomRasterDecodeObservation, - RadrootsBlossomRasterDimensions, RadrootsBlossomRasterFormat, verify_publication_readiness, + RadrootsBlossomPublicationReadinessEvidenceDigest, RadrootsBlossomRasterDimensions, + RadrootsBlossomRasterFormat, }; pub use url::{RadrootsBlossomApprovedBlobUrl, RadrootsBlossomBlobUrl}; diff --git a/crates/blossom/src/publication_readiness.rs b/crates/blossom/src/publication_readiness.rs @@ -1,20 +1,39 @@ 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, - RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError, RadrootsBlossomMediaType, - RadrootsBlossomSha256, + 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"; @@ -68,6 +87,7 @@ impl RadrootsBlossomRasterFormat { } } + #[cfg(feature = "raster-decode")] const fn digest_code(self) -> u8 { match self { Self::Jpeg => 1, @@ -127,6 +147,7 @@ pub enum RadrootsBlossomAuthoredRasterDimensions { } impl RadrootsBlossomAuthoredRasterDimensions { + #[cfg(feature = "raster-decode")] const fn exact(self) -> Option<RadrootsBlossomRasterDimensions> { match self { Self::Unspecified => None, @@ -135,53 +156,6 @@ impl RadrootsBlossomAuthoredRasterDimensions { } } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsBlossomRasterDecodeObservation { - format: RadrootsBlossomRasterFormat, - complete_bytes_sha256: RadrootsBlossomSha256, - complete_byte_length: u64, - dimensions: RadrootsBlossomRasterDimensions, -} - -impl RadrootsBlossomRasterDecodeObservation { - pub fn new( - format: RadrootsBlossomRasterFormat, - complete_bytes_sha256: RadrootsBlossomSha256, - complete_byte_length: u64, - frame_count: u32, - width: u32, - height: u32, - ) -> Result<Self, RadrootsBlossomError> { - if frame_count != 1 { - return Err(RadrootsBlossomError::PublicationRasterFrameCountMismatch { - actual: frame_count, - }); - } - Ok(Self { - format, - complete_bytes_sha256, - complete_byte_length, - dimensions: RadrootsBlossomRasterDimensions::new(width, height)?, - }) - } - - pub const fn format(&self) -> RadrootsBlossomRasterFormat { - self.format - } - - pub const fn complete_bytes_sha256(&self) -> RadrootsBlossomSha256 { - self.complete_bytes_sha256 - } - - pub const fn complete_byte_length(&self) -> u64 { - self.complete_byte_length - } - - pub const fn dimensions(&self) -> RadrootsBlossomRasterDimensions { - self.dimensions - } -} - /// A successful BUD-02 response descriptor observed by a transport adapter. /// /// Construction accepts only status 200 or 201 and applies the public URL @@ -427,6 +401,7 @@ impl RadrootsBlossomPublicationReadinessEvidence { } } +#[cfg(feature = "raster-decode")] pub fn verify_publication_readiness( authored_descriptor: &RadrootsBlossomByteVerifiedDescriptor, exact_authored_bytes: &[u8], @@ -434,7 +409,6 @@ pub fn verify_publication_readiness( upload: &RadrootsBlossomBud02UploadObservation, head: &RadrootsBlossomBud01HeadObservation, get: &RadrootsBlossomBud01GetObservation, - decode: &RadrootsBlossomRasterDecodeObservation, ) -> Result<RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { let expected_url = authored_descriptor.url(); let expected_hash = authored_descriptor.sha256(); @@ -497,36 +471,18 @@ pub fn verify_publication_readiness( actual: get.declared_size(), }); } - let retrieved_hash = RadrootsBlossomSha256::digest(get.bytes()); - if retrieved_hash != expected_hash { - return Err(RadrootsBlossomError::PublicationRetrievedBytesHashMismatch); - } - if get.bytes() != exact_authored_bytes { - return Err(RadrootsBlossomError::PublicationRetrievedBytesMismatch); - } + 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 container_dimensions = validate_raster_container(get.bytes(), expected_format)?; - if decode.format() != expected_format { - return Err(RadrootsBlossomError::PublicationRasterDecodeFormatMismatch); - } - if decode.complete_byte_length() != expected_size { - return Err( - RadrootsBlossomError::PublicationRasterDecodeLengthMismatch { - expected: expected_size, - actual: decode.complete_byte_length(), - }, - ); - } - if decode.complete_bytes_sha256() != expected_hash { - return Err(RadrootsBlossomError::PublicationRasterDecodeHashMismatch); - } - if container_dimensions.is_some_and(|dimensions| dimensions != decode.dimensions()) { - return Err(RadrootsBlossomError::PublicationRasterContainerDimensionMismatch); - } + let decoded_dimensions = decode_raster(get.bytes(), expected_format)?; if authored_dimensions .exact() - .is_some_and(|dimensions| dimensions != decode.dimensions()) + .is_some_and(|dimensions| dimensions != decoded_dimensions) { return Err(RadrootsBlossomError::PublicationAuthoredRasterDimensionMismatch); } @@ -534,7 +490,7 @@ pub fn verify_publication_readiness( let evidence_digest = evidence_digest( authored_descriptor, expected_format, - decode.dimensions(), + decoded_dimensions, upload, ); Ok(RadrootsBlossomPublicationReadinessEvidence { @@ -543,13 +499,190 @@ pub fn verify_publication_readiness( size: expected_size, media_type: expected_media_type.clone(), raster_format: expected_format, - dimensions: decode.dimensions(), + 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, @@ -576,29 +709,33 @@ fn evidence_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); } -fn validate_raster_container( - bytes: &[u8], - format: RadrootsBlossomRasterFormat, -) -> Result<Option<RadrootsBlossomRasterDimensions>, RadrootsBlossomError> { - match format { - RadrootsBlossomRasterFormat::Jpeg => validate_jpeg_container(bytes).map(Some), - RadrootsBlossomRasterFormat::Png => validate_png_container(bytes).map(Some), - RadrootsBlossomRasterFormat::StillWebP => validate_webp_container(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) } -fn validate_png_container( - bytes: &[u8], -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { +#[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(); @@ -606,6 +743,7 @@ fn validate_png_container( 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) @@ -652,13 +790,12 @@ fn validate_png_container( } b"IHDR" => return invalid_raster(), b"IDAT" if dimensions.is_some() => has_image_data = true, - b"acTL" | b"fcTL" | b"fdAT" => { - return Err(RadrootsBlossomError::PublicationRasterFrameCountMismatch { - actual: 2, - }); - } + b"acTL" | b"fcTL" | b"fdAT" => animated = true, b"IEND" if data.is_empty() && has_image_data && position == bytes.len() => { - return dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster); + return Ok(RasterContainerInspection { + dimensions: dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster)?, + animated, + }); } b"IEND" => return invalid_raster(), _ if dimensions.is_none() => return invalid_raster(), @@ -668,9 +805,8 @@ fn validate_png_container( invalid_raster() } -fn validate_webp_container( - bytes: &[u8], -) -> Result<Option<RadrootsBlossomRasterDimensions>, RadrootsBlossomError> { +#[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(); } @@ -686,6 +822,7 @@ fn validate_webp_container( 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) @@ -716,17 +853,9 @@ fn validate_webp_container( position = padded_end; match &kind { - b"ANIM" | b"ANMF" => { - return Err(RadrootsBlossomError::PublicationRasterFrameCountMismatch { - actual: 2, - }); - } + b"ANIM" | b"ANMF" => animated = true, b"VP8X" if data.len() == 10 => { - if data[0] & 0b0000_0010 != 0 { - return Err(RadrootsBlossomError::PublicationRasterFrameCountMismatch { - actual: 2, - }); - } + 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)?); @@ -774,26 +903,33 @@ fn validate_webp_container( _ => {} } } - if position != bytes.len() || primary_chunks != 1 { + if primary_chunks == 0 { + if !animated { + return invalid_raster(); + } + } else if primary_chunks != 1 { return invalid_raster(); } - Ok(dimensions) + 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) } -fn validate_jpeg_container( - bytes: &[u8], -) -> Result<RadrootsBlossomRasterDimensions, RadrootsBlossomError> { +#[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 { - let marker_start = position; if bytes.get(position) != Some(&0xff) { return invalid_raster(); } @@ -806,7 +942,10 @@ fn validate_jpeg_container( position += 1; match marker { 0xd9 if position == bytes.len() => { - return dimensions.ok_or(RadrootsBlossomError::InvalidPublicationRaster); + 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, @@ -840,20 +979,28 @@ fn validate_jpeg_container( 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)?; - if position <= marker_start { - return invalid_raster(); - } } } } +#[cfg(any(feature = "raster-decode", test))] fn is_jpeg_start_of_frame(marker: u8) -> bool { matches!( marker, @@ -861,6 +1008,7 @@ fn is_jpeg_start_of_frame(marker: u8) -> bool { ) } +#[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 { @@ -883,15 +1031,51 @@ fn jpeg_scan_end(bytes: &[u8], mut position: usize) -> Result<usize, RadrootsBlo } #[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, 0x04, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb1, 0x00, 0x00, + 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, ]; @@ -905,6 +1089,51 @@ mod tests { 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( @@ -932,7 +1161,6 @@ mod tests { RadrootsBlossomBud02UploadObservation, RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud01GetObservation, - RadrootsBlossomRasterDecodeObservation, ) { let expected = verified(bytes); let upload = RadrootsBlossomBud02UploadObservation::new( @@ -956,22 +1184,14 @@ mod tests { bytes, ) .unwrap(); - let decode = RadrootsBlossomRasterDecodeObservation::new( - RadrootsBlossomRasterFormat::Png, - RadrootsBlossomSha256::digest(bytes), - bytes.len() as u64, - 1, - 1, - 1, - ) - .unwrap(); - (upload, head, get, decode) + (upload, head, get) } + #[cfg(feature = "raster-decode")] #[test] fn publication_readiness_accepts_exact_complete_observations() { let expected = verified(PNG); - let (upload, head, get, decode) = observations(PNG); + let (upload, head, get) = observations(PNG); let evidence = verify_publication_readiness( &expected, PNG, @@ -981,12 +1201,11 @@ mod tests { &upload, &head, &get, - &decode, ) .unwrap(); assert_eq!( evidence.url().as_str(), - "https://cdn.example/0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9.png" + "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png" ); assert_eq!(evidence.sha256(), RadrootsBlossomSha256::digest(PNG)); assert_eq!(evidence.size(), PNG.len() as u64); @@ -997,7 +1216,7 @@ mod tests { assert_eq!(evidence.uploaded(), 1_800_000_000); assert_eq!( evidence.evidence_digest().to_string(), - "c52edeba688fa36c7963a478a35ff78504d7dd79a637c67f93d5acb635110660" + "44e63303e594ea42d863be995b23ac4297ed77e4378d0707c94f28e77164bd3b" ); assert_eq!( evidence.evidence_digest().as_sha256().to_string(), @@ -1049,7 +1268,7 @@ mod tests { } #[test] - fn observation_constructors_reject_invalid_status_frames_and_dimensions() { + fn observation_constructors_reject_invalid_status_and_dimensions() { assert_eq!( RadrootsBlossomBud02UploadObservation::new( 204, @@ -1071,22 +1290,14 @@ mod tests { .code(), "invalid_bud01_head_status" ); - for (frames, width, height, code) in [ - (2, 1, 1, "publication_raster_frame_count_mismatch"), - (1, 0, 1, "publication_raster_dimensions_out_of_range"), - (1, 5_000, 5_000, "publication_raster_pixel_limit_exceeded"), + for (width, height, code) in [ + (0, 1, "publication_raster_dimensions_out_of_range"), + (5_000, 5_000, "publication_raster_pixel_limit_exceeded"), ] { assert_eq!( - RadrootsBlossomRasterDecodeObservation::new( - RadrootsBlossomRasterFormat::Png, - RadrootsBlossomSha256::digest(PNG), - PNG.len() as u64, - frames, - width, - height, - ) - .unwrap_err() - .code(), + RadrootsBlossomRasterDimensions::new(width, height) + .unwrap_err() + .code(), code ); } @@ -1105,6 +1316,69 @@ mod tests { } #[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(), @@ -1132,7 +1406,7 @@ mod tests { ); assert_eq!( validate_png_container(&apng).unwrap_err().code(), - "publication_raster_frame_count_mismatch" + "publication_raster_animation_forbidden" ); assert_eq!( validate_png_container(b"not png").unwrap_err().code(), @@ -1153,7 +1427,7 @@ mod tests { ]; assert_eq!( validate_webp_container(&animated_webp).unwrap_err().code(), - "publication_raster_frame_count_mismatch" + "publication_raster_animation_forbidden" ); animated_webp[4] = 21; assert_eq!( @@ -1179,6 +1453,232 @@ mod tests { } #[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:?}"); @@ -1186,4 +1686,256 @@ mod tests { 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 @@ -6,31 +6,31 @@ "id": "valid_created", "kind": "blossom.verify_publication_readiness.valid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "none" }, "expected": { - "url": "https://cdn.example/0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9.png", - "sha256": "0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9", + "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": "c52edeba688fa36c7963a478a35ff78504d7dd79a637c67f93d5acb635110660" + "evidence_digest": "44e63303e594ea42d863be995b23ac4297ed77e4378d0707c94f28e77164bd3b" } }, { "id": "valid_ok_without_authored_dimensions", "kind": "blossom.verify_publication_readiness.valid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_status_200" }, "expected": { - "url": "https://cdn.example/0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9.png", - "sha256": "0d1c097e006a87476e84014ba5842f04c725ed2fc5a081743ab2b5bf13a538b9", + "url": "https://cdn.example/4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png", + "sha256": "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd", "size": 70, "media_type": "image/png", "format": "png", @@ -43,7 +43,7 @@ "id": "invalid_upload_status", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_status_202" }, "expected": { @@ -54,7 +54,7 @@ "id": "invalid_head_status", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "head_status_204" }, "expected": { @@ -65,7 +65,7 @@ "id": "invalid_get_status", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_status_206" }, "expected": { @@ -76,7 +76,7 @@ "id": "declared_size_over_public_max", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_size_over_max" }, "expected": { @@ -87,7 +87,7 @@ "id": "missing_get_body", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_body_missing" }, "expected": { @@ -98,7 +98,7 @@ "id": "short_get_body", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_body_short" }, "expected": { @@ -109,7 +109,7 @@ "id": "trailing_get_body", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_body_trailing" }, "expected": { @@ -120,7 +120,7 @@ "id": "authored_bytes_short", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "authored_bytes_short" }, "expected": { @@ -131,7 +131,7 @@ "id": "authored_bytes_wrong_hash", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "authored_bytes_wrong_hash" }, "expected": { @@ -142,7 +142,7 @@ "id": "upload_url_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_url_mismatch" }, "expected": { @@ -153,7 +153,7 @@ "id": "upload_hash_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_hash_mismatch" }, "expected": { @@ -164,7 +164,7 @@ "id": "upload_size_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_size_mismatch" }, "expected": { @@ -175,7 +175,7 @@ "id": "upload_mime_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "upload_mime_mismatch" }, "expected": { @@ -186,7 +186,7 @@ "id": "head_url_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "head_url_mismatch" }, "expected": { @@ -197,7 +197,7 @@ "id": "head_size_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "head_size_mismatch" }, "expected": { @@ -208,7 +208,7 @@ "id": "head_mime_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "head_mime_mismatch" }, "expected": { @@ -219,7 +219,7 @@ "id": "get_url_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_url_mismatch" }, "expected": { @@ -230,7 +230,7 @@ "id": "get_declared_size_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_declared_size_mismatch" }, "expected": { @@ -241,7 +241,7 @@ "id": "get_complete_hash_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "get_bytes_wrong_hash" }, "expected": { @@ -252,7 +252,7 @@ "id": "unsupported_raster_mime", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "unsupported_mime" }, "expected": { @@ -263,7 +263,7 @@ "id": "malformed_raster", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "malformed_container" }, "expected": { @@ -274,62 +274,62 @@ "id": "animated_png", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "animated_png" }, "expected": { - "error": "publication_raster_frame_count_mismatch" + "error": "publication_raster_animation_forbidden" } }, { - "id": "decode_format_mismatch", + "id": "declared_format_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_format_mismatch" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "declared_mime_jpeg" }, "expected": { - "error": "publication_raster_decode_format_mismatch" + "error": "invalid_publication_raster" } }, { - "id": "decode_length_mismatch", + "id": "corrupt_png_crc", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_length_mismatch" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "corrupt_png_crc" }, "expected": { - "error": "publication_raster_decode_length_mismatch" + "error": "publication_raster_decode_failed" } }, { - "id": "decode_hash_mismatch", + "id": "corrupt_png_deflate", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_hash_mismatch" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "corrupt_png_deflate" }, "expected": { - "error": "publication_raster_decode_hash_mismatch" + "error": "publication_raster_decode_failed" } }, { - "id": "decode_container_dimension_mismatch", + "id": "invalid_png_color_type", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_container_dimension_mismatch" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "invalid_png_color_type" }, "expected": { - "error": "publication_raster_container_dimension_mismatch" + "error": "publication_raster_decode_failed" } }, { "id": "authored_dimension_mismatch", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", "mutation": "authored_dimension_mismatch" }, "expected": { @@ -337,48 +337,92 @@ } }, { - "id": "decode_zero_frames", + "id": "animated_webp", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_zero_frames" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "animated_webp" }, "expected": { - "error": "publication_raster_frame_count_mismatch" + "error": "publication_raster_animation_forbidden" } }, { - "id": "decode_zero_width", + "id": "zero_width", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_zero_width" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "zero_width" }, "expected": { "error": "publication_raster_dimensions_out_of_range" } }, { - "id": "decode_dimension_over_max", + "id": "dimension_over_max", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_dimension_over_max" + "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + "mutation": "dimension_over_max" }, "expected": { "error": "publication_raster_dimensions_out_of_range" } }, { - "id": "decode_pixel_limit", + "id": "pixel_limit", "kind": "blossom.verify_publication_readiness.invalid", "input": { - "bytes_hex": "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff000000401010018dd8db10000000049454e44ae426082", - "mutation": "decode_pixel_limit" + "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 @@ -1,10 +1,12 @@ +#![cfg(feature = "raster-decode")] + +use image::{ExtendedColorType, ImageEncoder, codecs::webp::WebPEncoder}; use radroots_blossom::{ RadrootsBlossomApprovedBlobUrl, RadrootsBlossomAuthoredRasterDimensions, RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomBud01GetObservation, RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, - RadrootsBlossomError, RadrootsBlossomMediaType, RadrootsBlossomRasterDecodeObservation, - RadrootsBlossomRasterDimensions, RadrootsBlossomRasterFormat, RadrootsBlossomSha256, - verify_publication_readiness, + RadrootsBlossomError, RadrootsBlossomMediaType, RadrootsBlossomRasterDimensions, + RadrootsBlossomRasterFormat, RadrootsBlossomSha256, verify_publication_readiness, }; use serde::Deserialize; use serde_json::Value; @@ -29,7 +31,7 @@ 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(), 33); + 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), @@ -39,6 +41,260 @@ fn publication_readiness_vectors_execute_against_public_api() { } } +#[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"), @@ -144,12 +400,6 @@ fn run_mutation( let mut head_size_delta = 0_i64; let mut head_media_type = "image/png"; let mut get_declared_size_delta = 0_i64; - let mut decode_format = RadrootsBlossomRasterFormat::Png; - let mut decode_hash_bytes = canonical.clone(); - let mut decode_size_delta = 0_i64; - let mut frame_count = 1; - let mut decoded_width = 1; - let mut decoded_height = 1; let mut authored_dimensions = Some((1, 1)); match mutation { @@ -194,35 +444,135 @@ fn run_mutation( exact_authored_bytes = sealed_bytes.clone(); retrieved_bytes = sealed_bytes.clone(); upload_hash_bytes = sealed_bytes.clone(); - decode_hash_bytes = sealed_bytes.clone(); } "animated_png" => { - let iend = sealed_bytes.len() - 12; - sealed_bytes.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, - ], - ); + sealed_bytes = encoded_animated_png(); exact_authored_bytes = sealed_bytes.clone(); retrieved_bytes = sealed_bytes.clone(); upload_hash_bytes = sealed_bytes.clone(); - decode_hash_bytes = sealed_bytes.clone(); } - "decode_format_mismatch" => decode_format = RadrootsBlossomRasterFormat::Jpeg, - "decode_length_mismatch" => decode_size_delta = 1, - "decode_hash_mismatch" => decode_hash_bytes[69] ^= 1, - "decode_container_dimension_mismatch" => { - decoded_width = 2; - authored_dimensions = None; + "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)), - "decode_zero_frames" => frame_count = 0, - "decode_zero_width" => decoded_width = 0, - "decode_dimension_over_max" => decoded_width = 16_385, - "decode_pixel_limit" => { - decoded_width = 5_000; - decoded_height = 5_000; + "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), } @@ -269,14 +619,6 @@ fn run_mutation( adjusted_size(sealed_bytes.len(), get_declared_size_delta), &retrieved_bytes, )?; - let decode = RadrootsBlossomRasterDecodeObservation::new( - decode_format, - RadrootsBlossomSha256::digest(&decode_hash_bytes), - adjusted_size(sealed_bytes.len(), decode_size_delta), - frame_count, - decoded_width, - decoded_height, - )?; let authored_dimensions = match authored_dimensions { Some((width, height)) => RadrootsBlossomAuthoredRasterDimensions::Exact( RadrootsBlossomRasterDimensions::new(width, height)?, @@ -290,10 +632,22 @@ fn run_mutation( &upload, &head, &get, - &decode, ) } +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") @@ -328,6 +682,26 @@ 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() diff --git a/tools/xtask/src/contract/blossom_publication_readiness.rs b/tools/xtask/src/contract/blossom_publication_readiness.rs @@ -9,9 +9,17 @@ 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"; @@ -20,6 +28,9 @@ 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", @@ -27,19 +38,31 @@ const RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ "tools/xtask/src/contract/nip09_reconciliation.rs", RAW_PREDECESSOR_GOVERNANCE_RELATIVE, ]; -const TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = - &["crates/blossom/src/error.rs", BLOSSOM_LIB_RELATIVE]; +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, @@ -86,13 +109,18 @@ const IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS: &[(&str, usize, &str)] = &[ const CURRENT_BYTE_BOUND_BLOSSOM_SOURCES: &[(&str, usize, &str)] = &[ ( BLOSSOM_LIB_RELATIVE, - 2_088, - "ab0431ba43619431f4384a474c1e8b7e3e646a802443d66cf992df467fa9c36b", + 2_172, + "97cae38f693795445cc17671649f3d88c0c492fc8d71d2c98a4ca02502e5d43a", ), ( "crates/blossom/src/error.rs", - 30_667, - "2d30f4e21d71b6978cb3cc564d5ab542d238cf56c2eab89801fce8d1421bccf6", + 30_918, + "bd77810306b3556434d93057ca5ee62db474e9b0af94176ba4aa7a2ef25be7d8", + ), + ( + BLOSSOM_URL_RELATIVE, + 15_794, + "e9673f074ba6328a121aa3008fc11cd4d9d22cae3b09f26602ea6fea3f964c80", ), ]; @@ -105,7 +133,6 @@ const REQUIRED_PUBLIC_TYPES: &[&str] = &[ "RadrootsBlossomRasterFormat", "RadrootsBlossomRasterDimensions", "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomRasterDecodeObservation", "RadrootsBlossomPublicationReadinessEvidenceDigest", "RadrootsBlossomPublicationReadinessEvidence", ]; @@ -253,31 +280,31 @@ const VECTOR_EXPECTATIONS: &[(&str, &str, &str, Option<&str>)] = &[ "animated_png", "blossom.verify_publication_readiness.invalid", "animated_png", - Some("publication_raster_frame_count_mismatch"), + Some("publication_raster_animation_forbidden"), ), ( - "decode_format_mismatch", + "declared_format_mismatch", "blossom.verify_publication_readiness.invalid", - "decode_format_mismatch", - Some("publication_raster_decode_format_mismatch"), + "declared_mime_jpeg", + Some("invalid_publication_raster"), ), ( - "decode_length_mismatch", + "corrupt_png_crc", "blossom.verify_publication_readiness.invalid", - "decode_length_mismatch", - Some("publication_raster_decode_length_mismatch"), + "corrupt_png_crc", + Some("publication_raster_decode_failed"), ), ( - "decode_hash_mismatch", + "corrupt_png_deflate", "blossom.verify_publication_readiness.invalid", - "decode_hash_mismatch", - Some("publication_raster_decode_hash_mismatch"), + "corrupt_png_deflate", + Some("publication_raster_decode_failed"), ), ( - "decode_container_dimension_mismatch", + "invalid_png_color_type", "blossom.verify_publication_readiness.invalid", - "decode_container_dimension_mismatch", - Some("publication_raster_container_dimension_mismatch"), + "invalid_png_color_type", + Some("publication_raster_decode_failed"), ), ( "authored_dimension_mismatch", @@ -286,29 +313,53 @@ const VECTOR_EXPECTATIONS: &[(&str, &str, &str, Option<&str>)] = &[ Some("publication_authored_raster_dimension_mismatch"), ), ( - "decode_zero_frames", + "animated_webp", "blossom.verify_publication_readiness.invalid", - "decode_zero_frames", - Some("publication_raster_frame_count_mismatch"), + "animated_webp", + Some("publication_raster_animation_forbidden"), ), ( - "decode_zero_width", + "zero_width", "blossom.verify_publication_readiness.invalid", - "decode_zero_width", + "zero_width", Some("publication_raster_dimensions_out_of_range"), ), ( - "decode_dimension_over_max", + "dimension_over_max", "blossom.verify_publication_readiness.invalid", - "decode_dimension_over_max", + "dimension_over_max", Some("publication_raster_dimensions_out_of_range"), ), ( - "decode_pixel_limit", + "pixel_limit", "blossom.verify_publication_readiness.invalid", - "decode_pixel_limit", + "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> { @@ -399,11 +450,19 @@ fn validate_source_boundary(workspace_root: &Path) -> Result<(), String> { READINESS_SOURCE_RELATIVE, )?) .map_err(|error| format!("{READINESS_SOURCE_RELATIVE} must be UTF-8: {error}"))?; - validate_readiness_source_text(&source)?; + 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("verify_publication_readiness") + || !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( @@ -421,11 +480,14 @@ fn validate_source_boundary(workspace_root: &Path) -> Result<(), String> { .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<_>>(); @@ -434,6 +496,198 @@ fn validate_source_boundary(workspace_root: &Path) -> Result<(), String> { "{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, @@ -448,7 +702,7 @@ fn validate_raw_predecessor_successor_routing(source: &str) -> Result<(), 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]=&[\"crates/blossom/src/error.rs\",\"crates/blossom/src/lib.rs\"];", + "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!( @@ -459,13 +713,33 @@ fn validate_raw_predecessor_successor_routing(source: &str) -> Result<(), String Ok(()) } -fn validate_readiness_source_text(source: &str) -> Result<(), String> { +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", - "pub fn verify_publication_readiness(", + "#[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) { @@ -474,7 +748,26 @@ fn validate_readiness_source_text(source: &str) -> Result<(), String> { )); } } - let lowercase = source.to_ascii_lowercase(); + 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::", @@ -492,12 +785,30 @@ fn validate_readiness_source_text(source: &str) -> Result<(), String> { )); } } - if source.contains("serde::Deserialize") || source.contains("derive(Deserialize") { + 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(()) } @@ -603,6 +914,47 @@ fn validate_operation(workspace_root: &Path) -> Result<(), String> { "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(()) } @@ -684,10 +1036,14 @@ mod tests { read_regular_file(&workspace_root(), READINESS_SOURCE_RELATIVE).unwrap(), ) .unwrap(); - validate_readiness_source_text(&source).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) + validate_readiness_source_text(&injected, &sequential_jpeg_source) .unwrap_err() .contains("transport-neutral") ); @@ -696,10 +1052,23 @@ mod tests { "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_001", ); assert!( - validate_readiness_source_text(&removed) + 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(); diff --git a/tools/xtask/src/contract/food_availability_projection.rs b/tools/xtask/src/contract/food_availability_projection.rs @@ -3993,8 +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()), @@ -17213,9 +17220,12 @@ mod tests { use super::*; use std::fs; - const RAW_SOURCE_REBUILD_PREDECESSOR_SUPERSEDED_PATHS: [&str; 13] = [ + 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", @@ -17325,6 +17335,55 @@ mod tests { let predecessor = toml::to_string_pretty(&manifest).expect("serialize predecessor Cargo manifest"); fs::write(manifest_path, predecessor).expect("restore predecessor 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) { diff --git a/tools/xtask/src/contract/raw_source_rebuild.rs b/tools/xtask/src/contract/raw_source_rebuild.rs @@ -867,8 +867,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", @@ -881,8 +884,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] = - &["crates/blossom/src/error.rs", "crates/blossom/src/lib.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, @@ -1734,7 +1745,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!( @@ -5134,7 +5171,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" @@ -6546,7 +6583,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"); } @@ -6560,12 +6601,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); @@ -7256,17 +7311,13 @@ mod tests { #[test] fn generated_bundle_render_is_deterministic() { let root = workspace_root(); - let first = expected_artifacts(&root) - .expect("first render") - .into_iter() - .map(|artifact| (artifact.relative, artifact.contents)) - .collect::<Vec<_>>(); - let second = expected_artifacts(&root) - .expect("second render") - .into_iter() - .map(|artifact| (artifact.relative, artifact.contents)) - .collect::<Vec<_>>(); + 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] @@ -7476,9 +7527,8 @@ mod tests { fn schema_rejects_unknown_runtime_fields() { let schema = manifest_schema(); let root = workspace_root(); - let schema_bytes = canonical_json_bytes(&schema).expect("schema bytes"); - let mut manifest = serde_json::to_value( - describe_manifest(&root, &schema_bytes).expect("current manifest"), + let mut manifest: Value = serde_json::from_slice( + &read_regular_file(&root, MANIFEST_RELATIVE).expect("immutable manifest"), ) .expect("manifest value"); manifest