lib

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

commit fc0a0d69f9af0adae4ca2c43c966195d10e39d5c
parent c3f5491e397ba1526e6021fad5c9a4f79b9be1a2
Author: triesap <tyson@radroots.org>
Date:   Sun, 26 Jul 2026 12:06:28 +0000

publication: bind Phase 1 media readiness

- enforce the closed Blossom media envelope across artifact construction and reload
- bind one sealed BUD-02 and BUD-01 observation to every canonical media URL
- govern strict canonical persistence with executable vectors and semantic contract artifacts
- verify feature, package, contract, Nix, and measured coverage lanes

Diffstat:
MCHANGELOG.md | 11+++++++++++
Acontracts/conformance/vectors/publication/phase1_media_readiness.v1.json | 435+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/operations.toml | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 11+++++++++++
Mcrates/event_codec/Cargo.toml | 9++++++++-
Acrates/event_codec/contracts/phase1_publication_media_readiness_binding_v1.schema.json | 37+++++++++++++++++++++++++++++++++++++
Acrates/event_codec/contracts/phase1_publication_media_readiness_v1.descriptor.json | 32++++++++++++++++++++++++++++++++
Acrates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json | 336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256 | 1+
Mcrates/event_codec/src/wire/publication.rs | 41+++++++++++++++++++++++++++++++++++++++--
Acrates/event_codec/src/wire/publication/media_readiness.rs | 660+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/fixtures/phase1_publication_media_readiness.v1.json | 435+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/publication_media_readiness.rs | 597+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mtools/xtask/src/contract/nip09_reconciliation.rs | 3++-
Mtools/xtask/src/contract/phase1_publication_artifact.rs | 2+-
Atools/xtask/src/contract/phase1_publication_media_readiness.rs | 1539+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract/raw_source_rebuild.rs | 38++++++++++++--------------------------
Mtools/xtask/src/main.rs | 21+++++++++++++++++++++
20 files changed, 4461 insertions(+), 46 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -199,6 +199,17 @@ publish policy both pass for the same source revision. non-events and likewise fail closed. Event-contract registry v7 remains byte-identical, and the new gate grants no signing, upload, retrieval, relay, or entitlement authority. +<!-- release-change: phase1-publication-media-readiness --> +- The event-codec publication surface now binds each allowlisted artifact to + one canonical sealed Blossom readiness observation per distinct media URL. + The independent version-1 binding persists ordered evidence and the artifact + digest without duplicating artifact bytes, enforces exact hash, size, MIME, + URL, format, and authored dimensions where present, and requires canonical + empty evidence for media-free artifacts. Its bounded reload rejects missing, + duplicate, extra, reordered, stale-policy, cross-artifact, private BUD-11, + noncanonical, or digest-mutated state before signing. The artifact boundary + now admits only nonempty, at-most-10-MiB JPEG, PNG, or still-WebP references + whose canonical URLs are at most 4,096 bytes. <!-- release-change: blossom-publication-readiness-evidence --> - Blossom publication media now advances beyond local byte verification only after typed BUD-02 status and descriptor agreement, an independent BUD-01 diff --git a/contracts/conformance/vectors/publication/phase1_media_readiness.v1.json b/contracts/conformance/vectors/publication/phase1_media_readiness.v1.json @@ -0,0 +1,435 @@ +{ + "suite": "phase1_publication_media_readiness", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "profile_media_ready", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "profile", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "update_media_free", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "update", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "photo_update_primary_and_fallback", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "photo_update", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "ask_primary_and_fallback", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "ask", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "date_event_media_ready", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "event_date", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "time_event_media_ready", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "event_time", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "food_availability_media_ready", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "food_availability", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "canonical_binding_serializes", + "kind": "publication_media_readiness.to_canonical_json.valid", + "input": { + "fixture": "ask", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "canonical_binding_reloads", + "kind": "publication_media_readiness.from_canonical_json.valid", + "input": { + "fixture": "ask", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "sealed_binding_validates", + "kind": "publication_media_readiness.validate.valid", + "input": { + "fixture": "ask", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "missing_evidence_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "missing" + }, + "expected": { + "error": "publication_media_readiness_evidence_count_mismatch" + } + }, + { + "id": "extra_evidence_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "extra" + }, + "expected": { + "error": "publication_media_readiness_evidence_count_mismatch" + } + }, + { + "id": "duplicate_evidence_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "duplicate" + }, + "expected": { + "error": "publication_media_readiness_evidence_order_mismatch" + } + }, + { + "id": "reordered_evidence_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "reordered" + }, + "expected": { + "error": "publication_media_readiness_evidence_order_mismatch" + } + }, + { + "id": "fact_mismatch_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "size_mismatch" + }, + "expected": { + "error": "publication_media_readiness_evidence_fact_mismatch" + } + }, + { + "id": "post_dimension_mismatch_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "dimension_mismatch" + }, + "expected": { + "error": "publication_media_readiness_evidence_dimension_mismatch" + } + }, + { + "id": "food_dimension_mismatch_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "food_availability", + "mutation": "dimension_mismatch" + }, + "expected": { + "error": "publication_media_readiness_evidence_dimension_mismatch" + } + }, + { + "id": "cross_artifact_binding_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "cross_artifact" + }, + "expected": { + "error": "publication_media_readiness_artifact_digest_mismatch" + } + }, + { + "id": "schema_version_is_strict", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "schema_version" + }, + "expected": { + "error": "publication_media_readiness_schema_version_unsupported" + } + }, + { + "id": "policy_version_is_strict", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "policy_version" + }, + "expected": { + "error": "publication_media_readiness_policy_version_unsupported" + } + }, + { + "id": "binding_digest_mismatch_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "digest_mismatch" + }, + "expected": { + "error": "publication_media_readiness_digest_mismatch" + } + }, + { + "id": "malformed_binding_digest_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "digest_invalid" + }, + "expected": { + "error": "publication_media_readiness_digest_invalid" + } + }, + { + "id": "leading_whitespace_is_noncanonical", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "leading_whitespace" + }, + "expected": { + "error": "publication_media_readiness_non_canonical_json" + } + }, + { + "id": "field_reordering_is_noncanonical", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "reordered_fields" + }, + "expected": { + "error": "publication_media_readiness_non_canonical_json" + } + }, + { + "id": "unknown_field_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "unknown_field" + }, + "expected": { + "error": "publication_media_readiness_invalid_json" + } + }, + { + "id": "private_bud11_field_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "bud11_field" + }, + "expected": { + "error": "publication_media_readiness_invalid_json" + } + }, + { + "id": "nested_private_field_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "nested_bud11_field" + }, + "expected": { + "error": "publication_media_readiness_evidence_invalid" + } + }, + { + "id": "exact_binding_byte_limit_reaches_parser", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "binding_exact_max" + }, + "expected": { + "error": "publication_media_readiness_invalid_json" + } + }, + { + "id": "oversized_binding_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "binding_over_max" + }, + "expected": { + "error": "publication_media_readiness_binding_too_large" + } + }, + { + "id": "exact_count_reaches_parity_check", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "update", + "mutation": "evidence_count_exact_max" + }, + "expected": { + "error": "publication_media_readiness_evidence_count_mismatch" + } + }, + { + "id": "one_over_count_rejected_early", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "update", + "mutation": "evidence_count_over_max" + }, + "expected": { + "error": "publication_media_readiness_evidence_count_exceeded" + } + }, + { + "id": "wire_exact_count_reaches_evidence_reload", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "update", + "mutation": "wire_evidence_count_exact_max" + }, + "expected": { + "error": "publication_media_readiness_evidence_invalid" + } + }, + { + "id": "wire_one_over_count_rejected_by_bounded_visitor", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "update", + "mutation": "wire_evidence_count_over_max" + }, + "expected": { + "error": "publication_media_readiness_invalid_json" + } + }, + { + "id": "artifact_url_exact_max_reaches_profile_validation", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "url_exact_max" + }, + "expected": { + "error": "publication_media_inventory_mismatch" + } + }, + { + "id": "artifact_url_over_max_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "url_over_max" + }, + "expected": { + "error": "publication_media_reference_invalid" + } + }, + { + "id": "artifact_zero_size_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "size_zero" + }, + "expected": { + "error": "publication_media_reference_invalid" + } + }, + { + "id": "artifact_oversized_media_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "size_over_max" + }, + "expected": { + "error": "publication_media_reference_invalid" + } + }, + { + "id": "artifact_unsupported_mime_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "mime_unsupported" + }, + "expected": { + "error": "publication_media_reference_invalid" + } + }, + { + "id": "artifact_unbindable_dimensions_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "ask", + "mutation": "dimensions_over_max" + }, + "expected": { + "error": "publication_post_profile_invalid" + } + } + ] +} diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -119,6 +119,9 @@ public = [ "RadrootsPhase1PublicationLeaf", "RadrootsPhase1AllowlistedPublicationArtifact", "RadrootsPhase1PublicationAllowlistError", + "RadrootsPhase1PublicationMediaReadinessBindingDigest", + "RadrootsPhase1MediaReadyPublicationArtifact", + "RadrootsPhase1PublicationMediaReadinessError", "RadrootsFarm", "RadrootsOperationalListing", "RadrootsPost", @@ -1057,6 +1060,113 @@ case_kinds = [ "publication_allowlist.allow_canonical_json.invalid", ] +[operations.phase1_publication_media_readiness_bind] +domain = "publication" +id = "publication_media_readiness.bind" +stability = "beta" +inputs = [ + "RadrootsPhase1AllowlistedPublicationArtifact", + "RadrootsBlossomPublicationReadinessEvidence", +] +outputs = ["RadrootsPhase1MediaReadyPublicationArtifact"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.phase1_publication_media_readiness_bind.implementation] +rust_modules = [ + "crates/event_codec/src/wire/publication.rs", + "crates/event_codec/src/wire/publication/allowlist.rs", + "crates/event_codec/src/wire/publication/media_readiness.rs", +] +rust_types = [ + "radroots_blossom::RadrootsBlossomPublicationReadinessEvidence", + "radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact", + "radroots_event_codec::wire::publication::RadrootsPhase1PublicationMediaReadinessError", +] + +[operations.phase1_publication_media_readiness_bind.conformance] +vector = "contracts/conformance/vectors/publication/phase1_media_readiness.v1.json" +case_kinds = [ + "publication_media_readiness.bind.valid", + "publication_media_readiness.bind.invalid", + "publication_media_readiness.bind.artifact_invalid", +] + +[operations.phase1_publication_media_readiness_to_canonical_json] +domain = "publication" +id = "publication_media_readiness.to_canonical_json" +stability = "beta" +inputs = ["RadrootsPhase1MediaReadyPublicationArtifact"] +outputs = ["Bytes"] +error_class = "none" +deterministic = true +signing = "none" +transport = "none" + +[operations.phase1_publication_media_readiness_to_canonical_json.implementation] +rust_modules = ["crates/event_codec/src/wire/publication/media_readiness.rs"] +rust_types = [ + "radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact", +] + +[operations.phase1_publication_media_readiness_to_canonical_json.conformance] +vector = "contracts/conformance/vectors/publication/phase1_media_readiness.v1.json" +case_kinds = ["publication_media_readiness.to_canonical_json.valid"] + +[operations.phase1_publication_media_readiness_from_canonical_json] +domain = "publication" +id = "publication_media_readiness.from_canonical_json" +stability = "beta" +inputs = ["RadrootsPhase1AllowlistedPublicationArtifact", "Bytes"] +outputs = ["RadrootsPhase1MediaReadyPublicationArtifact"] +error_class = "parse_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.phase1_publication_media_readiness_from_canonical_json.implementation] +rust_modules = [ + "crates/event_codec/src/wire/publication.rs", + "crates/event_codec/src/wire/publication/allowlist.rs", + "crates/event_codec/src/wire/publication/media_readiness.rs", +] +rust_types = [ + "radroots_blossom::RadrootsBlossomPublicationReadinessEvidence", + "radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact", + "radroots_event_codec::wire::publication::RadrootsPhase1PublicationMediaReadinessError", +] + +[operations.phase1_publication_media_readiness_from_canonical_json.conformance] +vector = "contracts/conformance/vectors/publication/phase1_media_readiness.v1.json" +case_kinds = [ + "publication_media_readiness.from_canonical_json.valid", + "publication_media_readiness.from_canonical_json.invalid", +] + +[operations.phase1_publication_media_readiness_validate] +domain = "publication" +id = "publication_media_readiness.validate" +stability = "beta" +inputs = ["RadrootsPhase1MediaReadyPublicationArtifact"] +outputs = ["Unit"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.phase1_publication_media_readiness_validate.implementation] +rust_modules = ["crates/event_codec/src/wire/publication/media_readiness.rs"] +rust_types = [ + "radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact", + "radroots_event_codec::wire::publication::RadrootsPhase1PublicationMediaReadinessError", +] + +[operations.phase1_publication_media_readiness_validate.conformance] +vector = "contracts/conformance/vectors/publication/phase1_media_readiness.v1.json" +case_kinds = ["publication_media_readiness.validate.valid"] + [operations.profile_build_authored_draft] domain = "profile" id = "profile.build_authored_draft" diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -313,6 +313,17 @@ semver_impacts = [ summary = "Add an opaque publication-lane allowlist and canonical-JSON durable-reload adapter over sealed Phase 1 artifacts with one supporting Profile leaf and six root leaves, strict same-kind reprojection, typed-only date/time Event handling, focused FoodAvailability marker partitioning, and fail-closed exclusion of every other generic, ephemeral, or deferred product family." [[changes]] +id = "phase1-publication-media-readiness" +classification = "feature" +semver_impacts = [ + "add_exported_type", + "add_exported_function", + "add_exported_constant", + "add_conformance_vector", +] +summary = "Bind one sealed Blossom readiness observation to every canonical media URL in an allowlisted Phase 1 artifact, persist the bounded URL-complete binding independently from artifact bytes, enforce exact authored dimensions where declared, and reject incomplete, reordered, stale, cross-artifact, or mutated evidence before signing." + +[[changes]] id = "event-store-validity-visibility-split" classification = "breaking" semver_impacts = [ diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml @@ -16,7 +16,13 @@ readme = "README" default = ["std"] std = ["radroots_blossom/std", "radroots_core/std", "radroots_event/std"] serde = ["dep:serde", "radroots_core/serde", "radroots_event/serde"] -serde_json = ["serde", "dep:hex", "dep:serde_json", "dep:sha2"] +serde_json = [ + "serde", + "dep:hex", + "dep:serde_json", + "dep:sha2", + "radroots_blossom/serde", +] nostr = ["dep:nostr", "std"] knowledge = ["serde_json", "radroots_event/knowledge"] knowledge-nip54 = ["knowledge", "radroots_event/knowledge-nip54"] @@ -31,6 +37,7 @@ serde = { workspace = true, default-features = false, features = [ ], optional = true } serde_json = { workspace = true, default-features = false, features = [ "alloc", + "raw_value", ], optional = true } nostr = { workspace = true, optional = true } hex = { version = "0.4", default-features = false, features = [ diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_binding_v1.schema.json b/crates/event_codec/contracts/phase1_publication_media_readiness_binding_v1.schema.json @@ -0,0 +1,37 @@ +{ + "$id": "https://radroots.org/schemas/event-codec/phase1-publication-media-readiness-binding-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "artifact_digest": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "binding_digest": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "evidence": { + "items": { + "type": "object" + }, + "maxItems": 4096, + "type": "array" + }, + "readiness_policy_version": { + "const": 1 + }, + "schema_version": { + "const": 1 + } + }, + "required": [ + "schema_version", + "readiness_policy_version", + "artifact_digest", + "evidence", + "binding_digest" + ], + "title": "Radroots Phase 1 publication media-readiness binding v1", + "type": "object" +} diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_v1.descriptor.json b/crates/event_codec/contracts/phase1_publication_media_readiness_v1.descriptor.json @@ -0,0 +1,32 @@ +{ + "binding_schema": { + "byte_length": 855, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/event_codec/contracts/phase1_publication_media_readiness_binding_v1.schema.json", + "sha256": "1ae4de83b579d55954e79a7839092633170534cadd1a34518fd2ef0a7ad3e456" + }, + "contract_id": "radroots_event_codec.phase1_publication_media_readiness_v1", + "manifest": { + "byte_length": 14193, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json", + "sha256": "97bcb8b03defd0e6058bddf1b9714347e3917e1188e6fdd1513f236cc11e95d9" + }, + "manifest_schema": { + "byte_length": 2628, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json", + "sha256": "7c0a9ec9aa36959dbf705dbf4273e254351a0e286d1bbadf0980f4fd883e2f7d" + }, + "manifest_sidecar": { + "byte_length": 65, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256", + "sha256": "78980b567981907d6bd4be58584cd60e53010badc2c9868be2a70300e335114c" + }, + "predecessor_contract_ids": [ + "radroots_event_codec.phase1_publication_allowlist_v1", + "radroots_blossom.publication_readiness_v1" + ], + "schema_version": 1 +} diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json b/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json @@ -0,0 +1,336 @@ +{ + "schema_version": 1, + "contract_id": "radroots_event_codec.phase1_publication_media_readiness_v1", + "authority_id": "phase1_publication_media_readiness_v1", + "manifest_schema": { + "path": "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json", + "byte_length": 2628, + "sha256": "7c0a9ec9aa36959dbf705dbf4273e254351a0e286d1bbadf0980f4fd883e2f7d", + "hash_algorithm": "sha256_bytes_v1" + }, + "predecessors": [ + { + "contract_id": "radroots_event_codec.phase1_publication_allowlist_v1", + "immutable_artifacts": [ + { + "path": "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.json", + "byte_length": 10601, + "sha256": "8629b5c547e8f9daad473ab9d570b206d00db134cc22b4d8e81be10d0f3d10ec", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.schema.json", + "byte_length": 9016, + "sha256": "638601348dece886ed9666251b5cf68d0a7f96c26dce115b65ed859a2db03d93", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.sha256", + "byte_length": 65, + "sha256": "4dd9bf6f230f02d8c2ca3c323e83fe9b77eb9f0895f708eb0949b7153b2fd6bd", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_codec/contracts/phase1_publication_allowlist_v1.descriptor.json", + "byte_length": 1104, + "sha256": "d7286a1206f822382226f28e2e601dc4f12cb743f9135238467092a31bde7bee", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "contracts/conformance/vectors/publication/phase1_allowlist.v1.json", + "byte_length": 49975, + "sha256": "2867ee401db8cfad3a77869847c57567e869623f55d3d6c9e98a7fa0a643c3d6", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_codec/tests/fixtures/phase1_publication_allowlist.v1.json", + "byte_length": 49975, + "sha256": "2867ee401db8cfad3a77869847c57567e869623f55d3d6c9e98a7fa0a643c3d6", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/event_codec/tests/publication_allowlist.rs", + "byte_length": 7562, + "sha256": "342d3d3d1100cb5d31aca94b0540b1e03761b034de23475bdb5142baabeed8fd", + "hash_algorithm": "sha256_bytes_v1" + } + ], + "source_supersessions": [ + "CHANGELOG.md", + "contracts/operations.toml", + "contracts/releases/1.0.0-alpha.1.toml", + "crates/event_codec/Cargo.toml", + "crates/event_codec/src/wire/publication.rs", + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + "tools/xtask/src/contract/phase1_publication_artifact.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", + "tools/xtask/src/main.rs" + ] + }, + { + "contract_id": "radroots_blossom.publication_readiness_v1", + "immutable_artifacts": [ + { + "path": "crates/blossom/contracts/publication_readiness_v1.manifest.json", + "byte_length": 13038, + "sha256": "9359c5531548778b4a03e1a603a4048f17ba498b16dad50f7c62cca0ddb6240b", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/contracts/publication_readiness_v1.manifest.schema.json", + "byte_length": 11884, + "sha256": "e62ecf4b43bd03831f7e36e9cb4c98e2ed7e3a12d02cbca48a49e402b3feaa7d", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/contracts/publication_readiness_v1.manifest.sha256", + "byte_length": 65, + "sha256": "e8c2be84eef68e965ac0e119016d6840cb9503e58d5d9c40e13a6512e1ec74b7", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/contracts/publication_readiness_v1.descriptor.json", + "byte_length": 1410, + "sha256": "4a3b33eb2b04b5a56a99b7b5bed45988a0697cb28736bfd899e012f37fc02d93", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/contracts/publication_readiness_evidence_v1.schema.json", + "byte_length": 1890, + "sha256": "c8f5f3488dd91a660f8eaa018d9aba63d03c882dc3ff22b47ac192b7189b0ce2", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "contracts/conformance/vectors/blossom/publication_readiness.v1.json", + "byte_length": 16423, + "sha256": "6408e3b5bc4a376c7411e304833431af918a4072f196e8a8da55c3f0ef8610c9", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/tests/fixtures/publication_readiness.v1.json", + "byte_length": 16423, + "sha256": "6408e3b5bc4a376c7411e304833431af918a4072f196e8a8da55c3f0ef8610c9", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/tests/publication_readiness.rs", + "byte_length": 33431, + "sha256": "17abd5491bcfe0c717f7f67202c8c2d85912a042167d027727c07d9f07306641", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json", + "byte_length": 9264, + "sha256": "e892ff6353afe8997a151a9aff4db2fd82c96d94db7bff31bc2269333adc2512", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/tests/fixtures/publication_readiness_persistence.v1.json", + "byte_length": 9264, + "sha256": "e892ff6353afe8997a151a9aff4db2fd82c96d94db7bff31bc2269333adc2512", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "path": "crates/blossom/tests/publication_readiness_persistence.rs", + "byte_length": 11567, + "sha256": "aa14d20ee5747fcc979907b4b59a67687a3a02852850e33aede65e2ab0e0ca8d", + "hash_algorithm": "sha256_bytes_v1" + } + ], + "source_supersessions": [ + "CHANGELOG.md", + "contracts/operations.toml", + "contracts/releases/1.0.0-alpha.1.toml", + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + "tools/xtask/src/contract/phase1_publication_artifact.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", + "tools/xtask/src/main.rs" + ] + } + ], + "protocol_sources": [ + { + "id": "nostr_nips", + "repository": "https://github.com/nostr-protocol/nips", + "revision": "bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91" + }, + { + "id": "blossom", + "repository": "https://github.com/hzrd149/blossom", + "revision": "b5bd2801d1763aa635fc8fea7a76597e0eb18990" + } + ], + "public_api": { + "constants": [ + "RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES", + "RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_SCHEMA_VERSION" + ], + "types": [ + "RadrootsPhase1MediaReadyPublicationArtifact", + "RadrootsPhase1PublicationMediaReadinessBindingDigest", + "RadrootsPhase1PublicationMediaReadinessError" + ], + "functions": [ + "bind_phase1_publication_media_readiness", + "validate_phase1_publication_media_readiness" + ], + "methods": [ + "RadrootsPhase1MediaReadyPublicationArtifact::allowlisted_artifact", + "RadrootsPhase1MediaReadyPublicationArtifact::artifact", + "RadrootsPhase1MediaReadyPublicationArtifact::binding_digest", + "RadrootsPhase1MediaReadyPublicationArtifact::canonical_json", + "RadrootsPhase1MediaReadyPublicationArtifact::evidence", + "RadrootsPhase1MediaReadyPublicationArtifact::from_canonical_json", + "RadrootsPhase1MediaReadyPublicationArtifact::into_allowlisted_artifact", + "RadrootsPhase1MediaReadyPublicationArtifact::to_canonical_json", + "RadrootsPhase1PublicationMediaReadinessBindingDigest::as_bytes", + "RadrootsPhase1PublicationMediaReadinessBindingDigest::to_hex", + "RadrootsPhase1PublicationMediaReadinessError::code" + ], + "sealed_types": [ + "RadrootsPhase1MediaReadyPublicationArtifact", + "RadrootsPhase1PublicationMediaReadinessBindingDigest" + ] + }, + "media_envelope": { + "max_url_utf8_bytes": 4096, + "min_raster_bytes": 1, + "max_raster_bytes": 10485760, + "media_types": [ + "image/jpeg", + "image/png", + "image/webp" + ], + "exact_dimension_variants": [ + "photo_update", + "ask", + "food_availability" + ], + "decoded_only_dimension_variants": [ + "profile", + "event_date", + "event_time" + ] + }, + "binding": { + "schema_version": 1, + "readiness_policy_version": 1, + "schema": { + "path": "crates/event_codec/contracts/phase1_publication_media_readiness_binding_v1.schema.json", + "byte_length": 855, + "sha256": "1ae4de83b579d55954e79a7839092633170534cadd1a34518fd2ef0a7ad3e456", + "hash_algorithm": "sha256_bytes_v1" + }, + "max_canonical_json_bytes": 4194304, + "max_evidence_count": 4096, + "max_evidence_json_bytes": 8192, + "wire_field_order": [ + "schema_version", + "readiness_policy_version", + "artifact_digest", + "evidence", + "binding_digest" + ], + "digest_domain": "radroots.phase1.publication-media-readiness.v1\u0000", + "digest_framing": "domain_bytes_then_u32be_schema_then_u16be_policy_then_raw_artifact_digest_then_u32be_evidence_count_then_repeated_u64be_url_length_url_bytes_raw_evidence_digest_v1", + "bind_operation_id": "publication_media_readiness.bind", + "serialize_operation_id": "publication_media_readiness.to_canonical_json", + "reload_operation_id": "publication_media_readiness.from_canonical_json", + "validate_operation_id": "publication_media_readiness.validate", + "invariants": [ + "allowlisted_artifact_required_v1", + "one_evidence_per_distinct_artifact_ordered_canonical_url_v1", + "media_free_artifact_requires_empty_evidence_v1", + "artifact_and_binding_bytes_persisted_separately_v1", + "closed_jpeg_png_still_webp_media_envelope_v1", + "canonical_url_max_4096_utf8_bytes_v1", + "nonzero_media_size_max_10485760_bytes_v1", + "post_ask_food_authored_dimensions_equal_decoded_dimensions_v1", + "profile_event_decoded_dimensions_persisted_without_authored_claim_v1", + "binding_input_bounded_before_parse_v1", + "evidence_count_max_4096_v1", + "canonical_json_round_trip_required_v1", + "sealed_binding_without_deserialize_v1", + "private_bounded_deny_unknown_fields_wire_v1", + "private_domain_separated_digest_derivation_v1", + "no_bud11_credentials_entitlement_or_topology_persistence_v1" + ], + "error_codes": [ + "publication_media_readiness_binding_too_large", + "publication_media_readiness_evidence_count_exceeded", + "publication_media_readiness_evidence_count_mismatch", + "publication_media_readiness_invalid_json", + "publication_media_readiness_non_canonical_json", + "publication_media_readiness_schema_version_unsupported", + "publication_media_readiness_policy_version_unsupported", + "publication_media_readiness_artifact_digest_mismatch", + "publication_media_readiness_artifact_profile_invalid", + "publication_media_readiness_evidence_invalid", + "publication_media_readiness_evidence_order_mismatch", + "publication_media_readiness_evidence_fact_mismatch", + "publication_media_readiness_evidence_dimension_mismatch", + "publication_media_readiness_digest_invalid", + "publication_media_readiness_digest_mismatch", + "publication_media_readiness_state_mismatch", + "publication_media_readiness_allocation_failed", + "publication_media_readiness_serialization" + ] + }, + "result_vector": { + "canonical_path": "contracts/conformance/vectors/publication/phase1_media_readiness.v1.json", + "mirror_path": "crates/event_codec/tests/fixtures/phase1_publication_media_readiness.v1.json", + "byte_length": 11687, + "sha256": "b102090a2cc5fa3062533edc6032af06863038f66efe93db9a3e5d68c4688048", + "hash_algorithm": "sha256_bytes_v1", + "executor": { + "path": "crates/event_codec/tests/publication_media_readiness.rs", + "byte_length": 22995, + "sha256": "3de03a5aa1d8b37083efabac44c423659b263ebf8597831b056fdd257f333b85", + "hash_algorithm": "sha256_bytes_v1" + }, + "executor_test": "phase1_publication_media_readiness_vector_executes_every_case", + "case_ids": [ + "artifact_oversized_media_rejected", + "artifact_unbindable_dimensions_rejected", + "artifact_unsupported_mime_rejected", + "artifact_url_exact_max_reaches_profile_validation", + "artifact_url_over_max_rejected", + "artifact_zero_size_rejected", + "ask_primary_and_fallback", + "binding_digest_mismatch_rejected", + "canonical_binding_reloads", + "canonical_binding_serializes", + "cross_artifact_binding_rejected", + "date_event_media_ready", + "duplicate_evidence_rejected", + "exact_binding_byte_limit_reaches_parser", + "exact_count_reaches_parity_check", + "extra_evidence_rejected", + "fact_mismatch_rejected", + "field_reordering_is_noncanonical", + "food_availability_media_ready", + "food_dimension_mismatch_rejected", + "leading_whitespace_is_noncanonical", + "malformed_binding_digest_rejected", + "missing_evidence_rejected", + "nested_private_field_rejected", + "one_over_count_rejected_early", + "oversized_binding_rejected", + "photo_update_primary_and_fallback", + "policy_version_is_strict", + "post_dimension_mismatch_rejected", + "private_bud11_field_rejected", + "profile_media_ready", + "reordered_evidence_rejected", + "schema_version_is_strict", + "sealed_binding_validates", + "time_event_media_ready", + "unknown_field_rejected", + "update_media_free", + "wire_exact_count_reaches_evidence_reload", + "wire_one_over_count_rejected_by_bounded_visitor" + ] + } +} diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json b/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json @@ -0,0 +1,118 @@ +{ + "$defs": { + "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" + } + }, + "$id": "https://radroots.org/schemas/event-codec/phase1-publication-media-readiness-manifest-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "authority_id": { + "const": "phase1_publication_media_readiness_v1" + }, + "binding": { + "type": "object" + }, + "contract_id": { + "const": "radroots_event_codec.phase1_publication_media_readiness_v1" + }, + "manifest_schema": { + "$ref": "#/$defs/file" + }, + "media_envelope": { + "type": "object" + }, + "predecessors": { + "items": { + "additionalProperties": false, + "properties": { + "contract_id": { + "minLength": 1, + "type": "string" + }, + "immutable_artifacts": { + "items": { + "$ref": "#/$defs/file" + }, + "minItems": 1, + "type": "array" + }, + "source_supersessions": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "contract_id", + "immutable_artifacts", + "source_supersessions" + ], + "type": "object" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "protocol_sources": { + "items": { + "type": "object" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "public_api": { + "type": "object" + }, + "result_vector": { + "type": "object" + }, + "schema_version": { + "const": 1 + } + }, + "required": [ + "schema_version", + "contract_id", + "authority_id", + "manifest_schema", + "predecessors", + "protocol_sources", + "public_api", + "media_envelope", + "binding", + "result_vector" + ], + "title": "Radroots Phase 1 publication media-readiness semantic contract", + "type": "object" +} diff --git a/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256 b/crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256 @@ -0,0 +1 @@ +97bcb8b03defd0e6058bddf1b9714347e3917e1188e6fdd1513f236cc11e95d9 diff --git a/crates/event_codec/src/wire/publication.rs b/crates/event_codec/src/wire/publication.rs @@ -12,6 +12,16 @@ //! remain the cryptographic authenticity boundary. pub mod allowlist; +mod media_readiness; + +pub use media_readiness::{ + RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES, + RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_SCHEMA_VERSION, + RadrootsPhase1MediaReadyPublicationArtifact, + RadrootsPhase1PublicationMediaReadinessBindingDigest, + RadrootsPhase1PublicationMediaReadinessError, bind_phase1_publication_media_readiness, + validate_phase1_publication_media_readiness, +}; #[cfg(not(feature = "std"))] use alloc::{ @@ -23,8 +33,11 @@ use alloc::{ use core::fmt; use radroots_blossom::{ - RadrootsBlossomApprovedBlobUrl, RadrootsBlossomByteVerifiedDescriptor, - RadrootsBlossomMediaType, RadrootsBlossomSha256, url::RadrootsBlossomBlobUrl, + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES, RadrootsBlossomApprovedBlobUrl, + RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomMediaType, + RadrootsBlossomRasterDimensions, RadrootsBlossomRasterFormat, RadrootsBlossomSha256, + url::RadrootsBlossomBlobUrl, }; use radroots_event::{ RadrootsEventTags, @@ -282,6 +295,10 @@ impl RadrootsPhase1PublicationMediaReference { size: wire.size, media_type, }) + .and_then(|reference| { + validate_media_reference_envelope(&reference)?; + Ok(reference) + }) } fn to_wire(&self) -> MediaReferenceWire { @@ -640,6 +657,9 @@ impl RadrootsPhase1PublicationArtifact { }, ); } + for reference in &media_references { + validate_media_reference_envelope(reference)?; + } canonicalize_media_references(&mut media_references)?; let expected_event_id = compute_canonical_nip01_event_id( expected_author.as_str(), @@ -1083,6 +1103,8 @@ fn validate_post( ) else { return Err(RadrootsPhase1PublicationArtifactError::InvalidPostProfile); }; + RadrootsBlossomRasterDimensions::new(dimensions.width(), dimensions.height()) + .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidPostProfile)?; let mut tag = vec![ "imeta".to_string(), format!("url {url}"), @@ -1302,6 +1324,8 @@ fn validate_food_availability( let (Some(url), Some(dimensions)) = (image.url(), image.dimensions()) else { return Err(RadrootsPhase1PublicationArtifactError::InvalidFoodAvailabilityProfile); }; + RadrootsBlossomRasterDimensions::new(dimensions.width(), dimensions.height()) + .map_err(|_| RadrootsPhase1PublicationArtifactError::InvalidFoodAvailabilityProfile)?; canonical.push(vec![ "image".to_string(), url.to_string(), @@ -1361,6 +1385,19 @@ fn validate_primary_media_reference( Ok(()) } +fn validate_media_reference_envelope( + reference: &RadrootsPhase1PublicationMediaReference, +) -> Result<(), RadrootsPhase1PublicationArtifactError> { + if reference.url.as_str().len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_URL_MAX_BYTES + || reference.size == 0 + || reference.size > RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES + || RadrootsBlossomRasterFormat::from_media_type(&reference.media_type).is_err() + { + return Err(RadrootsPhase1PublicationArtifactError::InvalidMediaReference); + } + Ok(()) +} + fn validate_media_urls<'a>( urls: impl Iterator<Item = &'a str>, media: &[RadrootsPhase1PublicationMediaReference], diff --git a/crates/event_codec/src/wire/publication/media_readiness.rs b/crates/event_codec/src/wire/publication/media_readiness.rs @@ -0,0 +1,660 @@ +//! Sealed media-readiness binding for Phase 1 publication artifacts. +//! +//! This module binds transport-neutral Blossom observations to an already +//! allowlisted artifact. It does not perform HTTP requests, retain BUD-11 +//! authorization, or grant entitlement. + +#[cfg(not(feature = "std"))] +use alloc::{ + boxed::Box, + string::{String, ToString}, + vec, + vec::Vec, +}; +use core::{fmt, marker::PhantomData}; +#[cfg(feature = "std")] +use std::{ + boxed::Box, + string::{String, ToString}, + vec::Vec, +}; + +use radroots_blossom::{ + RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES, + RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, + RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomRasterDimensions, + RadrootsBlossomRasterFormat, +}; +use serde::{ + Deserialize, Deserializer, Serialize, + de::{Error as _, SeqAccess, Visitor}, +}; +use serde_json::value::RawValue; +use sha2::{Digest, Sha256}; + +use crate::{ + food_availability::inbound::{ + RadrootsFoodAvailabilityProjectionOutcome, + registry_v7::project_inbound_food_availability_parts, + }, + post::inbound::registry_v7::project_inbound_post_parts, +}; + +use super::{ + RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT, RadrootsPhase1PublicationArtifact, + RadrootsPhase1PublicationMediaReference, RadrootsPhase1PublicationSemanticVariant, + allowlist::RadrootsPhase1AllowlistedPublicationArtifact, +}; + +pub const RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_SCHEMA_VERSION: u32 = 1; + +const BINDING_WIRE_CEILING_BYTES: usize = 4 * 1024 * 1024; +const BINDING_WIRE_FIXED_BYTES: usize = br#"{"schema_version":1,"readiness_policy_version":1,"artifact_digest":"","evidence":[],"binding_digest":""}"#.len() + + 64 + + 64; +const BINDING_WIRE_FORMULA_MAX_BYTES: usize = BINDING_WIRE_FIXED_BYTES + + RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT + * RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES + + RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT.saturating_sub(1); + +pub const RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES: usize = + if BINDING_WIRE_FORMULA_MAX_BYTES < BINDING_WIRE_CEILING_BYTES { + BINDING_WIRE_FORMULA_MAX_BYTES + } else { + BINDING_WIRE_CEILING_BYTES + }; + +const _: () = assert!( + RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES <= BINDING_WIRE_CEILING_BYTES +); +const BINDING_DIGEST_DOMAIN: &[u8] = b"radroots.phase1.publication-media-readiness.v1\0"; + +/// Domain-separated identity for an artifact's exact ordered readiness set. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsPhase1PublicationMediaReadinessBindingDigest([u8; 32]); + +impl RadrootsPhase1PublicationMediaReadinessBindingDigest { + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(self) -> String { + hex::encode(self.0) + } + + fn parse(value: &str) -> Result<Self, RadrootsPhase1PublicationMediaReadinessError> { + if value.len() != 64 { + return Err(RadrootsPhase1PublicationMediaReadinessError::InvalidDigest); + } + let mut bytes = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = lowercase_hex_nibble(pair[0]) + .ok_or(RadrootsPhase1PublicationMediaReadinessError::InvalidDigest)?; + let low = lowercase_hex_nibble(pair[1]) + .ok_or(RadrootsPhase1PublicationMediaReadinessError::InvalidDigest)?; + bytes[index] = (high << 4) | low; + } + Ok(Self(bytes)) + } +} + +const fn lowercase_hex_nibble(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +impl fmt::Display for RadrootsPhase1PublicationMediaReadinessBindingDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.to_hex()) + } +} + +/// An allowlisted artifact with one sealed readiness observation per media URL. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsPhase1MediaReadyPublicationArtifact { + allowlisted_artifact: RadrootsPhase1AllowlistedPublicationArtifact, + evidence: Vec<RadrootsBlossomPublicationReadinessEvidence>, + binding_digest: RadrootsPhase1PublicationMediaReadinessBindingDigest, + canonical_json: Vec<u8>, +} + +impl RadrootsPhase1MediaReadyPublicationArtifact { + pub fn allowlisted_artifact(&self) -> &RadrootsPhase1AllowlistedPublicationArtifact { + &self.allowlisted_artifact + } + + pub fn artifact(&self) -> &RadrootsPhase1PublicationArtifact { + self.allowlisted_artifact.artifact() + } + + pub fn evidence(&self) -> &[RadrootsBlossomPublicationReadinessEvidence] { + &self.evidence + } + + pub const fn binding_digest(&self) -> RadrootsPhase1PublicationMediaReadinessBindingDigest { + self.binding_digest + } + + pub fn canonical_json(&self) -> &[u8] { + &self.canonical_json + } + + pub fn to_canonical_json(&self) -> Vec<u8> { + self.canonical_json.clone() + } + + pub fn into_allowlisted_artifact(self) -> RadrootsPhase1AllowlistedPublicationArtifact { + self.allowlisted_artifact + } + + /// Reloads canonical binding bytes against a separately revalidated artifact. + pub fn from_canonical_json( + allowlisted_artifact: RadrootsPhase1AllowlistedPublicationArtifact, + bytes: &[u8], + ) -> Result<Self, RadrootsPhase1PublicationMediaReadinessError> { + if bytes.len() > RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES { + return Err( + RadrootsPhase1PublicationMediaReadinessError::BindingTooLarge { + max: RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES, + actual: bytes.len(), + }, + ); + } + let wire: BindingWire<'_> = serde_json::from_slice(bytes) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::InvalidJson)?; + if wire.schema_version != RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_SCHEMA_VERSION + { + return Err( + RadrootsPhase1PublicationMediaReadinessError::UnsupportedSchemaVersion { + expected: RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_SCHEMA_VERSION, + actual: wire.schema_version, + }, + ); + } + if wire.readiness_policy_version != RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION { + return Err( + RadrootsPhase1PublicationMediaReadinessError::UnsupportedReadinessPolicyVersion { + expected: RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, + actual: wire.readiness_policy_version, + }, + ); + } + let expected_artifact_digest = allowlisted_artifact.artifact().artifact_digest(); + if wire.artifact_digest != expected_artifact_digest.to_hex() { + return Err(RadrootsPhase1PublicationMediaReadinessError::ArtifactDigestMismatch); + } + let claimed_digest = + RadrootsPhase1PublicationMediaReadinessBindingDigest::parse(wire.binding_digest)?; + let mut evidence = Vec::new(); + evidence + .try_reserve_exact(wire.evidence.len()) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::AllocationFailed)?; + for raw in wire.evidence { + evidence.push( + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json( + raw.get().as_bytes(), + ) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::EvidenceInvalid)?, + ); + } + let ready = build_media_readiness_binding(allowlisted_artifact, evidence)?; + if ready.binding_digest != claimed_digest { + return Err(RadrootsPhase1PublicationMediaReadinessError::DigestMismatch); + } + if ready.canonical_json != bytes { + return Err(RadrootsPhase1PublicationMediaReadinessError::NonCanonicalJson); + } + Ok(ready) + } +} + +/// Binds one sealed observation to every canonical media URL in artifact order. +pub fn bind_phase1_publication_media_readiness<I>( + allowlisted_artifact: RadrootsPhase1AllowlistedPublicationArtifact, + evidence: I, +) -> Result<RadrootsPhase1MediaReadyPublicationArtifact, RadrootsPhase1PublicationMediaReadinessError> +where + I: IntoIterator<Item = RadrootsBlossomPublicationReadinessEvidence>, +{ + let mut bounded = Vec::new(); + let mut evidence = evidence.into_iter(); + let (lower_bound, _) = evidence.size_hint(); + bounded + .try_reserve_exact(lower_bound.min(RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT)) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::AllocationFailed)?; + for item in &mut evidence { + if bounded.len() == RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT { + return Err( + RadrootsPhase1PublicationMediaReadinessError::EvidenceCountExceeded { + max: RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT, + actual: bounded.len() + 1, + }, + ); + } + bounded.push(item); + } + build_media_readiness_binding(allowlisted_artifact, bounded) +} + +/// Revalidates an in-memory media-ready typestate through canonical reload. +pub fn validate_phase1_publication_media_readiness( + ready: &RadrootsPhase1MediaReadyPublicationArtifact, +) -> Result<(), RadrootsPhase1PublicationMediaReadinessError> { + let reloaded = RadrootsPhase1MediaReadyPublicationArtifact::from_canonical_json( + ready.allowlisted_artifact.clone(), + &ready.canonical_json, + )?; + if &reloaded != ready { + return Err(RadrootsPhase1PublicationMediaReadinessError::StateMismatch); + } + Ok(()) +} + +fn build_media_readiness_binding( + allowlisted_artifact: RadrootsPhase1AllowlistedPublicationArtifact, + evidence: Vec<RadrootsBlossomPublicationReadinessEvidence>, +) -> Result<RadrootsPhase1MediaReadyPublicationArtifact, RadrootsPhase1PublicationMediaReadinessError> +{ + validate_evidence_parity(allowlisted_artifact.artifact(), &evidence)?; + let binding_digest = compute_binding_digest(allowlisted_artifact.artifact(), &evidence); + let canonical_json = + serialize_binding(allowlisted_artifact.artifact(), &evidence, binding_digest)?; + if canonical_json.len() > RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES { + return Err( + RadrootsPhase1PublicationMediaReadinessError::BindingTooLarge { + max: RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES, + actual: canonical_json.len(), + }, + ); + } + Ok(RadrootsPhase1MediaReadyPublicationArtifact { + allowlisted_artifact, + evidence, + binding_digest, + canonical_json, + }) +} + +fn validate_evidence_parity( + artifact: &RadrootsPhase1PublicationArtifact, + evidence: &[RadrootsBlossomPublicationReadinessEvidence], +) -> Result<(), RadrootsPhase1PublicationMediaReadinessError> { + let media = artifact.media_references(); + if evidence.len() != media.len() { + return Err( + RadrootsPhase1PublicationMediaReadinessError::EvidenceCountMismatch { + expected: media.len(), + actual: evidence.len(), + }, + ); + } + let dimensions = expected_dimensions(artifact)?; + for (index, ((reference, observation), expected_dimensions)) in + media.iter().zip(evidence).zip(dimensions).enumerate() + { + if observation.url() != reference.url() { + return Err( + RadrootsPhase1PublicationMediaReadinessError::EvidenceOrderMismatch { index }, + ); + } + let expected_format = RadrootsBlossomRasterFormat::from_media_type(reference.media_type()) + .map_err( + |_| RadrootsPhase1PublicationMediaReadinessError::EvidenceFactMismatch { index }, + )?; + if observation.sha256() != reference.sha256() + || observation.size() != reference.size() + || observation.media_type() != reference.media_type() + || observation.raster_format() != expected_format + { + return Err( + RadrootsPhase1PublicationMediaReadinessError::EvidenceFactMismatch { index }, + ); + } + if expected_dimensions.is_some_and(|expected| observation.dimensions() != expected) { + return Err( + RadrootsPhase1PublicationMediaReadinessError::EvidenceDimensionMismatch { index }, + ); + } + } + Ok(()) +} + +fn expected_dimensions( + artifact: &RadrootsPhase1PublicationArtifact, +) -> Result< + Vec<Option<RadrootsBlossomRasterDimensions>>, + RadrootsPhase1PublicationMediaReadinessError, +> { + match artifact.semantic_variant() { + RadrootsPhase1PublicationSemanticVariant::PhotoUpdate + | RadrootsPhase1PublicationSemanticVariant::Ask => post_dimensions(artifact), + RadrootsPhase1PublicationSemanticVariant::FoodAvailability => food_dimensions(artifact), + RadrootsPhase1PublicationSemanticVariant::Profile + | RadrootsPhase1PublicationSemanticVariant::Update + | RadrootsPhase1PublicationSemanticVariant::Event(_) => { + Ok(vec![None; artifact.media_references().len()]) + } + } +} + +fn post_dimensions( + artifact: &RadrootsPhase1PublicationArtifact, +) -> Result< + Vec<Option<RadrootsBlossomRasterDimensions>>, + RadrootsPhase1PublicationMediaReadinessError, +> { + let draft = artifact.draft(); + let projection = project_inbound_post_parts(draft.kind(), draft.tags(), draft.content()) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; + if !projection.diagnostics().is_empty() { + return Err(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid); + } + let mut entries = Vec::new(); + for imeta in projection.imeta() { + let url = imeta + .url() + .ok_or(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; + let dimensions = imeta + .dimensions() + .ok_or(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; + let dimensions = + RadrootsBlossomRasterDimensions::new(dimensions.width(), dimensions.height()).map_err( + |_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid, + )?; + entries.push((url.to_string(), dimensions)); + entries.extend( + imeta + .fallbacks() + .iter() + .cloned() + .map(|fallback| (fallback, dimensions)), + ); + } + align_dimensions(artifact.media_references(), entries) +} + +fn food_dimensions( + artifact: &RadrootsPhase1PublicationArtifact, +) -> Result< + Vec<Option<RadrootsBlossomRasterDimensions>>, + RadrootsPhase1PublicationMediaReadinessError, +> { + let draft = artifact.draft(); + let tags = radroots_event::RadrootsEventTags::new(draft.tags().to_vec()) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; + let projection = project_inbound_food_availability_parts( + draft.kind(), + draft.created_at(), + &tags, + draft.content(), + ) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; + let RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) = projection else { + return Err(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid); + }; + if !projection.diagnostics().is_empty() { + return Err(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid); + } + let mut entries = Vec::new(); + for image in projection.images() { + let url = image + .url() + .ok_or(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; + let dimensions = image + .dimensions() + .ok_or(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid)?; + let dimensions = + RadrootsBlossomRasterDimensions::new(dimensions.width(), dimensions.height()).map_err( + |_| RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid, + )?; + entries.push((url.to_string(), dimensions)); + } + align_dimensions(artifact.media_references(), entries) +} + +fn align_dimensions( + media: &[RadrootsPhase1PublicationMediaReference], + mut entries: Vec<(String, RadrootsBlossomRasterDimensions)>, +) -> Result< + Vec<Option<RadrootsBlossomRasterDimensions>>, + RadrootsPhase1PublicationMediaReadinessError, +> { + entries.sort_by(|left, right| left.0.cmp(&right.0)); + if entries.windows(2).any(|pair| pair[0].0 == pair[1].0) { + return Err(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid); + } + if entries.len() != media.len() { + return Err(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid); + } + media + .iter() + .zip(entries) + .map(|(reference, (url, dimensions))| { + if reference.url().as_str() != url { + return Err(RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid); + } + Ok(Some(dimensions)) + }) + .collect() +} + +fn compute_binding_digest( + artifact: &RadrootsPhase1PublicationArtifact, + evidence: &[RadrootsBlossomPublicationReadinessEvidence], +) -> RadrootsPhase1PublicationMediaReadinessBindingDigest { + let mut hasher = Sha256::new(); + hasher.update(BINDING_DIGEST_DOMAIN); + hasher.update(RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_SCHEMA_VERSION.to_be_bytes()); + hasher.update(RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION.to_be_bytes()); + hasher.update(artifact.artifact_digest().as_bytes()); + hasher.update((evidence.len() as u32).to_be_bytes()); + for item in evidence { + let url = item.url().as_str().as_bytes(); + hasher.update((url.len() as u64).to_be_bytes()); + hasher.update(url); + hasher.update(item.evidence_digest().as_sha256().as_bytes()); + } + RadrootsPhase1PublicationMediaReadinessBindingDigest(hasher.finalize().into()) +} + +fn serialize_binding( + artifact: &RadrootsPhase1PublicationArtifact, + evidence: &[RadrootsBlossomPublicationReadinessEvidence], + binding_digest: RadrootsPhase1PublicationMediaReadinessBindingDigest, +) -> Result<Vec<u8>, RadrootsPhase1PublicationMediaReadinessError> { + let mut raw_evidence = Vec::new(); + raw_evidence + .try_reserve_exact(evidence.len()) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::AllocationFailed)?; + let mut canonical_len = BINDING_WIRE_FIXED_BYTES + .checked_add(evidence.len().saturating_sub(1)) + .ok_or(RadrootsPhase1PublicationMediaReadinessError::Serialization)?; + let mut binding_too_large = false; + for item in evidence { + let canonical = item + .to_canonical_json() + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::EvidenceInvalid)?; + if canonical.len() > RADROOTS_BLOSSOM_PUBLICATION_READINESS_EVIDENCE_MAX_BYTES { + return Err(RadrootsPhase1PublicationMediaReadinessError::EvidenceInvalid); + } + canonical_len = canonical_len + .checked_add(canonical.len()) + .ok_or(RadrootsPhase1PublicationMediaReadinessError::Serialization)?; + if canonical_len <= RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES { + let canonical = String::from_utf8(canonical) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::EvidenceInvalid)?; + raw_evidence.push( + RawValue::from_string(canonical) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::EvidenceInvalid)?, + ); + } else { + binding_too_large = true; + } + } + if binding_too_large { + return Err( + RadrootsPhase1PublicationMediaReadinessError::BindingTooLarge { + max: RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES, + actual: canonical_len, + }, + ); + } + serde_json::to_vec(&BindingSerializeWire { + schema_version: RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_SCHEMA_VERSION, + readiness_policy_version: RADROOTS_BLOSSOM_PUBLICATION_READINESS_POLICY_VERSION, + artifact_digest: artifact.artifact_digest().to_hex(), + evidence: raw_evidence.iter().map(Box::as_ref).collect(), + binding_digest: binding_digest.to_hex(), + }) + .map_err(|_| RadrootsPhase1PublicationMediaReadinessError::Serialization) +} + +#[derive(Serialize)] +struct BindingSerializeWire<'a> { + schema_version: u32, + readiness_policy_version: u16, + artifact_digest: String, + evidence: Vec<&'a RawValue>, + binding_digest: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BindingWire<'a> { + schema_version: u32, + readiness_policy_version: u16, + artifact_digest: &'a str, + #[serde(borrow, deserialize_with = "deserialize_bounded_evidence")] + evidence: Vec<&'a RawValue>, + binding_digest: &'a str, +} + +fn deserialize_bounded_evidence<'de, D>(deserializer: D) -> Result<Vec<&'de RawValue>, D::Error> +where + D: Deserializer<'de>, +{ + struct BoundedEvidenceVisitor<'de>(PhantomData<&'de RawValue>); + + impl<'de> Visitor<'de> for BoundedEvidenceVisitor<'de> { + type Value = Vec<&'de RawValue>; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "at most {RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT} readiness evidence items" + ) + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + values + .try_reserve_exact( + sequence + .size_hint() + .unwrap_or(0) + .min(RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT), + ) + .map_err(|_| A::Error::custom("readiness evidence allocation failed"))?; + while let Some(value) = sequence.next_element::<&'de RawValue>()? { + if values.len() == RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT { + return Err(A::Error::custom("readiness evidence count exceeds maximum")); + } + values.push(value); + } + Ok(values) + } + } + + deserializer.deserialize_seq(BoundedEvidenceVisitor(PhantomData)) +} + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsPhase1PublicationMediaReadinessError { + BindingTooLarge { max: usize, actual: usize }, + EvidenceCountExceeded { max: usize, actual: usize }, + EvidenceCountMismatch { expected: usize, actual: usize }, + InvalidJson, + NonCanonicalJson, + UnsupportedSchemaVersion { expected: u32, actual: u32 }, + UnsupportedReadinessPolicyVersion { expected: u16, actual: u16 }, + ArtifactDigestMismatch, + ArtifactProfileInvalid, + EvidenceInvalid, + EvidenceOrderMismatch { index: usize }, + EvidenceFactMismatch { index: usize }, + EvidenceDimensionMismatch { index: usize }, + InvalidDigest, + DigestMismatch, + StateMismatch, + AllocationFailed, + Serialization, +} + +impl RadrootsPhase1PublicationMediaReadinessError { + pub const fn code(&self) -> &'static str { + match self { + Self::BindingTooLarge { .. } => "publication_media_readiness_binding_too_large", + Self::EvidenceCountExceeded { .. } => { + "publication_media_readiness_evidence_count_exceeded" + } + Self::EvidenceCountMismatch { .. } => { + "publication_media_readiness_evidence_count_mismatch" + } + Self::InvalidJson => "publication_media_readiness_invalid_json", + Self::NonCanonicalJson => "publication_media_readiness_non_canonical_json", + Self::UnsupportedSchemaVersion { .. } => { + "publication_media_readiness_schema_version_unsupported" + } + Self::UnsupportedReadinessPolicyVersion { .. } => { + "publication_media_readiness_policy_version_unsupported" + } + Self::ArtifactDigestMismatch => "publication_media_readiness_artifact_digest_mismatch", + Self::ArtifactProfileInvalid => "publication_media_readiness_artifact_profile_invalid", + Self::EvidenceInvalid => "publication_media_readiness_evidence_invalid", + Self::EvidenceOrderMismatch { .. } => { + "publication_media_readiness_evidence_order_mismatch" + } + Self::EvidenceFactMismatch { .. } => { + "publication_media_readiness_evidence_fact_mismatch" + } + Self::EvidenceDimensionMismatch { .. } => { + "publication_media_readiness_evidence_dimension_mismatch" + } + Self::InvalidDigest => "publication_media_readiness_digest_invalid", + Self::DigestMismatch => "publication_media_readiness_digest_mismatch", + Self::StateMismatch => "publication_media_readiness_state_mismatch", + Self::AllocationFailed => "publication_media_readiness_allocation_failed", + Self::Serialization => "publication_media_readiness_serialization", + } + } +} + +impl fmt::Display for RadrootsPhase1PublicationMediaReadinessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BindingTooLarge { max, actual } => write!( + formatter, + "publication media-readiness binding is {actual} bytes; maximum is {max}" + ), + Self::EvidenceCountExceeded { max, actual } => write!( + formatter, + "publication media-readiness evidence count is {actual}; maximum is {max}" + ), + Self::EvidenceCountMismatch { expected, actual } => write!( + formatter, + "publication media-readiness evidence count must be {expected}, got {actual}" + ), + error => formatter.write_str(error.code()), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsPhase1PublicationMediaReadinessError {} diff --git a/crates/event_codec/tests/fixtures/phase1_publication_media_readiness.v1.json b/crates/event_codec/tests/fixtures/phase1_publication_media_readiness.v1.json @@ -0,0 +1,435 @@ +{ + "suite": "phase1_publication_media_readiness", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "profile_media_ready", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "profile", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "update_media_free", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "update", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "photo_update_primary_and_fallback", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "photo_update", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "ask_primary_and_fallback", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "ask", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "date_event_media_ready", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "event_date", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "time_event_media_ready", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "event_time", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "food_availability_media_ready", + "kind": "publication_media_readiness.bind.valid", + "input": { + "fixture": "food_availability", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "canonical_binding_serializes", + "kind": "publication_media_readiness.to_canonical_json.valid", + "input": { + "fixture": "ask", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "canonical_binding_reloads", + "kind": "publication_media_readiness.from_canonical_json.valid", + "input": { + "fixture": "ask", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "sealed_binding_validates", + "kind": "publication_media_readiness.validate.valid", + "input": { + "fixture": "ask", + "mutation": "none" + }, + "expected": { + "decision": "allow" + } + }, + { + "id": "missing_evidence_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "missing" + }, + "expected": { + "error": "publication_media_readiness_evidence_count_mismatch" + } + }, + { + "id": "extra_evidence_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "extra" + }, + "expected": { + "error": "publication_media_readiness_evidence_count_mismatch" + } + }, + { + "id": "duplicate_evidence_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "duplicate" + }, + "expected": { + "error": "publication_media_readiness_evidence_order_mismatch" + } + }, + { + "id": "reordered_evidence_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "reordered" + }, + "expected": { + "error": "publication_media_readiness_evidence_order_mismatch" + } + }, + { + "id": "fact_mismatch_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "size_mismatch" + }, + "expected": { + "error": "publication_media_readiness_evidence_fact_mismatch" + } + }, + { + "id": "post_dimension_mismatch_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "ask", + "mutation": "dimension_mismatch" + }, + "expected": { + "error": "publication_media_readiness_evidence_dimension_mismatch" + } + }, + { + "id": "food_dimension_mismatch_rejected", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "food_availability", + "mutation": "dimension_mismatch" + }, + "expected": { + "error": "publication_media_readiness_evidence_dimension_mismatch" + } + }, + { + "id": "cross_artifact_binding_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "cross_artifact" + }, + "expected": { + "error": "publication_media_readiness_artifact_digest_mismatch" + } + }, + { + "id": "schema_version_is_strict", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "schema_version" + }, + "expected": { + "error": "publication_media_readiness_schema_version_unsupported" + } + }, + { + "id": "policy_version_is_strict", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "policy_version" + }, + "expected": { + "error": "publication_media_readiness_policy_version_unsupported" + } + }, + { + "id": "binding_digest_mismatch_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "digest_mismatch" + }, + "expected": { + "error": "publication_media_readiness_digest_mismatch" + } + }, + { + "id": "malformed_binding_digest_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "digest_invalid" + }, + "expected": { + "error": "publication_media_readiness_digest_invalid" + } + }, + { + "id": "leading_whitespace_is_noncanonical", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "leading_whitespace" + }, + "expected": { + "error": "publication_media_readiness_non_canonical_json" + } + }, + { + "id": "field_reordering_is_noncanonical", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "reordered_fields" + }, + "expected": { + "error": "publication_media_readiness_non_canonical_json" + } + }, + { + "id": "unknown_field_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "unknown_field" + }, + "expected": { + "error": "publication_media_readiness_invalid_json" + } + }, + { + "id": "private_bud11_field_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "bud11_field" + }, + "expected": { + "error": "publication_media_readiness_invalid_json" + } + }, + { + "id": "nested_private_field_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "nested_bud11_field" + }, + "expected": { + "error": "publication_media_readiness_evidence_invalid" + } + }, + { + "id": "exact_binding_byte_limit_reaches_parser", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "binding_exact_max" + }, + "expected": { + "error": "publication_media_readiness_invalid_json" + } + }, + { + "id": "oversized_binding_rejected", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "ask", + "mutation": "binding_over_max" + }, + "expected": { + "error": "publication_media_readiness_binding_too_large" + } + }, + { + "id": "exact_count_reaches_parity_check", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "update", + "mutation": "evidence_count_exact_max" + }, + "expected": { + "error": "publication_media_readiness_evidence_count_mismatch" + } + }, + { + "id": "one_over_count_rejected_early", + "kind": "publication_media_readiness.bind.invalid", + "input": { + "fixture": "update", + "mutation": "evidence_count_over_max" + }, + "expected": { + "error": "publication_media_readiness_evidence_count_exceeded" + } + }, + { + "id": "wire_exact_count_reaches_evidence_reload", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "update", + "mutation": "wire_evidence_count_exact_max" + }, + "expected": { + "error": "publication_media_readiness_evidence_invalid" + } + }, + { + "id": "wire_one_over_count_rejected_by_bounded_visitor", + "kind": "publication_media_readiness.from_canonical_json.invalid", + "input": { + "fixture": "update", + "mutation": "wire_evidence_count_over_max" + }, + "expected": { + "error": "publication_media_readiness_invalid_json" + } + }, + { + "id": "artifact_url_exact_max_reaches_profile_validation", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "url_exact_max" + }, + "expected": { + "error": "publication_media_inventory_mismatch" + } + }, + { + "id": "artifact_url_over_max_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "url_over_max" + }, + "expected": { + "error": "publication_media_reference_invalid" + } + }, + { + "id": "artifact_zero_size_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "size_zero" + }, + "expected": { + "error": "publication_media_reference_invalid" + } + }, + { + "id": "artifact_oversized_media_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "size_over_max" + }, + "expected": { + "error": "publication_media_reference_invalid" + } + }, + { + "id": "artifact_unsupported_mime_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "event_date", + "mutation": "mime_unsupported" + }, + "expected": { + "error": "publication_media_reference_invalid" + } + }, + { + "id": "artifact_unbindable_dimensions_rejected", + "kind": "publication_media_readiness.bind.artifact_invalid", + "input": { + "fixture": "ask", + "mutation": "dimensions_over_max" + }, + "expected": { + "error": "publication_post_profile_invalid" + } + } + ] +} diff --git a/crates/event_codec/tests/publication_media_readiness.rs b/crates/event_codec/tests/publication_media_readiness.rs @@ -0,0 +1,597 @@ +#![cfg(feature = "serde_json")] + +use radroots_blossom::{ + RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES, RadrootsBlossomPublicationReadinessEvidence, +}; +use radroots_event::wire::compute_canonical_nip01_event_id; +use radroots_event_codec::wire::publication::{ + RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT, + RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES, + RadrootsPhase1PublicationArtifact, RadrootsPhase1PublicationMediaReadinessError, + RadrootsPhase1PublicationMediaReference, + allowlist::{ + RadrootsPhase1AllowlistedPublicationArtifact, allow_phase1_publication_canonical_json, + }, + bind_phase1_publication_media_readiness, validate_phase1_publication_media_readiness, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const CANONICAL_VECTOR: &[u8] = include_bytes!( + "../../../contracts/conformance/vectors/publication/phase1_media_readiness.v1.json" +); +const PACKAGED_VECTOR: &[u8] = + include_bytes!("fixtures/phase1_publication_media_readiness.v1.json"); +const ARTIFACT_VECTOR: &[u8] = include_bytes!("fixtures/phase1_publication_artifact.v1.json"); + +#[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 { + fixture: String, + mutation: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorExpected { + #[serde(default)] + decision: Option<String>, + #[serde(default)] + error: Option<String>, +} + +#[derive(Serialize)] +struct EvidenceDimensionsWire { + width: u32, + height: u32, +} + +#[derive(Serialize)] +struct EvidenceWire<'a> { + schema_version: u32, + policy_version: u16, + url: &'a str, + sha256: String, + 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 phase1_publication_media_readiness_vector_executes_every_case() { + assert_eq!(CANONICAL_VECTOR, PACKAGED_VECTOR, "packaged vector drift"); + let suite: VectorSuite = serde_json::from_slice(PACKAGED_VECTOR).expect("media vector"); + assert_eq!(suite.suite, "phase1_publication_media_readiness"); + assert_eq!(suite.contract_version, "1.0.0"); + assert_eq!(suite.vectors.len(), 39); + + for vector in suite.vectors { + match vector.kind.as_str() { + kind @ ("publication_media_readiness.bind.valid" + | "publication_media_readiness.to_canonical_json.valid" + | "publication_media_readiness.from_canonical_json.valid" + | "publication_media_readiness.validate.valid") => { + let ready = ready_fixture(&vector.input.fixture); + assert_eq!(vector.input.mutation, "none", "{}", vector.id); + assert_eq!(vector.expected.decision.as_deref(), Some("allow")); + assert!(vector.expected.error.is_none()); + match kind { + "publication_media_readiness.bind.valid" => {} + "publication_media_readiness.to_canonical_json.valid" => { + let canonical = ready.to_canonical_json(); + assert_eq!(canonical, ready.canonical_json(), "{}", vector.id); + } + "publication_media_readiness.from_canonical_json.valid" => { + let reloaded = + radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact::from_canonical_json( + allowlisted_fixture(&vector.input.fixture), + ready.canonical_json(), + ) + .unwrap_or_else(|error| panic!("{}: {error}", vector.id)); + assert_eq!(reloaded, ready, "{}", vector.id); + } + "publication_media_readiness.validate.valid" => { + validate_phase1_publication_media_readiness(&ready) + .unwrap_or_else(|error| panic!("{}: {error}", vector.id)); + } + _ => unreachable!("closed valid operation kind"), + } + assert_eq!( + independent_binding_digest(&ready), + *ready.binding_digest().as_bytes(), + "{}", + vector.id + ); + } + "publication_media_readiness.bind.invalid" + | "publication_media_readiness.from_canonical_json.invalid" => { + assert!(vector.expected.decision.is_none(), "{}", vector.id); + let error = execute_binding_mutation(&vector.input.fixture, &vector.input.mutation); + assert_eq!( + Some(error.code()), + vector.expected.error.as_deref(), + "{}", + vector.id + ); + } + "publication_media_readiness.bind.artifact_invalid" => { + assert!(vector.expected.decision.is_none(), "{}", vector.id); + let error = + execute_artifact_mutation(&vector.input.fixture, &vector.input.mutation); + assert_eq!( + Some(error.code()), + vector.expected.error.as_deref(), + "{}", + vector.id + ); + } + kind => panic!("{} has unsupported kind {kind}", vector.id), + } + } +} + +#[test] +fn media_readiness_public_accessors_and_error_taxonomy_are_executable() { + let ready = ready_fixture("ask"); + let expected_allowlisted = allowlisted_fixture("ask"); + assert_eq!(ready.allowlisted_artifact(), &expected_allowlisted); + assert_eq!(ready.to_canonical_json(), ready.canonical_json()); + assert_eq!( + ready.binding_digest().to_string(), + ready.binding_digest().to_hex() + ); + assert_eq!( + ready.clone().into_allowlisted_artifact(), + expected_allowlisted + ); + + let errors = [ + RadrootsPhase1PublicationMediaReadinessError::BindingTooLarge { max: 4, actual: 5 }, + RadrootsPhase1PublicationMediaReadinessError::EvidenceCountExceeded { max: 4, actual: 5 }, + RadrootsPhase1PublicationMediaReadinessError::EvidenceCountMismatch { + expected: 1, + actual: 0, + }, + RadrootsPhase1PublicationMediaReadinessError::InvalidJson, + RadrootsPhase1PublicationMediaReadinessError::NonCanonicalJson, + RadrootsPhase1PublicationMediaReadinessError::UnsupportedSchemaVersion { + expected: 1, + actual: 2, + }, + RadrootsPhase1PublicationMediaReadinessError::UnsupportedReadinessPolicyVersion { + expected: 1, + actual: 2, + }, + RadrootsPhase1PublicationMediaReadinessError::ArtifactDigestMismatch, + RadrootsPhase1PublicationMediaReadinessError::ArtifactProfileInvalid, + RadrootsPhase1PublicationMediaReadinessError::EvidenceInvalid, + RadrootsPhase1PublicationMediaReadinessError::EvidenceOrderMismatch { index: 0 }, + RadrootsPhase1PublicationMediaReadinessError::EvidenceFactMismatch { index: 0 }, + RadrootsPhase1PublicationMediaReadinessError::EvidenceDimensionMismatch { index: 0 }, + RadrootsPhase1PublicationMediaReadinessError::InvalidDigest, + RadrootsPhase1PublicationMediaReadinessError::DigestMismatch, + RadrootsPhase1PublicationMediaReadinessError::StateMismatch, + RadrootsPhase1PublicationMediaReadinessError::AllocationFailed, + RadrootsPhase1PublicationMediaReadinessError::Serialization, + ]; + for error in errors { + assert!(error.code().starts_with("publication_media_readiness_")); + assert!(!error.to_string().is_empty()); + } +} + +fn execute_binding_mutation( + fixture: &str, + mutation: &str, +) -> RadrootsPhase1PublicationMediaReadinessError { + let artifact = allowlisted_fixture(fixture); + let mut evidence = evidence_for_fixture(fixture, artifact.artifact().media_references()); + match mutation { + "missing" => { + evidence.pop(); + bind_phase1_publication_media_readiness(artifact, evidence).unwrap_err() + } + "extra" => { + evidence.push(evidence[0].clone()); + bind_phase1_publication_media_readiness(artifact, evidence).unwrap_err() + } + "duplicate" => { + evidence[1] = evidence[0].clone(); + bind_phase1_publication_media_readiness(artifact, evidence).unwrap_err() + } + "reordered" => { + evidence.swap(0, 1); + bind_phase1_publication_media_readiness(artifact, evidence).unwrap_err() + } + "size_mismatch" => { + let reference = &artifact.artifact().media_references()[0]; + evidence[0] = evidence_for_reference( + reference, + expected_dimensions(fixture)[0], + reference.size() + 1, + ); + bind_phase1_publication_media_readiness(artifact, evidence).unwrap_err() + } + "dimension_mismatch" => { + let reference = &artifact.artifact().media_references()[0]; + evidence[0] = evidence_for_reference(reference, (1, 1), reference.size()); + bind_phase1_publication_media_readiness(artifact, evidence).unwrap_err() + } + "cross_artifact" => { + let ready = bind_phase1_publication_media_readiness(artifact, evidence).unwrap(); + radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact::from_canonical_json( + allowlisted_fixture("photo_update"), + ready.canonical_json(), + ) + .unwrap_err() + } + "evidence_count_exact_max" | "evidence_count_over_max" => { + let sample_artifact = allowlisted_fixture("event_date"); + let sample = + evidence_for_fixture("event_date", sample_artifact.artifact().media_references()) + .remove(0); + let count = RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT + + usize::from(mutation == "evidence_count_over_max"); + bind_phase1_publication_media_readiness(artifact, vec![sample; count]).unwrap_err() + } + "wire_evidence_count_exact_max" | "wire_evidence_count_over_max" => { + let ready = + bind_phase1_publication_media_readiness(artifact.clone(), evidence).unwrap(); + let count = RADROOTS_PHASE1_PUBLICATION_MEDIA_MAX_COUNT + + usize::from(mutation == "wire_evidence_count_over_max"); + let bytes = replace_evidence_array(ready.canonical_json(), count); + radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact::from_canonical_json( + artifact, + &bytes, + ) + .unwrap_err() + } + mutation => { + let ready = + bind_phase1_publication_media_readiness(artifact.clone(), evidence).unwrap(); + let bytes = mutate_binding(ready.canonical_json(), mutation); + radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact::from_canonical_json( + artifact, + &bytes, + ) + .unwrap_err() + } + } +} + +fn execute_artifact_mutation( + fixture: &str, + mutation: &str, +) -> radroots_event_codec::wire::publication::RadrootsPhase1PublicationArtifactError { + let canonical = artifact_canonical_json(fixture); + let mut value: Value = serde_json::from_slice(&canonical).unwrap(); + match mutation { + "url_exact_max" | "url_over_max" => { + let length = 4_096 + usize::from(mutation == "url_over_max"); + value["media_references"][0]["url"] = Value::String(blob_url_with_length(length)); + } + "size_zero" => value["media_references"][0]["size"] = Value::from(0_u64), + "size_over_max" => { + value["media_references"][0]["size"] = + Value::from(RADROOTS_BLOSSOM_PUBLICATION_RASTER_MAX_BYTES + 1); + } + "mime_unsupported" => { + value["media_references"][0]["media_type"] = Value::String("image/gif".to_string()); + } + "dimensions_over_max" => { + let tags = value["draft"]["tags"].as_array_mut().unwrap(); + let imeta = tags + .iter_mut() + .find(|tag| tag[0].as_str() == Some("imeta")) + .unwrap() + .as_array_mut() + .unwrap(); + let dimension = imeta + .iter_mut() + .find(|element| { + element + .as_str() + .is_some_and(|value| value.starts_with("dim ")) + }) + .unwrap(); + *dimension = Value::String("dim 16384x16384".to_string()); + let draft = &value["draft"]; + let tags: Vec<Vec<String>> = serde_json::from_value(draft["tags"].clone()).unwrap(); + let event_id = compute_canonical_nip01_event_id( + value["expected_author"].as_str().unwrap(), + draft["created_at"].as_u64().unwrap(), + u32::try_from(draft["kind"].as_u64().unwrap()).unwrap(), + &tags, + draft["content"].as_str().unwrap(), + ) + .unwrap(); + value["expected_event_id"] = Value::String(event_id.as_str().to_string()); + } + _ => panic!("unsupported artifact mutation {mutation}"), + } + RadrootsPhase1PublicationArtifact::from_canonical_json(&serde_json::to_vec(&value).unwrap()) + .unwrap_err() +} + +fn ready_fixture( + fixture: &str, +) -> radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact { + let artifact = allowlisted_fixture(fixture); + let evidence = evidence_for_fixture(fixture, artifact.artifact().media_references()); + bind_phase1_publication_media_readiness(artifact, evidence).unwrap() +} + +fn allowlisted_fixture(fixture: &str) -> RadrootsPhase1AllowlistedPublicationArtifact { + allow_phase1_publication_canonical_json(&artifact_canonical_json(fixture)).unwrap() +} + +fn artifact_canonical_json(fixture: &str) -> Vec<u8> { + let root: Value = serde_json::from_slice(ARTIFACT_VECTOR).unwrap(); + root["vectors"] + .as_array() + .unwrap() + .iter() + .find(|vector| { + vector["kind"] + .as_str() + .is_some_and(|kind| kind.ends_with(".valid")) + && vector["input"]["fixture"].as_str() == Some(fixture) + }) + .and_then(|vector| vector["expected"]["canonical_json"].as_str()) + .unwrap_or_else(|| panic!("missing artifact fixture {fixture}")) + .as_bytes() + .to_vec() +} + +fn evidence_for_fixture( + fixture: &str, + media: &[RadrootsPhase1PublicationMediaReference], +) -> Vec<RadrootsBlossomPublicationReadinessEvidence> { + let dimensions = expected_dimensions(fixture); + assert_eq!(dimensions.len(), media.len()); + media + .iter() + .zip(dimensions) + .map(|(reference, dimensions)| { + evidence_for_reference(reference, dimensions, reference.size()) + }) + .collect() +} + +fn expected_dimensions(fixture: &str) -> Vec<(u32, u32)> { + match fixture { + "profile" => vec![(640, 640), (1_600, 600)], + "update" => Vec::new(), + "photo_update" | "ask" => vec![(1_200, 900), (1_200, 900)], + "event_date" | "event_time" => vec![(640, 480)], + "food_availability" => vec![(1_200, 800)], + _ => panic!("unknown fixture {fixture}"), + } +} + +fn evidence_for_reference( + reference: &RadrootsPhase1PublicationMediaReference, + dimensions: (u32, u32), + size: u64, +) -> RadrootsBlossomPublicationReadinessEvidence { + let media_type = reference.media_type().as_str(); + let (raster_format, format_code) = match media_type { + "image/jpeg" => ("jpeg", 1), + "image/png" => ("png", 2), + "image/webp" => ("still_webp", 3), + _ => panic!("unsupported test MIME {media_type}"), + }; + let url = reference.url().as_str(); + let uploaded = 1_800_000_001_u64; + let evidence_digest = evidence_digest( + url, + reference.sha256().as_bytes(), + size, + media_type, + format_code, + dimensions, + uploaded, + ); + let wire = EvidenceWire { + schema_version: 1, + policy_version: 1, + url, + sha256: reference.sha256().to_hex(), + size, + media_type, + raster_format, + dimensions: EvidenceDimensionsWire { + width: dimensions.0, + height: dimensions.1, + }, + bud02_status: 201, + bud01_head_status: 200, + bud01_get_status: 200, + uploaded, + evidence_digest, + }; + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json( + &serde_json::to_vec(&wire).unwrap(), + ) + .unwrap() +} + +fn evidence_digest( + url: &str, + sha256: &[u8; 32], + size: u64, + media_type: &str, + format_code: u8, + dimensions: (u32, u32), + uploaded: u64, +) -> 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(sha256); + hasher.update(size.to_be_bytes()); + update_length_prefixed(&mut hasher, media_type.as_bytes()); + hasher.update([format_code]); + hasher.update(dimensions.0.to_be_bytes()); + hasher.update(dimensions.1.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(uploaded.to_be_bytes()); + hex::encode(hasher.finalize()) +} + +fn independent_binding_digest( + ready: &radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"radroots.phase1.publication-media-readiness.v1\0"); + hasher.update(1_u32.to_be_bytes()); + hasher.update(1_u16.to_be_bytes()); + hasher.update(ready.artifact().artifact_digest().as_bytes()); + hasher.update((ready.evidence().len() as u32).to_be_bytes()); + for evidence in ready.evidence() { + update_length_prefixed(&mut hasher, evidence.url().as_str().as_bytes()); + hasher.update(evidence.evidence_digest().as_sha256().as_bytes()); + } + hasher.finalize().into() +} + +fn update_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +fn mutate_binding(canonical: &[u8], mutation: &str) -> Vec<u8> { + match mutation { + "schema_version" => { + replace_once(canonical, b"\"schema_version\":1", b"\"schema_version\":2") + } + "policy_version" => replace_once( + canonical, + b"\"readiness_policy_version\":1", + b"\"readiness_policy_version\":2", + ), + "digest_mismatch" => mutate_binding_digest(canonical), + "digest_invalid" => replace_binding_digest(canonical, b'G'), + "leading_whitespace" => [b" ".as_slice(), canonical].concat(), + "reordered_fields" => replace_once( + canonical, + b"{\"schema_version\":1,\"readiness_policy_version\":1", + b"{\"readiness_policy_version\":1,\"schema_version\":1", + ), + "unknown_field" => append_field(canonical, b",\"unknown\":true"), + "bud11_field" => append_field(canonical, b",\"authorization\":\"Nostr token\""), + "nested_bud11_field" => replace_once( + canonical, + b",\"evidence_digest\"", + b",\"authorization\":\"Nostr token\",\"evidence_digest\"", + ), + "binding_exact_max" => { + vec![b' '; RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES] + } + "binding_over_max" => { + vec![b' '; RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES + 1] + } + _ => panic!("unsupported binding mutation {mutation}"), + } +} + +fn mutate_binding_digest(canonical: &[u8]) -> Vec<u8> { + let mut output = canonical.to_vec(); + let start = binding_digest_start(canonical); + output[start] = if output[start] == b'0' { b'1' } else { b'0' }; + output +} + +fn replace_binding_digest(canonical: &[u8], replacement: u8) -> Vec<u8> { + let mut output = canonical.to_vec(); + output[binding_digest_start(canonical)] = replacement; + output +} + +fn binding_digest_start(canonical: &[u8]) -> usize { + let marker = b"\"binding_digest\":\""; + canonical + .windows(marker.len()) + .position(|candidate| candidate == marker) + .unwrap() + + marker.len() +} + +fn replace_evidence_array(canonical: &[u8], count: usize) -> Vec<u8> { + let start_marker = b"\"evidence\":["; + let start = canonical + .windows(start_marker.len()) + .position(|candidate| candidate == start_marker) + .unwrap() + + start_marker.len(); + let end_marker = b"],\"binding_digest\""; + let end = canonical + .windows(end_marker.len()) + .position(|candidate| candidate == end_marker) + .unwrap(); + let mut output = Vec::with_capacity(canonical.len() + count * 3); + output.extend_from_slice(&canonical[..start]); + for index in 0..count { + if index != 0 { + output.push(b','); + } + output.extend_from_slice(b"{}"); + } + output.extend_from_slice(&canonical[end..]); + output +} + +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(); + 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 +} + +fn blob_url_with_length(length: usize) -> String { + const PREFIX: &str = concat!( + "https://media.example/", + "0a422cbf828d421341c40c678f4cfbd6451841760db126e5f5ac3d2e06fd80b8." + ); + assert!(length >= PREFIX.len()); + format!("{PREFIX}{}", "a".repeat(length - PREFIX.len())) +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -2,12 +2,15 @@ mod admission_authority; mod artifact_bundle; +#[allow(dead_code)] mod blossom_publication_readiness; mod comment_authority; mod deletion_authority; mod food_availability_projection; mod nip09_reconciliation; +#[allow(dead_code)] mod phase1_publication_allowlist; +mod phase1_publication_media_readiness; // The former live generator remains compiled for historical unit tests and // immutable predecessor validation, but the allowlist successor owns writes. #[allow(dead_code)] @@ -20,21 +23,18 @@ 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, }; pub(crate) use nip09_reconciliation::{ validate_nip09_reconciliation_manifest, write_nip09_reconciliation_manifest, }; -pub(crate) use phase1_publication_allowlist::{ - validate_phase1_publication_allowlist_manifest, write_phase1_publication_allowlist_manifest, -}; -pub(crate) use phase1_publication_artifact::{ +pub(crate) use phase1_publication_media_readiness::{ + validate_immutable_blossom_publication_readiness_predecessor, + validate_immutable_phase1_publication_allowlist_predecessor, validate_immutable_phase1_publication_artifact_predecessor, - validate_phase1_publication_artifact_manifest, write_phase1_publication_artifact_manifest, + validate_phase1_publication_media_readiness_manifest, + write_phase1_publication_media_readiness_manifest, }; pub(crate) use registry_v7::{ validate_event_contract_registry_v7_inventory, write_event_contract_registry_v7_inventory, @@ -72,22 +72,59 @@ pub(crate) fn validate_artifact_contracts(workspace_root: &Path) -> Result<(), S validate_nip09_reconciliation_manifest(workspace_root)?; validate_food_availability_projection_manifest(workspace_root)?; validate_source_maintenance_manifest(workspace_root)?; - validate_raw_source_rebuild_manifest(workspace_root)?; + phase1_publication_artifact::validate_immutable_raw_source_rebuild_predecessor(workspace_root)?; validate_immutable_phase1_publication_artifact_predecessor(workspace_root)?; - validate_phase1_publication_allowlist_manifest(workspace_root)?; validate_release_provenance_schema(workspace_root)?; - blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root)?; + validate_phase1_publication_media_readiness_manifest(workspace_root)?; validate_knowledge_contract_manifest(workspace_root) } pub(crate) fn validate_raw_source_rebuild_manifest(workspace_root: &Path) -> Result<(), String> { - phase1_publication_artifact::validate_immutable_raw_source_rebuild_predecessor(workspace_root)?; - blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root) + phase1_publication_artifact::validate_immutable_raw_source_rebuild_predecessor(workspace_root) +} + +pub(crate) fn validate_phase1_publication_artifact_manifest( + workspace_root: &Path, +) -> Result<(), String> { + validate_immutable_phase1_publication_artifact_predecessor(workspace_root) +} + +pub(crate) fn write_phase1_publication_artifact_manifest( + workspace_root: &Path, +) -> Result<(), String> { + validate_phase1_publication_artifact_manifest(workspace_root)?; + Err("Phase 1 publication artifact v1 is an immutable predecessor and cannot be rewritten; write the active Phase 1 publication media-readiness successor instead".to_owned()) } pub(crate) fn write_raw_source_rebuild_manifest(workspace_root: &Path) -> Result<(), String> { validate_raw_source_rebuild_manifest(workspace_root)?; - Err("raw-source rebuild is an immutable predecessor and cannot be rewritten; write the active publication successor instead".to_owned()) + Err("raw-source rebuild is an immutable predecessor and cannot be rewritten; write the active Phase 1 publication media-readiness successor instead".to_owned()) +} + +pub(crate) fn validate_phase1_publication_allowlist_manifest( + workspace_root: &Path, +) -> Result<(), String> { + validate_immutable_phase1_publication_allowlist_predecessor(workspace_root) +} + +pub(crate) fn write_phase1_publication_allowlist_manifest( + workspace_root: &Path, +) -> Result<(), String> { + validate_phase1_publication_allowlist_manifest(workspace_root)?; + Err("Phase 1 publication allowlist v1 is an immutable predecessor and cannot be rewritten; write the active Phase 1 publication media-readiness successor instead".to_owned()) +} + +pub(crate) fn validate_blossom_publication_readiness_manifest( + workspace_root: &Path, +) -> Result<(), String> { + validate_immutable_blossom_publication_readiness_predecessor(workspace_root) +} + +pub(crate) fn write_blossom_publication_readiness_manifest( + workspace_root: &Path, +) -> Result<(), String> { + validate_blossom_publication_readiness_manifest(workspace_root)?; + Err("Blossom publication readiness v1 is an immutable predecessor and cannot be rewritten; write the active Phase 1 publication media-readiness successor instead".to_owned()) } const CONFORMANCE_ROOT_RELATIVE: &str = "contracts/conformance"; @@ -126,7 +163,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); 27] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 28] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -228,6 +265,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 27] = [ "crates/event_codec/tests/fixtures/phase1_publication_allowlist.v1.json", ), ( + "contracts/conformance/vectors/publication/phase1_media_readiness.v1.json", + "crates/event_codec/tests/fixtures/phase1_publication_media_readiness.v1.json", + ), + ( "contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json", "crates/trade/tests/fixtures/parse_classified_listing_address.v1.json", ), diff --git a/tools/xtask/src/contract/nip09_reconciliation.rs b/tools/xtask/src/contract/nip09_reconciliation.rs @@ -221,7 +221,7 @@ const GOVERNED_DEPENDENCY_TABLE_SHA256: [(&str, &str); 7] = [ ), ( EVENT_CODEC_CARGO_MANIFEST_RELATIVE, - "e135922812764cff3d3552bee4f953fa827b9a593d00fda081e93f6dc04c860d", + "b4ae0cfe31e089ad5ee69d398e69be36e7f24d0c101ec3d0a00a34aa109e9f06", ), ( BLOSSOM_CARGO_MANIFEST_RELATIVE, @@ -17358,6 +17358,7 @@ mod tests { Some("dep:hex"), Some("dep:serde_json"), Some("dep:sha2"), + Some("radroots_blossom/serde"), ], "publication successor feature edges must retain their exact semantic shape" ); diff --git a/tools/xtask/src/contract/phase1_publication_artifact.rs b/tools/xtask/src/contract/phase1_publication_artifact.rs @@ -1962,7 +1962,7 @@ mod tests { #[test] fn publication_predecessor_operations_and_vector_authority_are_current() { let root = workspace_root(); - validate_immutable_phase1_publication_artifact_predecessor(&root) + crate::contract::phase1_publication_media_readiness::validate_immutable_phase1_publication_artifact_predecessor(&root) .expect("immutable publication predecessor"); validate_operations_authority(&root).expect("operations authority"); validate_result_vector(&root).expect("result vector"); diff --git a/tools/xtask/src/contract/phase1_publication_media_readiness.rs b/tools/xtask/src/contract/phase1_publication_media_readiness.rs @@ -0,0 +1,1539 @@ +use super::artifact_bundle::{ + GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeSet, path::Path}; +use syn::{Expr, ImplItem, Item, Visibility, punctuated::Punctuated, token::Comma, visit::Visit}; + +const SCHEMA_VERSION: u32 = 1; +const CONTRACT_ID: &str = "radroots_event_codec.phase1_publication_media_readiness_v1"; +const AUTHORITY_ID: &str = "phase1_publication_media_readiness_v1"; +const HASH_ALGORITHM: &str = "sha256_bytes_v1"; +const WRITE_COMMAND: &str = + "cargo xtask contract phase1-publication-media-readiness-manifest --write"; + +const MANIFEST_RELATIVE: &str = + "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.json"; +const MANIFEST_SCHEMA_RELATIVE: &str = + "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.schema.json"; +const MANIFEST_SHA256_RELATIVE: &str = + "crates/event_codec/contracts/phase1_publication_media_readiness_v1.manifest.sha256"; +const GENERATED_DESCRIPTOR_RELATIVE: &str = + "crates/event_codec/contracts/phase1_publication_media_readiness_v1.descriptor.json"; +const BINDING_SCHEMA_RELATIVE: &str = + "crates/event_codec/contracts/phase1_publication_media_readiness_binding_v1.schema.json"; +const VECTOR_RELATIVE: &str = + "contracts/conformance/vectors/publication/phase1_media_readiness.v1.json"; +const VECTOR_MIRROR_RELATIVE: &str = + "crates/event_codec/tests/fixtures/phase1_publication_media_readiness.v1.json"; +const VECTOR_EXECUTOR_RELATIVE: &str = "crates/event_codec/tests/publication_media_readiness.rs"; +const VECTOR_EXECUTOR_TEST: &str = "phase1_publication_media_readiness_vector_executes_every_case"; +const SOURCE_RELATIVE: &str = "crates/event_codec/src/wire/publication/media_readiness.rs"; +const PUBLICATION_SOURCE_RELATIVE: &str = "crates/event_codec/src/wire/publication.rs"; +const EVENT_CODEC_MANIFEST_RELATIVE: &str = "crates/event_codec/Cargo.toml"; +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 RELEASE_CHANGE_ID: &str = "phase1-publication-media-readiness"; +const CHANGELOG_MARKER: &str = "<!-- release-change: phase1-publication-media-readiness -->"; + +const BINDING_SCHEMA_VERSION: u32 = 1; +const READINESS_POLICY_VERSION: u16 = 1; +const BINDING_MAX_BYTES: u64 = 4 * 1024 * 1024; +const EVIDENCE_MAX_COUNT: u32 = 4096; +const EVIDENCE_MAX_BYTES: u32 = 8192; +const URL_MAX_BYTES: u32 = 4096; +const RASTER_MAX_BYTES: u64 = 10_485_760; +const DIGEST_DOMAIN: &str = "radroots.phase1.publication-media-readiness.v1\0"; + +const BIND_OPERATION_ID: &str = "publication_media_readiness.bind"; +const SERIALIZE_OPERATION_ID: &str = "publication_media_readiness.to_canonical_json"; +const RELOAD_OPERATION_ID: &str = "publication_media_readiness.from_canonical_json"; +const VALIDATE_OPERATION_ID: &str = "publication_media_readiness.validate"; + +const PUBLIC_CONSTANTS: &[&str] = &[ + "RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_MAX_BYTES", + "RADROOTS_PHASE1_PUBLICATION_MEDIA_READINESS_BINDING_SCHEMA_VERSION", +]; +const PUBLIC_TYPES: &[&str] = &[ + "RadrootsPhase1MediaReadyPublicationArtifact", + "RadrootsPhase1PublicationMediaReadinessBindingDigest", + "RadrootsPhase1PublicationMediaReadinessError", +]; +const PUBLIC_FUNCTIONS: &[&str] = &[ + "bind_phase1_publication_media_readiness", + "validate_phase1_publication_media_readiness", +]; +const PUBLIC_METHODS: &[&str] = &[ + "RadrootsPhase1MediaReadyPublicationArtifact::allowlisted_artifact", + "RadrootsPhase1MediaReadyPublicationArtifact::artifact", + "RadrootsPhase1MediaReadyPublicationArtifact::binding_digest", + "RadrootsPhase1MediaReadyPublicationArtifact::canonical_json", + "RadrootsPhase1MediaReadyPublicationArtifact::evidence", + "RadrootsPhase1MediaReadyPublicationArtifact::from_canonical_json", + "RadrootsPhase1MediaReadyPublicationArtifact::into_allowlisted_artifact", + "RadrootsPhase1MediaReadyPublicationArtifact::to_canonical_json", + "RadrootsPhase1PublicationMediaReadinessBindingDigest::as_bytes", + "RadrootsPhase1PublicationMediaReadinessBindingDigest::to_hex", + "RadrootsPhase1PublicationMediaReadinessError::code", +]; +const SEALED_TYPES: &[&str] = &[ + "RadrootsPhase1MediaReadyPublicationArtifact", + "RadrootsPhase1PublicationMediaReadinessBindingDigest", +]; +const PRIVATE_WIRE_TYPES: &[&str] = &["BindingWire"]; +const WIRE_FIELD_ORDER: &[&str] = &[ + "schema_version", + "readiness_policy_version", + "artifact_digest", + "evidence", + "binding_digest", +]; +const SEMANTIC_INVARIANTS: &[&str] = &[ + "allowlisted_artifact_required_v1", + "one_evidence_per_distinct_artifact_ordered_canonical_url_v1", + "media_free_artifact_requires_empty_evidence_v1", + "artifact_and_binding_bytes_persisted_separately_v1", + "closed_jpeg_png_still_webp_media_envelope_v1", + "canonical_url_max_4096_utf8_bytes_v1", + "nonzero_media_size_max_10485760_bytes_v1", + "post_ask_food_authored_dimensions_equal_decoded_dimensions_v1", + "profile_event_decoded_dimensions_persisted_without_authored_claim_v1", + "binding_input_bounded_before_parse_v1", + "evidence_count_max_4096_v1", + "canonical_json_round_trip_required_v1", + "sealed_binding_without_deserialize_v1", + "private_bounded_deny_unknown_fields_wire_v1", + "private_domain_separated_digest_derivation_v1", + "no_bud11_credentials_entitlement_or_topology_persistence_v1", +]; +const ERROR_CODES: &[&str] = &[ + "publication_media_readiness_binding_too_large", + "publication_media_readiness_evidence_count_exceeded", + "publication_media_readiness_evidence_count_mismatch", + "publication_media_readiness_invalid_json", + "publication_media_readiness_non_canonical_json", + "publication_media_readiness_schema_version_unsupported", + "publication_media_readiness_policy_version_unsupported", + "publication_media_readiness_artifact_digest_mismatch", + "publication_media_readiness_artifact_profile_invalid", + "publication_media_readiness_evidence_invalid", + "publication_media_readiness_evidence_order_mismatch", + "publication_media_readiness_evidence_fact_mismatch", + "publication_media_readiness_evidence_dimension_mismatch", + "publication_media_readiness_digest_invalid", + "publication_media_readiness_digest_mismatch", + "publication_media_readiness_state_mismatch", + "publication_media_readiness_allocation_failed", + "publication_media_readiness_serialization", +]; +const ARTIFACT_ERROR_CODES: &[&str] = &[ + "publication_media_inventory_mismatch", + "publication_media_reference_invalid", + "publication_post_profile_invalid", +]; + +const PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_ARTIFACTS: &[ImmutableArtifactSpec] = &[ + ImmutableArtifactSpec::new( + "crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.json", + 89_464, + "a07aace74f4747ba6e769a99acad7eadaac2d19d26aa0dd1c280ab92454519b5", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.schema.json", + 11_972, + "1d72cee2754e7ac45105d79b1ecf7d44251991be7a18ba106166e962000e8320", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/contracts/phase1_publication_artifact_v1.manifest.sha256", + 65, + "26acab1047ed184ff9d9b8bbac5aa0f6de35662a5375dad6f83cfa033dcfeabf", + ), + ImmutableArtifactSpec::new( + "contracts/conformance/vectors/publication/phase1_artifact.v1.json", + 23_113, + "ec18c687d5b0710a48624ddb620d89157e6b645dbea8bb91c62e3a111d20c622", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/tests/fixtures/phase1_publication_artifact.v1.json", + 23_113, + "ec18c687d5b0710a48624ddb620d89157e6b645dbea8bb91c62e3a111d20c622", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/tests/publication_artifact.rs", + 34_582, + "7a31169eac4217a38cb3ef25eb9213f2f89e11fb17e76ceaf7449b34225e98af", + ), +]; + +const ALLOWLIST_PREDECESSOR_ARTIFACTS: &[ImmutableArtifactSpec] = &[ + ImmutableArtifactSpec::new( + "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.json", + 10_601, + "8629b5c547e8f9daad473ab9d570b206d00db134cc22b4d8e81be10d0f3d10ec", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.schema.json", + 9_016, + "638601348dece886ed9666251b5cf68d0a7f96c26dce115b65ed859a2db03d93", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/contracts/phase1_publication_allowlist_v1.manifest.sha256", + 65, + "4dd9bf6f230f02d8c2ca3c323e83fe9b77eb9f0895f708eb0949b7153b2fd6bd", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/contracts/phase1_publication_allowlist_v1.descriptor.json", + 1_104, + "d7286a1206f822382226f28e2e601dc4f12cb743f9135238467092a31bde7bee", + ), + ImmutableArtifactSpec::new( + "contracts/conformance/vectors/publication/phase1_allowlist.v1.json", + 49_975, + "2867ee401db8cfad3a77869847c57567e869623f55d3d6c9e98a7fa0a643c3d6", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/tests/fixtures/phase1_publication_allowlist.v1.json", + 49_975, + "2867ee401db8cfad3a77869847c57567e869623f55d3d6c9e98a7fa0a643c3d6", + ), + ImmutableArtifactSpec::new( + "crates/event_codec/tests/publication_allowlist.rs", + 7_562, + "342d3d3d1100cb5d31aca94b0540b1e03761b034de23475bdb5142baabeed8fd", + ), +]; +const BLOSSOM_PREDECESSOR_ARTIFACTS: &[ImmutableArtifactSpec] = &[ + ImmutableArtifactSpec::new( + "crates/blossom/contracts/publication_readiness_v1.manifest.json", + 13_038, + "9359c5531548778b4a03e1a603a4048f17ba498b16dad50f7c62cca0ddb6240b", + ), + ImmutableArtifactSpec::new( + "crates/blossom/contracts/publication_readiness_v1.manifest.schema.json", + 11_884, + "e62ecf4b43bd03831f7e36e9cb4c98e2ed7e3a12d02cbca48a49e402b3feaa7d", + ), + ImmutableArtifactSpec::new( + "crates/blossom/contracts/publication_readiness_v1.manifest.sha256", + 65, + "e8c2be84eef68e965ac0e119016d6840cb9503e58d5d9c40e13a6512e1ec74b7", + ), + ImmutableArtifactSpec::new( + "crates/blossom/contracts/publication_readiness_v1.descriptor.json", + 1_410, + "4a3b33eb2b04b5a56a99b7b5bed45988a0697cb28736bfd899e012f37fc02d93", + ), + ImmutableArtifactSpec::new( + "crates/blossom/contracts/publication_readiness_evidence_v1.schema.json", + 1_890, + "c8f5f3488dd91a660f8eaa018d9aba63d03c882dc3ff22b47ac192b7189b0ce2", + ), + ImmutableArtifactSpec::new( + "contracts/conformance/vectors/blossom/publication_readiness.v1.json", + 16_423, + "6408e3b5bc4a376c7411e304833431af918a4072f196e8a8da55c3f0ef8610c9", + ), + ImmutableArtifactSpec::new( + "crates/blossom/tests/fixtures/publication_readiness.v1.json", + 16_423, + "6408e3b5bc4a376c7411e304833431af918a4072f196e8a8da55c3f0ef8610c9", + ), + ImmutableArtifactSpec::new( + "crates/blossom/tests/publication_readiness.rs", + 33_431, + "17abd5491bcfe0c717f7f67202c8c2d85912a042167d027727c07d9f07306641", + ), + ImmutableArtifactSpec::new( + "contracts/conformance/vectors/blossom/publication_readiness_persistence.v1.json", + 9_264, + "e892ff6353afe8997a151a9aff4db2fd82c96d94db7bff31bc2269333adc2512", + ), + ImmutableArtifactSpec::new( + "crates/blossom/tests/fixtures/publication_readiness_persistence.v1.json", + 9_264, + "e892ff6353afe8997a151a9aff4db2fd82c96d94db7bff31bc2269333adc2512", + ), + ImmutableArtifactSpec::new( + "crates/blossom/tests/publication_readiness_persistence.rs", + 11_567, + "aa14d20ee5747fcc979907b4b59a67687a3a02852850e33aede65e2ab0e0ca8d", + ), +]; +const ALLOWLIST_PREDECESSOR_SOURCE_SUPERSESSIONS: &[&str] = &[ + "CHANGELOG.md", + "contracts/operations.toml", + "contracts/releases/1.0.0-alpha.1.toml", + "crates/event_codec/Cargo.toml", + "crates/event_codec/src/wire/publication.rs", + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + "tools/xtask/src/contract/phase1_publication_artifact.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", + "tools/xtask/src/main.rs", +]; +const BLOSSOM_PREDECESSOR_SOURCE_SUPERSESSIONS: &[&str] = &[ + "CHANGELOG.md", + "contracts/operations.toml", + "contracts/releases/1.0.0-alpha.1.toml", + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + "tools/xtask/src/contract/phase1_publication_artifact.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", + "tools/xtask/src/main.rs", +]; + +#[derive(Clone, Copy)] +struct ImmutableArtifactSpec { + relative: &'static str, + byte_length: usize, + sha256: &'static str, +} + +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>, + source_supersessions: Vec<String>, +} + +#[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>, + sealed_types: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct MediaEnvelopeDescriptor { + max_url_utf8_bytes: u32, + min_raster_bytes: u32, + max_raster_bytes: u64, + media_types: Vec<String>, + exact_dimension_variants: Vec<String>, + decoded_only_dimension_variants: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct BindingDescriptor { + schema_version: u32, + readiness_policy_version: u16, + schema: FileDescriptor, + max_canonical_json_bytes: u64, + max_evidence_count: u32, + max_evidence_json_bytes: u32, + wire_field_order: Vec<String>, + digest_domain: String, + digest_framing: String, + bind_operation_id: String, + serialize_operation_id: String, + reload_operation_id: String, + validate_operation_id: String, + invariants: Vec<String>, + error_codes: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ResultVectorDescriptor { + 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 MediaReadinessManifest { + schema_version: u32, + contract_id: String, + authority_id: String, + manifest_schema: FileDescriptor, + predecessors: Vec<PredecessorDescriptor>, + protocol_sources: Vec<ProtocolSourcePin>, + public_api: PublicApiDescriptor, + media_envelope: MediaEnvelopeDescriptor, + binding: BindingDescriptor, + result_vector: ResultVectorDescriptor, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct VectorSuite { + suite: String, + contract_version: String, + vectors: Vec<VectorCase>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct VectorCase { + id: String, + kind: String, + input: VectorInput, + expected: VectorExpected, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct VectorInput { + fixture: String, + mutation: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct VectorExpected { + #[serde(default, skip_serializing_if = "Option::is_none")] + decision: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option<String>, +} + +struct ValidatedVector { + bytes: Vec<u8>, + case_ids: Vec<String>, +} + +pub(crate) fn write_phase1_publication_media_readiness_manifest( + workspace_root: &Path, +) -> Result<(), String> { + validate_predecessors(workspace_root)?; + with_artifact_bundle_transaction(workspace_root, |transaction| { + transaction.write(expected_artifacts(workspace_root)?)?; + validate_manifest_under_lock(workspace_root) + }) +} + +pub(crate) fn validate_phase1_publication_media_readiness_manifest( + workspace_root: &Path, +) -> Result<(), String> { + validate_predecessors(workspace_root)?; + with_artifact_bundle_transaction(workspace_root, |_| { + validate_manifest_under_lock(workspace_root) + }) +} + +pub(crate) fn validate_immutable_phase1_publication_allowlist_predecessor( + workspace_root: &Path, +) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |_| { + validate_immutable_predecessor( + workspace_root, + "Phase 1 publication allowlist", + ALLOWLIST_PREDECESSOR_ARTIFACTS, + ) + }) +} + +pub(crate) fn validate_immutable_phase1_publication_artifact_predecessor( + workspace_root: &Path, +) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |_| { + validate_immutable_predecessor( + workspace_root, + "Phase 1 publication artifact", + PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_ARTIFACTS, + ) + }) +} + +pub(crate) fn validate_immutable_blossom_publication_readiness_predecessor( + workspace_root: &Path, +) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |_| { + validate_immutable_predecessor( + workspace_root, + "Blossom publication readiness", + BLOSSOM_PREDECESSOR_ARTIFACTS, + ) + }) +} + +fn validate_predecessors(workspace_root: &Path) -> Result<(), String> { + validate_source_supersessions(workspace_root, ALLOWLIST_PREDECESSOR_SOURCE_SUPERSESSIONS)?; + validate_source_supersessions(workspace_root, BLOSSOM_PREDECESSOR_SOURCE_SUPERSESSIONS)?; + validate_immutable_predecessor( + workspace_root, + "Phase 1 publication artifact", + PUBLICATION_ARTIFACT_TRANSITIVE_PREDECESSOR_ARTIFACTS, + )?; + validate_immutable_predecessor( + workspace_root, + "Phase 1 publication allowlist", + ALLOWLIST_PREDECESSOR_ARTIFACTS, + )?; + validate_immutable_predecessor( + workspace_root, + "Blossom publication readiness", + BLOSSOM_PREDECESSOR_ARTIFACTS, + ) +} + +fn validate_source_supersessions(workspace_root: &Path, paths: &[&str]) -> Result<(), String> { + if paths.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err("predecessor source supersessions must be sorted and unique".to_owned()); + } + for path in paths { + if !workspace_root.join(path).is_file() { + return Err(format!( + "predecessor source supersession {path} must name an existing file" + )); + } + } + Ok(()) +} + +fn validate_immutable_predecessor( + workspace_root: &Path, + label: &str, + artifacts: &[ImmutableArtifactSpec], +) -> Result<(), String> { + for artifact in 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 {label} predecessor artifact {} drifted", + artifact.relative + )); + } + } + Ok(()) +} + +fn validate_manifest_under_lock(workspace_root: &Path) -> Result<(), String> { + for artifact in expected_artifacts(workspace_root)? { + let actual = read_regular_file(workspace_root, artifact.relative)?; + if actual != artifact.contents { + return Err(format!( + "generated Phase 1 media-readiness contract {} is stale; run {WRITE_COMMAND}", + artifact.relative + )); + } + } + let bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; + let manifest: MediaReadinessManifest = serde_json::from_slice(&bytes) + .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; + validate_canonical_json(MANIFEST_RELATIVE, &bytes, &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}: {error}"))?, + )?; + let binding_schema_bytes = read_regular_file(workspace_root, BINDING_SCHEMA_RELATIVE)?; + let binding_schema: Value = serde_json::from_slice(&binding_schema_bytes) + .map_err(|error| format!("parse {BINDING_SCHEMA_RELATIVE}: {error}"))?; + validate_canonical_json( + BINDING_SCHEMA_RELATIVE, + &binding_schema_bytes, + &binding_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_source_contract(workspace_root)?; + let binding_schema_bytes = canonical_json_bytes(&binding_schema())?; + let manifest_schema_bytes = canonical_json_bytes(&manifest_schema())?; + let manifest = describe_manifest( + workspace_root, + &manifest_schema_bytes, + &binding_schema_bytes, + )?; + let manifest_bytes = canonical_json_bytes(&manifest)?; + let sidecar_bytes = format!("{}\n", sha256_hex(&manifest_bytes)).into_bytes(); + 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), + "binding_schema": descriptor_for_bytes(BINDING_SCHEMA_RELATIVE, &binding_schema_bytes), + "predecessor_contract_ids": [ + "radroots_event_codec.phase1_publication_allowlist_v1", + "radroots_blossom.publication_readiness_v1" + ] + }))?; + let vector_bytes = read_regular_file(workspace_root, VECTOR_RELATIVE)?; + 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: BINDING_SCHEMA_RELATIVE, + contents: binding_schema_bytes, + }, + GeneratedArtifact { + relative: VECTOR_MIRROR_RELATIVE, + contents: vector_bytes, + }, + ]) +} + +fn describe_manifest( + workspace_root: &Path, + manifest_schema_bytes: &[u8], + binding_schema_bytes: &[u8], +) -> Result<MediaReadinessManifest, String> { + let vector = validate_vector(workspace_root)?; + Ok(MediaReadinessManifest { + 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), + predecessors: vec![ + predecessor_descriptor( + "radroots_event_codec.phase1_publication_allowlist_v1", + ALLOWLIST_PREDECESSOR_ARTIFACTS, + ALLOWLIST_PREDECESSOR_SOURCE_SUPERSESSIONS, + ), + predecessor_descriptor( + "radroots_blossom.publication_readiness_v1", + BLOSSOM_PREDECESSOR_ARTIFACTS, + BLOSSOM_PREDECESSOR_SOURCE_SUPERSESSIONS, + ), + ], + protocol_sources: vec![ + ProtocolSourcePin { + id: "nostr_nips".to_owned(), + repository: "https://github.com/nostr-protocol/nips".to_owned(), + revision: "bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91".to_owned(), + }, + ProtocolSourcePin { + id: "blossom".to_owned(), + repository: "https://github.com/hzrd149/blossom".to_owned(), + revision: "b5bd2801d1763aa635fc8fea7a76597e0eb18990".to_owned(), + }, + ], + public_api: PublicApiDescriptor { + constants: owned(PUBLIC_CONSTANTS), + types: owned(PUBLIC_TYPES), + functions: owned(PUBLIC_FUNCTIONS), + methods: owned(PUBLIC_METHODS), + sealed_types: owned(SEALED_TYPES), + }, + media_envelope: MediaEnvelopeDescriptor { + max_url_utf8_bytes: URL_MAX_BYTES, + min_raster_bytes: 1, + max_raster_bytes: RASTER_MAX_BYTES, + media_types: owned(&["image/jpeg", "image/png", "image/webp"]), + exact_dimension_variants: owned(&["photo_update", "ask", "food_availability"]), + decoded_only_dimension_variants: owned(&[ + "profile", + "event_date", + "event_time", + ]), + }, + binding: BindingDescriptor { + schema_version: BINDING_SCHEMA_VERSION, + readiness_policy_version: READINESS_POLICY_VERSION, + schema: descriptor_for_bytes(BINDING_SCHEMA_RELATIVE, binding_schema_bytes), + max_canonical_json_bytes: BINDING_MAX_BYTES, + max_evidence_count: EVIDENCE_MAX_COUNT, + max_evidence_json_bytes: EVIDENCE_MAX_BYTES, + wire_field_order: owned(WIRE_FIELD_ORDER), + digest_domain: DIGEST_DOMAIN.to_owned(), + digest_framing: "domain_bytes_then_u32be_schema_then_u16be_policy_then_raw_artifact_digest_then_u32be_evidence_count_then_repeated_u64be_url_length_url_bytes_raw_evidence_digest_v1".to_owned(), + bind_operation_id: BIND_OPERATION_ID.to_owned(), + serialize_operation_id: SERIALIZE_OPERATION_ID.to_owned(), + reload_operation_id: RELOAD_OPERATION_ID.to_owned(), + validate_operation_id: VALIDATE_OPERATION_ID.to_owned(), + invariants: owned(SEMANTIC_INVARIANTS), + error_codes: owned(ERROR_CODES), + }, + result_vector: ResultVectorDescriptor { + canonical_path: VECTOR_RELATIVE.to_owned(), + mirror_path: VECTOR_MIRROR_RELATIVE.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, VECTOR_EXECUTOR_RELATIVE)?, + executor_test: VECTOR_EXECUTOR_TEST.to_owned(), + case_ids: vector.case_ids, + }, + }) +} + +fn predecessor_descriptor( + contract_id: &str, + artifacts: &[ImmutableArtifactSpec], + source_supersessions: &[&str], +) -> PredecessorDescriptor { + PredecessorDescriptor { + contract_id: contract_id.to_owned(), + immutable_artifacts: 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(), + source_supersessions: owned(source_supersessions), + } +} + +fn validate_source_contract(workspace_root: &Path) -> Result<(), String> { + validate_public_api(workspace_root)?; + validate_publication_route(workspace_root)?; + validate_feature_authority(workspace_root)?; + validate_operations_authority(workspace_root)?; + validate_release_authority(workspace_root)?; + validate_vector_executor(workspace_root)?; + validate_vector(workspace_root)?; + Ok(()) +} + +fn validate_vector_executor(workspace_root: &Path) -> Result<(), String> { + let bytes = read_regular_file(workspace_root, VECTOR_EXECUTOR_RELATIVE)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("{VECTOR_EXECUTOR_RELATIVE} must be UTF-8: {error}"))?; + let file = syn::parse_file(source) + .map_err(|error| format!("parse {VECTOR_EXECUTOR_RELATIVE}: {error}"))?; + let tests = file + .items + .iter() + .filter_map(|item| match item { + Item::Fn(function) if function.sig.ident == VECTOR_EXECUTOR_TEST => Some(function), + _ => None, + }) + .collect::<Vec<_>>(); + if tests.len() != 1 { + return Err(format!( + "{VECTOR_EXECUTOR_RELATIVE} must define {VECTOR_EXECUTOR_TEST} exactly once" + )); + } + let test = tests[0]; + if test.attrs.len() != 1 + || !test.attrs[0].path().is_ident("test") + || test.sig.constness.is_some() + || test.sig.asyncness.is_some() + || test.sig.unsafety.is_some() + || test.sig.abi.is_some() + || !test.sig.inputs.is_empty() + || !matches!(test.sig.output, syn::ReturnType::Default) + { + return Err(format!( + "{VECTOR_EXECUTOR_RELATIVE}::{VECTOR_EXECUTOR_TEST} must be one unconditional zero-argument #[test]" + )); + } + + #[derive(Default)] + struct OperationCalls(BTreeSet<String>); + + impl<'ast> Visit<'ast> for OperationCalls { + fn visit_expr_call(&mut self, call: &'ast syn::ExprCall) { + if let Expr::Path(path) = call.func.as_ref() + && let Some(segment) = path.path.segments.last() + { + self.0.insert(segment.ident.to_string()); + } + syn::visit::visit_expr_call(self, call); + } + + fn visit_expr_method_call(&mut self, call: &'ast syn::ExprMethodCall) { + self.0.insert(call.method.to_string()); + syn::visit::visit_expr_method_call(self, call); + } + } + + let mut calls = OperationCalls::default(); + calls.visit_file(&file); + for required in [ + "bind_phase1_publication_media_readiness", + "to_canonical_json", + "from_canonical_json", + "validate_phase1_publication_media_readiness", + ] { + if !calls.0.contains(required) { + return Err(format!( + "{VECTOR_EXECUTOR_RELATIVE} must execute the public media-readiness operation {required}" + )); + } + } + Ok(()) +} + +fn validate_public_api(workspace_root: &Path) -> Result<(), String> { + let bytes = read_regular_file(workspace_root, SOURCE_RELATIVE)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("{SOURCE_RELATIVE} must be UTF-8: {error}"))?; + let file = + syn::parse_file(source).map_err(|error| format!("parse {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 sealed_without_deserialize = BTreeSet::new(); + let mut public_types_with_private_fields = 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::Struct(item) if is_public(&item.vis) => { + let name = item.ident.to_string(); + types.insert(name.clone()); + if item.fields.iter().all(|field| !is_public(&field.vis)) { + public_types_with_private_fields.insert(name.clone()); + } + if SEALED_TYPES.contains(&name.as_str()) && !derives_deserialize(&item.attrs)? { + sealed_without_deserialize.insert(name); + } + } + 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_types_with_private_fields.insert(name.clone()); + } + if SEALED_TYPES.contains(&name.as_str()) && !derives_deserialize(&item.attrs)? { + sealed_without_deserialize.insert(name); + } + } + Item::Struct(item) => { + let name = item.ident.to_string(); + if PRIVATE_WIRE_TYPES.contains(&name.as_str()) + && derives_deserialize(&item.attrs)? + && 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) if item.trait_.is_none() => { + let syn::Type::Path(self_type) = item.self_ty.as_ref() else { + continue; + }; + let Some(type_name) = self_type.path.segments.last() else { + continue; + }; + for method in &item.items { + if let ImplItem::Fn(method) = method + && is_public(&method.vis) + { + methods.insert(format!("{}::{}", type_name.ident, method.sig.ident)); + } + } + } + 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; + }; + if item.trait_.as_ref().is_some_and(|(_, path, _)| { + path.segments + .last() + .is_some_and(|segment| segment.ident == "Deserialize") + }) && SEALED_TYPES.contains(&type_name.ident.to_string().as_str()) + { + forbidden_deserialize.insert(type_name.ident.to_string()); + } + } + _ => {} + } + } + for (label, actual, expected) in [ + ("constants", constants, expected_set(PUBLIC_CONSTANTS)), + ("types", types, expected_set(PUBLIC_TYPES)), + ("functions", functions, expected_set(PUBLIC_FUNCTIONS)), + ("methods", methods, expected_set(PUBLIC_METHODS)), + ] { + if actual != expected { + return Err(format!( + "{SOURCE_RELATIVE} public {label} drifted: expected {expected:?}, found {actual:?}" + )); + } + } + if sealed_without_deserialize != expected_set(SEALED_TYPES) { + return Err("sealed media-readiness types must not derive Deserialize".to_owned()); + } + if public_types_with_private_fields != expected_set(PUBLIC_TYPES) { + return Err("public media-readiness types must expose no public fields".to_owned()); + } + if private_wire_types != expected_set(PRIVATE_WIRE_TYPES) { + return Err( + "private media-readiness wire types must derive Deserialize with deny_unknown_fields" + .to_owned(), + ); + } + if !forbidden_deserialize.is_empty() { + return Err(format!( + "sealed media-readiness types must not implement Deserialize: {forbidden_deserialize:?}" + )); + } + Ok(()) +} + +fn derives_deserialize(attributes: &[syn::Attribute]) -> Result<bool, String> { + for attribute in attributes { + if attribute.path().is_ident("derive") { + let paths = attribute + .parse_args_with(Punctuated::<syn::Path, Comma>::parse_terminated) + .map_err(|error| format!("parse derive attribute: {error}"))?; + if paths.iter().any(|path| { + path.segments + .last() + .is_some_and(|segment| segment.ident == "Deserialize") + }) { + return Ok(true); + } + } + } + Ok(false) +} + +fn has_serde_deny_unknown_fields(attributes: &[syn::Attribute]) -> Result<bool, String> { + for attribute in attributes { + if attribute.path().is_ident("serde") { + let mut found = false; + attribute + .parse_nested_meta(|meta| { + if meta.path.is_ident("deny_unknown_fields") { + found = true; + } + Ok(()) + }) + .map_err(|error| format!("parse serde attribute: {error}"))?; + if found { + return Ok(true); + } + } + } + Ok(false) +} + +fn validate_publication_route(workspace_root: &Path) -> Result<(), String> { + let bytes = read_regular_file(workspace_root, PUBLICATION_SOURCE_RELATIVE)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("{PUBLICATION_SOURCE_RELATIVE} must be UTF-8: {error}"))?; + let file = syn::parse_file(source) + .map_err(|error| format!("parse {PUBLICATION_SOURCE_RELATIVE}: {error}"))?; + let module = file.items.iter().any(|item| { + matches!(item, Item::Mod(module) if module.ident == "media_readiness" && module.content.is_none()) + }); + if !module { + return Err(format!( + "{PUBLICATION_SOURCE_RELATIVE} must declare the media_readiness module" + )); + } + let mut reexports = BTreeSet::new(); + let mut reexport_declarations = 0usize; + for item in &file.items { + if let Item::Use(item) = item + && is_public(&item.vis) + && let syn::UseTree::Path(path) = &item.tree + && path.ident == "media_readiness" + { + reexport_declarations += 1; + collect_use_names(&path.tree, &mut reexports); + } + } + let expected = PUBLIC_CONSTANTS + .iter() + .chain(PUBLIC_TYPES) + .chain(PUBLIC_FUNCTIONS) + .map(|value| (*value).to_owned()) + .collect::<BTreeSet<_>>(); + if reexport_declarations != 1 || reexports != expected { + return Err(format!( + "{PUBLICATION_SOURCE_RELATIVE} media-readiness reexports drifted: expected {expected:?}, found {reexports:?}" + )); + } + Ok(()) +} + +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_feature_authority(workspace_root: &Path) -> Result<(), String> { + let manifest = parse_toml(workspace_root, EVENT_CODEC_MANIFEST_RELATIVE)?; + let features = manifest + .get("features") + .and_then(toml::Value::as_table) + .ok_or_else(|| "event-codec features table is missing".to_owned())?; + let serde_json = + toml_string_array("event-codec serde_json feature", features.get("serde_json"))?; + let expected = [ + "serde", + "dep:hex", + "dep:serde_json", + "dep:sha2", + "radroots_blossom/serde", + ] + .into_iter() + .map(str::to_owned) + .collect::<BTreeSet<_>>(); + if serde_json.into_iter().collect::<BTreeSet<_>>() != expected { + return Err("event-codec serde_json feature authority drifted".to_owned()); + } + let dependency = manifest + .get("dependencies") + .and_then(|dependencies| dependencies.get("serde_json")) + .and_then(toml::Value::as_table) + .ok_or_else(|| "event-codec serde_json dependency is missing".to_owned())?; + let dependency_features = toml_string_array( + "event-codec serde_json dependency features", + dependency.get("features"), + )?; + if dependency + .get("default-features") + .and_then(toml::Value::as_bool) + != Some(false) + || dependency.get("optional").and_then(toml::Value::as_bool) != Some(true) + || dependency_features.into_iter().collect::<BTreeSet<_>>() + != ["alloc", "raw_value"] + .into_iter() + .map(str::to_owned) + .collect() + { + return Err("event-codec bounded raw JSON dependency profile drifted".to_owned()); + } + Ok(()) +} + +fn validate_operations_authority(workspace_root: &Path) -> Result<(), String> { + let manifest = parse_toml(workspace_root, OPERATIONS_RELATIVE)?; + let public_types = toml_string_array( + "shared_types.public", + manifest + .get("shared_types") + .and_then(|value| value.get("public")), + )?; + for required in PUBLIC_TYPES { + if public_types + .iter() + .filter(|value| value.as_str() == *required) + .count() + != 1 + { + return Err(format!( + "shared public types must contain {required} exactly once" + )); + } + } + let operations = manifest + .get("operations") + .and_then(toml::Value::as_table) + .ok_or_else(|| "operations.toml has no operations table".to_owned())?; + for expectation in [ + OperationExpectation::new( + "phase1_publication_media_readiness_bind", + BIND_OPERATION_ID, + &[ + "RadrootsPhase1AllowlistedPublicationArtifact", + "RadrootsBlossomPublicationReadinessEvidence", + ], + &["RadrootsPhase1MediaReadyPublicationArtifact"], + "validation_error", + &[ + "publication_media_readiness.bind.valid", + "publication_media_readiness.bind.invalid", + "publication_media_readiness.bind.artifact_invalid", + ], + ), + OperationExpectation::new( + "phase1_publication_media_readiness_to_canonical_json", + SERIALIZE_OPERATION_ID, + &["RadrootsPhase1MediaReadyPublicationArtifact"], + &["Bytes"], + "none", + &["publication_media_readiness.to_canonical_json.valid"], + ), + OperationExpectation::new( + "phase1_publication_media_readiness_from_canonical_json", + RELOAD_OPERATION_ID, + &["RadrootsPhase1AllowlistedPublicationArtifact", "Bytes"], + &["RadrootsPhase1MediaReadyPublicationArtifact"], + "parse_error", + &[ + "publication_media_readiness.from_canonical_json.valid", + "publication_media_readiness.from_canonical_json.invalid", + ], + ), + OperationExpectation::new( + "phase1_publication_media_readiness_validate", + VALIDATE_OPERATION_ID, + &["RadrootsPhase1MediaReadyPublicationArtifact"], + &["Unit"], + "validation_error", + &["publication_media_readiness.validate.valid"], + ), + ] { + let operation = operations + .get(expectation.key) + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("operations.toml is missing {}", expectation.key))?; + validate_operation(operation, expectation)?; + } + Ok(()) +} + +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 matching = changes + .iter() + .filter(|change| change.get("id").and_then(toml::Value::as_str) == Some(RELEASE_CHANGE_ID)) + .collect::<Vec<_>>(); + if matching.len() != 1 + || matching[0] + .get("classification") + .and_then(toml::Value::as_str) + != Some("feature") + { + return Err(format!( + "{RELEASE_RELATIVE} must contain exactly one feature change {RELEASE_CHANGE_ID}" + )); + } + let changelog = read_regular_file(workspace_root, CHANGELOG_RELATIVE)?; + let changelog = std::str::from_utf8(&changelog) + .map_err(|error| format!("{CHANGELOG_RELATIVE} must be UTF-8: {error}"))?; + if changelog.matches(CHANGELOG_MARKER).count() != 1 { + return Err(format!( + "{CHANGELOG_RELATIVE} must contain exactly one {CHANGELOG_MARKER}" + )); + } + Ok(()) +} + +#[derive(Clone, Copy)] +struct OperationExpectation { + key: &'static str, + id: &'static str, + inputs: &'static [&'static str], + outputs: &'static [&'static str], + error_class: &'static str, + case_kinds: &'static [&'static str], +} + +impl OperationExpectation { + const fn new( + key: &'static str, + id: &'static str, + inputs: &'static [&'static str], + outputs: &'static [&'static str], + error_class: &'static str, + case_kinds: &'static [&'static str], + ) -> Self { + Self { + key, + id, + inputs, + outputs, + error_class, + case_kinds, + } + } +} + +fn validate_operation( + operation: &toml::map::Map<String, toml::Value>, + expectation: OperationExpectation, +) -> Result<(), String> { + for (field, expected) in [ + ("domain", "publication"), + ("id", expectation.id), + ("stability", "beta"), + ("error_class", expectation.error_class), + ("signing", "none"), + ("transport", "none"), + ] { + if operation.get(field).and_then(toml::Value::as_str) != Some(expected) { + return Err(format!("{} {field} must be {expected}", expectation.key)); + } + } + if operation + .get("deterministic") + .and_then(toml::Value::as_bool) + != Some(true) + || toml_string_array("operation inputs", operation.get("inputs"))? + != owned(expectation.inputs) + || toml_string_array("operation outputs", operation.get("outputs"))? + != owned(expectation.outputs) + { + return Err(format!( + "{} signature or determinism drifted", + expectation.key + )); + } + let implementation = operation + .get("implementation") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{} implementation is missing", expectation.key))?; + let modules = toml_string_array("operation modules", implementation.get("rust_modules"))?; + if !modules.iter().any(|module| module == SOURCE_RELATIVE) { + return Err(format!( + "{} must route through {SOURCE_RELATIVE}", + expectation.key + )); + } + let conformance = operation + .get("conformance") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{} conformance is missing", expectation.key))?; + if conformance.get("vector").and_then(toml::Value::as_str) != Some(VECTOR_RELATIVE) { + return Err(format!("{} conformance vector drifted", expectation.key)); + } + if toml_string_array("operation case kinds", conformance.get("case_kinds"))? + != owned(expectation.case_kinds) + { + return Err(format!( + "{} conformance case kinds drifted", + expectation.key + )); + } + Ok(()) +} + +fn validate_vector(workspace_root: &Path) -> Result<ValidatedVector, String> { + let bytes = read_regular_file(workspace_root, VECTOR_RELATIVE)?; + let suite: VectorSuite = serde_json::from_slice(&bytes) + .map_err(|error| format!("parse {VECTOR_RELATIVE}: {error}"))?; + if suite.suite != "phase1_publication_media_readiness" || suite.contract_version != "1.0.0" { + return Err("Phase 1 media-readiness vector identity drifted".to_owned()); + } + validate_canonical_json(VECTOR_RELATIVE, &bytes, &suite)?; + if suite.vectors.len() != 39 { + return Err("Phase 1 media-readiness vector must contain exactly 39 cases".to_owned()); + } + let mut ids = BTreeSet::new(); + let mut valid_fixtures = BTreeSet::new(); + let mut mutations = BTreeSet::new(); + for case in &suite.vectors { + if !ids.insert(case.id.clone()) + || case.input.fixture.is_empty() + || case.input.mutation.is_empty() + { + return Err( + "Phase 1 media-readiness vector ids and inputs must be unique/nonempty".to_owned(), + ); + } + match case.kind.as_str() { + "publication_media_readiness.bind.valid" + | "publication_media_readiness.to_canonical_json.valid" + | "publication_media_readiness.from_canonical_json.valid" + | "publication_media_readiness.validate.valid" => { + if case.expected.decision.as_deref() != Some("allow") + || case.expected.error.is_some() + { + return Err(format!("{} has invalid allow expectation", case.id)); + } + valid_fixtures.insert(case.input.fixture.clone()); + } + "publication_media_readiness.bind.invalid" + | "publication_media_readiness.from_canonical_json.invalid" + | "publication_media_readiness.bind.artifact_invalid" => { + if case.expected.decision.is_some() + || !case.expected.error.as_deref().is_some_and(|error| { + ERROR_CODES.contains(&error) || ARTIFACT_ERROR_CODES.contains(&error) + }) + { + return Err(format!("{} has invalid rejection expectation", case.id)); + } + mutations.insert(case.input.mutation.clone()); + } + kind => return Err(format!("{} uses unsupported kind {kind}", case.id)), + } + } + let expected_fixtures = [ + "profile", + "update", + "photo_update", + "ask", + "event_date", + "event_time", + "food_availability", + ] + .into_iter() + .map(str::to_owned) + .collect::<BTreeSet<_>>(); + if valid_fixtures != expected_fixtures { + return Err("Phase 1 media-readiness vector must execute all seven leaves".to_owned()); + } + for required in [ + "missing", + "extra", + "duplicate", + "reordered", + "size_mismatch", + "dimension_mismatch", + "cross_artifact", + "digest_invalid", + "binding_exact_max", + "binding_over_max", + "evidence_count_exact_max", + "evidence_count_over_max", + "wire_evidence_count_exact_max", + "wire_evidence_count_over_max", + "nested_bud11_field", + "url_exact_max", + "url_over_max", + "size_zero", + "size_over_max", + "mime_unsupported", + "dimensions_over_max", + ] { + if !mutations.contains(required) { + return Err(format!( + "Phase 1 media-readiness vector is missing {required}" + )); + } + } + Ok(ValidatedVector { + bytes, + case_ids: ids.into_iter().collect(), + }) +} + +fn manifest_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/schemas/event-codec/phase1-publication-media-readiness-manifest-v1.json", + "title": "Radroots Phase 1 publication media-readiness semantic contract", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "contract_id", "authority_id", "manifest_schema", "predecessors", "protocol_sources", "public_api", "media_envelope", "binding", "result_vector"], + "properties": { + "schema_version": {"const": SCHEMA_VERSION}, + "contract_id": {"const": CONTRACT_ID}, + "authority_id": {"const": AUTHORITY_ID}, + "manifest_schema": {"$ref": "#/$defs/file"}, + "predecessors": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["contract_id", "immutable_artifacts", "source_supersessions"], + "properties": { + "contract_id": {"type": "string", "minLength": 1}, + "immutable_artifacts": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/file"}}, + "source_supersessions": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}, "uniqueItems": true} + } + } + }, + "protocol_sources": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "object"}}, + "public_api": {"type": "object"}, + "media_envelope": {"type": "object"}, + "binding": {"type": "object"}, + "result_vector": {"type": "object"} + }, + "$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} + } + } + } + }) +} + +fn binding_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/schemas/event-codec/phase1-publication-media-readiness-binding-v1.json", + "title": "Radroots Phase 1 publication media-readiness binding v1", + "type": "object", + "additionalProperties": false, + "required": WIRE_FIELD_ORDER, + "properties": { + "schema_version": {"const": BINDING_SCHEMA_VERSION}, + "readiness_policy_version": {"const": READINESS_POLICY_VERSION}, + "artifact_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "evidence": { + "type": "array", + "maxItems": EVIDENCE_MAX_COUNT, + "items": {"type": "object"} + }, + "binding_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + }) +} + +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 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 parse_toml(workspace_root: &Path, relative: &str) -> Result<toml::Value, String> { + let bytes = read_regular_file(workspace_root, relative)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("{relative} must be UTF-8: {error}"))?; + toml::from_str(source).map_err(|error| format!("parse {relative}: {error}")) +} + +fn toml_string_array(label: &str, value: Option<&toml::Value>) -> Result<Vec<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 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("; ") + )) + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} diff --git a/tools/xtask/src/contract/raw_source_rebuild.rs b/tools/xtask/src/contract/raw_source_rebuild.rs @@ -4557,34 +4557,20 @@ fn validate_command_reachability(workspace_root: &Path) -> Result<(), String> { .map_err(|error| format!("parse {CONTRACT_COMMAND_SOURCE_RELATIVE}: {error}"))?, "validate_artifact_contracts", )?); - for ordered in [ + let ordered = [ "validate_source_maintenance_manifest(workspace_root)?", - "blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root)?", + "phase1_publication_artifact::validate_immutable_raw_source_rebuild_predecessor(workspace_root)?", + "validate_immutable_phase1_publication_artifact_predecessor(workspace_root)?", + "validate_release_provenance_schema(workspace_root)?", + "validate_phase1_publication_media_readiness_manifest(workspace_root)?", "validate_knowledge_contract_manifest(workspace_root)", - ] { - if !aggregate.contains(ordered) { - return Err(format!( - "aggregate contract authority does not reach raw-source rebuild validation through `{ordered}`" - )); - } - } - let source_index = aggregate - .find("validate_source_maintenance_manifest(workspace_root)?") - .expect("checked above"); - let readiness_index = aggregate - .find( - "blossom_publication_readiness::validate_blossom_publication_readiness(workspace_root)?", - ) - .expect("checked above"); - let knowledge_index = aggregate - .find("validate_knowledge_contract_manifest(workspace_root)") - .expect("checked above"); - if !(source_index < readiness_index && readiness_index < knowledge_index) { - return Err( - "aggregate contract authority must validate immutable predecessors before the Blossom readiness successor and knowledge contracts" - .to_owned(), - ); - } + ]; + require_ordered_markers( + CONTRACT_COMMAND_SOURCE_RELATIVE, + "aggregate contract authority", + &aggregate, + &ordered, + )?; for (function_name, expected_call) in [ ( diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -25,6 +25,7 @@ fn usage() { 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 phase1-publication-media-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"); @@ -205,6 +206,16 @@ fn run_contract(args: &[String]) -> Result<(), String> { .to_string(), ), }, + Some("phase1-publication-media-readiness-manifest") => match &args[1..] { + [] => contract::validate_phase1_publication_media_readiness_manifest(&workspace_root()), + [flag] if flag == "--write" => { + contract::write_phase1_publication_media_readiness_manifest(&workspace_root()) + } + _ => Err( + "phase1-publication-media-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" => { @@ -352,6 +363,12 @@ mod tests { ]) .expect_err("invalid Phase 1 publication allowlist manifest mode"); assert!(invalid_publication_allowlist.contains("exactly --write")); + let invalid_publication_media_readiness = run_contract(&[ + "phase1-publication-media-readiness-manifest".to_string(), + "--invalid".to_string(), + ]) + .expect_err("invalid Phase 1 publication media-readiness manifest mode"); + assert!(invalid_publication_media_readiness.contains("exactly --write")); let invalid_release_provenance_schema = run_contract(&[ "release-provenance-schema".to_string(), "--invalid".to_string(), @@ -471,6 +488,10 @@ mod tests { .expect("contract Phase 1 publication artifact manifest"); run_contract(&["phase1-publication-allowlist-manifest".to_string()]) .expect("contract Phase 1 publication allowlist manifest"); + run_contract(&["blossom-publication-readiness-manifest".to_string()]) + .expect("contract Blossom publication-readiness manifest"); + run_contract(&["phase1-publication-media-readiness-manifest".to_string()]) + .expect("contract Phase 1 publication media-readiness manifest"); run_contract(&["release-provenance-schema".to_string()]) .expect("contract release provenance schema"); run_contract(&["knowledge-manifest".to_string()]).expect("contract knowledge manifest");