lib

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

commit c3f5491e397ba1526e6021fad5c9a4f79b9be1a2
parent f45235936b8e551422cab34fd1792d02e63fdcb3
Author: triesap <tyson@radroots.org>
Date:   Sun, 26 Jul 2026 09:55:14 +0000

blossom: persist sealed publication readiness evidence

- Add bounded canonical JSON persistence and strict sealed reload for readiness evidence.
- Generate the semantic readiness contract, evidence schema, and executable persistence vectors.
- Validate public API and sealed wire authority through structured Rust, Cargo, and operation checks.
- Exercise serde-only and raster lanes while providing Git to Nix provenance tests.

Diffstat:
MCHANGELOG.md | 5+++++
Mbuild/nix/checks.nix | 4++++
Acontracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/events/blossom-media.md | 21+++++++++++++++++++++
Mcontracts/operations.toml | 45++++++++++++++++++++++++++++++++++++++++++---
Mcontracts/releases/1.0.0-alpha.1.toml | 2+-
Mcrates/blossom/Cargo.toml | 5++++-
Mcrates/blossom/README | 10++++++++++
Acrates/blossom/contracts/publication_readiness_evidence_v1.schema.json | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/contracts/publication_readiness_v1.descriptor.json | 31+++++++++++++++++++++++++++++++
Acrates/blossom/contracts/publication_readiness_v1.manifest.json | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/contracts/publication_readiness_v1.manifest.schema.json | 383+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/contracts/publication_readiness_v1.manifest.sha256 | 1+
Mcrates/blossom/src/error.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/src/lib.rs | 5++++-
Mcrates/blossom/src/publication_readiness.rs | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Acrates/blossom/tests/fixtures/publication_readiness_persistence.v1.json | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/tests/publication_readiness.rs | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/blossom/tests/publication_readiness_persistence.rs | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 9++++++++-
Mtools/xtask/src/contract/blossom_publication_readiness.rs | 2268++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mtools/xtask/src/contract/nip09_reconciliation.rs | 2+-
Mtools/xtask/src/main.rs | 11+++++++++++
23 files changed, 3833 insertions(+), 982 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -215,6 +215,11 @@ publish policy both pass for the same source revision. Deterministic per-URL evidence remains transport-neutral and contains no HTTP credentials, BUD-11 material, entitlement decision, or private service topology. + The `serde` surface persists this sealed evidence as bounded canonical + compact JSON and reloads it only after strict schema/policy, field, + URL/hash/MIME/size/dimension/status, digest, and canonical-byte validation. + General-purpose deserialization cannot construct the sealed evidence state, + and the portable serde-only lane does not require the raster decoder. - 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/build/nix/checks.nix b/build/nix/checks.nix @@ -19,6 +19,7 @@ let inherit (common) cargoArtifacts; pname = "radroots-cargo-test"; doCheck = false; + nativeBuildInputs = common.commonCraneArgs.nativeBuildInputs ++ [ pkgs.gitMinimal ]; buildPhaseCargoCommand = '' cargo test ${common.coreContractCargoArgs} ''; @@ -33,6 +34,7 @@ let doCheck = false; buildPhaseCargoCommand = '' cargo check -p radroots_blossom --lib --no-default-features + cargo check -p radroots_blossom --lib --no-default-features --features serde cargo check -p radroots_blossom --lib --no-default-features --features raster-decode ''; installPhaseCommand = "mkdir -p $out"; @@ -45,6 +47,8 @@ let pname = "radroots-blossom-raster-decode-test"; doCheck = false; buildPhaseCargoCommand = '' + cargo test -p radroots_blossom --no-default-features --features serde \ + --test publication_readiness_persistence cargo test -p radroots_blossom --no-default-features --features raster-decode,serde ''; installPhaseCommand = "mkdir -p $out"; diff --git a/contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json b/contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json @@ -0,0 +1,319 @@ +{ + "suite": "blossom_publication_readiness_persistence", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "canonical_evidence_reloads", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.valid", + "input": { + "mutation": "none" + }, + "expected": { + "policy_version": 1, + "schema_version": 1 + } + }, + { + "id": "canonical_evidence_serializes", + "kind": "blossom.publication_readiness_evidence.to_canonical_json.valid", + "input": { + "mutation": "none" + }, + "expected": { + "policy_version": 1, + "schema_version": 1 + } + }, + { + "id": "leading_whitespace_is_noncanonical", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "leading_whitespace" + }, + "expected": { + "error": "publication_readiness_evidence_json_non_canonical" + } + }, + { + "id": "noncanonical_string_escaping_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "noncanonical_escaping" + }, + "expected": { + "error": "publication_readiness_evidence_json_non_canonical" + } + }, + { + "id": "field_reordering_is_noncanonical", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "reordered_fields" + }, + "expected": { + "error": "publication_readiness_evidence_json_non_canonical" + } + }, + { + "id": "unknown_field_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "unknown_field" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "private_bud11_field_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "bud11_authorization" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "missing_field_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "missing_uploaded" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "duplicate_field_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "duplicate_schema_version" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "wrong_field_type_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "size_wrong_type" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "schema_version_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "schema_version" + }, + "expected": { + "error": "publication_readiness_evidence_schema_version_unsupported" + } + }, + { + "id": "policy_version_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "policy_version" + }, + "expected": { + "error": "publication_readiness_evidence_policy_version_unsupported" + } + }, + { + "id": "url_must_be_canonical", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "url_invalid" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "url_exact_maximum_is_valid", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.valid", + "input": { + "mutation": "url_exact_max" + }, + "expected": { + "policy_version": 1, + "schema_version": 1 + } + }, + { + "id": "url_one_over_maximum_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "url_over_max" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "url_hash_must_match_sha256", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "url_hash_mismatch" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "sha256_must_be_lower_hex", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "sha256_invalid" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "size_must_be_nonzero", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "size_zero" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "size_must_be_bounded", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "size_over_max" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "mime_must_be_supported", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "mime_unsupported" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "format_must_match_mime", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "format_mismatch" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "dimensions_must_be_nonzero", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "width_zero" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "dimensions_must_respect_axis_limit", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "height_over_max" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "dimensions_must_respect_pixel_limit", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "pixels_over_max" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "bud02_status_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "bud02_status" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "bud01_head_status_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "bud01_head_status" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "bud01_get_status_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "bud01_get_status" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "uploaded_mutation_breaks_digest", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "uploaded" + }, + "expected": { + "error": "publication_readiness_evidence_digest_mismatch" + } + }, + { + "id": "digest_must_be_lower_hex", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "digest_invalid" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "digest_must_match_all_facts", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "digest_mismatch" + }, + "expected": { + "error": "publication_readiness_evidence_digest_mismatch" + } + }, + { + "id": "input_is_bounded_before_parse", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "oversized_input" + }, + "expected": { + "error": "publication_readiness_evidence_too_large" + } + } + ] +} diff --git a/contracts/events/blossom-media.md b/contracts/events/blossom-media.md @@ -330,6 +330,27 @@ future artifact readiness-binding digest. The artifact adapter remains responsib exact URL-complete set, rejecting missing/duplicate/extra/reordered evidence, and binding that set to its independently computed artifact digest. +Evidence persistence schema v1 is the canonical compact JSON object defined by +`crates/blossom/contracts/publication_readiness_evidence_v1.schema.json`. Its +field order is fixed as schema version, policy version, URL, SHA-256, size, +MIME, raster format, decoded dimensions, BUD-02 status, BUD-01 HEAD/GET +statuses, upload timestamp, and evidence digest. Both input and output are +bounded to `8,192` bytes. Reload uses private `deny_unknown_fields` wire models, +then reconstructs every governed type, checks the URL/hash/MIME/format/size and +dimension relationships, recomputes the frozen digest preimage, and requires +byte-for-byte canonical reserialization. The sealed evidence and digest types +do not implement general-purpose `Deserialize`. + +`contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json` +executes canonical reload plus unknown, missing, duplicate, wrong-type, +ordering, escaping, schema, policy, URL, hash, size, MIME, format, dimension, +status, timestamp, digest, and exact-limit mutations. Its packaged mirror and +behavior executor are authenticated by the generated semantic successor +`radroots_blossom.publication_readiness_v1`. That successor preserves the +immutable predecessor artifacts while binding protocol pins, schemas, +operations, public API inventories, invariants, and executable vector bytes; +mutable source formatting and release files are not protocol identity. + `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 diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -409,6 +409,47 @@ case_kinds = [ "blossom.verify_publication_readiness.invalid", ] +[operations.blossom_publication_readiness_evidence_to_canonical_json] +domain = "blossom" +id = "blossom.publication_readiness_evidence.to_canonical_json" +stability = "beta" +inputs = ["RadrootsBlossomPublicationReadinessEvidence"] +outputs = ["CanonicalJsonBytes"] +error_class = "encode_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_publication_readiness_evidence_to_canonical_json.implementation] +rust_modules = ["crates/blossom/src/publication_readiness.rs"] +rust_types = ["radroots_blossom::RadrootsBlossomPublicationReadinessEvidence"] + +[operations.blossom_publication_readiness_evidence_to_canonical_json.conformance] +vector = "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json" +case_kinds = ["blossom.publication_readiness_evidence.to_canonical_json.valid"] + +[operations.blossom_publication_readiness_evidence_from_canonical_json] +domain = "blossom" +id = "blossom.publication_readiness_evidence.from_canonical_json" +stability = "beta" +inputs = ["CanonicalJsonBytes"] +outputs = ["RadrootsBlossomPublicationReadinessEvidence"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_publication_readiness_evidence_from_canonical_json.implementation] +rust_modules = ["crates/blossom/src/publication_readiness.rs"] +rust_types = ["radroots_blossom::RadrootsBlossomPublicationReadinessEvidence"] + +[operations.blossom_publication_readiness_evidence_from_canonical_json.conformance] +vector = "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json" +case_kinds = [ + "blossom.publication_readiness_evidence.from_canonical_json.valid", + "blossom.publication_readiness_evidence.from_canonical_json.invalid", +] + [operations.blossom_build_upload_authorization_claim] domain = "blossom" id = "blossom.build_upload_authorization_claim" @@ -982,9 +1023,7 @@ rust_types = [ [operations.phase1_publication_allowlist_allow_artifact.conformance] vector = "contracts/conformance/vectors/publication/phase1_allowlist.v1.json" -case_kinds = [ - "publication_allowlist.allow_artifact.valid", -] +case_kinds = ["publication_allowlist.allow_artifact.valid"] [operations.phase1_publication_allowlist_allow_canonical_json] domain = "publication" diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -509,4 +509,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, exact sequential JPEG entropy accounting plus pinned strict zune-jpeg decoding, and internally authoritative full decoding for static JPEG, PNG, and WebP rasters." +summary = "Add transport-neutral BUD-02 plus BUD-01 publication-readiness evidence with bounded complete-byte verification, canonical persisted reload, 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 @@ -14,7 +14,7 @@ readme = "README" [features] default = ["serde"] -serde = ["dep:serde"] +serde = ["dep:serde", "dep:serde_json"] std = ["serde?/std", "sha2/std", "url_nostd/std"] raster-decode = ["std", "dep:image", "dep:zune-core", "dep:zune-jpeg"] @@ -22,6 +22,9 @@ raster-decode = ["std", "dep:image", "dep:zune-core", "dep:zune-jpeg"] image = { workspace = true, optional = true } mediatype = { workspace = true } serde = { workspace = true, optional = true } +serde_json = { workspace = true, default-features = false, features = [ + "alloc", +], optional = true } sha2 = { workspace = true } unicode-general-category = { workspace = true } url_nostd = { workspace = true } diff --git a/crates/blossom/README b/crates/blossom/README @@ -38,6 +38,16 @@ remains available without `raster-decode`, but cannot construct readiness evidence. Policy v1 has no serialized or caller-supplied decode-observation input. +With the `serde` feature, sealed readiness evidence can be stored as canonical +compact JSON and reloaded through an explicit bounded validator. The v1 wire is +limited to `8,192` bytes, fixes field order, rejects unknown, missing, +duplicate, reordered, or noncanonical fields, and revalidates the canonical +hash-path URL, closed raster profile, dimensions, observation statuses, and +domain-separated evidence digest. The sealed evidence type itself does not +implement `Deserialize`; ordinary JSON decoding cannot manufacture readiness +authority. This persistence surface remains available in the portable +`no_std + alloc` serde lane without enabling raster decoding. + Protocol behavior is pinned to Blossom commit `b5bd2801d1763aa635fc8fea7a76597e0eb18990`: diff --git a/crates/blossom/contracts/publication_readiness_evidence_v1.schema.json b/crates/blossom/contracts/publication_readiness_evidence_v1.schema.json @@ -0,0 +1,98 @@ +{ + "$id": "https://radroots.org/contracts/blossom/publication-readiness-evidence-v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "bud01_get_status": { + "const": 200 + }, + "bud01_head_status": { + "const": 200 + }, + "bud02_status": { + "enum": [ + 200, + 201 + ] + }, + "dimensions": { + "additionalProperties": false, + "properties": { + "height": { + "maximum": 16384, + "minimum": 1, + "type": "integer" + }, + "width": { + "maximum": 16384, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" + }, + "evidence_digest": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "media_type": { + "enum": [ + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "policy_version": { + "const": 1 + }, + "raster_format": { + "enum": [ + "jpeg", + "png", + "still_webp" + ] + }, + "schema_version": { + "const": 1 + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "size": { + "maximum": 10485760, + "minimum": 1, + "type": "integer" + }, + "uploaded": { + "minimum": 0, + "type": "integer" + }, + "url": { + "maxLength": 4096, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "schema_version", + "policy_version", + "url", + "sha256", + "size", + "media_type", + "raster_format", + "dimensions", + "bud02_status", + "bud01_head_status", + "bud01_get_status", + "uploaded", + "evidence_digest" + ], + "title": "Radroots Blossom Publication Readiness Evidence v1", + "type": "object" +} diff --git a/crates/blossom/contracts/publication_readiness_v1.descriptor.json b/crates/blossom/contracts/publication_readiness_v1.descriptor.json @@ -0,0 +1,31 @@ +{ + "behavior_vector_sha256": "6408e3b5bc4a376c7411e304833431af918a4072f196e8a8da55c3f0ef8610c9", + "contract_id": "radroots_blossom.publication_readiness_v1", + "evidence_schema": { + "byte_length": 1890, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/blossom/contracts/publication_readiness_evidence_v1.schema.json", + "sha256": "c8f5f3488dd91a660f8eaa018d9aba63d03c882dc3ff22b47ac192b7189b0ce2" + }, + "manifest": { + "byte_length": 13038, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/blossom/contracts/publication_readiness_v1.manifest.json", + "sha256": "9359c5531548778b4a03e1a603a4048f17ba498b16dad50f7c62cca0ddb6240b" + }, + "manifest_schema": { + "byte_length": 11884, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/blossom/contracts/publication_readiness_v1.manifest.schema.json", + "sha256": "e62ecf4b43bd03831f7e36e9cb4c98e2ed7e3a12d02cbca48a49e402b3feaa7d" + }, + "manifest_sidecar": { + "byte_length": 65, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/blossom/contracts/publication_readiness_v1.manifest.sha256", + "sha256": "e8c2be84eef68e965ac0e119016d6840cb9503e58d5d9c40e13a6512e1ec74b7" + }, + "persistence_vector_sha256": "e892ff6353afe8997a151a9aff4db2fd82c96d94db7bff31bc2269333adc2512", + "predecessor_manifest_sha256": "03253ce31dc31d465880a895d2685f5deb1274948e0a4eabe81a2f08f238c483", + "schema_version": 1 +} diff --git a/crates/blossom/contracts/publication_readiness_v1.manifest.json b/crates/blossom/contracts/publication_readiness_v1.manifest.json @@ -0,0 +1,314 @@ +{ + "schema_version": 1, + "contract_id": "radroots_blossom.publication_readiness_v1", + "authority_id": "blossom_publication_readiness_v1", + "manifest_schema": { + "path": "crates/blossom/contracts/publication_readiness_v1.manifest.schema.json", + "byte_length": 11884, + "sha256": "e62ecf4b43bd03831f7e36e9cb4c98e2ed7e3a12d02cbca48a49e402b3feaa7d", + "hash_algorithm": "sha256_bytes_v1" + }, + "predecessor": { + "contract_id": "radroots_event_store.raw_source_rebuild_v1", + "immutable_artifacts": [ + { + "path": "crates/event_store/contracts/raw_source_rebuild_v1.manifest.json", + "byte_length": 45449, + "sha256": "03253ce31dc31d465880a895d2685f5deb1274948e0a4eabe81a2f08f238c483", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_store/contracts/raw_source_rebuild_v1.manifest.schema.json", + "byte_length": 17896, + "sha256": "f9d210967e54b66f39c8bb965d97b2001a0ebc0927e7c2c14edb8e474bfda695", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_store/contracts/raw_source_rebuild_v1.manifest.sha256", + "byte_length": 65, + "sha256": "2b8bc07cd479be2281781660efd26fd7a8f480e5f3f62053aeccd2b5e6b2070c", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_store/src/generated/raw_source_rebuild_manifest.rs", + "byte_length": 50735, + "sha256": "3763fbee3ee45621afca990002b9298c791bf0396ebf7bccde0ae1bc9aecb7f2", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "contracts/conformance/vectors/event_store/raw_source_rebuild.v1.json", + "byte_length": 26833, + "sha256": "c37a2bf3714f53ab04fae8c5c9dbe2ad4b3f5310efa51f46bd8b116660f1fe15", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_store/tests/fixtures/raw_source_rebuild.v1.json", + "byte_length": 26833, + "sha256": "c37a2bf3714f53ab04fae8c5c9dbe2ad4b3f5310efa51f46bd8b116660f1fe15", + "hash_algorithm": "sha256_bytes_v1" + } + ] + }, + "protocol_sources": [ + { + "id": "blossom", + "repository": "https://github.com/hzrd149/blossom", + "revision": "b5bd2801d1763aa635fc8fea7a76597e0eb18990" + } + ], + "public_api": { + "constants": [ + "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_EVIDENCE_MAX_BYTES", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES" + ], + "types": [ + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomBud01GetCollector", + "RadrootsBlossomBud01GetObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud02UploadStatus", + "RadrootsBlossomPublicationReadinessEvidence", + "RadrootsBlossomPublicationReadinessEvidenceDigest", + "RadrootsBlossomRasterDimensions", + "RadrootsBlossomRasterFormat" + ], + "functions": [ + "verify_publication_readiness" + ], + "methods": [ + "RadrootsBlossomBud01GetCollector::finish", + "RadrootsBlossomBud01GetCollector::new", + "RadrootsBlossomBud01GetCollector::push_chunk", + "RadrootsBlossomBud01GetObservation::bytes", + "RadrootsBlossomBud01GetObservation::declared_size", + "RadrootsBlossomBud01GetObservation::from_complete_body", + "RadrootsBlossomBud01GetObservation::url", + "RadrootsBlossomBud01HeadObservation::content_length", + "RadrootsBlossomBud01HeadObservation::media_type", + "RadrootsBlossomBud01HeadObservation::new", + "RadrootsBlossomBud01HeadObservation::url", + "RadrootsBlossomBud02UploadObservation::descriptor", + "RadrootsBlossomBud02UploadObservation::new", + "RadrootsBlossomBud02UploadObservation::status", + "RadrootsBlossomBud02UploadStatus::as_u16", + "RadrootsBlossomPublicationReadinessEvidence::bud02_status", + "RadrootsBlossomPublicationReadinessEvidence::dimensions", + "RadrootsBlossomPublicationReadinessEvidence::evidence_digest", + "RadrootsBlossomPublicationReadinessEvidence::from_canonical_json", + "RadrootsBlossomPublicationReadinessEvidence::media_type", + "RadrootsBlossomPublicationReadinessEvidence::policy_version", + "RadrootsBlossomPublicationReadinessEvidence::raster_format", + "RadrootsBlossomPublicationReadinessEvidence::schema_version", + "RadrootsBlossomPublicationReadinessEvidence::sha256", + "RadrootsBlossomPublicationReadinessEvidence::size", + "RadrootsBlossomPublicationReadinessEvidence::to_canonical_json", + "RadrootsBlossomPublicationReadinessEvidence::uploaded", + "RadrootsBlossomPublicationReadinessEvidence::url", + "RadrootsBlossomPublicationReadinessEvidenceDigest::as_sha256", + "RadrootsBlossomRasterDimensions::height", + "RadrootsBlossomRasterDimensions::new", + "RadrootsBlossomRasterDimensions::pixels", + "RadrootsBlossomRasterDimensions::width", + "RadrootsBlossomRasterFormat::as_str", + "RadrootsBlossomRasterFormat::from_media_type" + ] + }, + "readiness": { + "verify_operation_id": "blossom.verify_publication_readiness", + "input_types": [ + "RadrootsBlossomByteVerifiedDescriptor", + "Bytes", + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud01GetObservation" + ], + "output_type": "RadrootsBlossomPublicationReadinessEvidence", + "evidence": { + "schema_version": 1, + "policy_version": 1, + "schema": { + "path": "crates/blossom/contracts/publication_readiness_evidence_v1.schema.json", + "byte_length": 1890, + "sha256": "c8f5f3488dd91a660f8eaa018d9aba63d03c882dc3ff22b47ac192b7189b0ce2", + "hash_algorithm": "sha256_bytes_v1" + }, + "max_canonical_json_bytes": 8192, + "max_url_utf8_bytes": 4096, + "max_raster_bytes": 10485760, + "max_decoded_bytes": 160000000, + "max_dimension": 16384, + "max_pixels": 20000000, + "wire_field_order": [ + "schema_version", + "policy_version", + "url", + "sha256", + "size", + "media_type", + "raster_format", + "dimensions", + "bud02_status", + "bud01_head_status", + "bud01_get_status", + "uploaded", + "evidence_digest" + ], + "digest_domain": "radroots.blossom.publication-readiness-evidence.v1\u0000", + "digest_framing": "domain_bytes_then_u16be_policy_then_u64be_length_prefixed_url_then_raw_sha256_then_u64be_size_then_u64be_length_prefixed_mime_then_u8_format_then_u32be_width_then_u32be_height_then_u16be_bud02_then_u16be_head_then_u16be_get_then_u64be_uploaded_v1", + "serialize_operation_id": "blossom.publication_readiness_evidence.to_canonical_json", + "reload_operation_id": "blossom.publication_readiness_evidence.from_canonical_json", + "invariants": [ + "bounded_input_before_json_parse_v1", + "fixed_compact_json_field_order_v1", + "canonical_json_round_trip_required_v1", + "sealed_evidence_without_deserialize_v1", + "private_deny_unknown_fields_wire_models_v1", + "canonical_hash_path_url_max_4096_utf8_bytes_v1", + "closed_jpeg_png_still_webp_mime_and_format_v1", + "nonzero_size_max_10485760_bytes_v1", + "nonzero_dimensions_axis_max_16384_pixels_max_20000000_v1", + "bud02_status_200_or_201_and_bud01_status_200_v1", + "descriptor_head_get_complete_byte_agreement_v1", + "private_domain_separated_digest_derivation_v1", + "no_bud11_credentials_entitlement_or_topology_persistence_v1" + ], + "decode_error_codes": [ + "publication_readiness_evidence_too_large", + "publication_readiness_evidence_invalid_json", + "publication_readiness_evidence_schema_version_unsupported", + "publication_readiness_evidence_policy_version_unsupported", + "publication_readiness_evidence_field_invalid", + "publication_readiness_evidence_json_non_canonical", + "publication_readiness_evidence_digest_mismatch" + ] + }, + "behavior_vector": { + "canonical_path": "contracts/conformance/vectors/blossom/publication_readiness.v1.json", + "mirror_path": "crates/blossom/tests/fixtures/publication_readiness.v1.json", + "byte_length": 16423, + "sha256": "6408e3b5bc4a376c7411e304833431af918a4072f196e8a8da55c3f0ef8610c9", + "hash_algorithm": "sha256_bytes_v1", + "executor": { + "path": "crates/blossom/tests/publication_readiness.rs", + "byte_length": 33431, + "sha256": "17abd5491bcfe0c717f7f67202c8c2d85912a042167d027727c07d9f07306641", + "hash_algorithm": "sha256_bytes_v1" + }, + "executor_test": "publication_readiness_conformance_vector_executes_every_case", + "case_ids": [ + "valid_created", + "valid_ok_without_authored_dimensions", + "invalid_upload_status", + "invalid_head_status", + "invalid_get_status", + "declared_size_over_public_max", + "missing_get_body", + "short_get_body", + "trailing_get_body", + "authored_bytes_short", + "authored_bytes_wrong_hash", + "upload_url_mismatch", + "upload_hash_mismatch", + "upload_size_mismatch", + "upload_mime_mismatch", + "head_url_mismatch", + "head_size_mismatch", + "head_mime_mismatch", + "get_url_mismatch", + "get_declared_size_mismatch", + "get_complete_hash_mismatch", + "unsupported_raster_mime", + "malformed_raster", + "animated_png", + "declared_format_mismatch", + "corrupt_png_crc", + "corrupt_png_deflate", + "invalid_png_color_type", + "authored_dimension_mismatch", + "animated_webp", + "zero_width", + "dimension_over_max", + "pixel_limit", + "progressive_jpeg", + "jpeg_entropy_stripped", + "jpeg_entropy_partial", + "malformed_jpeg_dqt" + ] + }, + "persistence_vector": { + "canonical_path": "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json", + "mirror_path": "crates/blossom/tests/fixtures/publication_readiness_persistence.v1.json", + "byte_length": 9264, + "sha256": "e892ff6353afe8997a151a9aff4db2fd82c96d94db7bff31bc2269333adc2512", + "hash_algorithm": "sha256_bytes_v1", + "executor": { + "path": "crates/blossom/tests/publication_readiness_persistence.rs", + "byte_length": 11567, + "sha256": "aa14d20ee5747fcc979907b4b59a67687a3a02852850e33aede65e2ab0e0ca8d", + "hash_algorithm": "sha256_bytes_v1" + }, + "executor_test": "publication_readiness_persistence_vector_executes_every_case", + "case_ids": [ + "canonical_evidence_reloads", + "canonical_evidence_serializes", + "leading_whitespace_is_noncanonical", + "noncanonical_string_escaping_is_rejected", + "field_reordering_is_noncanonical", + "unknown_field_is_rejected", + "private_bud11_field_is_rejected", + "missing_field_is_rejected", + "duplicate_field_is_rejected", + "wrong_field_type_is_rejected", + "schema_version_is_strict", + "policy_version_is_strict", + "url_must_be_canonical", + "url_exact_maximum_is_valid", + "url_one_over_maximum_is_rejected", + "url_hash_must_match_sha256", + "sha256_must_be_lower_hex", + "size_must_be_nonzero", + "size_must_be_bounded", + "mime_must_be_supported", + "format_must_match_mime", + "dimensions_must_be_nonzero", + "dimensions_must_respect_axis_limit", + "dimensions_must_respect_pixel_limit", + "bud02_status_is_strict", + "bud01_head_status_is_strict", + "bud01_get_status_is_strict", + "uploaded_mutation_breaks_digest", + "digest_must_be_lower_hex", + "digest_must_match_all_facts", + "input_is_bounded_before_parse" + ] + } + }, + "predecessor_source_supersessions": [ + "Cargo.lock", + "Cargo.toml", + "build/nix/common.nix", + "CHANGELOG.md", + "contracts/releases/1.0.0-alpha.1.toml", + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/food_availability_projection.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", + "tools/xtask/src/contract/source_maintenance.rs", + "tools/xtask/src/main.rs" + ], + "transitive_predecessor_source_supersessions": [ + "Cargo.toml", + "crates/blossom/Cargo.toml", + "crates/blossom/src/error.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs" + ] +} diff --git a/crates/blossom/contracts/publication_readiness_v1.manifest.schema.json b/crates/blossom/contracts/publication_readiness_v1.manifest.schema.json @@ -0,0 +1,383 @@ +{ + "$defs": { + "evidence": { + "additionalProperties": false, + "properties": { + "decode_error_codes": { + "const": [ + "publication_readiness_evidence_too_large", + "publication_readiness_evidence_invalid_json", + "publication_readiness_evidence_schema_version_unsupported", + "publication_readiness_evidence_policy_version_unsupported", + "publication_readiness_evidence_field_invalid", + "publication_readiness_evidence_json_non_canonical", + "publication_readiness_evidence_digest_mismatch" + ] + }, + "digest_domain": { + "const": "radroots.blossom.publication-readiness-evidence.v1\u0000" + }, + "digest_framing": { + "minLength": 1, + "type": "string" + }, + "invariants": { + "const": [ + "bounded_input_before_json_parse_v1", + "fixed_compact_json_field_order_v1", + "canonical_json_round_trip_required_v1", + "sealed_evidence_without_deserialize_v1", + "private_deny_unknown_fields_wire_models_v1", + "canonical_hash_path_url_max_4096_utf8_bytes_v1", + "closed_jpeg_png_still_webp_mime_and_format_v1", + "nonzero_size_max_10485760_bytes_v1", + "nonzero_dimensions_axis_max_16384_pixels_max_20000000_v1", + "bud02_status_200_or_201_and_bud01_status_200_v1", + "descriptor_head_get_complete_byte_agreement_v1", + "private_domain_separated_digest_derivation_v1", + "no_bud11_credentials_entitlement_or_topology_persistence_v1" + ] + }, + "max_canonical_json_bytes": { + "const": 8192 + }, + "max_decoded_bytes": { + "const": 160000000 + }, + "max_dimension": { + "const": 16384 + }, + "max_pixels": { + "const": 20000000 + }, + "max_raster_bytes": { + "const": 10485760 + }, + "max_url_utf8_bytes": { + "const": 4096 + }, + "policy_version": { + "const": 1 + }, + "reload_operation_id": { + "const": "blossom.publication_readiness_evidence.from_canonical_json" + }, + "schema": { + "$ref": "#/$defs/file" + }, + "schema_version": { + "const": 1 + }, + "serialize_operation_id": { + "const": "blossom.publication_readiness_evidence.to_canonical_json" + }, + "wire_field_order": { + "const": [ + "schema_version", + "policy_version", + "url", + "sha256", + "size", + "media_type", + "raster_format", + "dimensions", + "bud02_status", + "bud01_head_status", + "bud01_get_status", + "uploaded", + "evidence_digest" + ] + } + }, + "required": [ + "schema_version", + "policy_version", + "schema", + "max_canonical_json_bytes", + "max_url_utf8_bytes", + "max_raster_bytes", + "max_decoded_bytes", + "max_dimension", + "max_pixels", + "wire_field_order", + "digest_domain", + "digest_framing", + "serialize_operation_id", + "reload_operation_id", + "invariants", + "decode_error_codes" + ], + "type": "object" + }, + "file": { + "additionalProperties": false, + "properties": { + "byte_length": { + "minimum": 1, + "type": "integer" + }, + "hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "path": { + "minLength": 1, + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "path", + "byte_length", + "sha256", + "hash_algorithm" + ], + "type": "object" + }, + "readiness": { + "additionalProperties": false, + "properties": { + "behavior_vector": { + "$ref": "#/$defs/vector" + }, + "evidence": { + "$ref": "#/$defs/evidence" + }, + "input_types": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 6, + "minItems": 6, + "type": "array" + }, + "output_type": { + "const": "RadrootsBlossomPublicationReadinessEvidence" + }, + "persistence_vector": { + "$ref": "#/$defs/vector" + }, + "verify_operation_id": { + "const": "blossom.verify_publication_readiness" + } + }, + "required": [ + "verify_operation_id", + "input_types", + "output_type", + "evidence", + "behavior_vector", + "persistence_vector" + ], + "type": "object" + }, + "vector": { + "additionalProperties": false, + "properties": { + "byte_length": { + "minimum": 1, + "type": "integer" + }, + "canonical_path": { + "minLength": 1, + "type": "string" + }, + "case_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "executor": { + "$ref": "#/$defs/file" + }, + "executor_test": { + "minLength": 1, + "type": "string" + }, + "hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "mirror_path": { + "minLength": 1, + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "canonical_path", + "mirror_path", + "byte_length", + "sha256", + "hash_algorithm", + "executor", + "executor_test", + "case_ids" + ], + "type": "object" + } + }, + "$id": "https://radroots.org/contracts/blossom/publication-readiness-v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "authority_id": { + "const": "blossom_publication_readiness_v1" + }, + "contract_id": { + "const": "radroots_blossom.publication_readiness_v1" + }, + "manifest_schema": { + "$ref": "#/$defs/file" + }, + "predecessor": { + "additionalProperties": false, + "properties": { + "contract_id": { + "const": "radroots_event_store.raw_source_rebuild_v1" + }, + "immutable_artifacts": { + "items": { + "$ref": "#/$defs/file" + }, + "maxItems": 6, + "minItems": 6, + "type": "array" + } + }, + "required": [ + "contract_id", + "immutable_artifacts" + ], + "type": "object" + }, + "predecessor_source_supersessions": { + "const": [ + "Cargo.lock", + "Cargo.toml", + "build/nix/common.nix", + "CHANGELOG.md", + "contracts/releases/1.0.0-alpha.1.toml", + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/food_availability_projection.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", + "tools/xtask/src/contract/source_maintenance.rs", + "tools/xtask/src/main.rs" + ] + }, + "protocol_sources": { + "const": [ + { + "id": "blossom", + "repository": "https://github.com/hzrd149/blossom", + "revision": "b5bd2801d1763aa635fc8fea7a76597e0eb18990" + } + ] + }, + "public_api": { + "const": { + "constants": [ + "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_EVIDENCE_MAX_BYTES", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES" + ], + "functions": [ + "verify_publication_readiness" + ], + "methods": [ + "RadrootsBlossomBud01GetCollector::finish", + "RadrootsBlossomBud01GetCollector::new", + "RadrootsBlossomBud01GetCollector::push_chunk", + "RadrootsBlossomBud01GetObservation::bytes", + "RadrootsBlossomBud01GetObservation::declared_size", + "RadrootsBlossomBud01GetObservation::from_complete_body", + "RadrootsBlossomBud01GetObservation::url", + "RadrootsBlossomBud01HeadObservation::content_length", + "RadrootsBlossomBud01HeadObservation::media_type", + "RadrootsBlossomBud01HeadObservation::new", + "RadrootsBlossomBud01HeadObservation::url", + "RadrootsBlossomBud02UploadObservation::descriptor", + "RadrootsBlossomBud02UploadObservation::new", + "RadrootsBlossomBud02UploadObservation::status", + "RadrootsBlossomBud02UploadStatus::as_u16", + "RadrootsBlossomPublicationReadinessEvidence::bud02_status", + "RadrootsBlossomPublicationReadinessEvidence::dimensions", + "RadrootsBlossomPublicationReadinessEvidence::evidence_digest", + "RadrootsBlossomPublicationReadinessEvidence::from_canonical_json", + "RadrootsBlossomPublicationReadinessEvidence::media_type", + "RadrootsBlossomPublicationReadinessEvidence::policy_version", + "RadrootsBlossomPublicationReadinessEvidence::raster_format", + "RadrootsBlossomPublicationReadinessEvidence::schema_version", + "RadrootsBlossomPublicationReadinessEvidence::sha256", + "RadrootsBlossomPublicationReadinessEvidence::size", + "RadrootsBlossomPublicationReadinessEvidence::to_canonical_json", + "RadrootsBlossomPublicationReadinessEvidence::uploaded", + "RadrootsBlossomPublicationReadinessEvidence::url", + "RadrootsBlossomPublicationReadinessEvidenceDigest::as_sha256", + "RadrootsBlossomRasterDimensions::height", + "RadrootsBlossomRasterDimensions::new", + "RadrootsBlossomRasterDimensions::pixels", + "RadrootsBlossomRasterDimensions::width", + "RadrootsBlossomRasterFormat::as_str", + "RadrootsBlossomRasterFormat::from_media_type" + ], + "types": [ + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomBud01GetCollector", + "RadrootsBlossomBud01GetObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud02UploadStatus", + "RadrootsBlossomPublicationReadinessEvidence", + "RadrootsBlossomPublicationReadinessEvidenceDigest", + "RadrootsBlossomRasterDimensions", + "RadrootsBlossomRasterFormat" + ] + } + }, + "readiness": { + "$ref": "#/$defs/readiness" + }, + "schema_version": { + "const": 1 + }, + "transitive_predecessor_source_supersessions": { + "const": [ + "Cargo.toml", + "crates/blossom/Cargo.toml", + "crates/blossom/src/error.rs", + "crates/blossom/src/lib.rs", + "crates/blossom/src/url.rs" + ] + } + }, + "required": [ + "schema_version", + "contract_id", + "authority_id", + "manifest_schema", + "predecessor", + "protocol_sources", + "public_api", + "readiness", + "predecessor_source_supersessions", + "transitive_predecessor_source_supersessions" + ], + "title": "Radroots Blossom Publication Readiness Semantic Contract", + "type": "object" +} diff --git a/crates/blossom/contracts/publication_readiness_v1.manifest.sha256 b/crates/blossom/contracts/publication_readiness_v1.manifest.sha256 @@ -0,0 +1 @@ +9359c5531548778b4a03e1a603a4048f17ba498b16dad50f7c62cca0ddb6240b diff --git a/crates/blossom/src/error.rs b/crates/blossom/src/error.rs @@ -43,6 +43,8 @@ pub enum RadrootsBlossomError { InvalidBud02UploadStatus { actual: u16 }, InvalidBud01HeadStatus { actual: u16 }, InvalidBud01GetStatus { actual: u16 }, + PublicationReadinessUrlTooLarge { max: usize, actual: usize }, + PublicationRasterEmpty, PublicationRasterByteLimitExceeded { declared: u64, maximum: u64 }, PublicationGetBodyAllocationFailed, PublicationGetBodyLengthOverflow, @@ -73,6 +75,14 @@ pub enum RadrootsBlossomError { PublicationRasterDecodeFailed, PublicationRasterContainerDimensionMismatch, PublicationAuthoredRasterDimensionMismatch, + PublicationReadinessEvidenceTooLarge { max: usize, actual: usize }, + PublicationReadinessEvidenceInvalidJson, + PublicationReadinessEvidenceUnsupportedSchemaVersion { expected: u32, actual: u32 }, + PublicationReadinessEvidenceUnsupportedPolicyVersion { expected: u16, actual: u16 }, + PublicationReadinessEvidenceInvalidField { field: &'static str }, + PublicationReadinessEvidenceDigestMismatch, + PublicationReadinessEvidenceNonCanonicalJson, + PublicationReadinessEvidenceSerialization, } impl RadrootsBlossomError { @@ -118,6 +128,8 @@ impl RadrootsBlossomError { Self::InvalidBud02UploadStatus { .. } => "invalid_bud02_upload_status", Self::InvalidBud01HeadStatus { .. } => "invalid_bud01_head_status", Self::InvalidBud01GetStatus { .. } => "invalid_bud01_get_status", + Self::PublicationReadinessUrlTooLarge { .. } => "publication_readiness_url_too_large", + Self::PublicationRasterEmpty => "publication_raster_empty", Self::PublicationRasterByteLimitExceeded { .. } => { "publication_raster_byte_limit_exceeded" } @@ -172,6 +184,30 @@ impl RadrootsBlossomError { Self::PublicationAuthoredRasterDimensionMismatch => { "publication_authored_raster_dimension_mismatch" } + Self::PublicationReadinessEvidenceTooLarge { .. } => { + "publication_readiness_evidence_too_large" + } + Self::PublicationReadinessEvidenceInvalidJson => { + "publication_readiness_evidence_invalid_json" + } + Self::PublicationReadinessEvidenceUnsupportedSchemaVersion { .. } => { + "publication_readiness_evidence_schema_version_unsupported" + } + Self::PublicationReadinessEvidenceUnsupportedPolicyVersion { .. } => { + "publication_readiness_evidence_policy_version_unsupported" + } + Self::PublicationReadinessEvidenceInvalidField { .. } => { + "publication_readiness_evidence_field_invalid" + } + Self::PublicationReadinessEvidenceDigestMismatch => { + "publication_readiness_evidence_digest_mismatch" + } + Self::PublicationReadinessEvidenceNonCanonicalJson => { + "publication_readiness_evidence_json_non_canonical" + } + Self::PublicationReadinessEvidenceSerialization => { + "publication_readiness_evidence_serialization_failed" + } } } } @@ -281,6 +317,13 @@ impl fmt::Display for RadrootsBlossomError { Self::InvalidBud01GetStatus { actual } => { write!(f, "BUD-01 GET status must be 200, got {actual}") } + Self::PublicationReadinessUrlTooLarge { max, actual } => write!( + f, + "publication-readiness URL contains {actual} bytes, exceeding maximum {max}" + ), + Self::PublicationRasterEmpty => { + f.write_str("publication raster must contain at least one byte") + } Self::PublicationRasterByteLimitExceeded { declared, maximum } => write!( f, "publication raster declares {declared} bytes, exceeding maximum {maximum}" @@ -379,6 +422,40 @@ impl fmt::Display for RadrootsBlossomError { Self::PublicationAuthoredRasterDimensionMismatch => { f.write_str("decoded raster dimensions do not match the authored dimensions") } + Self::PublicationReadinessEvidenceTooLarge { max, actual } => write!( + f, + "publication-readiness evidence is {actual} bytes, exceeding maximum {max}" + ), + Self::PublicationReadinessEvidenceInvalidJson => { + f.write_str("publication-readiness evidence is not valid strict JSON") + } + Self::PublicationReadinessEvidenceUnsupportedSchemaVersion { expected, actual } => { + write!( + f, + "publication-readiness evidence schema version mismatch: expected {expected}, got {actual}" + ) + } + Self::PublicationReadinessEvidenceUnsupportedPolicyVersion { expected, actual } => { + write!( + f, + "publication-readiness evidence policy version mismatch: expected {expected}, got {actual}" + ) + } + Self::PublicationReadinessEvidenceInvalidField { field } => { + write!( + f, + "publication-readiness evidence field `{field}` is invalid" + ) + } + Self::PublicationReadinessEvidenceDigestMismatch => { + f.write_str("publication-readiness evidence digest does not match its facts") + } + Self::PublicationReadinessEvidenceNonCanonicalJson => { + f.write_str("publication-readiness evidence JSON is not canonical") + } + Self::PublicationReadinessEvidenceSerialization => { + f.write_str("publication-readiness evidence serialization failed") + } } } } @@ -415,6 +492,8 @@ mod tests { RadrootsBlossomError::InvalidBud02UploadStatus { actual: 202 }, RadrootsBlossomError::InvalidBud01HeadStatus { actual: 204 }, RadrootsBlossomError::InvalidBud01GetStatus { actual: 206 }, + RadrootsBlossomError::PublicationReadinessUrlTooLarge { max: 1, actual: 2 }, + RadrootsBlossomError::PublicationRasterEmpty, RadrootsBlossomError::PublicationRasterByteLimitExceeded { declared: 2, maximum: 1, @@ -472,6 +551,20 @@ mod tests { RadrootsBlossomError::PublicationRasterDecodeFailed, RadrootsBlossomError::PublicationRasterContainerDimensionMismatch, RadrootsBlossomError::PublicationAuthoredRasterDimensionMismatch, + RadrootsBlossomError::PublicationReadinessEvidenceTooLarge { max: 1, actual: 2 }, + RadrootsBlossomError::PublicationReadinessEvidenceInvalidJson, + RadrootsBlossomError::PublicationReadinessEvidenceUnsupportedSchemaVersion { + expected: 1, + actual: 2, + }, + RadrootsBlossomError::PublicationReadinessEvidenceUnsupportedPolicyVersion { + expected: 1, + actual: 2, + }, + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" }, + RadrootsBlossomError::PublicationReadinessEvidenceDigestMismatch, + RadrootsBlossomError::PublicationReadinessEvidenceNonCanonicalJson, + RadrootsBlossomError::PublicationReadinessEvidenceSerialization, RadrootsBlossomError::InvalidAuthorizationContent, RadrootsBlossomError::InvalidAuthorizationAction, RadrootsBlossomError::InvalidAuthorizationServerDomain, diff --git a/crates/blossom/src/lib.rs b/crates/blossom/src/lib.rs @@ -33,7 +33,10 @@ pub use publication_readiness::{ 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, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RadrootsBlossomAuthoredRasterDimensions, RadrootsBlossomBud01GetCollector, RadrootsBlossomBud01GetObservation, RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, RadrootsBlossomBud02UploadStatus, RadrootsBlossomPublicationReadinessEvidence, diff --git a/crates/blossom/src/publication_readiness.rs b/crates/blossom/src/publication_readiness.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "serde")] +use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; #[cfg(feature = "raster-decode")] @@ -5,7 +7,7 @@ use image::{ ImageDecoder, Limits, codecs::{png::PngDecoder, webp::WebPDecoder}, }; -#[cfg(feature = "raster-decode")] +#[cfg(any(feature = "raster-decode", feature = "serde"))] use sha2::{Digest, Sha256}; #[cfg(feature = "raster-decode")] use std::io::Cursor; @@ -17,6 +19,8 @@ use zune_jpeg::JpegDecoder as StrictJpegDecoder; #[cfg(feature = "raster-decode")] mod sequential_jpeg; +#[cfg(feature = "serde")] +use crate::RadrootsBlossomBlobUrl; #[cfg(feature = "raster-decode")] use crate::RadrootsBlossomByteVerifiedDescriptor; use crate::{ @@ -27,13 +31,16 @@ use crate::{ const _: () = assert!(usize::BITS <= u64::BITS); pub const RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION: u16 = 1; +pub const RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION: u32 = 1; +pub const RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES: usize = 8 * 1024; +pub const RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES: usize = 4 * 1024; 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")] +#[cfg(any(feature = "raster-decode", feature = "serde"))] const READINESS_EVIDENCE_DIGEST_DOMAIN: &[u8] = b"radroots.blossom.publication-readiness-evidence.v1\0"; @@ -87,7 +94,7 @@ impl RadrootsBlossomRasterFormat { } } - #[cfg(feature = "raster-decode")] + #[cfg(any(feature = "raster-decode", feature = "serde"))] const fn digest_code(self) -> u8 { match self { Self::Jpeg => 1, @@ -364,6 +371,14 @@ pub struct RadrootsBlossomPublicationReadinessEvidence { } impl RadrootsBlossomPublicationReadinessEvidence { + pub const fn schema_version(&self) -> u32 { + RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION + } + + pub const fn policy_version(&self) -> u16 { + RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION + } + pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { &self.url } @@ -399,6 +414,28 @@ impl RadrootsBlossomPublicationReadinessEvidence { pub const fn evidence_digest(&self) -> RadrootsBlossomPublicationReadinessEvidenceDigest { self.evidence_digest } + + #[cfg(feature = "serde")] + pub fn to_canonical_json(&self) -> Result<Vec<u8>, RadrootsBlossomError> { + serialize_readiness_evidence(self) + } + + #[cfg(feature = "serde")] + pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, RadrootsBlossomError> { + if bytes.len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES { + return Err(RadrootsBlossomError::PublicationReadinessEvidenceTooLarge { + max: RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, + actual: bytes.len(), + }); + } + let wire: PublicationReadinessEvidenceWire = serde_json::from_slice(bytes) + .map_err(|_| RadrootsBlossomError::PublicationReadinessEvidenceInvalidJson)?; + let evidence = readiness_evidence_from_wire(wire)?; + if serialize_readiness_evidence(&evidence)? != bytes { + return Err(RadrootsBlossomError::PublicationReadinessEvidenceNonCanonicalJson); + } + Ok(evidence) + } } #[cfg(feature = "raster-decode")] @@ -415,6 +452,15 @@ pub fn verify_publication_readiness( let expected_size = authored_descriptor.size(); let expected_media_type = authored_descriptor.media_type(); + if expected_url.as_str().len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES { + return Err(RadrootsBlossomError::PublicationReadinessUrlTooLarge { + max: RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, + actual: expected_url.as_str().len(), + }); + } + if expected_size == 0 { + return Err(RadrootsBlossomError::PublicationRasterEmpty); + } if expected_size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES { return Err(RadrootsBlossomError::PublicationRasterByteLimitExceeded { declared: expected_size, @@ -487,12 +533,16 @@ pub fn verify_publication_readiness( return Err(RadrootsBlossomError::PublicationAuthoredRasterDimensionMismatch); } - let evidence_digest = evidence_digest( - authored_descriptor, - expected_format, - decoded_dimensions, - upload, - ); + let evidence_digest = evidence_digest_from_facts(&ReadinessEvidenceDigestFacts { + url: expected_url, + sha256: expected_hash, + size: expected_size, + media_type: expected_media_type, + format: expected_format, + dimensions: decoded_dimensions, + bud02_status: upload.status(), + uploaded: upload_descriptor.uploaded(), + }); Ok(RadrootsBlossomPublicationReadinessEvidence { url: expected_url.clone(), sha256: expected_hash, @@ -682,39 +732,254 @@ fn validate_retrieved_body( Ok(()) } -#[cfg(feature = "raster-decode")] -fn evidence_digest( - descriptor: &RadrootsBlossomByteVerifiedDescriptor, +#[cfg(any(feature = "raster-decode", feature = "serde"))] +struct ReadinessEvidenceDigestFacts<'a> { + url: &'a RadrootsBlossomApprovedBlobUrl, + sha256: RadrootsBlossomSha256, + size: u64, + media_type: &'a RadrootsBlossomMediaType, format: RadrootsBlossomRasterFormat, dimensions: RadrootsBlossomRasterDimensions, - upload: &RadrootsBlossomBud02UploadObservation, + bud02_status: RadrootsBlossomBud02UploadStatus, + uploaded: u64, +} + +#[cfg(any(feature = "raster-decode", feature = "serde"))] +fn evidence_digest_from_facts( + facts: &ReadinessEvidenceDigestFacts<'_>, ) -> RadrootsBlossomPublicationReadinessEvidenceDigest { let mut hasher = Sha256::new(); hasher.update(READINESS_EVIDENCE_DIGEST_DOMAIN); hasher.update(RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION.to_be_bytes()); - update_length_prefixed(&mut hasher, descriptor.url().as_str().as_bytes()); - hasher.update(descriptor.sha256().as_bytes()); - hasher.update(descriptor.size().to_be_bytes()); - update_length_prefixed(&mut hasher, descriptor.media_type().as_str().as_bytes()); - hasher.update([format.digest_code()]); - hasher.update(dimensions.width().to_be_bytes()); - hasher.update(dimensions.height().to_be_bytes()); - hasher.update(upload.status().as_u16().to_be_bytes()); + update_length_prefixed(&mut hasher, facts.url.as_str().as_bytes()); + hasher.update(facts.sha256.as_bytes()); + hasher.update(facts.size.to_be_bytes()); + update_length_prefixed(&mut hasher, facts.media_type.as_str().as_bytes()); + hasher.update([facts.format.digest_code()]); + hasher.update(facts.dimensions.width().to_be_bytes()); + hasher.update(facts.dimensions.height().to_be_bytes()); + hasher.update(facts.bud02_status.as_u16().to_be_bytes()); hasher.update(200_u16.to_be_bytes()); hasher.update(200_u16.to_be_bytes()); - hasher.update(upload.descriptor().descriptor().uploaded().to_be_bytes()); + hasher.update(facts.uploaded.to_be_bytes()); let digest = hasher.finalize(); let mut bytes = [0_u8; 32]; bytes.copy_from_slice(&digest); RadrootsBlossomPublicationReadinessEvidenceDigest(RadrootsBlossomSha256::from_bytes(bytes)) } -#[cfg(feature = "raster-decode")] +#[cfg(any(feature = "raster-decode", feature = "serde"))] fn update_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) { hasher.update((bytes.len() as u64).to_be_bytes()); hasher.update(bytes); } +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct PublicationReadinessDimensionsWire { + width: u32, + height: u32, +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct PublicationReadinessEvidenceWire { + schema_version: u32, + policy_version: u16, + url: String, + sha256: String, + size: u64, + media_type: String, + raster_format: String, + dimensions: PublicationReadinessDimensionsWire, + bud02_status: u16, + bud01_head_status: u16, + bud01_get_status: u16, + uploaded: u64, + evidence_digest: String, +} + +#[cfg(feature = "serde")] +fn serialize_readiness_evidence( + evidence: &RadrootsBlossomPublicationReadinessEvidence, +) -> Result<Vec<u8>, RadrootsBlossomError> { + let wire = PublicationReadinessEvidenceWire { + schema_version: RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION, + policy_version: RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, + url: evidence.url.as_str().to_string(), + sha256: evidence.sha256.to_string(), + size: evidence.size, + media_type: evidence.media_type.as_str().to_string(), + raster_format: evidence.raster_format.as_str().to_string(), + dimensions: PublicationReadinessDimensionsWire { + width: evidence.dimensions.width(), + height: evidence.dimensions.height(), + }, + bud02_status: evidence.bud02_status.as_u16(), + bud01_head_status: 200, + bud01_get_status: 200, + uploaded: evidence.uploaded, + evidence_digest: evidence.evidence_digest.to_string(), + }; + let bytes = serde_json::to_vec(&wire) + .map_err(|_| RadrootsBlossomError::PublicationReadinessEvidenceSerialization)?; + if bytes.len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES { + return Err(RadrootsBlossomError::PublicationReadinessEvidenceTooLarge { + max: RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, + actual: bytes.len(), + }); + } + Ok(bytes) +} + +#[cfg(feature = "serde")] +fn readiness_evidence_from_wire( + wire: PublicationReadinessEvidenceWire, +) -> Result<RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomError> { + if wire.schema_version != RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceUnsupportedSchemaVersion { + expected: RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION, + actual: wire.schema_version, + }, + ); + } + if wire.policy_version != RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceUnsupportedPolicyVersion { + expected: RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, + actual: wire.policy_version, + }, + ); + } + if wire.url.len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" }, + ); + } + if wire.size == 0 || wire.size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "size" }, + ); + } + let sha256 = RadrootsBlossomSha256::from_hex(&wire.sha256).map_err(|_| { + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "sha256" } + })?; + let media_type = RadrootsBlossomMediaType::parse(&wire.media_type).map_err(|_| { + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "media_type", + } + })?; + if media_type.as_str() != wire.media_type { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "media_type", + }, + ); + } + let raster_format = match wire.raster_format.as_str() { + "jpeg" => RadrootsBlossomRasterFormat::Jpeg, + "png" => RadrootsBlossomRasterFormat::Png, + "still_webp" => RadrootsBlossomRasterFormat::StillWebP, + _ => { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "raster_format", + }, + ); + } + }; + if RadrootsBlossomRasterFormat::from_media_type(&media_type).map_err(|_| { + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "media_type", + } + })? != raster_format + { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "raster_format", + }, + ); + } + let dimensions = + RadrootsBlossomRasterDimensions::new(wire.dimensions.width, wire.dimensions.height) + .map_err( + |_| RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "dimensions", + }, + )?; + let bud02_status = + RadrootsBlossomBud02UploadStatus::parse(wire.bud02_status).map_err(|_| { + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "bud02_status", + } + })?; + if wire.bud01_head_status != 200 { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "bud01_head_status", + }, + ); + } + if wire.bud01_get_status != 200 { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "bud01_get_status", + }, + ); + } + let blob_url = RadrootsBlossomBlobUrl::parse(&wire.url).map_err(|_| { + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" } + })?; + if blob_url.as_str() != wire.url { + return Err( + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" }, + ); + } + let approved = RadrootsBlossomBlobDescriptor::new( + blob_url, + sha256, + wire.size, + media_type.clone(), + wire.uploaded, + ) + .and_then(RadrootsBlossomBlobDescriptor::approve_reference) + .map_err(|_| RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { field: "url" })?; + let evidence_digest = RadrootsBlossomPublicationReadinessEvidenceDigest( + RadrootsBlossomSha256::from_hex(&wire.evidence_digest).map_err(|_| { + RadrootsBlossomError::PublicationReadinessEvidenceInvalidField { + field: "evidence_digest", + } + })?, + ); + let expected_digest = evidence_digest_from_facts(&ReadinessEvidenceDigestFacts { + url: approved.url(), + sha256, + size: wire.size, + media_type: &media_type, + format: raster_format, + dimensions, + bud02_status, + uploaded: wire.uploaded, + }); + if evidence_digest != expected_digest { + return Err(RadrootsBlossomError::PublicationReadinessEvidenceDigestMismatch); + } + Ok(RadrootsBlossomPublicationReadinessEvidence { + url: approved.url().clone(), + sha256, + size: wire.size, + media_type, + raster_format, + dimensions, + bud02_status, + uploaded: wire.uploaded, + evidence_digest, + }) +} + #[cfg(any(feature = "raster-decode", test))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct RasterContainerInspection { diff --git a/crates/blossom/tests/fixtures/publication_readiness_persistence.v1.json b/crates/blossom/tests/fixtures/publication_readiness_persistence.v1.json @@ -0,0 +1,319 @@ +{ + "suite": "blossom_publication_readiness_persistence", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "canonical_evidence_reloads", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.valid", + "input": { + "mutation": "none" + }, + "expected": { + "policy_version": 1, + "schema_version": 1 + } + }, + { + "id": "canonical_evidence_serializes", + "kind": "blossom.publication_readiness_evidence.to_canonical_json.valid", + "input": { + "mutation": "none" + }, + "expected": { + "policy_version": 1, + "schema_version": 1 + } + }, + { + "id": "leading_whitespace_is_noncanonical", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "leading_whitespace" + }, + "expected": { + "error": "publication_readiness_evidence_json_non_canonical" + } + }, + { + "id": "noncanonical_string_escaping_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "noncanonical_escaping" + }, + "expected": { + "error": "publication_readiness_evidence_json_non_canonical" + } + }, + { + "id": "field_reordering_is_noncanonical", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "reordered_fields" + }, + "expected": { + "error": "publication_readiness_evidence_json_non_canonical" + } + }, + { + "id": "unknown_field_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "unknown_field" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "private_bud11_field_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "bud11_authorization" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "missing_field_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "missing_uploaded" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "duplicate_field_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "duplicate_schema_version" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "wrong_field_type_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "size_wrong_type" + }, + "expected": { + "error": "publication_readiness_evidence_invalid_json" + } + }, + { + "id": "schema_version_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "schema_version" + }, + "expected": { + "error": "publication_readiness_evidence_schema_version_unsupported" + } + }, + { + "id": "policy_version_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "policy_version" + }, + "expected": { + "error": "publication_readiness_evidence_policy_version_unsupported" + } + }, + { + "id": "url_must_be_canonical", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "url_invalid" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "url_exact_maximum_is_valid", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.valid", + "input": { + "mutation": "url_exact_max" + }, + "expected": { + "policy_version": 1, + "schema_version": 1 + } + }, + { + "id": "url_one_over_maximum_is_rejected", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "url_over_max" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "url_hash_must_match_sha256", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "url_hash_mismatch" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "sha256_must_be_lower_hex", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "sha256_invalid" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "size_must_be_nonzero", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "size_zero" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "size_must_be_bounded", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "size_over_max" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "mime_must_be_supported", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "mime_unsupported" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "format_must_match_mime", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "format_mismatch" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "dimensions_must_be_nonzero", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "width_zero" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "dimensions_must_respect_axis_limit", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "height_over_max" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "dimensions_must_respect_pixel_limit", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "pixels_over_max" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "bud02_status_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "bud02_status" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "bud01_head_status_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "bud01_head_status" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "bud01_get_status_is_strict", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "bud01_get_status" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "uploaded_mutation_breaks_digest", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "uploaded" + }, + "expected": { + "error": "publication_readiness_evidence_digest_mismatch" + } + }, + { + "id": "digest_must_be_lower_hex", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "digest_invalid" + }, + "expected": { + "error": "publication_readiness_evidence_field_invalid" + } + }, + { + "id": "digest_must_match_all_facts", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "digest_mismatch" + }, + "expected": { + "error": "publication_readiness_evidence_digest_mismatch" + } + }, + { + "id": "input_is_bounded_before_parse", + "kind": "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "input": { + "mutation": "oversized_input" + }, + "expected": { + "error": "publication_readiness_evidence_too_large" + } + } + ] +} diff --git a/crates/blossom/tests/publication_readiness.rs b/crates/blossom/tests/publication_readiness.rs @@ -2,10 +2,14 @@ use image::{ExtendedColorType, ImageEncoder, codecs::webp::WebPEncoder}; use radroots_blossom::{ - RadrootsBlossomApprovedBlobUrl, RadrootsBlossomAuthoredRasterDimensions, - RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomBud01GetObservation, - RadrootsBlossomBud01HeadObservation, RadrootsBlossomBud02UploadObservation, - RadrootsBlossomError, RadrootsBlossomMediaType, RadrootsBlossomRasterDimensions, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RadrootsBlossomApprovedBlobUrl, + RadrootsBlossomAuthoredRasterDimensions, RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, + RadrootsBlossomBud01GetObservation, RadrootsBlossomBud01HeadObservation, + RadrootsBlossomBud02UploadObservation, RadrootsBlossomError, RadrootsBlossomMediaType, + RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomRasterDimensions, RadrootsBlossomRasterFormat, RadrootsBlossomSha256, verify_publication_readiness, }; use serde::Deserialize; @@ -80,6 +84,240 @@ fn publication_readiness_accepts_public_jpeg_and_still_webp() { } #[test] +fn publication_readiness_evidence_round_trips_only_through_strict_canonical_json() { + let bytes = canonical_png(); + let evidence = verify_public_raster( + &bytes, + "image/png", + "png", + RadrootsBlossomAuthoredRasterDimensions::Exact( + RadrootsBlossomRasterDimensions::new(1, 1).unwrap(), + ), + ) + .unwrap(); + let canonical = evidence.to_canonical_json().unwrap(); + assert!(canonical.len() <= RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES); + + let reloaded = RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&canonical) + .expect("canonical evidence must reload"); + assert_eq!(reloaded, evidence); + assert_eq!( + reloaded.schema_version(), + RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION + ); + assert_eq!( + reloaded.policy_version(), + RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION + ); + assert_eq!(reloaded.to_canonical_json().unwrap(), canonical); + + let pretty = serde_json::to_vec_pretty( + &serde_json::from_slice::<Value>(&canonical).expect("canonical evidence JSON"), + ) + .unwrap(); + assert_eq!( + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&pretty) + .unwrap_err() + .code(), + "publication_readiness_evidence_json_non_canonical" + ); + + let mut unknown = canonical.clone(); + unknown.pop(); + unknown.extend_from_slice(br#","authorization":"Nostr token"}"#); + assert_eq!( + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&unknown) + .unwrap_err() + .code(), + "publication_readiness_evidence_invalid_json" + ); + + let oversized = vec![b' '; RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES + 1]; + assert_eq!( + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&oversized) + .unwrap_err() + .code(), + "publication_readiness_evidence_too_large" + ); +} + +#[test] +fn publication_readiness_evidence_revalidates_every_persisted_fact() { + let evidence = verify_public_raster( + &canonical_png(), + "image/png", + "png", + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + ) + .unwrap(); + let canonical = evidence.to_canonical_json().unwrap(); + + let cases = [ + ( + "schema_version", + serde_json::json!(2), + "publication_readiness_evidence_schema_version_unsupported", + ), + ( + "policy_version", + serde_json::json!(2), + "publication_readiness_evidence_policy_version_unsupported", + ), + ( + "size", + serde_json::json!(0), + "publication_readiness_evidence_field_invalid", + ), + ( + "media_type", + serde_json::json!("image/jpeg"), + "publication_readiness_evidence_field_invalid", + ), + ( + "raster_format", + serde_json::json!("jpeg"), + "publication_readiness_evidence_field_invalid", + ), + ( + "bud02_status", + serde_json::json!(202), + "publication_readiness_evidence_field_invalid", + ), + ( + "bud01_head_status", + serde_json::json!(204), + "publication_readiness_evidence_field_invalid", + ), + ( + "bud01_get_status", + serde_json::json!(206), + "publication_readiness_evidence_field_invalid", + ), + ( + "uploaded", + serde_json::json!(1_800_000_002_u64), + "publication_readiness_evidence_digest_mismatch", + ), + ( + "evidence_digest", + serde_json::json!("00".repeat(32)), + "publication_readiness_evidence_digest_mismatch", + ), + ]; + for (field, value, expected) in cases { + let mut wire: Value = serde_json::from_slice(&canonical).unwrap(); + wire[field] = value; + let mutated = serde_json::to_vec(&wire).unwrap(); + assert_eq!( + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&mutated) + .unwrap_err() + .code(), + expected, + "{field}" + ); + } + + let mut dimensions: Value = serde_json::from_slice(&canonical).unwrap(); + dimensions["dimensions"]["width"] = serde_json::json!(0); + assert_eq!( + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json( + &serde_json::to_vec(&dimensions).unwrap(), + ) + .unwrap_err() + .code(), + "publication_readiness_evidence_field_invalid" + ); +} + +#[test] +fn publication_readiness_enforces_nonempty_bytes_and_bounded_urls() { + let empty_hash = RadrootsBlossomSha256::digest(&[]); + let empty_url = format!("https://cdn.example/{empty_hash}.png"); + let empty_media_type = RadrootsBlossomMediaType::parse("image/png").unwrap(); + let empty_descriptor = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&empty_url).unwrap(), + empty_hash, + 0, + empty_media_type.clone(), + 1_800_000_000, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(&[], &empty_media_type) + .unwrap(); + let empty_upload = RadrootsBlossomBud02UploadObservation::new( + 201, + RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&empty_url).unwrap(), + empty_hash, + 0, + empty_media_type.clone(), + 1_800_000_001, + ) + .unwrap(), + ) + .unwrap(); + let empty_approved_url = RadrootsBlossomBlobUrl::parse(&empty_url) + .unwrap() + .approve() + .unwrap(); + let empty_head = RadrootsBlossomBud01HeadObservation::new( + 200, + empty_approved_url.clone(), + 0, + empty_media_type, + ) + .unwrap(); + let nonempty_get = + RadrootsBlossomBud01GetObservation::from_complete_body(200, empty_approved_url, 1, &[0]) + .unwrap(); + assert_eq!( + verify_publication_readiness( + &empty_descriptor, + &[], + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + &empty_upload, + &empty_head, + &nonempty_get, + ) + .unwrap_err() + .code(), + "publication_raster_empty" + ); + + let bytes = canonical_png(); + let hash = RadrootsBlossomSha256::digest(&bytes); + let prefix = format!("https://cdn.example/{hash}."); + let exact_extension = + "p".repeat(RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES - prefix.len()); + let evidence = verify_public_raster( + &bytes, + "image/png", + &exact_extension, + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + ) + .unwrap(); + assert_eq!( + evidence.url().as_str().len(), + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES + ); + + let over_extension = format!("{exact_extension}p"); + assert_eq!( + verify_public_raster( + &bytes, + "image/png", + &over_extension, + RadrootsBlossomAuthoredRasterDimensions::Unspecified, + ) + .unwrap_err() + .code(), + "publication_readiness_url_too_large" + ); +} + +#[test] fn publication_readiness_rejects_forbidden_and_corrupt_jpeg_and_animated_rasters() { let jpeg = encoded_jpeg(); let scan = jpeg @@ -171,6 +409,13 @@ fn encoded_jpeg() -> Vec<u8> { .unwrap() } +fn canonical_png() -> Vec<u8> { + hex::decode( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360f8cff0000003e201e03810ac1e0000000049454e44ae426082", + ) + .unwrap() +} + fn encoded_still_webp() -> Vec<u8> { let mut bytes = Vec::new(); WebPEncoder::new_lossless(&mut bytes) diff --git a/crates/blossom/tests/publication_readiness_persistence.rs b/crates/blossom/tests/publication_readiness_persistence.rs @@ -0,0 +1,306 @@ +#![cfg(feature = "serde")] + +use radroots_blossom::{ + RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, + RadrootsBlossomPublicationReadinessEvidence, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const CANONICAL_VECTOR: &[u8] = include_bytes!( + "../../../contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json" +); +const PACKAGED_VECTOR: &[u8] = include_bytes!("fixtures/publication_readiness_persistence.v1.json"); +const CANONICAL_EVIDENCE: &str = concat!( + "{\"schema_version\":1,\"policy_version\":1,", + "\"url\":\"https://cdn.example/", + "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd.png\",", + "\"sha256\":\"4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd\",", + "\"size\":70,\"media_type\":\"image/png\",\"raster_format\":\"png\",", + "\"dimensions\":{\"width\":1,\"height\":1},", + "\"bud02_status\":201,\"bud01_head_status\":200,\"bud01_get_status\":200,", + "\"uploaded\":1800000001,", + "\"evidence_digest\":\"637ac3e9ffbb00fbacb60dcf98b466c949f99fd9f608e163127bea970b91670c\"}" +); + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorSuite { + suite: String, + contract_version: String, + vectors: Vec<VectorCase>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorCase { + id: String, + kind: String, + input: VectorInput, + expected: VectorExpected, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorInput { + mutation: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorExpected { + #[serde(default)] + error: Option<String>, + #[serde(default)] + policy_version: Option<u16>, + #[serde(default)] + schema_version: Option<u32>, +} + +#[derive(Serialize)] +struct EvidenceDimensionsWire { + width: u32, + height: u32, +} + +#[derive(Serialize)] +struct EvidenceWire<'a> { + schema_version: u32, + policy_version: u16, + url: &'a str, + sha256: &'a str, + size: u64, + media_type: &'a str, + raster_format: &'a str, + dimensions: EvidenceDimensionsWire, + bud02_status: u16, + bud01_head_status: u16, + bud01_get_status: u16, + uploaded: u64, + evidence_digest: String, +} + +#[test] +fn publication_readiness_persistence_vector_executes_every_case() { + assert_eq!(CANONICAL_VECTOR, PACKAGED_VECTOR, "packaged vector drift"); + let suite: VectorSuite = serde_json::from_slice(PACKAGED_VECTOR).expect("persistence vector"); + assert_eq!(suite.suite, "blossom_publication_readiness_persistence"); + assert_eq!(suite.contract_version, "1.0.0"); + assert_eq!(suite.vectors.len(), 31); + + for vector in suite.vectors { + let bytes = mutated_evidence(&vector.input.mutation); + match vector.kind.as_str() { + "blossom.publication_readiness_evidence.from_canonical_json.valid" => { + let evidence = + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&bytes) + .unwrap_or_else(|error| panic!("{}: {error}", vector.id)); + assert_eq!( + Some(evidence.schema_version()), + vector.expected.schema_version + ); + assert_eq!( + Some(evidence.policy_version()), + vector.expected.policy_version + ); + assert_eq!(evidence.to_canonical_json().unwrap(), bytes); + assert!(vector.expected.error.is_none(), "{}", vector.id); + } + "blossom.publication_readiness_evidence.to_canonical_json.valid" => { + let evidence = + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&bytes) + .unwrap_or_else(|error| panic!("{}: {error}", vector.id)); + assert_eq!(evidence.to_canonical_json().unwrap(), bytes); + assert_eq!( + Some(evidence.schema_version()), + vector.expected.schema_version + ); + assert_eq!( + Some(evidence.policy_version()), + vector.expected.policy_version + ); + assert!(vector.expected.error.is_none(), "{}", vector.id); + } + "blossom.publication_readiness_evidence.from_canonical_json.invalid" => { + let error = + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json(&bytes) + .unwrap_err(); + assert_eq!( + Some(error.code()), + vector.expected.error.as_deref(), + "{}", + vector.id + ); + assert!(vector.expected.schema_version.is_none(), "{}", vector.id); + assert!(vector.expected.policy_version.is_none(), "{}", vector.id); + } + kind => panic!("{} has unsupported vector kind {kind}", vector.id), + } + } +} + +fn mutated_evidence(mutation: &str) -> Vec<u8> { + let canonical = CANONICAL_EVIDENCE.as_bytes(); + match mutation { + "none" => canonical.to_vec(), + "leading_whitespace" => [b" ".as_slice(), canonical].concat(), + "noncanonical_escaping" => replace_once( + canonical, + b"https://cdn.example/", + br"https:\/\/cdn.example/", + ), + "reordered_fields" => replace_once( + canonical, + b"{\"schema_version\":1,\"policy_version\":1", + b"{\"policy_version\":1,\"schema_version\":1", + ), + "unknown_field" => append_field(canonical, b",\"unknown\":true"), + "bud11_authorization" => append_field(canonical, b",\"authorization\":\"Nostr token\""), + "missing_uploaded" => replace_once(canonical, b",\"uploaded\":1800000001", b""), + "duplicate_schema_version" => replace_once(canonical, b"{", b"{\"schema_version\":1,"), + "size_wrong_type" => replace_once(canonical, b"\"size\":70", b"\"size\":\"70\""), + "schema_version" => { + replace_once(canonical, b"\"schema_version\":1", b"\"schema_version\":2") + } + "policy_version" => { + replace_once(canonical, b"\"policy_version\":1", b"\"policy_version\":2") + } + "url_invalid" => replace_once(canonical, b"https://cdn.example/", b"http://cdn.example/"), + "url_exact_max" => evidence_for_url(&url_with_length( + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, + )), + "url_over_max" => evidence_for_url(&url_with_length( + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES + 1, + )), + "url_hash_mismatch" => replace_once( + canonical, + b"https://cdn.example/449013", + b"https://cdn.example/049013", + ), + "sha256_invalid" => replace_once(canonical, b"\"sha256\":\"449013", b"\"sha256\":\"Z49013"), + "size_zero" => replace_once(canonical, b"\"size\":70", b"\"size\":0"), + "size_over_max" => replace_once(canonical, b"\"size\":70", b"\"size\":10485761"), + "mime_unsupported" => replace_once( + canonical, + b"\"media_type\":\"image/png\"", + b"\"media_type\":\"image/gif\"", + ), + "format_mismatch" => replace_once( + canonical, + b"\"raster_format\":\"png\"", + b"\"raster_format\":\"jpeg\"", + ), + "width_zero" => replace_once(canonical, b"\"width\":1", b"\"width\":0"), + "height_over_max" => replace_once(canonical, b"\"height\":1", b"\"height\":16385"), + "pixels_over_max" => replace_once( + canonical, + b"\"width\":1,\"height\":1", + b"\"width\":5000,\"height\":5000", + ), + "bud02_status" => replace_once(canonical, b"\"bud02_status\":201", b"\"bud02_status\":202"), + "bud01_head_status" => replace_once( + canonical, + b"\"bud01_head_status\":200", + b"\"bud01_head_status\":204", + ), + "bud01_get_status" => replace_once( + canonical, + b"\"bud01_get_status\":200", + b"\"bud01_get_status\":206", + ), + "uploaded" => replace_once( + canonical, + b"\"uploaded\":1800000001", + b"\"uploaded\":1800000002", + ), + "digest_invalid" => replace_once( + canonical, + b"\"evidence_digest\":\"637ac3", + b"\"evidence_digest\":\"Z37ac3", + ), + "digest_mismatch" => replace_once( + canonical, + b"\"evidence_digest\":\"637ac3", + b"\"evidence_digest\":\"037ac3", + ), + "oversized_input" => { + vec![b' '; RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES + 1] + } + other => panic!("unsupported persistence mutation {other}"), + } +} + +fn url_with_length(length: usize) -> String { + const HASH: &str = "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd"; + let prefix = format!("https://cdn.example/{HASH}."); + assert!(length >= prefix.len()); + let url = format!("{prefix}{}", "p".repeat(length - prefix.len())); + assert_eq!(url.len(), length); + url +} + +fn evidence_for_url(url: &str) -> Vec<u8> { + const HASH: &str = "4490130851783ff662845f5e72f1948618cc87f951f00f6c2ffb3dc01f3f40fd"; + let evidence = EvidenceWire { + schema_version: 1, + policy_version: 1, + url, + sha256: HASH, + size: 70, + media_type: "image/png", + raster_format: "png", + dimensions: EvidenceDimensionsWire { + width: 1, + height: 1, + }, + bud02_status: 201, + bud01_head_status: 200, + bud01_get_status: 200, + uploaded: 1_800_000_001, + evidence_digest: readiness_evidence_digest(url, HASH), + }; + serde_json::to_vec(&evidence).unwrap() +} + +fn readiness_evidence_digest(url: &str, sha256: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"radroots.blossom.publication-readiness-evidence.v1\0"); + hasher.update(1_u16.to_be_bytes()); + update_length_prefixed(&mut hasher, url.as_bytes()); + hasher.update(hex::decode(sha256).unwrap()); + hasher.update(70_u64.to_be_bytes()); + update_length_prefixed(&mut hasher, b"image/png"); + hasher.update([2]); + hasher.update(1_u32.to_be_bytes()); + hasher.update(1_u32.to_be_bytes()); + hasher.update(201_u16.to_be_bytes()); + hasher.update(200_u16.to_be_bytes()); + hasher.update(200_u16.to_be_bytes()); + hasher.update(1_800_000_001_u64.to_be_bytes()); + hex::encode(hasher.finalize()) +} + +fn update_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +fn append_field(canonical: &[u8], field: &[u8]) -> Vec<u8> { + let mut output = canonical[..canonical.len() - 1].to_vec(); + output.extend_from_slice(field); + output.push(b'}'); + output +} + +fn replace_once(input: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> { + let index = input + .windows(from.len()) + .position(|candidate| candidate == from) + .unwrap_or_else(|| panic!("missing mutation source {}", String::from_utf8_lossy(from))); + let mut output = Vec::with_capacity(input.len() - from.len() + to.len()); + output.extend_from_slice(&input[..index]); + output.extend_from_slice(to); + output.extend_from_slice(&input[index + from.len()..]); + output +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -20,6 +20,9 @@ mod registry_v7; mod release_provenance; mod source_maintenance; +pub(crate) use blossom_publication_readiness::{ + validate_blossom_publication_readiness_manifest, write_blossom_publication_readiness_manifest, +}; pub(crate) use food_availability_projection::{ validate_food_availability_projection_manifest, write_food_availability_projection_manifest, }; @@ -123,7 +126,7 @@ const REPLICA_CONTRACT_RELATIVE: &str = "contracts/replica.toml"; const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 26] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 27] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -137,6 +140,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 26] = [ "crates/blossom/tests/fixtures/publication_readiness.v1.json", ), ( + "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json", + "crates/blossom/tests/fixtures/publication_readiness_persistence.v1.json", + ), + ( "contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json", "crates/nostr/tests/fixtures/bud11_nostr_adapter.v1.json", ), diff --git a/tools/xtask/src/contract/blossom_publication_readiness.rs b/tools/xtask/src/contract/blossom_publication_readiness.rs @@ -1,1012 +1,1438 @@ -use super::artifact_bundle::{read_regular_file, with_artifact_bundle_transaction}; -use super::raw_source_rebuild::validate_raw_source_rebuild_predecessor_production_sources_under_lock; -use serde_json::Value; +use super::{ + artifact_bundle::{GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction}, + raw_source_rebuild::validate_raw_source_rebuild_predecessor_production_sources_under_lock, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use std::collections::BTreeSet; -use std::fs; -use std::path::Path; +use std::{collections::BTreeSet, path::Path}; +use syn::{ImplItem, Item, Visibility}; -const VECTOR_CANONICAL_RELATIVE: &str = +const SCHEMA_VERSION: u32 = 1; +const CONTRACT_ID: &str = "radroots_blossom.publication_readiness_v1"; +const AUTHORITY_ID: &str = "blossom_publication_readiness_v1"; +const HASH_ALGORITHM: &str = "sha256_bytes_v1"; +const WRITE_COMMAND: &str = "cargo xtask contract blossom-publication-readiness-manifest --write"; + +const MANIFEST_RELATIVE: &str = "crates/blossom/contracts/publication_readiness_v1.manifest.json"; +const MANIFEST_SCHEMA_RELATIVE: &str = + "crates/blossom/contracts/publication_readiness_v1.manifest.schema.json"; +const MANIFEST_SHA256_RELATIVE: &str = + "crates/blossom/contracts/publication_readiness_v1.manifest.sha256"; +const GENERATED_DESCRIPTOR_RELATIVE: &str = + "crates/blossom/contracts/publication_readiness_v1.descriptor.json"; +const EVIDENCE_SCHEMA_RELATIVE: &str = + "crates/blossom/contracts/publication_readiness_evidence_v1.schema.json"; +const BEHAVIOR_VECTOR_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 BEHAVIOR_VECTOR_MIRROR_RELATIVE: &str = + "crates/blossom/tests/fixtures/publication_readiness.v1.json"; +const BEHAVIOR_VECTOR_EXECUTOR_RELATIVE: &str = "crates/blossom/tests/publication_readiness.rs"; +const BEHAVIOR_VECTOR_EXECUTOR_TEST: &str = + "publication_readiness_conformance_vector_executes_every_case"; +const PERSISTENCE_VECTOR_RELATIVE: &str = + "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json"; +const PERSISTENCE_VECTOR_MIRROR_RELATIVE: &str = + "crates/blossom/tests/fixtures/publication_readiness_persistence.v1.json"; +const PERSISTENCE_VECTOR_EXECUTOR_RELATIVE: &str = + "crates/blossom/tests/publication_readiness_persistence.rs"; +const PERSISTENCE_VECTOR_EXECUTOR_TEST: &str = + "publication_readiness_persistence_vector_executes_every_case"; const READINESS_SOURCE_RELATIVE: &str = "crates/blossom/src/publication_readiness.rs"; -const SEQUENTIAL_JPEG_SOURCE_RELATIVE: &str = - "crates/blossom/src/publication_readiness/sequential_jpeg.rs"; const BLOSSOM_LIB_RELATIVE: &str = "crates/blossom/src/lib.rs"; -const BLOSSOM_URL_RELATIVE: &str = "crates/blossom/src/url.rs"; const BLOSSOM_MANIFEST_RELATIVE: &str = "crates/blossom/Cargo.toml"; -const COVERAGE_PROFILES_RELATIVE: &str = "contracts/coverage-profiles.toml"; -const NIX_COMMON_RELATIVE: &str = "build/nix/common.nix"; -const NIX_CHECKS_RELATIVE: &str = "build/nix/checks.nix"; const OPERATIONS_RELATIVE: &str = "contracts/operations.toml"; const RELEASE_RELATIVE: &str = "contracts/releases/1.0.0-alpha.1.toml"; const CHANGELOG_RELATIVE: &str = "CHANGELOG.md"; -const RAW_PREDECESSOR_GOVERNANCE_RELATIVE: &str = "tools/xtask/src/contract/raw_source_rebuild.rs"; const RELEASE_CHANGE_ID: &str = "blossom-publication-readiness-evidence"; const CHANGELOG_MARKER: &str = "<!-- release-change: blossom-publication-readiness-evidence -->"; +const VERIFY_OPERATION_ID: &str = "blossom.verify_publication_readiness"; +const SERIALIZE_OPERATION_ID: &str = "blossom.publication_readiness_evidence.to_canonical_json"; +const RELOAD_OPERATION_ID: &str = "blossom.publication_readiness_evidence.from_canonical_json"; +const EVIDENCE_DIGEST_DOMAIN: &str = "radroots.blossom.publication-readiness-evidence.v1\0"; + +const EVIDENCE_SCHEMA_VERSION: u32 = 1; +const READINESS_POLICY_VERSION: u16 = 1; +const EVIDENCE_MAX_BYTES: usize = 8 * 1024; +const URL_MAX_BYTES: usize = 4 * 1024; +const RASTER_MAX_BYTES: u64 = 10_485_760; +const RASTER_MAX_DECODED_BYTES: u64 = 160_000_000; +const RASTER_MAX_DIMENSION: u32 = 16_384; +const RASTER_MAX_PIXELS: u64 = 20_000_000; + +const PUBLIC_CONSTANTS: &[&str] = &[ + "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_EVIDENCE_MAX_BYTES", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_SCHEMA_VERSION", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION", + "RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES", +]; +const PUBLIC_TYPES: &[&str] = &[ + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomBud01GetCollector", + "RadrootsBlossomBud01GetObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud02UploadStatus", + "RadrootsBlossomPublicationReadinessEvidence", + "RadrootsBlossomPublicationReadinessEvidenceDigest", + "RadrootsBlossomRasterDimensions", + "RadrootsBlossomRasterFormat", +]; +const PUBLIC_FUNCTIONS: &[&str] = &["verify_publication_readiness"]; +const PUBLIC_METHODS: &[&str] = &[ + "RadrootsBlossomBud01GetCollector::finish", + "RadrootsBlossomBud01GetCollector::new", + "RadrootsBlossomBud01GetCollector::push_chunk", + "RadrootsBlossomBud01GetObservation::bytes", + "RadrootsBlossomBud01GetObservation::declared_size", + "RadrootsBlossomBud01GetObservation::from_complete_body", + "RadrootsBlossomBud01GetObservation::url", + "RadrootsBlossomBud01HeadObservation::content_length", + "RadrootsBlossomBud01HeadObservation::media_type", + "RadrootsBlossomBud01HeadObservation::new", + "RadrootsBlossomBud01HeadObservation::url", + "RadrootsBlossomBud02UploadObservation::descriptor", + "RadrootsBlossomBud02UploadObservation::new", + "RadrootsBlossomBud02UploadObservation::status", + "RadrootsBlossomBud02UploadStatus::as_u16", + "RadrootsBlossomPublicationReadinessEvidence::bud02_status", + "RadrootsBlossomPublicationReadinessEvidence::dimensions", + "RadrootsBlossomPublicationReadinessEvidence::evidence_digest", + "RadrootsBlossomPublicationReadinessEvidence::from_canonical_json", + "RadrootsBlossomPublicationReadinessEvidence::media_type", + "RadrootsBlossomPublicationReadinessEvidence::policy_version", + "RadrootsBlossomPublicationReadinessEvidence::raster_format", + "RadrootsBlossomPublicationReadinessEvidence::schema_version", + "RadrootsBlossomPublicationReadinessEvidence::sha256", + "RadrootsBlossomPublicationReadinessEvidence::size", + "RadrootsBlossomPublicationReadinessEvidence::to_canonical_json", + "RadrootsBlossomPublicationReadinessEvidence::uploaded", + "RadrootsBlossomPublicationReadinessEvidence::url", + "RadrootsBlossomPublicationReadinessEvidenceDigest::as_sha256", + "RadrootsBlossomRasterDimensions::height", + "RadrootsBlossomRasterDimensions::new", + "RadrootsBlossomRasterDimensions::pixels", + "RadrootsBlossomRasterDimensions::width", + "RadrootsBlossomRasterFormat::as_str", + "RadrootsBlossomRasterFormat::from_media_type", +]; +const SEALED_TYPES: &[&str] = &[ + "RadrootsBlossomPublicationReadinessEvidence", + "RadrootsBlossomPublicationReadinessEvidenceDigest", +]; +const PRIVATE_WIRE_TYPES: &[&str] = &[ + "PublicationReadinessDimensionsWire", + "PublicationReadinessEvidenceWire", +]; +const WIRE_FIELD_ORDER: &[&str] = &[ + "schema_version", + "policy_version", + "url", + "sha256", + "size", + "media_type", + "raster_format", + "dimensions", + "bud02_status", + "bud01_head_status", + "bud01_get_status", + "uploaded", + "evidence_digest", +]; +const SEMANTIC_INVARIANTS: &[&str] = &[ + "bounded_input_before_json_parse_v1", + "fixed_compact_json_field_order_v1", + "canonical_json_round_trip_required_v1", + "sealed_evidence_without_deserialize_v1", + "private_deny_unknown_fields_wire_models_v1", + "canonical_hash_path_url_max_4096_utf8_bytes_v1", + "closed_jpeg_png_still_webp_mime_and_format_v1", + "nonzero_size_max_10485760_bytes_v1", + "nonzero_dimensions_axis_max_16384_pixels_max_20000000_v1", + "bud02_status_200_or_201_and_bud01_status_200_v1", + "descriptor_head_get_complete_byte_agreement_v1", + "private_domain_separated_digest_derivation_v1", + "no_bud11_credentials_entitlement_or_topology_persistence_v1", +]; +const DECODE_ERROR_CODES: &[&str] = &[ + "publication_readiness_evidence_too_large", + "publication_readiness_evidence_invalid_json", + "publication_readiness_evidence_schema_version_unsupported", + "publication_readiness_evidence_policy_version_unsupported", + "publication_readiness_evidence_field_invalid", + "publication_readiness_evidence_json_non_canonical", + "publication_readiness_evidence_digest_mismatch", +]; +const PROTOCOL_SOURCE_PINS: &[(&str, &str, &str)] = &[( + "blossom", + "https://github.com/hzrd149/blossom", + "b5bd2801d1763aa635fc8fea7a76597e0eb18990", +)]; + const RAW_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ - WORKSPACE_LOCK_RELATIVE, - WORKSPACE_MANIFEST_RELATIVE, - NIX_COMMON_RELATIVE, + "Cargo.lock", + "Cargo.toml", + "build/nix/common.nix", CHANGELOG_RELATIVE, RELEASE_RELATIVE, "tools/xtask/src/contract.rs", "tools/xtask/src/contract/food_availability_projection.rs", "tools/xtask/src/contract/nip09_reconciliation.rs", - RAW_PREDECESSOR_GOVERNANCE_RELATIVE, + "tools/xtask/src/contract/raw_source_rebuild.rs", "tools/xtask/src/contract/source_maintenance.rs", "tools/xtask/src/main.rs", ]; const TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS: &[&str] = &[ - WORKSPACE_MANIFEST_RELATIVE, - BLOSSOM_MANIFEST_RELATIVE, - "crates/blossom/src/error.rs", - BLOSSOM_LIB_RELATIVE, - BLOSSOM_URL_RELATIVE, -]; - -const SOURCE_INVENTORY: &[&str] = &[ - WORKSPACE_LOCK_RELATIVE, - WORKSPACE_MANIFEST_RELATIVE, - NIX_CHECKS_RELATIVE, - NIX_COMMON_RELATIVE, - CHANGELOG_RELATIVE, + "Cargo.toml", BLOSSOM_MANIFEST_RELATIVE, - "crates/blossom/README", "crates/blossom/src/error.rs", BLOSSOM_LIB_RELATIVE, - READINESS_SOURCE_RELATIVE, - SEQUENTIAL_JPEG_SOURCE_RELATIVE, - BLOSSOM_URL_RELATIVE, - "crates/blossom/tests/publication_readiness.rs", - VECTOR_MIRROR_RELATIVE, - "contracts/events/blossom-media.md", - COVERAGE_PROFILES_RELATIVE, - VECTOR_CANONICAL_RELATIVE, - OPERATIONS_RELATIVE, - RELEASE_RELATIVE, - "tools/xtask/src/contract.rs", - "tools/xtask/src/main.rs", - "tools/xtask/src/contract/blossom_publication_readiness.rs", - "tools/xtask/src/contract/food_availability_projection.rs", - "tools/xtask/src/contract/nip09_reconciliation.rs", - RAW_PREDECESSOR_GOVERNANCE_RELATIVE, - "tools/xtask/src/contract/source_maintenance.rs", + "crates/blossom/src/url.rs", ]; -const IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS: &[(&str, usize, &str)] = &[ - ( +const IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS: &[ImmutableArtifactSpec] = &[ + ImmutableArtifactSpec::new( "crates/event_store/contracts/raw_source_rebuild_v1.manifest.json", 45_449, "03253ce31dc31d465880a895d2685f5deb1274948e0a4eabe81a2f08f238c483", ), - ( + ImmutableArtifactSpec::new( "crates/event_store/contracts/raw_source_rebuild_v1.manifest.schema.json", 17_896, "f9d210967e54b66f39c8bb965d97b2001a0ebc0927e7c2c14edb8e474bfda695", ), - ( + ImmutableArtifactSpec::new( "crates/event_store/contracts/raw_source_rebuild_v1.manifest.sha256", 65, "2b8bc07cd479be2281781660efd26fd7a8f480e5f3f62053aeccd2b5e6b2070c", ), - ( + ImmutableArtifactSpec::new( "crates/event_store/src/generated/raw_source_rebuild_manifest.rs", 50_735, "3763fbee3ee45621afca990002b9298c791bf0396ebf7bccde0ae1bc9aecb7f2", ), - ( + ImmutableArtifactSpec::new( "contracts/conformance/vectors/event_store/raw_source_rebuild.v1.json", 26_833, "c37a2bf3714f53ab04fae8c5c9dbe2ad4b3f5310efa51f46bd8b116660f1fe15", ), - ( + ImmutableArtifactSpec::new( "crates/event_store/tests/fixtures/raw_source_rebuild.v1.json", 26_833, "c37a2bf3714f53ab04fae8c5c9dbe2ad4b3f5310efa51f46bd8b116660f1fe15", ), ]; -const CURRENT_BYTE_BOUND_BLOSSOM_SOURCES: &[(&str, usize, &str)] = &[ - ( - BLOSSOM_LIB_RELATIVE, - 2_172, - "97cae38f693795445cc17671649f3d88c0c492fc8d71d2c98a4ca02502e5d43a", - ), - ( - "crates/blossom/src/error.rs", - 30_918, - "bd77810306b3556434d93057ca5ee62db474e9b0af94176ba4aa7a2ef25be7d8", - ), - ( - BLOSSOM_URL_RELATIVE, - 15_794, - "e9673f074ba6328a121aa3008fc11cd4d9d22cae3b09f26602ea6fea3f964c80", - ), +const BEHAVIOR_CASE_IDS: &[&str] = &[ + "valid_created", + "valid_ok_without_authored_dimensions", + "invalid_upload_status", + "invalid_head_status", + "invalid_get_status", + "declared_size_over_public_max", + "missing_get_body", + "short_get_body", + "trailing_get_body", + "authored_bytes_short", + "authored_bytes_wrong_hash", + "upload_url_mismatch", + "upload_hash_mismatch", + "upload_size_mismatch", + "upload_mime_mismatch", + "head_url_mismatch", + "head_size_mismatch", + "head_mime_mismatch", + "get_url_mismatch", + "get_declared_size_mismatch", + "get_complete_hash_mismatch", + "unsupported_raster_mime", + "malformed_raster", + "animated_png", + "declared_format_mismatch", + "corrupt_png_crc", + "corrupt_png_deflate", + "invalid_png_color_type", + "authored_dimension_mismatch", + "animated_webp", + "zero_width", + "dimension_over_max", + "pixel_limit", + "progressive_jpeg", + "jpeg_entropy_stripped", + "jpeg_entropy_partial", + "malformed_jpeg_dqt", ]; - -const REQUIRED_PUBLIC_TYPES: &[&str] = &[ - "RadrootsBlossomBud02UploadStatus", - "RadrootsBlossomBud02UploadObservation", - "RadrootsBlossomBud01HeadObservation", - "RadrootsBlossomBud01GetCollector", - "RadrootsBlossomBud01GetObservation", - "RadrootsBlossomRasterFormat", - "RadrootsBlossomRasterDimensions", - "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomPublicationReadinessEvidenceDigest", - "RadrootsBlossomPublicationReadinessEvidence", +const PERSISTENCE_CASE_IDS: &[&str] = &[ + "canonical_evidence_reloads", + "canonical_evidence_serializes", + "leading_whitespace_is_noncanonical", + "noncanonical_string_escaping_is_rejected", + "field_reordering_is_noncanonical", + "unknown_field_is_rejected", + "private_bud11_field_is_rejected", + "missing_field_is_rejected", + "duplicate_field_is_rejected", + "wrong_field_type_is_rejected", + "schema_version_is_strict", + "policy_version_is_strict", + "url_must_be_canonical", + "url_exact_maximum_is_valid", + "url_one_over_maximum_is_rejected", + "url_hash_must_match_sha256", + "sha256_must_be_lower_hex", + "size_must_be_nonzero", + "size_must_be_bounded", + "mime_must_be_supported", + "format_must_match_mime", + "dimensions_must_be_nonzero", + "dimensions_must_respect_axis_limit", + "dimensions_must_respect_pixel_limit", + "bud02_status_is_strict", + "bud01_head_status_is_strict", + "bud01_get_status_is_strict", + "uploaded_mutation_breaks_digest", + "digest_must_be_lower_hex", + "digest_must_match_all_facts", + "input_is_bounded_before_parse", ]; -const VECTOR_EXPECTATIONS: &[(&str, &str, &str, Option<&str>)] = &[ - ( - "valid_created", - "blossom.verify_publication_readiness.valid", - "none", - None, - ), - ( - "valid_ok_without_authored_dimensions", - "blossom.verify_publication_readiness.valid", - "upload_status_200", - None, - ), - ( - "invalid_upload_status", - "blossom.verify_publication_readiness.invalid", - "upload_status_202", - Some("invalid_bud02_upload_status"), - ), - ( - "invalid_head_status", - "blossom.verify_publication_readiness.invalid", - "head_status_204", - Some("invalid_bud01_head_status"), - ), - ( - "invalid_get_status", - "blossom.verify_publication_readiness.invalid", - "get_status_206", - Some("invalid_bud01_get_status"), - ), - ( - "declared_size_over_public_max", - "blossom.verify_publication_readiness.invalid", - "get_size_over_max", - Some("publication_raster_byte_limit_exceeded"), - ), - ( - "missing_get_body", - "blossom.verify_publication_readiness.invalid", - "get_body_missing", - Some("publication_get_body_missing"), - ), - ( - "short_get_body", - "blossom.verify_publication_readiness.invalid", - "get_body_short", - Some("publication_get_body_short"), - ), - ( - "trailing_get_body", - "blossom.verify_publication_readiness.invalid", - "get_body_trailing", - Some("publication_get_body_trailing"), - ), - ( - "authored_bytes_short", - "blossom.verify_publication_readiness.invalid", - "authored_bytes_short", - Some("publication_authored_bytes_size_mismatch"), - ), - ( - "authored_bytes_wrong_hash", - "blossom.verify_publication_readiness.invalid", - "authored_bytes_wrong_hash", - Some("publication_authored_bytes_hash_mismatch"), - ), - ( - "upload_url_mismatch", - "blossom.verify_publication_readiness.invalid", - "upload_url_mismatch", - Some("publication_upload_url_mismatch"), - ), - ( - "upload_hash_mismatch", - "blossom.verify_publication_readiness.invalid", - "upload_hash_mismatch", - Some("publication_upload_hash_mismatch"), - ), - ( - "upload_size_mismatch", - "blossom.verify_publication_readiness.invalid", - "upload_size_mismatch", - Some("publication_upload_size_mismatch"), - ), - ( - "upload_mime_mismatch", - "blossom.verify_publication_readiness.invalid", - "upload_mime_mismatch", - Some("publication_upload_media_type_mismatch"), - ), - ( - "head_url_mismatch", - "blossom.verify_publication_readiness.invalid", - "head_url_mismatch", - Some("publication_head_url_mismatch"), - ), - ( - "head_size_mismatch", - "blossom.verify_publication_readiness.invalid", - "head_size_mismatch", - Some("publication_head_size_mismatch"), - ), - ( - "head_mime_mismatch", - "blossom.verify_publication_readiness.invalid", - "head_mime_mismatch", - Some("publication_head_media_type_mismatch"), - ), - ( - "get_url_mismatch", - "blossom.verify_publication_readiness.invalid", - "get_url_mismatch", - Some("publication_get_url_mismatch"), - ), - ( - "get_declared_size_mismatch", - "blossom.verify_publication_readiness.invalid", - "get_declared_size_mismatch", - Some("publication_get_declared_size_mismatch"), - ), - ( - "get_complete_hash_mismatch", - "blossom.verify_publication_readiness.invalid", - "get_bytes_wrong_hash", - Some("publication_retrieved_bytes_hash_mismatch"), - ), - ( - "unsupported_raster_mime", - "blossom.verify_publication_readiness.invalid", - "unsupported_mime", - Some("unsupported_publication_raster_media_type"), - ), - ( - "malformed_raster", - "blossom.verify_publication_readiness.invalid", - "malformed_container", - Some("invalid_publication_raster"), - ), - ( - "animated_png", - "blossom.verify_publication_readiness.invalid", - "animated_png", - Some("publication_raster_animation_forbidden"), - ), - ( - "declared_format_mismatch", - "blossom.verify_publication_readiness.invalid", - "declared_mime_jpeg", - Some("invalid_publication_raster"), - ), - ( - "corrupt_png_crc", - "blossom.verify_publication_readiness.invalid", - "corrupt_png_crc", - Some("publication_raster_decode_failed"), - ), - ( - "corrupt_png_deflate", - "blossom.verify_publication_readiness.invalid", - "corrupt_png_deflate", - Some("publication_raster_decode_failed"), - ), - ( - "invalid_png_color_type", - "blossom.verify_publication_readiness.invalid", - "invalid_png_color_type", - Some("publication_raster_decode_failed"), - ), - ( - "authored_dimension_mismatch", - "blossom.verify_publication_readiness.invalid", - "authored_dimension_mismatch", - Some("publication_authored_raster_dimension_mismatch"), - ), - ( - "animated_webp", - "blossom.verify_publication_readiness.invalid", - "animated_webp", - Some("publication_raster_animation_forbidden"), - ), - ( - "zero_width", - "blossom.verify_publication_readiness.invalid", - "zero_width", - Some("publication_raster_dimensions_out_of_range"), - ), - ( - "dimension_over_max", - "blossom.verify_publication_readiness.invalid", - "dimension_over_max", - Some("publication_raster_dimensions_out_of_range"), - ), - ( - "pixel_limit", - "blossom.verify_publication_readiness.invalid", - "pixel_limit", - Some("publication_raster_pixel_limit_exceeded"), - ), - ( - "progressive_jpeg", - "blossom.verify_publication_readiness.invalid", - "progressive_jpeg", - Some("publication_jpeg_process_forbidden"), - ), - ( - "jpeg_entropy_stripped", - "blossom.verify_publication_readiness.invalid", - "jpeg_entropy_stripped", - Some("publication_raster_decode_failed"), - ), - ( - "jpeg_entropy_partial", - "blossom.verify_publication_readiness.invalid", - "jpeg_entropy_partial", - Some("publication_raster_decode_failed"), - ), - ( - "malformed_jpeg_dqt", - "blossom.verify_publication_readiness.invalid", - "malformed_jpeg_dqt", - Some("invalid_publication_raster"), - ), -]; +#[derive(Clone, Copy)] +struct ImmutableArtifactSpec { + relative: &'static str, + byte_length: usize, + sha256: &'static str, +} -pub(super) fn validate_blossom_publication_readiness(workspace_root: &Path) -> Result<(), String> { +impl ImmutableArtifactSpec { + const fn new(relative: &'static str, byte_length: usize, sha256: &'static str) -> Self { + Self { + relative, + byte_length, + sha256, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct FileDescriptor { + path: String, + byte_length: u64, + sha256: String, + hash_algorithm: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PredecessorDescriptor { + contract_id: String, + immutable_artifacts: Vec<FileDescriptor>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ProtocolSourcePin { + id: String, + repository: String, + revision: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PublicApiDescriptor { + constants: Vec<String>, + types: Vec<String>, + functions: Vec<String>, + methods: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct EvidenceDescriptor { + schema_version: u32, + policy_version: u16, + schema: FileDescriptor, + max_canonical_json_bytes: u64, + max_url_utf8_bytes: u64, + max_raster_bytes: u64, + max_decoded_bytes: u64, + max_dimension: u32, + max_pixels: u64, + wire_field_order: Vec<String>, + digest_domain: String, + digest_framing: String, + serialize_operation_id: String, + reload_operation_id: String, + invariants: Vec<String>, + decode_error_codes: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct VectorDescriptor { + canonical_path: String, + mirror_path: String, + byte_length: u64, + sha256: String, + hash_algorithm: String, + executor: FileDescriptor, + executor_test: String, + case_ids: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ReadinessDescriptor { + verify_operation_id: String, + input_types: Vec<String>, + output_type: String, + evidence: EvidenceDescriptor, + behavior_vector: VectorDescriptor, + persistence_vector: VectorDescriptor, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PublicationReadinessManifest { + schema_version: u32, + contract_id: String, + authority_id: String, + manifest_schema: FileDescriptor, + predecessor: PredecessorDescriptor, + protocol_sources: Vec<ProtocolSourcePin>, + public_api: PublicApiDescriptor, + readiness: ReadinessDescriptor, + predecessor_source_supersessions: Vec<String>, + transitive_predecessor_source_supersessions: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorSuite { + suite: String, + contract_version: String, + vectors: Vec<VectorCase>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorCase { + id: String, + kind: String, + input: Value, + expected: Value, +} + +struct ValidatedVector { + bytes: Vec<u8>, + case_ids: Vec<String>, +} + +pub(crate) fn write_blossom_publication_readiness_manifest( + workspace_root: &Path, +) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |transaction| { + transaction.write(expected_artifacts(workspace_root)?)?; + validate_manifest_under_lock(workspace_root) + }) +} + +pub(crate) fn validate_blossom_publication_readiness_manifest( + workspace_root: &Path, +) -> Result<(), String> { with_artifact_bundle_transaction(workspace_root, |_| { - validate_blossom_publication_readiness_under_lock(workspace_root) + validate_manifest_under_lock(workspace_root) }) } -fn validate_blossom_publication_readiness_under_lock(workspace_root: &Path) -> Result<(), String> { +pub(super) fn validate_blossom_publication_readiness(workspace_root: &Path) -> Result<(), String> { + validate_blossom_publication_readiness_manifest(workspace_root) +} + +fn validate_manifest_under_lock(workspace_root: &Path) -> Result<(), String> { + validate_immutable_predecessor(workspace_root)?; + validate_raw_source_rebuild_predecessor_production_sources_under_lock( + workspace_root, + RAW_PREDECESSOR_SUPERSEDED_PATHS, + TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS, + )?; + for artifact in expected_artifacts(workspace_root)? { + let actual = read_regular_file(workspace_root, artifact.relative)?; + if actual != artifact.contents { + return Err(format!( + "generated Blossom publication-readiness contract {} is stale; run {WRITE_COMMAND}", + artifact.relative + )); + } + } + + let bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; + let manifest: PublicationReadinessManifest = serde_json::from_slice(&bytes) + .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; + validate_canonical_json(MANIFEST_RELATIVE, &bytes, &manifest)?; + validate_manifest_shape(&manifest)?; + + let schema_bytes = read_regular_file(workspace_root, MANIFEST_SCHEMA_RELATIVE)?; + let schema: Value = serde_json::from_slice(&schema_bytes) + .map_err(|error| format!("parse {MANIFEST_SCHEMA_RELATIVE}: {error}"))?; + validate_canonical_json(MANIFEST_SCHEMA_RELATIVE, &schema_bytes, &schema)?; + validate_json_schema( + &schema, + &serde_json::to_value(&manifest).map_err(|error| { + format!("serialize {MANIFEST_RELATIVE} for schema validation: {error}") + })?, + )?; + + let evidence_schema_bytes = read_regular_file(workspace_root, EVIDENCE_SCHEMA_RELATIVE)?; + let evidence_schema: Value = serde_json::from_slice(&evidence_schema_bytes) + .map_err(|error| format!("parse {EVIDENCE_SCHEMA_RELATIVE}: {error}"))?; + validate_canonical_json( + EVIDENCE_SCHEMA_RELATIVE, + &evidence_schema_bytes, + &evidence_schema, + )?; + + let sidecar = read_regular_file(workspace_root, MANIFEST_SHA256_RELATIVE)?; + if sidecar != format!("{}\n", sha256_hex(&bytes)).as_bytes() { + return Err(format!( + "{MANIFEST_SHA256_RELATIVE} must authenticate the exact manifest bytes" + )); + } + Ok(()) +} + +fn expected_artifacts(workspace_root: &Path) -> Result<Vec<GeneratedArtifact>, String> { validate_immutable_predecessor(workspace_root)?; validate_raw_source_rebuild_predecessor_production_sources_under_lock( workspace_root, RAW_PREDECESSOR_SUPERSEDED_PATHS, TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS, )?; - validate_source_inventory(workspace_root)?; - validate_source_boundary(workspace_root)?; - validate_vector(workspace_root)?; - validate_operation(workspace_root)?; - validate_release(workspace_root) + validate_source_contract(workspace_root)?; + + let evidence_schema_bytes = canonical_json_bytes(&evidence_schema())?; + let manifest_schema_bytes = canonical_json_bytes(&manifest_schema())?; + let manifest = describe_manifest( + workspace_root, + &manifest_schema_bytes, + &evidence_schema_bytes, + )?; + let manifest_bytes = canonical_json_bytes(&manifest)?; + let sidecar_bytes = format!("{}\n", sha256_hex(&manifest_bytes)).into_bytes(); + let behavior_vector_bytes = read_regular_file(workspace_root, BEHAVIOR_VECTOR_RELATIVE)?; + let persistence_vector_bytes = read_regular_file(workspace_root, PERSISTENCE_VECTOR_RELATIVE)?; + let descriptor_bytes = canonical_json_bytes(&json!({ + "schema_version": SCHEMA_VERSION, + "contract_id": CONTRACT_ID, + "manifest": descriptor_for_bytes(MANIFEST_RELATIVE, &manifest_bytes), + "manifest_schema": descriptor_for_bytes(MANIFEST_SCHEMA_RELATIVE, &manifest_schema_bytes), + "manifest_sidecar": descriptor_for_bytes(MANIFEST_SHA256_RELATIVE, &sidecar_bytes), + "evidence_schema": descriptor_for_bytes(EVIDENCE_SCHEMA_RELATIVE, &evidence_schema_bytes), + "behavior_vector_sha256": sha256_hex(&behavior_vector_bytes), + "persistence_vector_sha256": sha256_hex(&persistence_vector_bytes), + "predecessor_manifest_sha256": IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS[0].sha256, + }))?; + + Ok(vec![ + GeneratedArtifact { + relative: MANIFEST_RELATIVE, + contents: manifest_bytes, + }, + GeneratedArtifact { + relative: MANIFEST_SCHEMA_RELATIVE, + contents: manifest_schema_bytes, + }, + GeneratedArtifact { + relative: MANIFEST_SHA256_RELATIVE, + contents: sidecar_bytes, + }, + GeneratedArtifact { + relative: GENERATED_DESCRIPTOR_RELATIVE, + contents: descriptor_bytes, + }, + GeneratedArtifact { + relative: EVIDENCE_SCHEMA_RELATIVE, + contents: evidence_schema_bytes, + }, + GeneratedArtifact { + relative: BEHAVIOR_VECTOR_MIRROR_RELATIVE, + contents: behavior_vector_bytes, + }, + GeneratedArtifact { + relative: PERSISTENCE_VECTOR_MIRROR_RELATIVE, + contents: persistence_vector_bytes, + }, + ]) +} + +fn describe_manifest( + workspace_root: &Path, + manifest_schema_bytes: &[u8], + evidence_schema_bytes: &[u8], +) -> Result<PublicationReadinessManifest, String> { + let behavior = validate_vector( + workspace_root, + BEHAVIOR_VECTOR_RELATIVE, + "blossom_publication_readiness", + BEHAVIOR_CASE_IDS, + &[ + "blossom.verify_publication_readiness.valid", + "blossom.verify_publication_readiness.invalid", + ], + )?; + let persistence = validate_vector( + workspace_root, + PERSISTENCE_VECTOR_RELATIVE, + "blossom_publication_readiness_persistence", + PERSISTENCE_CASE_IDS, + &[ + "blossom.publication_readiness_evidence.from_canonical_json.valid", + "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "blossom.publication_readiness_evidence.to_canonical_json.valid", + ], + )?; + + Ok(PublicationReadinessManifest { + schema_version: SCHEMA_VERSION, + contract_id: CONTRACT_ID.to_owned(), + authority_id: AUTHORITY_ID.to_owned(), + manifest_schema: descriptor_for_bytes(MANIFEST_SCHEMA_RELATIVE, manifest_schema_bytes), + predecessor: PredecessorDescriptor { + contract_id: "radroots_event_store.raw_source_rebuild_v1".to_owned(), + immutable_artifacts: IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS + .iter() + .map(|artifact| FileDescriptor { + path: artifact.relative.to_owned(), + byte_length: artifact.byte_length as u64, + sha256: artifact.sha256.to_owned(), + hash_algorithm: HASH_ALGORITHM.to_owned(), + }) + .collect(), + }, + protocol_sources: expected_protocol_sources(), + public_api: expected_public_api(), + readiness: ReadinessDescriptor { + verify_operation_id: VERIFY_OPERATION_ID.to_owned(), + input_types: owned(&[ + "RadrootsBlossomByteVerifiedDescriptor", + "Bytes", + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud01GetObservation", + ]), + output_type: "RadrootsBlossomPublicationReadinessEvidence".to_owned(), + evidence: EvidenceDescriptor { + schema_version: EVIDENCE_SCHEMA_VERSION, + policy_version: READINESS_POLICY_VERSION, + schema: descriptor_for_bytes(EVIDENCE_SCHEMA_RELATIVE, evidence_schema_bytes), + max_canonical_json_bytes: EVIDENCE_MAX_BYTES as u64, + max_url_utf8_bytes: URL_MAX_BYTES as u64, + max_raster_bytes: RASTER_MAX_BYTES, + max_decoded_bytes: RASTER_MAX_DECODED_BYTES, + max_dimension: RASTER_MAX_DIMENSION, + max_pixels: RASTER_MAX_PIXELS, + wire_field_order: owned(WIRE_FIELD_ORDER), + digest_domain: EVIDENCE_DIGEST_DOMAIN.to_owned(), + digest_framing: "domain_bytes_then_u16be_policy_then_u64be_length_prefixed_url_then_raw_sha256_then_u64be_size_then_u64be_length_prefixed_mime_then_u8_format_then_u32be_width_then_u32be_height_then_u16be_bud02_then_u16be_head_then_u16be_get_then_u64be_uploaded_v1".to_owned(), + serialize_operation_id: SERIALIZE_OPERATION_ID.to_owned(), + reload_operation_id: RELOAD_OPERATION_ID.to_owned(), + invariants: owned(SEMANTIC_INVARIANTS), + decode_error_codes: owned(DECODE_ERROR_CODES), + }, + behavior_vector: vector_descriptor( + behavior, + BEHAVIOR_VECTOR_RELATIVE, + BEHAVIOR_VECTOR_MIRROR_RELATIVE, + workspace_root, + BEHAVIOR_VECTOR_EXECUTOR_RELATIVE, + BEHAVIOR_VECTOR_EXECUTOR_TEST, + )?, + persistence_vector: vector_descriptor( + persistence, + PERSISTENCE_VECTOR_RELATIVE, + PERSISTENCE_VECTOR_MIRROR_RELATIVE, + workspace_root, + PERSISTENCE_VECTOR_EXECUTOR_RELATIVE, + PERSISTENCE_VECTOR_EXECUTOR_TEST, + )?, + }, + predecessor_source_supersessions: owned(RAW_PREDECESSOR_SUPERSEDED_PATHS), + transitive_predecessor_source_supersessions: owned( + TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS, + ), + }) +} + +fn vector_descriptor( + vector: ValidatedVector, + canonical_path: &str, + mirror_path: &str, + workspace_root: &Path, + executor_path: &str, + executor_test: &str, +) -> Result<VectorDescriptor, String> { + Ok(VectorDescriptor { + canonical_path: canonical_path.to_owned(), + mirror_path: mirror_path.to_owned(), + byte_length: vector.bytes.len() as u64, + sha256: sha256_hex(&vector.bytes), + hash_algorithm: HASH_ALGORITHM.to_owned(), + executor: descriptor_for_file(workspace_root, executor_path)?, + executor_test: executor_test.to_owned(), + case_ids: vector.case_ids, + }) +} + +fn validate_source_contract(workspace_root: &Path) -> Result<(), String> { + validate_public_api_authority(workspace_root)?; + validate_manifest_features(workspace_root)?; + validate_operations_authority(workspace_root)?; + validate_release_authority(workspace_root)?; + validate_vector( + workspace_root, + BEHAVIOR_VECTOR_RELATIVE, + "blossom_publication_readiness", + BEHAVIOR_CASE_IDS, + &[ + "blossom.verify_publication_readiness.valid", + "blossom.verify_publication_readiness.invalid", + ], + )?; + validate_vector( + workspace_root, + PERSISTENCE_VECTOR_RELATIVE, + "blossom_publication_readiness_persistence", + PERSISTENCE_CASE_IDS, + &[ + "blossom.publication_readiness_evidence.from_canonical_json.valid", + "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "blossom.publication_readiness_evidence.to_canonical_json.valid", + ], + )?; + Ok(()) } fn validate_immutable_predecessor(workspace_root: &Path) -> Result<(), String> { - for (relative, expected_length, expected_sha256) in IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS { - let bytes = read_regular_file(workspace_root, relative)?; - if bytes.len() != *expected_length || sha256_hex(&bytes) != *expected_sha256 { + for artifact in IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS { + let bytes = read_regular_file(workspace_root, artifact.relative)?; + if bytes.len() != artifact.byte_length || sha256_hex(&bytes) != artifact.sha256 { return Err(format!( - "immutable raw-source rebuild predecessor artifact `{relative}` drifted" + "immutable raw-source rebuild predecessor artifact `{}` drifted", + artifact.relative )); } } Ok(()) } -fn validate_source_inventory(workspace_root: &Path) -> Result<(), String> { - let unique = SOURCE_INVENTORY.iter().copied().collect::<BTreeSet<_>>(); - if unique.len() != SOURCE_INVENTORY.len() { - return Err("publication-readiness source inventory contains duplicates".to_owned()); - } - for relative in SOURCE_INVENTORY { - let metadata = fs::symlink_metadata(workspace_root.join(relative)).map_err(|error| { - format!("inspect publication-readiness source `{relative}`: {error}") - })?; - if !metadata.file_type().is_file() { - return Err(format!( - "publication-readiness source `{relative}` must be a regular file" - )); +fn validate_public_api_authority(workspace_root: &Path) -> Result<(), String> { + let source = read_utf8(workspace_root, READINESS_SOURCE_RELATIVE)?; + validate_public_api_source(&source)?; + let lib = read_utf8(workspace_root, BLOSSOM_LIB_RELATIVE)?; + validate_lib_reexports(&lib) +} + +fn validate_public_api_source(source: &str) -> Result<(), String> { + let file = syn::parse_file(source) + .map_err(|error| format!("parse {READINESS_SOURCE_RELATIVE}: {error}"))?; + let mut constants = BTreeSet::new(); + let mut types = BTreeSet::new(); + let mut functions = BTreeSet::new(); + let mut methods = BTreeSet::new(); + let mut public_type_fields_private = BTreeSet::new(); + let mut private_wire_types = BTreeSet::new(); + let mut forbidden_deserialize = BTreeSet::new(); + + for item in &file.items { + match item { + Item::Const(item) if is_public(&item.vis) => { + constants.insert(item.ident.to_string()); + } + Item::Enum(item) if is_public(&item.vis) => { + let name = item.ident.to_string(); + types.insert(name.clone()); + if item + .variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .all(|field| !is_public(&field.vis)) + { + public_type_fields_private.insert(name); + } + } + Item::Struct(item) => { + let name = item.ident.to_string(); + if is_public(&item.vis) { + types.insert(name.clone()); + if item.fields.iter().all(|field| !is_public(&field.vis)) { + public_type_fields_private.insert(name.clone()); + } + if SEALED_TYPES.contains(&name.as_str()) && derives(&item.attrs, "Deserialize") + { + forbidden_deserialize.insert(name); + } + } else if PRIVATE_WIRE_TYPES.contains(&name.as_str()) + && derives(&item.attrs, "Serialize") + && derives(&item.attrs, "Deserialize") + && has_serde_deny_unknown_fields(&item.attrs) + { + private_wire_types.insert(name); + } + } + Item::Fn(item) if is_public(&item.vis) => { + functions.insert(item.sig.ident.to_string()); + } + Item::Impl(item) => { + let syn::Type::Path(self_type) = item.self_ty.as_ref() else { + continue; + }; + let Some(type_name) = self_type.path.segments.last() else { + continue; + }; + let type_name = type_name.ident.to_string(); + if item.trait_.as_ref().is_some_and(|(_, path, _)| { + path.segments + .last() + .is_some_and(|segment| segment.ident == "Deserialize") + }) && SEALED_TYPES.contains(&type_name.as_str()) + { + forbidden_deserialize.insert(type_name.clone()); + } + if item.trait_.is_none() { + for method in &item.items { + if let ImplItem::Fn(method) = method + && is_public(&method.vis) + { + methods.insert(format!("{type_name}::{}", method.sig.ident)); + } + } + } + } + _ => {} } } - for superseded in RAW_PREDECESSOR_SUPERSEDED_PATHS - .iter() - .chain(TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS) - { - if !unique.contains(*superseded) { + + for (label, actual, expected) in [ + ("constants", constants, expected_set(PUBLIC_CONSTANTS)), + ("types", types.clone(), expected_set(PUBLIC_TYPES)), + ("functions", functions, expected_set(PUBLIC_FUNCTIONS)), + ("methods", methods, expected_set(PUBLIC_METHODS)), + ] { + if actual != expected { return Err(format!( - "superseded predecessor source `{superseded}` is not current-byte governed" + "{READINESS_SOURCE_RELATIVE} public {label} drifted: expected {expected:?}, found {actual:?}" )); } } - for (relative, expected_length, expected_sha256) in CURRENT_BYTE_BOUND_BLOSSOM_SOURCES { - validate_current_byte_bound_blossom_source( - relative, - &read_regular_file(workspace_root, relative)?, - *expected_length, - expected_sha256, - )?; + if public_type_fields_private != types { + return Err(format!( + "{READINESS_SOURCE_RELATIVE} public readiness types must expose no public fields" + )); + } + if private_wire_types != expected_set(PRIVATE_WIRE_TYPES) { + return Err(format!( + "{READINESS_SOURCE_RELATIVE} private wire models must derive Serialize/Deserialize with deny_unknown_fields" + )); + } + if !forbidden_deserialize.is_empty() { + return Err(format!( + "sealed readiness types must not implement Deserialize: {forbidden_deserialize:?}" + )); } Ok(()) } -fn validate_current_byte_bound_blossom_source( - relative: &str, - bytes: &[u8], - expected_length: usize, - expected_sha256: &str, -) -> Result<(), String> { - if bytes.len() != expected_length || sha256_hex(bytes) != expected_sha256 { +fn validate_lib_reexports(source: &str) -> Result<(), String> { + let file = syn::parse_file(source) + .map_err(|error| format!("parse {BLOSSOM_LIB_RELATIVE}: {error}"))?; + let mut names = BTreeSet::new(); + for item in &file.items { + if let Item::Use(item) = item + && is_public(&item.vis) + { + collect_use_names(&item.tree, &mut names); + } + } + let required = PUBLIC_CONSTANTS + .iter() + .chain(PUBLIC_TYPES) + .copied() + .collect::<BTreeSet<_>>(); + let missing = required + .into_iter() + .filter(|name| !names.contains(*name)) + .collect::<Vec<_>>(); + if !missing.is_empty() || !names.contains("verify_publication_readiness") { return Err(format!( - "publication-readiness current-byte source `{relative}` drifted" + "{BLOSSOM_LIB_RELATIVE} readiness reexports are incomplete: missing {missing:?}" )); } Ok(()) } -fn validate_source_boundary(workspace_root: &Path) -> Result<(), String> { - let source = String::from_utf8(read_regular_file( - workspace_root, - READINESS_SOURCE_RELATIVE, - )?) - .map_err(|error| format!("{READINESS_SOURCE_RELATIVE} must be UTF-8: {error}"))?; - let sequential_jpeg_source = String::from_utf8(read_regular_file( - workspace_root, - SEQUENTIAL_JPEG_SOURCE_RELATIVE, - )?) - .map_err(|error| format!("{SEQUENTIAL_JPEG_SOURCE_RELATIVE} must be UTF-8: {error}"))?; - validate_readiness_source_text(&source, &sequential_jpeg_source)?; - let lib = String::from_utf8(read_regular_file(workspace_root, BLOSSOM_LIB_RELATIVE)?) - .map_err(|error| format!("{BLOSSOM_LIB_RELATIVE} must be UTF-8: {error}"))?; - if lib.matches("pub mod publication_readiness;").count() != 1 - || !lib.contains( - "#[cfg(feature = \"raster-decode\")]\npub use publication_readiness::verify_publication_readiness;", - ) - || !lib.contains("RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES") - || !lib.contains("RadrootsBlossomPublicationReadinessEvidence") - { - return Err( - "Blossom crate root must route the readiness module and public API exactly".to_owned(), - ); +fn collect_use_names(tree: &syn::UseTree, names: &mut BTreeSet<String>) { + match tree { + syn::UseTree::Name(name) => { + names.insert(name.ident.to_string()); + } + syn::UseTree::Rename(rename) => { + names.insert(rename.rename.to_string()); + } + syn::UseTree::Path(path) => collect_use_names(&path.tree, names), + syn::UseTree::Group(group) => { + for item in &group.items { + collect_use_names(item, names); + } + } + syn::UseTree::Glob(_) => {} } +} +fn validate_manifest_features(workspace_root: &Path) -> Result<(), String> { let manifest = parse_toml(workspace_root, BLOSSOM_MANIFEST_RELATIVE)?; let dependencies = manifest .get("dependencies") .and_then(toml::Value::as_table) .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare dependencies"))?; - let actual = dependencies + let actual_dependencies = dependencies .keys() .map(String::as_str) .collect::<BTreeSet<_>>(); - let expected = [ + let expected_dependencies = BTreeSet::from([ "image", "mediatype", "serde", + "serde_json", "sha2", "unicode-general-category", "url_nostd", "zune-core", "zune-jpeg", - ] - .into_iter() - .collect::<BTreeSet<_>>(); - if actual != expected { - return Err(format!( - "{BLOSSOM_MANIFEST_RELATIVE} dependency boundary drifted: expected {expected:?}, found {actual:?}" - )); - } - let image_dependency = dependencies - .get("image") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare optional image"))?; - if image_dependency - .get("workspace") - .and_then(toml::Value::as_bool) - != Some(true) - || image_dependency - .get("optional") - .and_then(toml::Value::as_bool) - != Some(true) - { - return Err(format!( - "{BLOSSOM_MANIFEST_RELATIVE} image dependency must be optional and workspace-governed" - )); - } - let zune_core_dependency = dependencies - .get("zune-core") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare optional zune-core"))?; - if zune_core_dependency - .get("workspace") - .and_then(toml::Value::as_bool) - != Some(true) - || zune_core_dependency - .get("optional") - .and_then(toml::Value::as_bool) - != Some(true) - { - return Err(format!( - "{BLOSSOM_MANIFEST_RELATIVE} zune-core dependency must be optional and workspace-governed" - )); - } - let zune_jpeg_dependency = dependencies - .get("zune-jpeg") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{BLOSSOM_MANIFEST_RELATIVE} must declare optional zune-jpeg"))?; - if zune_jpeg_dependency - .get("workspace") - .and_then(toml::Value::as_bool) - != Some(true) - || zune_jpeg_dependency - .get("optional") - .and_then(toml::Value::as_bool) - != Some(true) - { - return Err(format!( - "{BLOSSOM_MANIFEST_RELATIVE} zune-jpeg dependency must be optional and workspace-governed" - )); - } - let workspace_manifest = parse_toml(workspace_root, WORKSPACE_MANIFEST_RELATIVE)?; - let workspace_image = workspace_manifest - .get("workspace") - .and_then(|value| value.get("dependencies")) - .and_then(|value| value.get("image")) - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{WORKSPACE_MANIFEST_RELATIVE} must govern image"))?; - let workspace_image_features = workspace_image - .get("features") - .and_then(toml::Value::as_array) - .into_iter() - .flatten() - .filter_map(toml::Value::as_str) - .collect::<BTreeSet<_>>(); - if workspace_image.get("version").and_then(toml::Value::as_str) != Some("=0.25.10") - || workspace_image - .get("default-features") - .and_then(toml::Value::as_bool) - != Some(false) - || workspace_image_features != BTreeSet::from(["png", "webp"]) - { - return Err(format!( - "{WORKSPACE_MANIFEST_RELATIVE} image decoder dependency must be exactly pinned to the PNG/WebP set" - )); - } - let workspace_zune_core = workspace_manifest - .get("workspace") - .and_then(|value| value.get("dependencies")) - .and_then(|value| value.get("zune-core")) - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{WORKSPACE_MANIFEST_RELATIVE} must govern zune-core"))?; - let workspace_zune_core_features = workspace_zune_core - .get("features") - .and_then(toml::Value::as_array) - .into_iter() - .flatten() - .filter_map(toml::Value::as_str) - .collect::<BTreeSet<_>>(); - if workspace_zune_core - .get("version") - .and_then(toml::Value::as_str) - != Some("=0.5.1") - || workspace_zune_core - .get("default-features") - .and_then(toml::Value::as_bool) - != Some(false) - || workspace_zune_core_features != BTreeSet::from(["std"]) - { + ]); + if actual_dependencies != expected_dependencies { return Err(format!( - "{WORKSPACE_MANIFEST_RELATIVE} JPEG decoder core must be exactly pinned to zune-core 0.5.1 with std only" + "{BLOSSOM_MANIFEST_RELATIVE} dependency boundary drifted: expected {expected_dependencies:?}, found {actual_dependencies:?}" )); } - 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" - )); + for dependency in ["serde", "serde_json", "image", "zune-core", "zune-jpeg"] { + let table = dependencies + .get(dependency) + .and_then(toml::Value::as_table) + .ok_or_else(|| { + format!("{BLOSSOM_MANIFEST_RELATIVE} must declare {dependency} as a table") + })?; + if table.get("workspace").and_then(toml::Value::as_bool) != Some(true) + || table.get("optional").and_then(toml::Value::as_bool) != Some(true) + { + return Err(format!( + "{BLOSSOM_MANIFEST_RELATIVE} dependency {dependency} must be optional and workspace-governed" + )); + } } 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", + for (feature, expected) in [ + ("default", BTreeSet::from(["serde"])), + ("serde", BTreeSet::from(["dep:serde", "dep:serde_json"])), + ( + "std", + BTreeSet::from(["serde?/std", "sha2/std", "url_nostd/std"]), + ), + ( + "raster-decode", + BTreeSet::from(["dep:image", "dep:zune-core", "dep:zune-jpeg", "std"]), + ), ] { - if !nix_check_commands.contains(required) { + let expected = expected + .into_iter() + .map(str::to_owned) + .collect::<BTreeSet<_>>(); + let actual = toml_string_set( + &format!("{BLOSSOM_MANIFEST_RELATIVE} feature {feature}"), + features.get(feature), + )?; + if actual != expected { return Err(format!( - "{NIX_CHECKS_RELATIVE} lacks governed Blossom verification `{required}`" + "{BLOSSOM_MANIFEST_RELATIVE} feature {feature} drifted: expected {expected:?}, found {actual:?}" )); } } - let raw_predecessor = String::from_utf8(read_regular_file( - workspace_root, - RAW_PREDECESSOR_GOVERNANCE_RELATIVE, - )?) - .map_err(|error| format!("{RAW_PREDECESSOR_GOVERNANCE_RELATIVE} must be UTF-8: {error}"))?; - validate_raw_predecessor_successor_routing(&raw_predecessor)?; Ok(()) } -fn validate_raw_predecessor_successor_routing(source: &str) -> Result<(), String> { - let compact = source.split_whitespace().collect::<String>(); - for required in [ - "pub(crate)fnwrite_raw_source_rebuild_manifest(workspace_root:&Path)->Result<(),String>{validate_raw_source_rebuild_manifest(workspace_root)}", - "super::blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root)", - "constBLOSSOM_READINESS_SUCCESSOR_TRANSITIVE_PATHS:&[&str]=&[\"Cargo.toml\",\"crates/blossom/Cargo.toml\",\"crates/blossom/src/error.rs\",\"crates/blossom/src/lib.rs\",\"crates/blossom/src/url.rs\",];", - ] { - if !compact.contains(required) { - return Err(format!( - "{RAW_PREDECESSOR_GOVERNANCE_RELATIVE} lacks validation-only successor route `{required}`" - )); - } - } - Ok(()) +fn validate_operations_authority(workspace_root: &Path) -> Result<(), String> { + let operations = parse_toml(workspace_root, OPERATIONS_RELATIVE)?; + let table = operations + .get("operations") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{OPERATIONS_RELATIVE} must declare operations"))?; + validate_operation( + table, + "blossom_verify_publication_readiness", + VERIFY_OPERATION_ID, + BEHAVIOR_VECTOR_RELATIVE, + &[ + "blossom.verify_publication_readiness.valid", + "blossom.verify_publication_readiness.invalid", + ], + )?; + validate_operation( + table, + "blossom_publication_readiness_evidence_to_canonical_json", + SERIALIZE_OPERATION_ID, + PERSISTENCE_VECTOR_RELATIVE, + &["blossom.publication_readiness_evidence.to_canonical_json.valid"], + )?; + validate_operation( + table, + "blossom_publication_readiness_evidence_from_canonical_json", + RELOAD_OPERATION_ID, + PERSISTENCE_VECTOR_RELATIVE, + &[ + "blossom.publication_readiness_evidence.from_canonical_json.valid", + "blossom.publication_readiness_evidence.from_canonical_json.invalid", + ], + ) } -fn validate_readiness_source_text( - source: &str, - sequential_jpeg_source: &str, +fn validate_operation( + operations: &toml::map::Map<String, toml::Value>, + key: &str, + id: &str, + vector: &str, + case_kinds: &[&str], ) -> Result<(), String> { - for required in [ - "RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION: u16 = 1", - "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES: u64 = 10_485_760", - "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DECODED_BYTES: u64 =", - "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_DIMENSION: u32 = 16_384", - "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_000", - "#[cfg(feature = \"raster-decode\")]\npub fn verify_publication_readiness(", - "use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};", - "use zune_jpeg::JpegDecoder as StrictJpegDecoder;", - "mod sequential_jpeg;", - "sequential_jpeg::validate(bytes, container)?;", - "StrictJpegDecoder::new_with_options(ZCursor::new(bytes), strict_jpeg_decoder_options())", - ".set_strict_mode(true)", - ".set_use_unsafe(false)", - ".jpeg_set_out_colorspace(ColorSpace::RGB)", - ".decode_headers()", - ".output_buffer_size()", - ".decode_into(&mut decoded)", - "if !matches!(marker, 0xc0 | 0xc1) || data[0] != 8", - "PublicationJpegProcessForbidden", - "PngDecoder::with_limits(Cursor::new(bytes), raster_decode_limits())", - "WebPDecoder::new(Cursor::new(bytes))", - ".read_image(&mut decoded)", - "b\"radroots.blossom.publication-readiness-evidence.v1\\0\"", - ] { - if !source.contains(required) { - return Err(format!( - "{READINESS_SOURCE_RELATIVE} is missing governed fragment `{required}`" - )); - } - } - for required in [ - "struct SequentialJpegHuffmanTable", - "struct SequentialJpegEntropyReader", - "sampling_product_sum > 10", - "value_count > 256", - "seen_values[value_index]", - "unused_codes == 0", - "payload[payload.len() - 3..] != [0, 63, 0]", - "reader.finish_restart(expected_restart)?", - "seen_components[component.frame_index] = true", - "checked_mcu_grid_count", - ] { - if !sequential_jpeg_source.contains(required) { - return Err(format!( - "{SEQUENTIAL_JPEG_SOURCE_RELATIVE} is missing governed fragment `{required}`" - )); - } - } - let combined = format!("{source}\n{sequential_jpeg_source}"); - let lowercase = combined.to_ascii_lowercase(); - for forbidden in [ - "reqwest", - "hyper::", - "tokio::", - "axum::", - "std::net", - "std::fs", - "authorization: nostr", - "bearer ", - "cookie", - ] { - if lowercase.contains(forbidden) { - return Err(format!( - "{READINESS_SOURCE_RELATIVE} crosses the transport-neutral boundary with `{forbidden}`" - )); - } - } - if combined.contains("serde::Deserialize") || combined.contains("derive(Deserialize") { - return Err( - "publication readiness typestates must not gain forgeable Deserialize implementations" - .to_owned(), - ); - } - if combined.contains("pub struct RadrootsBlossomRasterDecodeObservation") - || combined.contains("decode: &RadrootsBlossom") - || combined.contains("ImageReader") - || combined.contains("load_from_memory") - || combined.contains("JpegDecoder::new(Cursor::new(bytes))") - || combined.contains("push_backend(") - || combined.contains("gamut_core") - || combined.contains("gamut_jpeg") - || combined.contains("jpeg_decoder::") - || combined.contains("use jpeg_decoder") - || combined.contains("extern crate jpeg_decoder") - || combined.contains("zenjpeg") - { - return Err( - "publication readiness must force declared-format decode authority internally from exact bytes" - .to_owned(), - ); - } - Ok(()) -} - -fn validate_vector(workspace_root: &Path) -> Result<(), String> { - let canonical = read_regular_file(workspace_root, VECTOR_CANONICAL_RELATIVE)?; - let mirror = read_regular_file(workspace_root, VECTOR_MIRROR_RELATIVE)?; - if canonical != mirror { - return Err(format!( - "{VECTOR_MIRROR_RELATIVE} must byte-match {VECTOR_CANONICAL_RELATIVE}" - )); - } - let vector: Value = serde_json::from_slice(&canonical) - .map_err(|error| format!("parse {VECTOR_CANONICAL_RELATIVE}: {error}"))?; - validate_vector_value(&vector) -} - -fn validate_vector_value(vector: &Value) -> Result<(), String> { - if vector.get("suite").and_then(Value::as_str) != Some("blossom_publication_readiness") - || vector.get("contract_version").and_then(Value::as_str) != Some("1.0.0") - { - return Err("publication-readiness vector identity drifted".to_owned()); - } - let cases = vector - .get("vectors") - .and_then(Value::as_array) - .ok_or_else(|| "publication-readiness vectors must be an array".to_owned())?; - if cases.len() != VECTOR_EXPECTATIONS.len() { - return Err(format!( - "publication-readiness vector count drifted: expected {}, found {}", - VECTOR_EXPECTATIONS.len(), - cases.len() - )); - } - for (case, (id, kind, mutation, expected_error)) in cases.iter().zip(VECTOR_EXPECTATIONS) { - let actual_id = case.get("id").and_then(Value::as_str); - let actual_kind = case.get("kind").and_then(Value::as_str); - let actual_mutation = case - .get("input") - .and_then(|input| input.get("mutation")) - .and_then(Value::as_str); - let actual_error = case - .get("expected") - .and_then(|expected| expected.get("error")) - .and_then(Value::as_str); - if actual_id != Some(*id) - || actual_kind != Some(*kind) - || actual_mutation != Some(*mutation) - || actual_error != *expected_error - { - return Err(format!( - "publication-readiness vector `{id}` identity or expected error drifted" - )); - } - let bytes_hex = case - .get("input") - .and_then(|input| input.get("bytes_hex")) - .and_then(Value::as_str) - .ok_or_else(|| format!("publication-readiness vector `{id}` lacks bytes_hex"))?; - if bytes_hex.len() != 140 - || !bytes_hex - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(format!( - "publication-readiness vector `{id}` must bind the exact 70-byte lowercase-hex raster" - )); - } - } - Ok(()) -} - -fn validate_operation(workspace_root: &Path) -> Result<(), String> { - let manifest = parse_toml(workspace_root, OPERATIONS_RELATIVE)?; - let operation = manifest - .get("operations") - .and_then(|value| value.get("blossom_verify_publication_readiness")) - .ok_or_else(|| "operations contract lacks Blossom publication readiness".to_owned())?; - if operation.get("domain").and_then(toml::Value::as_str) != Some("blossom") - || operation.get("id").and_then(toml::Value::as_str) - != Some("blossom.verify_publication_readiness") - || operation.get("transport").and_then(toml::Value::as_str) != Some("none") - || operation.get("signing").and_then(toml::Value::as_str) != Some("none") + let operation = operations + .get(key) + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{OPERATIONS_RELATIVE} must declare {key}"))?; + let conformance = operation + .get("conformance") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{OPERATIONS_RELATIVE} operation {key} needs conformance"))?; + if operation.get("id").and_then(toml::Value::as_str) != Some(id) + || operation.get("domain").and_then(toml::Value::as_str) != Some("blossom") || operation .get("deterministic") .and_then(toml::Value::as_bool) != Some(true) - { - return Err("Blossom publication-readiness operation authority drifted".to_owned()); - } - let shared = manifest - .get("shared_types") - .and_then(|value| value.get("public")) - .and_then(toml::Value::as_array) - .ok_or_else(|| "operations contract shared public types are missing".to_owned())? - .iter() - .filter_map(toml::Value::as_str) - .collect::<BTreeSet<_>>(); - if let Some(missing) = REQUIRED_PUBLIC_TYPES - .iter() - .find(|required| !shared.contains(**required)) + || operation.get("transport").and_then(toml::Value::as_str) != Some("none") + || operation.get("signing").and_then(toml::Value::as_str) != Some("none") + || conformance.get("vector").and_then(toml::Value::as_str) != Some(vector) + || toml_string_set("operation case kinds", conformance.get("case_kinds"))? + != case_kinds.iter().map(|kind| (*kind).to_owned()).collect() { return Err(format!( - "operations contract lacks readiness public type `{missing}`" + "{OPERATIONS_RELATIVE} operation {key} semantic authority drifted" )); } - if shared.contains("RadrootsBlossomRasterDecodeObservation") { - return Err( - "operations contract must not expose caller-constructible raster decode authority" - .to_owned(), - ); - } - let inputs = operation - .get("inputs") - .and_then(toml::Value::as_array) - .into_iter() - .flatten() - .filter_map(toml::Value::as_str) - .collect::<BTreeSet<_>>(); - let expected_inputs = BTreeSet::from([ - "Bytes", - "RadrootsBlossomAuthoredRasterDimensions", - "RadrootsBlossomBud01GetObservation", - "RadrootsBlossomBud01HeadObservation", - "RadrootsBlossomBud02UploadObservation", - "RadrootsBlossomByteVerifiedDescriptor", - ]); - if inputs != expected_inputs { - return Err( - "Blossom publication-readiness inputs must contain transport evidence and exact bytes only" - .to_owned(), - ); - } - let rust_types = operation - .get("implementation") - .and_then(|value| value.get("rust_types")) - .and_then(toml::Value::as_array) - .into_iter() - .flatten() - .filter_map(toml::Value::as_str) - .collect::<BTreeSet<_>>(); - if rust_types.contains("radroots_blossom::RadrootsBlossomRasterDecodeObservation") { - return Err( - "Blossom publication-readiness implementation must derive decode facts internally" - .to_owned(), - ); - } Ok(()) } -fn validate_release(workspace_root: &Path) -> Result<(), String> { +fn validate_release_authority(workspace_root: &Path) -> Result<(), String> { let release = parse_toml(workspace_root, RELEASE_RELATIVE)?; let changes = release .get("changes") .and_then(toml::Value::as_array) .ok_or_else(|| format!("{RELEASE_RELATIVE} must declare changes"))?; - let matches = changes + let matching = changes .iter() .filter(|change| change.get("id").and_then(toml::Value::as_str) == Some(RELEASE_CHANGE_ID)) .collect::<Vec<_>>(); - if matches.len() != 1 - || matches[0] + if matching.len() != 1 + || matching[0] .get("classification") .and_then(toml::Value::as_str) != Some("feature") { return Err(format!( - "{RELEASE_RELATIVE} must contain one feature change `{RELEASE_CHANGE_ID}`" + "{RELEASE_RELATIVE} must contain exactly one feature change {RELEASE_CHANGE_ID}" )); } - let changelog = String::from_utf8(read_regular_file(workspace_root, CHANGELOG_RELATIVE)?) - .map_err(|error| format!("{CHANGELOG_RELATIVE} must be UTF-8: {error}"))?; + let changelog = read_utf8(workspace_root, CHANGELOG_RELATIVE)?; if changelog.matches(CHANGELOG_MARKER).count() != 1 { return Err(format!( - "{CHANGELOG_RELATIVE} must contain exactly one readiness release marker" + "{CHANGELOG_RELATIVE} must contain exactly one {CHANGELOG_MARKER}" )); } Ok(()) } -fn parse_toml(workspace_root: &Path, relative: &str) -> Result<toml::Value, String> { +fn validate_vector( + workspace_root: &Path, + relative: &str, + suite_name: &str, + expected_ids: &[&str], + allowed_kinds: &[&str], +) -> Result<ValidatedVector, String> { let bytes = read_regular_file(workspace_root, relative)?; - let source = std::str::from_utf8(&bytes) - .map_err(|error| format!("{relative} must be UTF-8 TOML: {error}"))?; - toml::from_str(source).map_err(|error| format!("parse {relative}: {error}")) + let suite: VectorSuite = + serde_json::from_slice(&bytes).map_err(|error| format!("parse {relative}: {error}"))?; + let ids = suite + .vectors + .iter() + .map(|case| case.id.as_str()) + .collect::<Vec<_>>(); + if suite.suite != suite_name + || suite.contract_version != "1.0.0" + || ids != expected_ids + || suite.vectors.iter().any(|case| { + !allowed_kinds.contains(&case.kind.as_str()) + || !case.input.is_object() + || !case.expected.is_object() + }) + { + return Err(format!("{relative} case inventory or shape drifted")); + } + let unique = ids.iter().copied().collect::<BTreeSet<_>>(); + if unique.len() != ids.len() { + return Err(format!("{relative} contains duplicate case ids")); + } + Ok(ValidatedVector { + bytes, + case_ids: expected_ids.iter().map(|id| (*id).to_owned()).collect(), + }) } -fn sha256_hex(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut output = String::with_capacity(64); - for byte in digest { - use std::fmt::Write; - write!(&mut output, "{byte:02x}").expect("String writes cannot fail"); +fn validate_manifest_shape(manifest: &PublicationReadinessManifest) -> Result<(), String> { + if manifest.schema_version != SCHEMA_VERSION + || manifest.contract_id != CONTRACT_ID + || manifest.authority_id != AUTHORITY_ID + || manifest.predecessor.contract_id != "radroots_event_store.raw_source_rebuild_v1" + || manifest.protocol_sources != expected_protocol_sources() + || manifest.public_api != expected_public_api() + || manifest.readiness.verify_operation_id != VERIFY_OPERATION_ID + || manifest.readiness.input_types + != owned(&[ + "RadrootsBlossomByteVerifiedDescriptor", + "Bytes", + "RadrootsBlossomAuthoredRasterDimensions", + "RadrootsBlossomBud02UploadObservation", + "RadrootsBlossomBud01HeadObservation", + "RadrootsBlossomBud01GetObservation", + ]) + || manifest.readiness.output_type != "RadrootsBlossomPublicationReadinessEvidence" + || manifest.readiness.evidence.schema_version != EVIDENCE_SCHEMA_VERSION + || manifest.readiness.evidence.policy_version != READINESS_POLICY_VERSION + || manifest.readiness.evidence.max_canonical_json_bytes != EVIDENCE_MAX_BYTES as u64 + || manifest.readiness.evidence.max_url_utf8_bytes != URL_MAX_BYTES as u64 + || manifest.readiness.evidence.max_raster_bytes != RASTER_MAX_BYTES + || manifest.readiness.evidence.max_decoded_bytes != RASTER_MAX_DECODED_BYTES + || manifest.readiness.evidence.max_dimension != RASTER_MAX_DIMENSION + || manifest.readiness.evidence.max_pixels != RASTER_MAX_PIXELS + || manifest.readiness.evidence.wire_field_order != owned(WIRE_FIELD_ORDER) + || manifest.readiness.evidence.digest_domain != EVIDENCE_DIGEST_DOMAIN + || manifest.readiness.evidence.serialize_operation_id != SERIALIZE_OPERATION_ID + || manifest.readiness.evidence.reload_operation_id != RELOAD_OPERATION_ID + || manifest.readiness.evidence.invariants != owned(SEMANTIC_INVARIANTS) + || manifest.readiness.evidence.decode_error_codes != owned(DECODE_ERROR_CODES) + || manifest.predecessor_source_supersessions != owned(RAW_PREDECESSOR_SUPERSEDED_PATHS) + || manifest.transitive_predecessor_source_supersessions + != owned(TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS) + || manifest.readiness.behavior_vector.case_ids != owned(BEHAVIOR_CASE_IDS) + || manifest.readiness.persistence_vector.case_ids != owned(PERSISTENCE_CASE_IDS) + { + return Err(format!("{MANIFEST_RELATIVE} semantic shape drifted")); + } + let predecessor = manifest + .predecessor + .immutable_artifacts + .iter() + .map(|artifact| { + ( + artifact.path.as_str(), + artifact.byte_length as usize, + artifact.sha256.as_str(), + ) + }) + .collect::<Vec<_>>(); + let expected = IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS + .iter() + .map(|artifact| (artifact.relative, artifact.byte_length, artifact.sha256)) + .collect::<Vec<_>>(); + if predecessor != expected { + return Err(format!("{MANIFEST_RELATIVE} predecessor identity drifted")); + } + Ok(()) +} + +fn manifest_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/contracts/blossom/publication-readiness-v1.schema.json", + "title": "Radroots Blossom Publication Readiness Semantic Contract", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "contract_id", "authority_id", "manifest_schema", + "predecessor", "protocol_sources", "public_api", "readiness", + "predecessor_source_supersessions", "transitive_predecessor_source_supersessions" + ], + "properties": { + "schema_version": {"const": SCHEMA_VERSION}, + "contract_id": {"const": CONTRACT_ID}, + "authority_id": {"const": AUTHORITY_ID}, + "manifest_schema": {"$ref": "#/$defs/file"}, + "predecessor": { + "type": "object", "additionalProperties": false, + "required": ["contract_id", "immutable_artifacts"], + "properties": { + "contract_id": {"const": "radroots_event_store.raw_source_rebuild_v1"}, + "immutable_artifacts": {"type": "array", "minItems": 6, "maxItems": 6, "items": {"$ref": "#/$defs/file"}} + } + }, + "protocol_sources": {"const": expected_protocol_sources()}, + "public_api": {"const": expected_public_api()}, + "readiness": {"$ref": "#/$defs/readiness"}, + "predecessor_source_supersessions": {"const": owned(RAW_PREDECESSOR_SUPERSEDED_PATHS)}, + "transitive_predecessor_source_supersessions": {"const": owned(TRANSITIVE_PREDECESSOR_SUPERSEDED_PATHS)} + }, + "$defs": { + "file": { + "type": "object", "additionalProperties": false, + "required": ["path", "byte_length", "sha256", "hash_algorithm"], + "properties": { + "path": {"type": "string", "minLength": 1}, + "byte_length": {"type": "integer", "minimum": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "hash_algorithm": {"const": HASH_ALGORITHM} + } + }, + "vector": { + "type": "object", "additionalProperties": false, + "required": ["canonical_path", "mirror_path", "byte_length", "sha256", "hash_algorithm", "executor", "executor_test", "case_ids"], + "properties": { + "canonical_path": {"type": "string", "minLength": 1}, + "mirror_path": {"type": "string", "minLength": 1}, + "byte_length": {"type": "integer", "minimum": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "hash_algorithm": {"const": HASH_ALGORITHM}, + "executor": {"$ref": "#/$defs/file"}, + "executor_test": {"type": "string", "minLength": 1}, + "case_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}} + } + }, + "evidence": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "policy_version", "schema", "max_canonical_json_bytes", "max_url_utf8_bytes", "max_raster_bytes", "max_decoded_bytes", "max_dimension", "max_pixels", "wire_field_order", "digest_domain", "digest_framing", "serialize_operation_id", "reload_operation_id", "invariants", "decode_error_codes"], + "properties": { + "schema_version": {"const": EVIDENCE_SCHEMA_VERSION}, + "policy_version": {"const": READINESS_POLICY_VERSION}, + "schema": {"$ref": "#/$defs/file"}, + "max_canonical_json_bytes": {"const": EVIDENCE_MAX_BYTES}, + "max_url_utf8_bytes": {"const": URL_MAX_BYTES}, + "max_raster_bytes": {"const": RASTER_MAX_BYTES}, + "max_decoded_bytes": {"const": RASTER_MAX_DECODED_BYTES}, + "max_dimension": {"const": RASTER_MAX_DIMENSION}, + "max_pixels": {"const": RASTER_MAX_PIXELS}, + "wire_field_order": {"const": owned(WIRE_FIELD_ORDER)}, + "digest_domain": {"const": EVIDENCE_DIGEST_DOMAIN}, + "digest_framing": {"type": "string", "minLength": 1}, + "serialize_operation_id": {"const": SERIALIZE_OPERATION_ID}, + "reload_operation_id": {"const": RELOAD_OPERATION_ID}, + "invariants": {"const": owned(SEMANTIC_INVARIANTS)}, + "decode_error_codes": {"const": owned(DECODE_ERROR_CODES)} + } + }, + "readiness": { + "type": "object", "additionalProperties": false, + "required": ["verify_operation_id", "input_types", "output_type", "evidence", "behavior_vector", "persistence_vector"], + "properties": { + "verify_operation_id": {"const": VERIFY_OPERATION_ID}, + "input_types": {"type": "array", "minItems": 6, "maxItems": 6, "items": {"type": "string", "minLength": 1}}, + "output_type": {"const": "RadrootsBlossomPublicationReadinessEvidence"}, + "evidence": {"$ref": "#/$defs/evidence"}, + "behavior_vector": {"$ref": "#/$defs/vector"}, + "persistence_vector": {"$ref": "#/$defs/vector"} + } + } + } + }) +} + +fn evidence_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/contracts/blossom/publication-readiness-evidence-v1.schema.json", + "title": "Radroots Blossom Publication Readiness Evidence v1", + "type": "object", + "additionalProperties": false, + "required": WIRE_FIELD_ORDER, + "properties": { + "schema_version": {"const": EVIDENCE_SCHEMA_VERSION}, + "policy_version": {"const": READINESS_POLICY_VERSION}, + "url": {"type": "string", "minLength": 1, "maxLength": URL_MAX_BYTES}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "size": {"type": "integer", "minimum": 1, "maximum": RASTER_MAX_BYTES}, + "media_type": {"enum": ["image/jpeg", "image/png", "image/webp"]}, + "raster_format": {"enum": ["jpeg", "png", "still_webp"]}, + "dimensions": { + "type": "object", "additionalProperties": false, + "required": ["width", "height"], + "properties": { + "width": {"type": "integer", "minimum": 1, "maximum": RASTER_MAX_DIMENSION}, + "height": {"type": "integer", "minimum": 1, "maximum": RASTER_MAX_DIMENSION} + } + }, + "bud02_status": {"enum": [200, 201]}, + "bud01_head_status": {"const": 200}, + "bud01_get_status": {"const": 200}, + "uploaded": {"type": "integer", "minimum": 0}, + "evidence_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + }) +} + +fn expected_protocol_sources() -> Vec<ProtocolSourcePin> { + PROTOCOL_SOURCE_PINS + .iter() + .map(|(id, repository, revision)| ProtocolSourcePin { + id: (*id).to_owned(), + repository: (*repository).to_owned(), + revision: (*revision).to_owned(), + }) + .collect() +} + +fn expected_public_api() -> PublicApiDescriptor { + PublicApiDescriptor { + constants: owned(PUBLIC_CONSTANTS), + types: owned(PUBLIC_TYPES), + functions: owned(PUBLIC_FUNCTIONS), + methods: owned(PUBLIC_METHODS), + } +} + +fn descriptor_for_file(workspace_root: &Path, path: &str) -> Result<FileDescriptor, String> { + Ok(descriptor_for_bytes( + path, + &read_regular_file(workspace_root, path)?, + )) +} + +fn descriptor_for_bytes(path: &str, bytes: &[u8]) -> FileDescriptor { + FileDescriptor { + path: path.to_owned(), + byte_length: bytes.len() as u64, + sha256: sha256_hex(bytes), + hash_algorithm: HASH_ALGORITHM.to_owned(), + } +} + +fn read_utf8(workspace_root: &Path, relative: &str) -> Result<String, String> { + String::from_utf8(read_regular_file(workspace_root, relative)?) + .map_err(|error| format!("{relative} must be UTF-8: {error}")) +} + +fn parse_toml(workspace_root: &Path, relative: &str) -> Result<toml::Value, String> { + let source = read_utf8(workspace_root, relative)?; + toml::from_str(&source).map_err(|error| format!("parse {relative}: {error}")) +} + +fn toml_string_set(label: &str, value: Option<&toml::Value>) -> Result<BTreeSet<String>, String> { + value + .and_then(toml::Value::as_array) + .ok_or_else(|| format!("{label} must be an array"))? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{label} values must be strings")) + }) + .collect() +} + +fn derives(attributes: &[syn::Attribute], derive: &str) -> bool { + attributes.iter().any(|attribute| { + attribute.path().is_ident("derive") + && attribute + .parse_args_with( + syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated, + ) + .is_ok_and(|paths| { + paths.iter().any(|path| { + path.segments + .last() + .is_some_and(|segment| segment.ident == derive) + }) + }) + }) +} + +fn has_serde_deny_unknown_fields(attributes: &[syn::Attribute]) -> bool { + attributes.iter().any(|attribute| { + if !attribute.path().is_ident("serde") { + return false; + } + let mut found = false; + let _ = attribute.parse_nested_meta(|meta| { + if meta.path.is_ident("deny_unknown_fields") { + found = true; + } + Ok(()) + }); + found + }) +} + +fn is_public(visibility: &Visibility) -> bool { + matches!(visibility, Visibility::Public(_)) +} + +fn expected_set(values: &[&str]) -> BTreeSet<String> { + values.iter().map(|value| (*value).to_owned()).collect() +} + +fn owned(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_owned()).collect() +} + +fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, String> { + let mut bytes = + serde_json::to_vec_pretty(value).map_err(|error| format!("serialize JSON: {error}"))?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn validate_canonical_json<T: Serialize>( + relative: &str, + bytes: &[u8], + value: &T, +) -> Result<(), String> { + if canonical_json_bytes(value)? != bytes { + return Err(format!("{relative} is not canonical pretty JSON")); + } + Ok(()) +} + +fn validate_json_schema(schema: &Value, instance: &Value) -> Result<(), String> { + let validator = jsonschema::validator_for(schema) + .map_err(|error| format!("compile {MANIFEST_SCHEMA_RELATIVE}: {error}"))?; + let errors = validator + .iter_errors(instance) + .map(|error| error.to_string()) + .collect::<Vec<_>>(); + if errors.is_empty() { + Ok(()) + } else { + Err(format!( + "{MANIFEST_RELATIVE} violates its schema: {}", + errors.join("; ") + )) } - output +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) } #[cfg(test)] @@ -1022,128 +1448,76 @@ mod tests { } #[test] - fn publication_readiness_vector_inventory_is_exact_and_mutation_sensitive() { - let bytes = read_regular_file(&workspace_root(), VECTOR_CANONICAL_RELATIVE).unwrap(); - let mut vector: Value = serde_json::from_slice(&bytes).unwrap(); - validate_vector_value(&vector).unwrap(); - vector["vectors"][0]["input"]["mutation"] = Value::String("renamed".to_owned()); - assert!( - validate_vector_value(&vector) - .unwrap_err() - .contains("valid_created") - ); - } + fn readiness_public_api_is_ast_governed() { + let source = read_utf8(&workspace_root(), READINESS_SOURCE_RELATIVE).unwrap(); + validate_public_api_source(&source).unwrap(); + validate_public_api_source(&format!("// unrelated comment\n{source}")).unwrap(); - #[test] - fn publication_readiness_source_boundary_rejects_transport_and_contract_drift() { - let source = String::from_utf8( - read_regular_file(&workspace_root(), READINESS_SOURCE_RELATIVE).unwrap(), - ) - .unwrap(); - let sequential_jpeg_source = String::from_utf8( - read_regular_file(&workspace_root(), SEQUENTIAL_JPEG_SOURCE_RELATIVE).unwrap(), - ) - .unwrap(); - validate_readiness_source_text(&source, &sequential_jpeg_source).unwrap(); - let injected = format!("{source}\nfn injected() {{ let _ = reqwest::get; }}\n"); - assert!( - validate_readiness_source_text(&injected, &sequential_jpeg_source) - .unwrap_err() - .contains("transport-neutral") - ); - let removed = source.replace( - "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_000", - "RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_PIXELS: u64 = 20_000_001", + let exposed = source.replacen( + " url: RadrootsBlossomApprovedBlobUrl,", + " pub url: RadrootsBlossomApprovedBlobUrl,", + 1, ); assert!( - validate_readiness_source_text(&removed, &sequential_jpeg_source) + validate_public_api_source(&exposed) .unwrap_err() - .contains("missing governed fragment") + .contains("public readiness types") ); - 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 deserializable = source.replacen( + "#[derive(Clone, Debug, PartialEq, Eq)]\npub struct RadrootsBlossomPublicationReadinessEvidence", + "#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]\npub struct RadrootsBlossomPublicationReadinessEvidence", + 1, ); - let legacy_decoder = format!("{source}\nuse jpeg_decoder::Decoder;\n"); assert!( - validate_readiness_source_text(&legacy_decoder, &sequential_jpeg_source) + validate_public_api_source(&deserializable) .unwrap_err() - .contains("decode authority") + .contains("must not implement Deserialize") ); - - for (relative, expected_length, expected_sha256) in CURRENT_BYTE_BOUND_BLOSSOM_SOURCES { - let mut bytes = read_regular_file(&workspace_root(), relative).unwrap(); - validate_current_byte_bound_blossom_source( - relative, - &bytes, - *expected_length, - expected_sha256, - ) - .unwrap(); - bytes.push(b' '); - assert!( - validate_current_byte_bound_blossom_source( - relative, - &bytes, - *expected_length, - expected_sha256, - ) - .unwrap_err() - .contains("current-byte source") - ); - } } #[test] - fn raw_predecessor_supersession_rejects_unknown_paths() { - let error = validate_raw_source_rebuild_predecessor_production_sources_under_lock( - &workspace_root(), - &["not/a/predecessor.rs"], - &[], + fn readiness_vectors_have_exact_semantic_inventories() { + let root = workspace_root(); + validate_vector( + &root, + BEHAVIOR_VECTOR_RELATIVE, + "blossom_publication_readiness", + BEHAVIOR_CASE_IDS, + &[ + "blossom.verify_publication_readiness.valid", + "blossom.verify_publication_readiness.invalid", + ], ) - .unwrap_err(); - assert!(error.contains("not predecessor-bound"), "{error}"); + .unwrap(); + validate_vector( + &root, + PERSISTENCE_VECTOR_RELATIVE, + "blossom_publication_readiness_persistence", + PERSISTENCE_CASE_IDS, + &[ + "blossom.publication_readiness_evidence.from_canonical_json.valid", + "blossom.publication_readiness_evidence.from_canonical_json.invalid", + "blossom.publication_readiness_evidence.to_canonical_json.valid", + ], + ) + .unwrap(); } #[test] - fn retired_raw_predecessor_write_route_is_validation_only() { + fn semantic_manifest_excludes_mutable_source_and_release_identity() { let root = workspace_root(); - let before = IMMUTABLE_RAW_PREDECESSOR_ARTIFACTS - .iter() - .map(|(relative, _, _)| { - ( - *relative, - read_regular_file(&root, relative).expect("immutable raw predecessor artifact"), - ) - }) - .collect::<Vec<_>>(); - super::super::raw_source_rebuild::write_raw_source_rebuild_manifest(&root) - .expect("retired raw predecessor write route validates its active successor"); - for (relative, expected) in before { - assert_eq!( - read_regular_file(&root, relative).expect("raw predecessor after validation"), - expected, - "retired raw predecessor writer mutated {relative}" - ); - } + let schema = canonical_json_bytes(&manifest_schema()).unwrap(); + let evidence_schema = canonical_json_bytes(&evidence_schema()).unwrap(); + let manifest = describe_manifest(&root, &schema, &evidence_schema).unwrap(); + let value = serde_json::to_value(manifest).unwrap(); + assert!(value.get("source_files").is_none()); + assert!(value.get("release").is_none()); + assert!(value.get("workspace_lock").is_none()); + } - let source = String::from_utf8( - read_regular_file(&root, RAW_PREDECESSOR_GOVERNANCE_RELATIVE).unwrap(), - ) - .unwrap(); - validate_raw_predecessor_successor_routing(&source).unwrap(); - let bypass = source.replacen( - "validate_raw_source_rebuild_manifest(workspace_root)", - "Ok(())", - 1, - ); - assert!( - validate_raw_predecessor_successor_routing(&bypass) - .unwrap_err() - .contains("validation-only successor route") - ); + #[test] + fn generated_readiness_contract_is_current() { + validate_blossom_publication_readiness_manifest(&workspace_root()).unwrap(); } } diff --git a/tools/xtask/src/contract/nip09_reconciliation.rs b/tools/xtask/src/contract/nip09_reconciliation.rs @@ -225,7 +225,7 @@ const GOVERNED_DEPENDENCY_TABLE_SHA256: [(&str, &str); 7] = [ ), ( BLOSSOM_CARGO_MANIFEST_RELATIVE, - "fecd2c6bd1d4920715b54e7b542d4ca85ff5b869b9697b4b7fe589955145e60e", + "03bd2a20057d4be04f2d0ec613ec65536b42eb6be4f8cd396fdf8b05e5b856fa", ), ( EVENT_STORE_CARGO_MANIFEST_RELATIVE, diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -24,6 +24,7 @@ fn usage() { eprintln!(" cargo xtask contract raw-source-rebuild-manifest [--write]"); eprintln!(" cargo xtask contract phase1-publication-artifact-manifest [--write]"); eprintln!(" cargo xtask contract phase1-publication-allowlist-manifest [--write]"); + eprintln!(" cargo xtask contract blossom-publication-readiness-manifest [--write]"); eprintln!(" cargo xtask contract release-provenance-schema [--write]"); eprintln!(" cargo xtask contract knowledge-manifest [--write]"); eprintln!(" cargo xtask dto-roots --check|--write"); @@ -194,6 +195,16 @@ fn run_contract(args: &[String]) -> Result<(), String> { .to_string(), ), }, + Some("blossom-publication-readiness-manifest") => match &args[1..] { + [] => contract::validate_blossom_publication_readiness_manifest(&workspace_root()), + [flag] if flag == "--write" => { + contract::write_blossom_publication_readiness_manifest(&workspace_root()) + } + _ => Err( + "blossom-publication-readiness-manifest accepts no arguments or exactly --write" + .to_string(), + ), + }, Some("release-provenance-schema") => match &args[1..] { [] => contract::validate_release_provenance_schema(&workspace_root()), [flag] if flag == "--write" => {