lib

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

commit 3dc0337cb58d58fbccdba67e49fd494fbcea6676
parent 5953aea9812bdfda4d0eae96b38801abb2d1443d
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Jul 2026 06:45:11 +0000

draft: validate frozen event contract parts

- add parts-level event contract shape validation for draft inputs
- reject malformed contract shapes before frozen draft event id computation
- carry contract validation failures through explicit draft errors
- ensure the wire validation lane runs frozen draft coverage

Diffstat:
Mcrates/events/src/contract.rs | 98++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mcrates/events/src/draft.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/events_codec/tests/wire.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
3 files changed, 248 insertions(+), 54 deletions(-)

diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs @@ -3183,20 +3183,29 @@ pub fn validate_event_contract_shape( event: &RadrootsNostrEvent, contract_id: &str, ) -> Result<(), RadrootsContractValidationError> { + validate_event_contract_parts(event.kind, &event.tags, event.content.as_str(), contract_id) +} + +pub fn validate_event_contract_parts( + kind: u32, + tags: &[Vec<String>], + content: &str, + contract_id: &str, +) -> Result<(), RadrootsContractValidationError> { let contract = event_contract(contract_id).ok_or_else(|| { RadrootsContractValidationError::UnknownContract { contract_id: contract_id.to_owned(), } })?; - if event.kind != contract.kind { + if kind != contract.kind { return Err(RadrootsContractValidationError::KindMismatch { expected: contract.kind, - actual: event.kind, + actual: kind, }); } - validate_content_shape(event, contract)?; - validate_contract_tags(event, contract)?; - validate_custom_knowledge_contract(event, contract)?; + validate_content_shape_parts(content, contract)?; + validate_contract_tags_parts(tags, contract)?; + validate_custom_knowledge_contract_parts(content, contract)?; Ok(()) } @@ -3260,13 +3269,13 @@ fn contract_family_for_id(id: &str) -> Option<RadrootsContractFamily> { } } -fn validate_content_shape( - event: &RadrootsNostrEvent, +fn validate_content_shape_parts( + content: &str, contract: &RadrootsEventContract, ) -> Result<(), RadrootsContractValidationError> { match contract.content_schema { RadrootsContentSchema::Empty => { - if event.content.is_empty() { + if content.is_empty() { Ok(()) } else { Err(RadrootsContractValidationError::ContentMustBeEmpty { @@ -3274,17 +3283,17 @@ fn validate_content_shape( }) } } - RadrootsContentSchema::JsonObject => parse_content_object(event, contract.id).map(|_| ()), + RadrootsContentSchema::JsonObject => parse_content_object(content, contract.id).map(|_| ()), _ => Ok(()), } } -fn validate_contract_tags( - event: &RadrootsNostrEvent, +fn validate_contract_tags_parts( + tags: &[Vec<String>], contract: &RadrootsEventContract, ) -> Result<(), RadrootsContractValidationError> { for tag_contract in contract.tags { - let count = tag_count(&event.tags, tag_contract.name); + let count = tag_count(tags, tag_contract.name); let has_multiple_contracts_for_name = contract .tags .iter() @@ -3325,7 +3334,7 @@ fn validate_contract_tags( RadrootsTagCardinality::OptionalMany => {} } if tag_contract.name == "contract" { - let actual = tag_value(&event.tags, "contract").map(ToOwned::to_owned); + let actual = tag_value(tags, "contract").map(ToOwned::to_owned); if actual.as_deref() != Some(contract.id) { return Err(RadrootsContractValidationError::TagValueMismatch { contract_id: contract.id, @@ -3335,18 +3344,17 @@ fn validate_contract_tags( }); } } - validate_contract_tag_values(event, contract, tag_contract)?; + validate_contract_tag_values(tags, contract, tag_contract)?; } Ok(()) } fn validate_contract_tag_values( - event: &RadrootsNostrEvent, + tags: &[Vec<String>], contract: &RadrootsEventContract, tag_contract: &RadrootsTagContract, ) -> Result<(), RadrootsContractValidationError> { - for tag in event - .tags + for tag in tags .iter() .filter(|tag| tag.first().map(|value| value.as_str()) == Some(tag_contract.name)) { @@ -3456,14 +3464,14 @@ fn tag_value_type_expectation(value_type: RadrootsTagValueType) -> &'static str } } -fn validate_custom_knowledge_contract( - event: &RadrootsNostrEvent, +fn validate_custom_knowledge_contract_parts( + content: &str, contract: &RadrootsEventContract, ) -> Result<(), RadrootsContractValidationError> { let Some(expected_schema) = custom_knowledge_schema(contract.id) else { return Ok(()); }; - let object = parse_content_object(event, contract.id)?; + let object = parse_content_object(content, contract.id)?; reject_forbidden_knowledge_fields(&object, contract.id)?; match object.get("schema").and_then(|value| value.as_str()) { @@ -3501,10 +3509,10 @@ fn validate_custom_knowledge_contract( } fn parse_content_object( - event: &RadrootsNostrEvent, + content: &str, contract_id: &'static str, ) -> Result<serde_json::Map<String, serde_json::Value>, RadrootsContractValidationError> { - match serde_json::from_str::<serde_json::Value>(&event.content) { + match serde_json::from_str::<serde_json::Value>(content) { Ok(serde_json::Value::Object(object)) => Ok(object), _ => Err(RadrootsContractValidationError::InvalidJsonContent { contract_id }), } @@ -4328,33 +4336,29 @@ mod tests { let required_many = synthetic_event_contract("radroots.test.required_many.v1", REQUIRED_MANY_TEST_TAGS); assert_eq!( - validate_contract_tags(&unsigned_event(KIND_POST, Vec::new(), ""), &required_many), + validate_contract_tags_parts(&[], &required_many), Err(RadrootsContractValidationError::MissingTag { contract_id: "radroots.test.required_many.v1", name: "test_many", }) ); assert_eq!( - validate_contract_tags( - &unsigned_event(KIND_POST, vec![vec!["test_many", "one"]], ""), - &required_many, + validate_contract_tags_parts( + &vec![vec!["test_many".to_owned(), "one".to_owned()]], + &required_many ), Ok(()) ); let optional_one = synthetic_event_contract("radroots.test.optional_one.v1", OPTIONAL_ONE_TEST_TAGS); + assert_eq!(validate_contract_tags_parts(&[], &optional_one), Ok(())); assert_eq!( - validate_contract_tags(&unsigned_event(KIND_POST, Vec::new(), ""), &optional_one), - Ok(()) - ); - assert_eq!( - validate_contract_tags( - &unsigned_event( - KIND_POST, - vec![vec!["test_optional", "one"], vec!["test_optional", "two"],], - "", - ), + validate_contract_tags_parts( + &vec![ + vec!["test_optional".to_owned(), "one".to_owned()], + vec!["test_optional".to_owned(), "two".to_owned()], + ], &optional_one, ), Err(RadrootsContractValidationError::TagCardinalityMismatch { @@ -4368,12 +4372,11 @@ mod tests { DUPLICATE_REQUIRED_TEST_TAGS, ); assert_eq!( - validate_contract_tags( - &unsigned_event( - KIND_POST, - vec![vec!["test_required", "one"], vec!["test_required", "two"],], - "", - ), + validate_contract_tags_parts( + &vec![ + vec!["test_required".to_owned(), "one".to_owned()], + vec!["test_required".to_owned(), "two".to_owned()], + ], &duplicate_required, ), Ok(()) @@ -4384,12 +4387,11 @@ mod tests { DUPLICATE_OPTIONAL_TEST_TAGS, ); assert_eq!( - validate_contract_tags( - &unsigned_event( - KIND_POST, - vec![vec!["test_optional", "one"], vec!["test_optional", "two"],], - "", - ), + validate_contract_tags_parts( + &vec![ + vec!["test_optional".to_owned(), "one".to_owned()], + vec!["test_optional".to_owned(), "two".to_owned()], + ], &duplicate_optional, ), Ok(()) diff --git a/crates/events/src/draft.rs b/crates/events/src/draft.rs @@ -15,7 +15,10 @@ use std::{ }; use crate::RadrootsNostrEvent; -use crate::contract::{RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, event_contract}; +use crate::contract::{ + RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, RadrootsContractValidationError, event_contract, + validate_event_contract_parts, +}; use crate::ids::{ RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, RadrootsPublicKey, }; @@ -30,6 +33,10 @@ pub enum RadrootsDraftError { expected_kind: u32, actual_kind: u32, }, + ContractShape { + contract_id: String, + error: RadrootsContractValidationError, + }, SignedEventPubkeyMismatch { expected_pubkey: String, actual_pubkey: String, @@ -76,6 +83,11 @@ impl fmt::Display for RadrootsDraftError { f, "event contract `{contract_id}` expects kind {expected_kind}, got {actual_kind}" ), + Self::ContractShape { contract_id, error } => write!( + f, + "event contract `{contract_id}` shape validation failed with code {}", + error.code() + ), Self::SignedEventPubkeyMismatch { expected_pubkey, actual_pubkey, @@ -182,6 +194,12 @@ impl RadrootsFrozenEventDraft { } let expected_pubkey = RadrootsPublicKey::parse(expected_pubkey.as_ref())?.into_string(); let content = content.into(); + validate_event_contract_parts(kind, &tags, content.as_str(), contract.id).map_err( + |error| RadrootsDraftError::ContractShape { + contract_id: contract.id.to_owned(), + error, + }, + )?; let expected_event_id = compute_nip01_event_id(expected_pubkey.as_str(), created_at, kind, &tags, &content)? .into_string(); @@ -381,7 +399,7 @@ fn push_json_string(target: &mut String, value: &str) -> Result<(), RadrootsDraf #[cfg(test)] mod tests { use super::*; - use crate::kinds::{KIND_POST, KIND_PROFILE}; + use crate::kinds::{KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, KIND_POST, KIND_PROFILE}; fn hex_64(character: char) -> String { core::iter::repeat_n(character, 64).collect() @@ -413,6 +431,10 @@ mod tests { .expect("draft") } + fn claim_content() -> &'static str { + r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"# + } + #[test] fn frozen_draft_computes_expected_event_id() { let draft = RadrootsFrozenEventDraft::new( @@ -519,6 +541,108 @@ mod tests { } #[test] + fn draft_constructor_rejects_contract_shape_errors() { + let missing_contract = RadrootsFrozenEventDraft::new( + "radroots.knowledge.claim.v1", + KIND_KNOWLEDGE_CLAIM, + 1, + Vec::new(), + claim_content(), + hex_64('a'), + ) + .expect_err("missing contract tag"); + assert!(matches!( + missing_contract, + RadrootsDraftError::ContractShape { + error: RadrootsContractValidationError::MissingTag { + name: "contract", + .. + }, + .. + } + )); + + let invalid_event_pointer = RadrootsFrozenEventDraft::new( + "radroots.knowledge.claim.v1", + KIND_KNOWLEDGE_CLAIM, + 1, + vec![ + vec![ + "contract".to_owned(), + "radroots.knowledge.claim.v1".to_owned(), + ], + vec![ + "source".to_owned(), + "not-hex".to_owned(), + hex_64('a'), + KIND_KNOWLEDGE_SOURCE.to_string(), + String::new(), + ], + ], + claim_content(), + hex_64('a'), + ) + .expect_err("invalid event pointer"); + assert!(matches!( + invalid_event_pointer, + RadrootsDraftError::ContractShape { + error: RadrootsContractValidationError::TagValueMismatch { name: "source", .. }, + .. + } + )); + + let invalid_relay = RadrootsFrozenEventDraft::new( + "radroots.knowledge.claim.v1", + KIND_KNOWLEDGE_CLAIM, + 1, + vec![ + vec![ + "contract".to_owned(), + "radroots.knowledge.claim.v1".to_owned(), + ], + vec![ + "source".to_owned(), + hex_64('b'), + hex_64('a'), + KIND_KNOWLEDGE_SOURCE.to_string(), + String::new(), + "http://relay.radroots.example".to_owned(), + ], + ], + claim_content(), + hex_64('a'), + ) + .expect_err("invalid event pointer relay"); + assert!(matches!( + invalid_relay, + RadrootsDraftError::ContractShape { + error: RadrootsContractValidationError::TagValueMismatch { name: "source", .. }, + .. + } + )); + + let invalid_json = RadrootsFrozenEventDraft::new( + "radroots.knowledge.claim.v1", + KIND_KNOWLEDGE_CLAIM, + 1, + vec![vec![ + "contract".to_owned(), + "radroots.knowledge.claim.v1".to_owned(), + ]], + "not-json", + hex_64('a'), + ) + .expect_err("invalid json"); + assert!(matches!( + invalid_json, + RadrootsDraftError::ContractShape { + error: RadrootsContractValidationError::InvalidJsonContent { .. }, + .. + } + )); + } + + #[test] fn signed_event_validates_ids_and_roundtrips_with_serde() { let signed = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { id: hex_64('d'), @@ -691,6 +815,13 @@ mod tests { expected_kind: KIND_POST, actual_kind: KIND_PROFILE, }, + RadrootsDraftError::ContractShape { + contract_id: "radroots.knowledge.claim.v1".to_owned(), + error: RadrootsContractValidationError::MissingTag { + contract_id: "radroots.knowledge.claim.v1", + name: "contract", + }, + }, RadrootsDraftError::SignedEventPubkeyMismatch { expected_pubkey: hex_64('a'), actual_pubkey: hex_64('b'), diff --git a/crates/events_codec/tests/wire.rs b/crates/events_codec/tests/wire.rs @@ -1,10 +1,12 @@ -use radroots_events::kinds::KIND_POST; +use radroots_events::contract::RadrootsContractValidationError; +use radroots_events::draft::RadrootsDraftError; +use radroots_events::kinds::{KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, KIND_POST}; use radroots_events_codec::wire::{ WireEventParts, canonicalize_tags, empty_content, to_frozen_draft, }; #[test] -fn canonicalize_tags_trims_sorts_and_dedups() { +fn wire_canonicalize_tags_trims_sorts_and_dedups() { let mut tags = vec![ vec![" z ".to_string(), "b".to_string()], vec!["t".to_string(), "a".to_string()], @@ -25,7 +27,7 @@ fn canonicalize_tags_trims_sorts_and_dedups() { } #[test] -fn to_frozen_draft_copies_fields_and_computes_expected_id() { +fn wire_to_frozen_draft_copies_fields_and_computes_expected_id() { let parts = WireEventParts { kind: KIND_POST, content: "hello".to_string(), @@ -44,7 +46,66 @@ fn to_frozen_draft_copies_fields_and_computes_expected_id() { } #[test] -fn empty_content_is_empty_string() { +fn wire_to_frozen_draft_rejects_contract_shape_errors() { + let missing_contract_tag = WireEventParts { + kind: KIND_KNOWLEDGE_CLAIM, + content: r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#.to_string(), + tags: Vec::new(), + }; + let error = to_frozen_draft( + missing_contract_tag, + "radroots.knowledge.claim.v1", + "a".repeat(64), + 99, + ) + .expect_err("missing contract tag"); + assert!(matches!( + error, + RadrootsDraftError::ContractShape { + error: RadrootsContractValidationError::MissingTag { + name: "contract", + .. + }, + .. + } + )); + + let invalid_relay = WireEventParts { + kind: KIND_KNOWLEDGE_CLAIM, + content: r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#.to_string(), + tags: vec![ + vec![ + "contract".to_string(), + "radroots.knowledge.claim.v1".to_string(), + ], + vec![ + "source".to_string(), + "b".repeat(64), + "a".repeat(64), + KIND_KNOWLEDGE_SOURCE.to_string(), + String::new(), + "http://relay.radroots.example".to_string(), + ], + ], + }; + let error = to_frozen_draft( + invalid_relay, + "radroots.knowledge.claim.v1", + "a".repeat(64), + 99, + ) + .expect_err("invalid relay"); + assert!(matches!( + error, + RadrootsDraftError::ContractShape { + error: RadrootsContractValidationError::TagValueMismatch { name: "source", .. }, + .. + } + )); +} + +#[test] +fn wire_empty_content_is_empty_string() { let content = empty_content(); assert!(content.is_empty()); }