lib

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

commit 445c2b27f9613df72d0e13c79221caf29c242530
parent 52eca30465aa7daf5f8e60c50a3fedff636b1282
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 18:05:25 +0000

contract: govern semantic validator inventory

- classify every contract validator by parser and governed input
- replace lexical Rust witnesses with structured AST validation
- distinguish public functions and methods in source authority
- refresh generated outbox manifests after validator changes

Diffstat:
Acontracts/semantic_validator_inventory.toml | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/outbox/contracts/migration_authority_v1.manifest.json | 8++++----
Mcrates/outbox/contracts/migration_authority_v1.manifest.sha256 | 2+-
Mcrates/outbox/contracts/phase1_publication_v1.manifest.json | 4++--
Mcrates/outbox/contracts/phase1_publication_v1.manifest.sha256 | 2+-
Mtools/xtask/src/contract.rs | 409++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtools/xtask/src/contract/admission_authority.rs | 14+++++++-------
Mtools/xtask/src/contract/blossom_raster_decoder_security.rs | 45---------------------------------------------
Mtools/xtask/src/contract/outbox_migration.rs | 26++++++++++++++++++++++++--
Atools/xtask/src/contract/validator_inventory.rs | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 849 insertions(+), 76 deletions(-)

diff --git a/contracts/semantic_validator_inventory.toml b/contracts/semantic_validator_inventory.toml @@ -0,0 +1,137 @@ +schema_version = 1 + +[[validators]] +id = "artifact_transactions" +semantic = true +parsers = ["governed_bytes", "json"] +implementation_paths = ["tools/xtask/src/contract/artifact_bundle.rs"] +governed_inputs = ["contracts", "crates"] +byte_hash_scopes = ["generated_contract_artifacts"] + +[[validators]] +id = "event_store_successors" +semantic = true +parsers = ["executable_vector", "governed_bytes", "json", "json_schema", "rust_ast", "sqlite_catalog", "toml"] +implementation_paths = [ + "tools/xtask/src/contract/food_availability_projection.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", +] +governed_inputs = [ + "contracts/conformance/vectors/event_store", + "crates/event_store/Cargo.toml", + "crates/event_store/contracts", + "crates/event_store/migrations", + "crates/event_store/src", +] +byte_hash_scopes = ["canonical_vectors_and_mirrors", "generated_contract_artifacts", "sql_migrations"] + +[[validators]] +id = "feature_support" +semantic = true +parsers = ["rust_ast", "toml"] +implementation_paths = ["tools/xtask/src/contract/feature_support.rs"] +governed_inputs = ["Cargo.toml", "crates/nostr/Cargo.toml", "crates/nostr/src/lib.rs"] + +[[validators]] +id = "immutable_predecessors" +semantic = false +parsers = ["governed_bytes", "json", "json_schema"] +implementation_paths = [ + "tools/xtask/src/contract/blossom_publication_readiness.rs", + "tools/xtask/src/contract/phase1_publication_allowlist.rs", + "tools/xtask/src/contract/phase1_publication_artifact.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", + "tools/xtask/src/contract/source_maintenance.rs", +] +governed_inputs = [ + "crates/blossom/contracts", + "crates/event_codec/contracts", + "crates/event_store/contracts", +] +byte_hash_scopes = ["immutable_predecessor_artifacts"] + +[[validators]] +id = "operations_and_boundaries" +semantic = true +parsers = ["executable_vector", "governed_bytes", "json", "markdown_table", "rust_ast", "toml"] +implementation_paths = [ + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/admission_authority.rs", + "tools/xtask/src/contract/comment_authority.rs", + "tools/xtask/src/contract/deletion_authority.rs", +] +governed_inputs = [ + "contracts/conformance", + "contracts/event_boundary_matrix.md", + "contracts/manifest.toml", + "contracts/operations.toml", + "contracts/replica.toml", + "crates/event/src", + "crates/event_codec/src", + "crates/replica_sync/src", +] +byte_hash_scopes = ["canonical_vectors_and_mirrors"] + +[[validators]] +id = "outbox_successors" +semantic = true +parsers = ["executable_vector", "governed_bytes", "json", "json_schema", "rust_ast", "sqlite_catalog", "toml"] +implementation_paths = [ + "tools/xtask/src/contract/outbox_migration.rs", + "tools/xtask/src/contract/outbox_phase1_publication.rs", +] +governed_inputs = [ + "contracts/conformance/vectors/outbox", + "contracts/outbox_feature_matrix.toml", + "crates/outbox/Cargo.toml", + "crates/outbox/contracts", + "crates/outbox/migrations", + "crates/outbox/src", +] +byte_hash_scopes = ["canonical_vectors_and_mirrors", "generated_contract_artifacts", "sql_migrations"] + +[[validators]] +id = "phase1_media_successors" +semantic = true +parsers = ["executable_vector", "governed_bytes", "json", "json_schema", "rust_ast", "toml"] +implementation_paths = [ + "tools/xtask/src/contract/blossom_raster_decoder_security.rs", + "tools/xtask/src/contract/phase1_publication_media_readiness.rs", +] +governed_inputs = [ + "contracts/conformance/vectors/blossom", + "contracts/conformance/vectors/publication", + "crates/blossom/contracts", + "crates/blossom/src", + "crates/event_codec/contracts", + "crates/event_codec/src", + "fuzz/corpus", + "fuzz/fuzz_targets", +] +byte_hash_scopes = ["canonical_vectors_and_mirrors", "generated_contract_artifacts", "raw_fuzz_seeds"] + +[[validators]] +id = "registry_v7" +semantic = true +parsers = ["governed_bytes", "json", "json_schema"] +implementation_paths = ["tools/xtask/src/contract/registry_v7.rs"] +governed_inputs = ["contracts/event_store", "crates/event/src/contract/registry_v7.rs"] +byte_hash_scopes = ["generated_contract_artifacts"] + +[[validators]] +id = "release_closure" +semantic = true +parsers = ["cargo_metadata", "git_object", "governed_bytes", "json", "json_schema", "tar_archive", "toml"] +implementation_paths = [ + "tools/xtask/src/contract/release_package.rs", + "tools/xtask/src/contract/release_provenance.rs", +] +governed_inputs = ["Cargo.lock", "Cargo.toml", "contracts/releases", "crates"] +byte_hash_scopes = ["package_archives", "release_provenance_artifacts"] + +[[validators]] +id = "validator_inventory" +semantic = true +parsers = ["rust_ast", "toml"] +implementation_paths = ["tools/xtask/src/contract/validator_inventory.rs"] +governed_inputs = ["contracts/semantic_validator_inventory.toml", "tools/xtask/src/contract"] diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.json b/crates/outbox/contracts/migration_authority_v1.manifest.json @@ -296,19 +296,19 @@ }, { "file": { - "byte_length": 46807, + "byte_length": 47759, "hash_algorithm": "sha256_bytes_v1", "path": "tools/xtask/src/contract/outbox_migration.rs", - "sha256": "feecba3ecb9ac05891877ef1de1b1e17f85fd349dbcc5b0f4cd7a09d2d6e0dda" + "sha256": "914361e2e65f544722a305c7e18076ebcf602f8b72bde39a8a16e7714efb237f" }, "role": "contract_governance" }, { "file": { - "byte_length": 492083, + "byte_length": 506380, "hash_algorithm": "sha256_bytes_v1", "path": "tools/xtask/src/contract.rs", - "sha256": "a71ba50241e9569642060cc555f370e4ef8c0a525ee98235ec079372b2848469" + "sha256": "a0bfb6155613050b7e19fa6b2f8af72528b5dc12f446cd9fec05319d3ce924a1" }, "role": "contract_dispatch" }, diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.sha256 b/crates/outbox/contracts/migration_authority_v1.manifest.sha256 @@ -1 +1 @@ -67186bb3671dd4a8a36a2ae078b9ade0436bfb9e704f42de5b5fde28f5cf3728 +264233c2333f787390e9d394e63a6818a8067998bab95473fcf5ec132feaaad4 diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.json b/crates/outbox/contracts/phase1_publication_v1.manifest.json @@ -112,9 +112,9 @@ }, { "file": { - "byte_length": 492083, + "byte_length": 506380, "path": "tools/xtask/src/contract.rs", - "sha256": "a71ba50241e9569642060cc555f370e4ef8c0a525ee98235ec079372b2848469" + "sha256": "a0bfb6155613050b7e19fa6b2f8af72528b5dc12f446cd9fec05319d3ce924a1" }, "role": "contract_dispatch" }, diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.sha256 b/crates/outbox/contracts/phase1_publication_v1.manifest.sha256 @@ -1 +1 @@ -79e03855f5cf0d8e18049a604de4f121fe0f6af688b0c9b4c4a2e547ba83dcfa +a53a72f000e9b7982a418e09063a51c1f39c30ed4bccd42a49e32f47cc063943 diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -28,6 +28,7 @@ mod registry_v7; mod release_package; mod release_provenance; mod source_maintenance; +mod validator_inventory; pub(crate) use blossom_raster_decoder_security::{ validate_blossom_raster_decoder_security_manifest, @@ -77,6 +78,7 @@ use deletion_authority::{ DELETION_SUPPRESSION_CONFORMANCE_VECTOR_RELATIVE, DELETION_SUPPRESSION_VALID_IDS, REQUIRED_DELETION_PUBLIC_TYPES, }; +use quote::ToTokens; use semver::Version; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -88,6 +90,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; pub(crate) fn validate_artifact_contracts(workspace_root: &Path) -> Result<(), String> { + validator_inventory::validate_semantic_validator_inventory(workspace_root)?; feature_support::validate_feature_support(workspace_root)?; validate_event_contract_registry_v7_inventory(workspace_root)?; validate_nip09_reconciliation_manifest(workspace_root)?; @@ -2093,7 +2096,7 @@ const COMMENT_WITNESSES: [EventBoundarySourceWitness; 8] = [ }, EventBoundarySourceWitness { relative_path: "crates/nostr/src/client.rs", - required_fragments: &["pub async fn send_nip22_comment_event_builder"], + required_fragments: &["pub async method fn send_nip22_comment_event_builder"], }, EventBoundarySourceWitness { relative_path: "crates/event/src/kinds.rs", @@ -2120,7 +2123,7 @@ const DELETION_WITNESSES: [EventBoundarySourceWitness; 8] = [ EventBoundarySourceWitness { relative_path: "crates/event_codec/src/deletion/reconciliation_v1.rs", required_fragments: &[ - "pub struct RadrootsAdmittedNip09DeletionRequestEvent", + "pub type RadrootsAdmittedNip09DeletionRequestEvent", "pub fn verify_and_admit_nip09_deletion_request_event", ], }, @@ -2144,7 +2147,7 @@ const DELETION_WITNESSES: [EventBoundarySourceWitness; 8] = [ }, EventBoundarySourceWitness { relative_path: "crates/nostr/src/client.rs", - required_fragments: &["pub async fn send_nip09_deletion_request_event_builder"], + required_fragments: &["pub async method fn send_nip09_deletion_request_event_builder"], }, EventBoundarySourceWitness { relative_path: "crates/event/src/kinds.rs", @@ -3521,19 +3524,305 @@ fn validate_event_boundary_source_witness( Ok(source) => source, Err(e) => return Err(format!("read {}: {e}", path.display())), }; - for fragment in witness.required_fragments { - if !source.contains(fragment) { - return Err(format!( - "canonical event row {} is missing required implementation fragment {} in {}", - domain, - fragment, + let syntax = syn::parse_file(&source) + .map_err(|error| format!("parse canonical event witness {}: {error}", path.display()))?; + for required in witness.required_fragments { + validate_rust_ast_witness(&syntax, required).map_err(|error| { + format!( + "canonical event row {domain} has invalid Rust AST witness `{required}` in {}: {error}", path.display() - )); - } + ) + })?; } Ok(()) } +#[derive(Default)] +struct RustAstWitnessInventory { + public_structs: BTreeSet<String>, + public_enums: BTreeSet<String>, + public_types: BTreeSet<String>, + public_functions: BTreeSet<String>, + public_methods: BTreeSet<String>, + public_consts: BTreeMap<String, (String, String)>, + public_exports: BTreeSet<String>, + match_arms: Vec<(String, String)>, + paths: BTreeSet<String>, + string_literals: BTreeSet<String>, +} + +impl<'ast> syn::visit::Visit<'ast> for RustAstWitnessInventory { + fn visit_item_struct(&mut self, item: &'ast syn::ItemStruct) { + if matches!(item.vis, syn::Visibility::Public(_)) { + self.public_structs.insert(item.ident.to_string()); + } + syn::visit::visit_item_struct(self, item); + } + + fn visit_item_enum(&mut self, item: &'ast syn::ItemEnum) { + if matches!(item.vis, syn::Visibility::Public(_)) { + self.public_enums.insert(item.ident.to_string()); + } + syn::visit::visit_item_enum(self, item); + } + + fn visit_item_type(&mut self, item: &'ast syn::ItemType) { + if matches!(item.vis, syn::Visibility::Public(_)) { + self.public_types.insert(item.ident.to_string()); + } + syn::visit::visit_item_type(self, item); + } + + fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) { + if matches!(item.vis, syn::Visibility::Public(_)) { + self.public_functions.insert(item.sig.ident.to_string()); + } + syn::visit::visit_item_fn(self, item); + } + + fn visit_impl_item_fn(&mut self, item: &'ast syn::ImplItemFn) { + if matches!(item.vis, syn::Visibility::Public(_)) { + self.public_methods.insert(item.sig.ident.to_string()); + } + syn::visit::visit_impl_item_fn(self, item); + } + + fn visit_item_const(&mut self, item: &'ast syn::ItemConst) { + if matches!(item.vis, syn::Visibility::Public(_)) { + self.public_consts.insert( + item.ident.to_string(), + ( + compact_ast_tokens(item.ty.as_ref()), + compact_ast_tokens(item.expr.as_ref()), + ), + ); + } + syn::visit::visit_item_const(self, item); + } + + fn visit_item_use(&mut self, item: &'ast syn::ItemUse) { + if matches!(item.vis, syn::Visibility::Public(_)) { + collect_use_tree_exports(&item.tree, &mut self.public_exports); + } + syn::visit::visit_item_use(self, item); + } + + fn visit_expr_match(&mut self, expression: &'ast syn::ExprMatch) { + self.match_arms.extend(expression.arms.iter().map(|arm| { + ( + compact_ast_tokens(&arm.pat), + compact_ast_tokens(arm.body.as_ref()), + ) + })); + syn::visit::visit_expr_match(self, expression); + } + + fn visit_expr_path(&mut self, expression: &'ast syn::ExprPath) { + self.paths.insert(compact_ast_tokens(&expression.path)); + syn::visit::visit_expr_path(self, expression); + } + + fn visit_lit_str(&mut self, literal: &'ast syn::LitStr) { + self.string_literals.insert(literal.value()); + syn::visit::visit_lit_str(self, literal); + } + + fn visit_macro(&mut self, item: &'ast syn::Macro) { + collect_macro_string_literals(item.tokens.clone(), &mut self.string_literals); + syn::visit::visit_macro(self, item); + } +} + +fn collect_macro_string_literals( + tokens: proc_macro2::TokenStream, + literals: &mut BTreeSet<String>, +) { + for token in tokens { + match token { + proc_macro2::TokenTree::Group(group) => { + collect_macro_string_literals(group.stream(), literals); + } + proc_macro2::TokenTree::Literal(literal) => { + if let Ok(syn::Lit::Str(value)) = syn::parse_str::<syn::Lit>(&literal.to_string()) { + literals.insert(value.value()); + } + } + proc_macro2::TokenTree::Ident(_) | proc_macro2::TokenTree::Punct(_) => {} + } + } +} + +fn collect_use_tree_exports(tree: &syn::UseTree, exports: &mut BTreeSet<String>) { + match tree { + syn::UseTree::Name(name) => { + exports.insert(name.ident.to_string()); + } + syn::UseTree::Rename(rename) => { + exports.insert(rename.rename.to_string()); + } + syn::UseTree::Path(path) => collect_use_tree_exports(path.tree.as_ref(), exports), + syn::UseTree::Group(group) => { + for item in &group.items { + collect_use_tree_exports(item, exports); + } + } + syn::UseTree::Glob(_) => {} + } +} + +fn compact_ast_tokens(tokens: &impl ToTokens) -> String { + tokens.to_token_stream().to_string().replace(' ', "") +} + +fn validate_rust_ast_witness(file: &syn::File, required: &str) -> Result<(), String> { + use syn::visit::Visit; + + let mut inventory = RustAstWitnessInventory::default(); + inventory.visit_file(file); + let required = required.trim(); + + if let Some(rest) = required.strip_prefix("pub struct ") { + let name = rest + .split(|character: char| { + character.is_whitespace() || character == '{' || character == '<' + }) + .next() + .unwrap_or_default(); + return inventory + .public_structs + .contains(name) + .then_some(()) + .ok_or_else(|| format!("missing public struct {name}")); + } + if let Some(rest) = required.strip_prefix("pub enum ") { + let name = rest + .split(|character: char| { + character.is_whitespace() || character == '{' || character == '<' + }) + .next() + .unwrap_or_default(); + return inventory + .public_enums + .contains(name) + .then_some(()) + .ok_or_else(|| format!("missing public enum {name}")); + } + if let Some(rest) = required.strip_prefix("pub type ") { + let name = rest + .split(|character: char| { + character.is_whitespace() || character == '=' || character == '<' + }) + .next() + .unwrap_or_default(); + return inventory + .public_types + .contains(name) + .then_some(()) + .ok_or_else(|| format!("missing public type alias {name}")); + } + if required.starts_with("pub method fn ") || required.starts_with("pub async method fn ") { + let rest = required + .strip_prefix("pub async method fn ") + .or_else(|| required.strip_prefix("pub method fn ")) + .expect("public method prefix checked"); + let name = rest + .split(|character: char| { + character == '(' || character == '<' || character.is_whitespace() + }) + .next() + .unwrap_or_default(); + return inventory + .public_methods + .contains(name) + .then_some(()) + .ok_or_else(|| format!("missing public method {name}")); + } + if required.starts_with("pub fn ") || required.starts_with("pub async fn ") { + let rest = required + .strip_prefix("pub async fn ") + .or_else(|| required.strip_prefix("pub fn ")) + .expect("public function prefix checked"); + let name = rest + .split(|character: char| { + character == '(' || character == '<' || character.is_whitespace() + }) + .next() + .unwrap_or_default(); + return inventory + .public_functions + .contains(name) + .then_some(()) + .ok_or_else(|| format!("missing public function {name}")); + } + if required.starts_with("pub const ") { + let declaration = required.trim_end_matches(';'); + let rest = declaration + .strip_prefix("pub const ") + .expect("public const prefix checked"); + let name = rest.split(':').next().unwrap_or_default().trim(); + let actual = inventory + .public_consts + .get(name) + .ok_or_else(|| format!("missing public const {name}"))?; + let expected_type = rest + .split_once(':') + .map(|(_, suffix)| suffix.split('=').next().unwrap_or(suffix).trim()) + .filter(|expected| !expected.is_empty()) + .ok_or_else(|| format!("public const witness {name} has no type"))?; + let expected_type = syn::parse_str::<syn::Type>(expected_type) + .map_err(|error| format!("parse expected const {name} type: {error}"))?; + if actual.0 != compact_ast_tokens(&expected_type) { + return Err(format!("public const {name} type differs")); + } + if let Some((_, expected_expression)) = declaration.split_once('=') { + let expected = syn::parse_str::<syn::Expr>(expected_expression.trim()) + .map_err(|error| format!("parse expected const {name} expression: {error}"))?; + if actual.1 != compact_ast_tokens(&expected) { + return Err(format!("public const {name} expression differs")); + } + } + return Ok(()); + } + if let Some((pattern, expression)) = required.split_once("=>") { + let pattern = pattern.split_whitespace().collect::<String>(); + let expression = expression.trim().trim_end_matches(','); + let matches = inventory + .match_arms + .iter() + .any(|(actual_pattern, actual_expression)| { + actual_pattern == &pattern + && (expression.is_empty() + || actual_expression == &expression.split_whitespace().collect::<String>()) + }); + return matches + .then_some(()) + .ok_or_else(|| format!("missing match arm {required}")); + } + if required.starts_with('"') { + let literal = syn::parse_str::<syn::LitStr>(required) + .map_err(|error| format!("parse expected string literal: {error}"))?; + return inventory + .string_literals + .contains(&literal.value()) + .then_some(()) + .ok_or_else(|| format!("missing string literal {}", literal.value())); + } + if required.contains("::") { + let path = required.split_whitespace().collect::<String>(); + return inventory + .paths + .contains(&path) + .then_some(()) + .ok_or_else(|| format!("missing Rust path {path}")); + } + + inventory + .public_exports + .contains(required) + .then_some(()) + .ok_or_else(|| format!("missing public export {required}")) +} + fn validate_canonical_event_boundary_with_override( workspace_root: &Path, event_boundary_override: Option<PathBuf>, @@ -5655,20 +5944,65 @@ fn validate_replica_policy_source_witnesses(sync_root: &Path) -> Result<(), Stri let types_path = sync_root.join("src/types.rs"); let types_source = fs::read_to_string(&types_path) .map_err(|error| format!("read {}: {error}", types_path.display()))?; + let types = syn::parse_file(&types_source).map_err(|error| { + format!( + "parse replica request source {}: {error}", + types_path.display() + ) + })?; for type_name in [ "RadrootsReplicaFarmSelector", "RadrootsReplicaSyncOptions", "RadrootsReplicaSyncRequest", ] { - let witness = format!("#[serde(deny_unknown_fields)]\npub struct {type_name}"); - if !types_source.contains(&witness) { + let structs = types + .items + .iter() + .filter_map(|item| match item { + syn::Item::Struct(item) if item.ident == type_name => Some(item), + _ => None, + }) + .collect::<Vec<_>>(); + let [request] = structs.as_slice() else { + return Err(format!( + "replica request source {} must define exactly one {type_name}", + types_path.display() + )); + }; + let deny_unknown_fields = request.attrs.iter().any(|attribute| { + if !attribute.path().is_ident("serde") { + return false; + } + let mut found = false; + let _ = attribute.parse_nested_meta(|meta| { + if meta.path.is_ident("deny_unknown_fields") { + found = true; + } + Ok(()) + }); + found + }); + if !matches!(request.vis, syn::Visibility::Public(_)) || !deny_unknown_fields { return Err(format!( "replica request type {type_name} must place #[serde(deny_unknown_fields)] immediately before its public struct declaration in {}", types_path.display() )); } } - if types_source.contains("include_profiles") { + + #[derive(Default)] + struct IdentifierInventory { + names: BTreeSet<String>, + } + impl<'ast> syn::visit::Visit<'ast> for IdentifierInventory { + fn visit_ident(&mut self, identifier: &'ast syn::Ident) { + self.names.insert(identifier.to_string()); + } + } + use syn::visit::Visit; + let mut identifiers = IdentifierInventory::default(); + identifiers.visit_file(&types); + if identifiers.names.contains("include_profiles") { return Err(format!( "retired replica request identifier include_profiles is forbidden in {}", types_path.display() @@ -11177,6 +11511,53 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] } #[test] + fn rust_ast_witnesses_ignore_formatting_and_reject_lexical_prefixes() { + let syntax = syn::parse_file( + r#" + pub struct ExactWitness<T> { value: T } + impl<T> ExactWitness<T> { + pub fn method_only() {} + } + pub type ExactAlias = ExactWitness<u8>; + pub const EXACT_KIND : u32 = 7; + pub use nested::{exported_symbol}; + fn mapping(value: Mapping) -> u32 { + match value { Mapping::Exact => EXACT_KIND } + } + macro_rules! identity { () => { "radroots.exact.v1" }; } + "#, + ) + .expect("structured Rust fixture"); + + for required in [ + "pub struct ExactWitness", + "pub method fn method_only", + "pub type ExactAlias", + "pub const EXACT_KIND: u32 = 7;", + "Mapping::Exact => EXACT_KIND", + "exported_symbol", + "\"radroots.exact.v1\"", + ] { + validate_rust_ast_witness(&syntax, required).expect(required); + } + assert!( + validate_rust_ast_witness(&syntax, "pub struct Exact") + .expect_err("prefix-only struct name must fail") + .contains("missing public struct Exact") + ); + assert!( + validate_rust_ast_witness(&syntax, "pub const EXACT_KIND: u32 = 8;") + .expect_err("different const expression must fail") + .contains("expression differs") + ); + assert!( + validate_rust_ast_witness(&syntax, "pub fn method_only") + .expect_err("public method must not satisfy a free-function witness") + .contains("missing public function method_only") + ); + } + + #[test] fn canonical_event_boundary_reports_row_drift() { let root = workspace_root(); let matrix_path = diff --git a/tools/xtask/src/contract/admission_authority.rs b/tools/xtask/src/contract/admission_authority.rs @@ -1,6 +1,6 @@ use super::{ ConformanceVectorEntry, OperationsContractManifest, collect_non_empty_set, - validate_conformance_vector_file, validate_operation_case_kinds, + validate_conformance_vector_file, validate_operation_case_kinds, validate_rust_ast_witness, }; use serde_json::{Map, Value}; use std::{ @@ -264,12 +264,12 @@ fn validate_source_witnesses(workspace_root: &Path) -> Result<(), String> { let source = fs::read_to_string(workspace_root.join(relative)).map_err(|error| { format!("failed to read verified admission witness {relative}: {error}") })?; - for fragment in fragments { - if !source.contains(fragment) { - return Err(format!( - "verified admission witness {relative} is missing `{fragment}`" - )); - } + let syntax = syn::parse_file(&source) + .map_err(|error| format!("parse verified admission witness {relative}: {error}"))?; + for required in fragments { + validate_rust_ast_witness(&syntax, required).map_err(|error| { + format!("verified admission Rust AST witness {relative} `{required}`: {error}") + })?; } } Ok(()) diff --git a/tools/xtask/src/contract/blossom_raster_decoder_security.rs b/tools/xtask/src/contract/blossom_raster_decoder_security.rs @@ -47,11 +47,6 @@ const FUZZ_LOCKFILE_RELATIVE: &str = "fuzz/Cargo.lock"; const FUZZ_TOOLCHAIN_RELATIVE: &str = "rust-toolchain-fuzz.toml"; const FUZZ_COMMON_RELATIVE: &str = "fuzz/fuzz_targets/common.rs"; const FUZZ_CORPUS_RELATIVE: &str = "fuzz/corpus"; -const NIX_APPS_RELATIVE: &str = "build/nix/apps.nix"; -const NIX_CHECKS_RELATIVE: &str = "build/nix/checks.nix"; -const NIX_COMMON_RELATIVE: &str = "build/nix/common.nix"; -const NIX_DEVSHELLS_RELATIVE: &str = "build/nix/devshells.nix"; -const NIX_TOOLCHAINS_RELATIVE: &str = "build/nix/toolchains.nix"; const SECURITY_DOCUMENT_RELATIVE: &str = "docs/blossom-raster-decoder-security.md"; const OPERATIONS_RELATIVE: &str = "contracts/operations.toml"; const RELEASE_RELATIVE: &str = "contracts/releases/1.0.0-alpha.1.toml"; @@ -1340,7 +1335,6 @@ fn validate_source_contract(workspace_root: &Path) -> Result<(), String> { validate_error_authority(workspace_root)?; validate_vector_executor(workspace_root)?; validate_fuzz_authority(workspace_root, false)?; - validate_nix_lanes(workspace_root)?; validate_operations_authority(workspace_root)?; validate_release_authority(workspace_root)?; validate_vector(workspace_root)?; @@ -1850,45 +1844,6 @@ fn validate_fuzz_authority(workspace_root: &Path, validate_corpus: bool) -> Resu Ok(()) } -fn validate_nix_lanes(workspace_root: &Path) -> Result<(), String> { - for (relative, needles) in [ - (NIX_APPS_RELATIVE, vec!["decoder-security"]), - (NIX_DEVSHELLS_RELATIVE, vec!["decoder-security"]), - ( - NIX_CHECKS_RELATIVE, - vec!["blossom-raster-decode-test", "blossom-decoder-fuzz-smoke"], - ), - (NIX_TOOLCHAINS_RELATIVE, vec!["rust-toolchain-fuzz.toml"]), - ( - NIX_COMMON_RELATIVE, - vec![ - "decoderSecurityCommand", - "decoderSecurityStableCommand", - "decoderSecurityFuzzCommand", - "fuzz/Cargo.lock", - "131072", - "-runs=256", - "-seed=424242", - "-max_len=65536", - "resource_cases='jpeg_grayscale jpeg_rgb jpeg_cmyk jpeg_sof1 png_rgb png_palette png_rgba png_adam7 webp_vp8_rgb webp_vp8_alpha webp_vp8l_rgb webp_vp8l_alpha'", - "for repetition in 1 2 3", - ], - ), - ] { - 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}"))?; - for needle in needles { - if !source.contains(needle) { - return Err(format!( - "{relative} must declare the governed decoder-security lane fragment {needle}" - )); - } - } - } - Ok(()) -} - fn validate_operations_authority(workspace_root: &Path) -> Result<(), String> { let manifest = parse_toml(workspace_root, OPERATIONS_RELATIVE)?; let operations = manifest diff --git a/tools/xtask/src/contract/outbox_migration.rs b/tools/xtask/src/contract/outbox_migration.rs @@ -9,6 +9,7 @@ use std::fs; use std::path::Path; #[cfg(test)] use std::path::PathBuf; +use syn::{ItemFn, visit::Visit}; const CONTRACT_ID: &str = "radroots_outbox.migration_authority.v1"; const AUTHORITY_ID: &str = "versioned_outbox_migration_authority_v1"; @@ -691,9 +692,30 @@ fn validate_vector(workspace_root: &Path) -> Result<(), String> { let source = read_regular_file(workspace_root, &authority.authority_path)?; let source = std::str::from_utf8(&source) .map_err(|error| format!("decode {}: {error}", authority.authority_path))?; - if !source.contains(&authority.authority) { + let syntax = syn::parse_file(source) + .map_err(|error| format!("parse {}: {error}", authority.authority_path))?; + #[derive(Default)] + struct TestFunctions(BTreeSet<String>); + impl<'ast> Visit<'ast> for TestFunctions { + fn visit_item_fn(&mut self, function: &'ast ItemFn) { + let is_test = function.attrs.iter().any(|attribute| { + attribute + .path() + .segments + .last() + .is_some_and(|segment| segment.ident == "test") + }); + if is_test { + self.0.insert(function.sig.ident.to_string()); + } + syn::visit::visit_item_fn(self, function); + } + } + let mut tests = TestFunctions::default(); + tests.visit_file(&syntax); + if !tests.0.contains(&authority.authority) { return Err(format!( - "outbox migration delegated authority `{}` is not present in {}", + "outbox migration delegated test authority `{}` is not present in the Rust AST for {}", authority.authority, authority.authority_path )); } diff --git a/tools/xtask/src/contract/validator_inventory.rs b/tools/xtask/src/contract/validator_inventory.rs @@ -0,0 +1,278 @@ +use serde::Deserialize; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Component, Path}, +}; + +const INVENTORY_RELATIVE: &str = "contracts/semantic_validator_inventory.toml"; +const EXPECTED_VALIDATOR_IDS: [&str; 10] = [ + "artifact_transactions", + "event_store_successors", + "feature_support", + "immutable_predecessors", + "operations_and_boundaries", + "outbox_successors", + "phase1_media_successors", + "registry_v7", + "release_closure", + "validator_inventory", +]; +const EXPECTED_IMPLEMENTATIONS: [&str; 21] = [ + "tools/xtask/src/contract.rs", + "tools/xtask/src/contract/admission_authority.rs", + "tools/xtask/src/contract/artifact_bundle.rs", + "tools/xtask/src/contract/blossom_publication_readiness.rs", + "tools/xtask/src/contract/blossom_raster_decoder_security.rs", + "tools/xtask/src/contract/comment_authority.rs", + "tools/xtask/src/contract/deletion_authority.rs", + "tools/xtask/src/contract/feature_support.rs", + "tools/xtask/src/contract/food_availability_projection.rs", + "tools/xtask/src/contract/nip09_reconciliation.rs", + "tools/xtask/src/contract/outbox_migration.rs", + "tools/xtask/src/contract/outbox_phase1_publication.rs", + "tools/xtask/src/contract/phase1_publication_allowlist.rs", + "tools/xtask/src/contract/phase1_publication_artifact.rs", + "tools/xtask/src/contract/phase1_publication_media_readiness.rs", + "tools/xtask/src/contract/raw_source_rebuild.rs", + "tools/xtask/src/contract/registry_v7.rs", + "tools/xtask/src/contract/release_package.rs", + "tools/xtask/src/contract/release_provenance.rs", + "tools/xtask/src/contract/source_maintenance.rs", + "tools/xtask/src/contract/validator_inventory.rs", +]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ValidatorInventory { + schema_version: u32, + validators: Vec<ValidatorEntry>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ValidatorEntry { + id: String, + semantic: bool, + parsers: Vec<ParserKind>, + implementation_paths: Vec<String>, + governed_inputs: Vec<String>, + #[serde(default)] + byte_hash_scopes: Vec<ByteHashScope>, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] +#[serde(rename_all = "snake_case")] +enum ParserKind { + CargoMetadata, + ExecutableVector, + GitObject, + GovernedBytes, + Json, + JsonSchema, + MarkdownTable, + RustAst, + SqliteCatalog, + TarArchive, + Toml, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] +#[serde(rename_all = "snake_case")] +enum ByteHashScope { + CanonicalVectorsAndMirrors, + GeneratedContractArtifacts, + ImmutablePredecessorArtifacts, + PackageArchives, + RawFuzzSeeds, + ReleaseProvenanceArtifacts, + SqlMigrations, +} + +pub(super) fn validate_semantic_validator_inventory(workspace_root: &Path) -> Result<(), String> { + let path = workspace_root.join(INVENTORY_RELATIVE); + let source = + fs::read_to_string(&path).map_err(|error| format!("read {INVENTORY_RELATIVE}: {error}"))?; + let inventory = toml::from_str::<ValidatorInventory>(&source) + .map_err(|error| format!("parse {INVENTORY_RELATIVE}: {error}"))?; + if inventory.schema_version != 1 { + return Err(format!("{INVENTORY_RELATIVE} schema_version must be 1")); + } + + let expected_ids = EXPECTED_VALIDATOR_IDS.into_iter().collect::<BTreeSet<_>>(); + let mut entries = BTreeMap::new(); + let mut previous_id = None::<&str>; + for entry in &inventory.validators { + if previous_id.is_some_and(|previous| previous >= entry.id.as_str()) { + return Err(format!( + "{INVENTORY_RELATIVE} validator ids must be strictly sorted and unique" + )); + } + previous_id = Some(entry.id.as_str()); + if entries.insert(entry.id.as_str(), entry).is_some() { + return Err(format!("{INVENTORY_RELATIVE} duplicates {}", entry.id)); + } + } + if entries.keys().copied().collect::<BTreeSet<_>>() != expected_ids { + return Err(format!( + "{INVENTORY_RELATIVE} validator id inventory is incomplete" + )); + } + + let mut implementations = BTreeSet::new(); + for entry in entries.values() { + validate_entry(workspace_root, entry)?; + for relative in &entry.implementation_paths { + if !implementations.insert(relative.as_str()) { + return Err(format!( + "{INVENTORY_RELATIVE} implementation {relative} has multiple owners" + )); + } + } + } + let expected_implementations = EXPECTED_IMPLEMENTATIONS + .into_iter() + .collect::<BTreeSet<_>>(); + if implementations != expected_implementations { + let missing = expected_implementations + .difference(&implementations) + .copied() + .collect::<Vec<_>>(); + let unexpected = implementations + .difference(&expected_implementations) + .copied() + .collect::<Vec<_>>(); + return Err(format!( + "{INVENTORY_RELATIVE} implementation inventory drifted; missing {missing:?}, unexpected {unexpected:?}" + )); + } + Ok(()) +} + +fn validate_entry(workspace_root: &Path, entry: &ValidatorEntry) -> Result<(), String> { + if entry.id.trim().is_empty() + || entry.parsers.is_empty() + || entry.implementation_paths.is_empty() + || entry.governed_inputs.is_empty() + { + return Err(format!( + "{INVENTORY_RELATIVE} validator {} has an empty authority field", + entry.id + )); + } + validate_sorted_unique(&entry.id, "parser", &entry.parsers)?; + validate_sorted_unique( + &entry.id, + "implementation path", + &entry.implementation_paths, + )?; + validate_sorted_unique(&entry.id, "governed input", &entry.governed_inputs)?; + validate_sorted_unique(&entry.id, "byte hash scope", &entry.byte_hash_scopes)?; + if entry.semantic && entry.parsers == [ParserKind::GovernedBytes] { + return Err(format!( + "{INVENTORY_RELATIVE} semantic validator {} cannot use byte identity as semantic authority", + entry.id + )); + } + if !entry.byte_hash_scopes.is_empty() && !entry.parsers.contains(&ParserKind::GovernedBytes) { + return Err(format!( + "{INVENTORY_RELATIVE} validator {} declares byte hash scope without governed_bytes parsing", + entry.id + )); + } + for relative in entry + .implementation_paths + .iter() + .chain(entry.governed_inputs.iter()) + { + validate_relative_path(relative)?; + if !workspace_root.join(relative).exists() { + return Err(format!( + "{INVENTORY_RELATIVE} validator {} references missing path {relative}", + entry.id + )); + } + } + for relative in &entry.implementation_paths { + if !relative.starts_with("tools/xtask/src/contract") || !relative.ends_with(".rs") { + return Err(format!( + "{INVENTORY_RELATIVE} validator {} has invalid implementation {relative}", + entry.id + )); + } + let source = fs::read_to_string(workspace_root.join(relative)) + .map_err(|error| format!("read validator implementation {relative}: {error}"))?; + syn::parse_file(&source) + .map_err(|error| format!("parse validator implementation {relative}: {error}"))?; + } + Ok(()) +} + +fn validate_relative_path(relative: &str) -> Result<(), String> { + let path = Path::new(relative); + if relative.is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(format!( + "{INVENTORY_RELATIVE} contains invalid relative path {relative}" + )); + } + Ok(()) +} + +fn validate_sorted_unique<T: Ord>( + validator: &str, + label: &str, + values: &[T], +) -> Result<(), String> { + if values.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(format!( + "{INVENTORY_RELATIVE} validator {validator} {label}s must be strictly sorted and unique" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("xtask workspace root") + .to_path_buf() + } + + #[test] + fn semantic_validator_inventory_is_complete_and_structured() { + validate_semantic_validator_inventory(&workspace_root()).expect("validator inventory"); + } + + #[test] + fn semantic_validator_cannot_delegate_meaning_to_byte_identity() { + let entry = ValidatorEntry { + id: "synthetic".to_owned(), + semantic: true, + parsers: vec![ParserKind::GovernedBytes], + implementation_paths: vec![ + "tools/xtask/src/contract/validator_inventory.rs".to_owned(), + ], + governed_inputs: vec!["contracts/semantic_validator_inventory.toml".to_owned()], + byte_hash_scopes: vec![ByteHashScope::GeneratedContractArtifacts], + }; + assert!( + validate_entry(&workspace_root(), &entry) + .expect_err("semantic byte-only authority must fail") + .contains("cannot use byte identity as semantic authority") + ); + } +}