lib

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

commit eb9246e86c9a72afde35ecb2fd994572250fcf07
parent cfd552f8b23a9cef8f70a865aa2f07b1216c5563
Author: triesap <tyson@radroots.org>
Date:   Sun,  5 Jul 2026 19:33:03 +0000

events: validate knowledge event semantics

- Add shared pure validators for wiki, MVP knowledge, and beta knowledge event models.

- Route knowledge codec encode and decode through the shared validation API with stable existing error mappings.

- Add model-level and codec-level semantic rejection coverage for valid and malformed knowledge payloads.

- Validation: cargo extbuild run -- cargo fmt --all -- --check; cargo extbuild run -- cargo check -p radroots_events --features std,serde,dto-bindgen,knowledge,knowledge-nip54; cargo extbuild run -- cargo test -p radroots_events --features std,serde,dto-bindgen,knowledge,knowledge-nip54; cargo extbuild run -- cargo check -p radroots_events_codec --features std,serde_json,nostr,knowledge,knowledge-nip54; cargo extbuild run -- cargo test -p radroots_events_codec --features std,serde_json,nostr,knowledge,knowledge-nip54.

Diffstat:
Mcrates/events/src/knowledge.rs | 768++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/src/knowledge/decode.rs | 91+++++++++++++++++++++++++++++--------------------------------------------------
Mcrates/events_codec/src/knowledge/encode.rs | 106++++++++++++++++++++++---------------------------------------------------------
Mcrates/events_codec/tests/knowledge.rs | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 1079 insertions(+), 138 deletions(-)

diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs @@ -6,6 +6,8 @@ use alloc::{string::String, vec::Vec}; use core::fmt; use crate::RadrootsNostrEventRef; +use crate::ids::{RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey}; +use crate::kinds::KIND_WIKI_ARTICLE; pub const RADROOTS_KNOWLEDGE_SCHEMA_VERSION: u16 = 1; pub const RADROOTS_WIKI_D_TAG_MAX_LEN: usize = 512; @@ -101,6 +103,414 @@ pub fn validate_wiki_d_tag(value: &str) -> Result<(), RadrootsWikiDTagError> { Ok(()) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsKnowledgeValidationError { + EmptyField(&'static str), + InvalidField(&'static str), +} + +impl RadrootsKnowledgeValidationError { + pub const fn code(self) -> &'static str { + match self { + Self::EmptyField(_) => "empty_field", + Self::InvalidField(_) => "invalid_field", + } + } + + pub const fn field(self) -> &'static str { + match self { + Self::EmptyField(field) | Self::InvalidField(field) => field, + } + } +} + +impl fmt::Display for RadrootsKnowledgeValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyField(field) => write!(formatter, "empty knowledge field: {field}"), + Self::InvalidField(field) => write!(formatter, "invalid knowledge field: {field}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsKnowledgeValidationError {} + +fn require_non_empty( + value: &str, + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + if value.trim().is_empty() { + Err(RadrootsKnowledgeValidationError::EmptyField(field)) + } else { + Ok(()) + } +} + +fn require_schema( + schema: &str, + schema_version: u16, + expected_schema: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + if schema != expected_schema { + return Err(RadrootsKnowledgeValidationError::InvalidField("schema")); + } + if schema_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION { + return Err(RadrootsKnowledgeValidationError::InvalidField( + "schema_version", + )); + } + Ok(()) +} + +fn validate_event_id( + value: &str, + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + if value.trim().is_empty() { + return Err(RadrootsKnowledgeValidationError::EmptyField(field)); + } + RadrootsEventId::parse(value) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field)) +} + +fn validate_pubkey( + value: &str, + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + if value.trim().is_empty() { + return Err(RadrootsKnowledgeValidationError::EmptyField(field)); + } + RadrootsPublicKey::parse(value) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field)) +} + +fn validate_relays( + relays: &[String], + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + for relay in relays { + if relay.is_empty() + || relay.trim() != relay + || relay.chars().any(|character| character.is_control()) + { + return Err(RadrootsKnowledgeValidationError::InvalidField(field)); + } + } + Ok(()) +} + +fn validate_event_ref( + event_ref: &RadrootsNostrEventRef, + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + validate_event_id(&event_ref.id, field)?; + validate_pubkey(&event_ref.author, field)?; + if let Some(d_tag) = event_ref.d_tag.as_deref() { + RadrootsDTag::parse(d_tag) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))?; + } + if let Some(relays) = event_ref.relays.as_deref() { + validate_relays(relays, field)?; + } + Ok(()) +} + +fn validate_event_refs( + refs: &[RadrootsNostrEventRef], + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + for event_ref in refs { + validate_event_ref(event_ref, field)?; + } + Ok(()) +} + +fn validate_address_ref( + address: &RadrootsAddressableRef, + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + if address.kind == 0 { + return Err(RadrootsKnowledgeValidationError::InvalidField(field)); + } + validate_pubkey(&address.pubkey, field)?; + RadrootsDTag::parse(address.d_tag.as_str()) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))?; + validate_relays(&address.relays, field) +} + +fn validate_wiki_article_address_ref( + address: &RadrootsAddressableRef, + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + if address.kind != KIND_WIKI_ARTICLE { + return Err(RadrootsKnowledgeValidationError::InvalidField(field)); + } + validate_pubkey(&address.pubkey, field)?; + validate_wiki_d_tag(address.d_tag.as_str()) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))?; + validate_relays(&address.relays, field) +} + +fn validate_wiki_article_version_ref( + version_ref: &RadrootsWikiArticleVersionRef, + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + validate_event_id(&version_ref.event_id, field)?; + validate_wiki_article_address_ref(&version_ref.address_ref, field) +} + +fn validate_node_ref( + node_ref: &RadrootsKnowledgeNodeRef, + field: &'static str, +) -> Result<(), RadrootsKnowledgeValidationError> { + let mut populated = 0u8; + if let Some(event_ref) = &node_ref.event_ref { + populated += 1; + validate_event_ref(event_ref, field)?; + } + if let Some(address_ref) = &node_ref.address_ref { + populated += 1; + validate_address_ref(address_ref, field)?; + } + if let Some(external_id) = node_ref.external_id.as_deref() { + populated += 1; + require_non_empty(external_id, field)?; + } + if populated == 1 { + Ok(()) + } else { + Err(RadrootsKnowledgeValidationError::InvalidField(field)) + } +} + +pub fn validate_wiki_article( + article: &RadrootsWikiArticle, +) -> Result<(), RadrootsKnowledgeValidationError> { + validate_wiki_d_tag(article.d_tag.as_str()) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("d_tag"))?; + require_non_empty(article.title.as_str(), "title")?; + require_non_empty(article.content_djot.as_str(), "content_djot")?; + validate_event_refs(&article.references, "references")?; + for version_ref in &article.forked_from { + validate_wiki_article_version_ref(version_ref, "forked_from")?; + } + if let Some(version_ref) = &article.deferred_to { + validate_wiki_article_version_ref(version_ref, "deferred_to")?; + } + Ok(()) +} + +pub fn validate_wiki_redirect( + redirect: &RadrootsWikiRedirect, +) -> Result<(), RadrootsKnowledgeValidationError> { + validate_wiki_d_tag(redirect.d_tag.as_str()) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("d_tag"))?; + validate_wiki_article_address_ref(&redirect.target, "wiki_redirect.target") +} + +pub fn validate_wiki_merge_request( + request: &RadrootsWikiMergeRequest, +) -> Result<(), RadrootsKnowledgeValidationError> { + validate_wiki_article_address_ref(&request.target_article, "target_article")?; + validate_pubkey(&request.destination_pubkey, "destination_pubkey")?; + if let Some(base) = request.base_version_event_id.as_deref() { + validate_event_id(base, "base_version_event_id")?; + } + validate_event_id( + request.source_version_event_id.as_str(), + "source_version_event_id", + ) +} + +pub fn validate_knowledge_source( + source: &RadrootsKnowledgeSource, +) -> Result<(), RadrootsKnowledgeValidationError> { + require_schema( + source.schema.as_str(), + source.schema_version, + RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, + )?; + validate_wiki_d_tag(source.d_tag.as_str()) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("d_tag"))?; + require_non_empty(source.title.as_str(), "title")?; + require_non_empty(source.source_type.as_str(), "source_type")?; + for author in &source.authors { + require_non_empty(author.as_str(), "authors")?; + } + validate_event_refs(&source.artifact_refs, "artifact_refs") +} + +pub fn validate_knowledge_claim( + claim: &RadrootsKnowledgeClaim, +) -> Result<(), RadrootsKnowledgeValidationError> { + require_schema( + claim.schema.as_str(), + claim.schema_version, + RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, + )?; + require_non_empty(claim.claim_type.as_str(), "claim_type")?; + require_non_empty(claim.text.as_str(), "text")?; + for citation in &claim.citation_spans { + if citation.source_ref.kind != crate::kinds::KIND_KNOWLEDGE_SOURCE { + return Err(RadrootsKnowledgeValidationError::InvalidField( + "citation_spans", + )); + } + validate_event_ref(&citation.source_ref, "citation_spans")?; + if let Some(artifact_ref) = &citation.artifact_ref { + validate_event_ref(artifact_ref, "citation_spans")?; + } + if let Some(quote_hash) = citation.quote_hash.as_deref() { + validate_event_id(quote_hash, "citation_spans")?; + } + } + validate_event_refs(&claim.supersedes, "supersedes") +} + +pub fn validate_knowledge_relation( + relation: &RadrootsKnowledgeRelation, +) -> Result<(), RadrootsKnowledgeValidationError> { + require_schema( + relation.schema.as_str(), + relation.schema_version, + RADROOTS_KNOWLEDGE_RELATION_SCHEMA, + )?; + validate_node_ref(&relation.subject, "subject")?; + require_non_empty(relation.predicate.as_str(), "predicate")?; + validate_node_ref(&relation.object, "object")?; + validate_event_refs(&relation.support_refs, "support_refs")?; + validate_event_refs(&relation.supersedes, "supersedes") +} + +pub fn validate_knowledge_review( + review: &RadrootsKnowledgeReview, +) -> Result<(), RadrootsKnowledgeValidationError> { + require_schema( + review.schema.as_str(), + review.schema_version, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, + )?; + validate_event_id(review.target.event_id.as_str(), "review_target")?; + validate_pubkey(review.target.author_pubkey.as_str(), "review_target")?; + if review.target.kind == 0 { + return Err(RadrootsKnowledgeValidationError::InvalidField( + "review_target", + )); + } + if let Some(address) = review.target.address.as_deref() { + RadrootsAddressableCoordinate::parse(address) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("review_target"))?; + } + validate_relays(&review.target.relays, "review_target")?; + require_non_empty(review.reviewer_role.as_str(), "reviewer_role")?; + require_non_empty(review.verdict.as_str(), "verdict")?; + for score in &review.scores { + require_non_empty(score.dimension.as_str(), "scores")?; + require_non_empty(score.value.as_str(), "scores")?; + } + validate_event_refs(&review.evidence_refs, "evidence_refs") +} + +pub fn validate_knowledge_field_report( + report: &RadrootsKnowledgeFieldReport, +) -> Result<(), RadrootsKnowledgeValidationError> { + require_schema( + report.schema.as_str(), + report.schema_version, + RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, + )?; + require_non_empty(report.report_type.as_str(), "report_type")?; + require_non_empty(report.title.as_str(), "title")?; + if report.context.location_precision + == RadrootsKnowledgeLocationPrecision::ExactPrivateReference + && report.context.private_location_ref.is_none() + { + return Err(RadrootsKnowledgeValidationError::EmptyField( + "private_location_ref", + )); + } + if let Some(private_location_ref) = &report.context.private_location_ref { + validate_event_ref(private_location_ref, "private_location_ref")?; + } + if report.observations.is_empty() { + return Err(RadrootsKnowledgeValidationError::EmptyField("observations")); + } + for observation in &report.observations { + require_non_empty(observation.observation_type.as_str(), "observations")?; + require_non_empty(observation.text.as_str(), "observations")?; + for value in &observation.values { + require_non_empty(value.key.as_str(), "observation_values")?; + require_non_empty(value.value.as_str(), "observation_values")?; + } + } + validate_event_refs(&report.artifact_refs, "artifact_refs")?; + validate_event_refs(&report.related_refs, "related_refs") +} + +pub fn validate_evidence_bounty( + bounty: &RadrootsEvidenceBounty, +) -> Result<(), RadrootsKnowledgeValidationError> { + require_schema( + bounty.schema.as_str(), + bounty.schema_version, + RADROOTS_EVIDENCE_BOUNTY_SCHEMA, + )?; + validate_wiki_d_tag(bounty.d_tag.as_str()) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("d_tag"))?; + require_non_empty(bounty.title.as_str(), "title")?; + if bounty.target_refs.is_empty() { + return Err(RadrootsKnowledgeValidationError::EmptyField("target_refs")); + } + validate_event_refs(&bounty.target_refs, "target_refs") +} + +pub fn validate_knowledge_change_proposal( + proposal: &RadrootsKnowledgeChangeProposal, +) -> Result<(), RadrootsKnowledgeValidationError> { + require_schema( + proposal.schema.as_str(), + proposal.schema_version, + RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, + )?; + validate_event_ref(&proposal.target, "target")?; + require_non_empty(proposal.proposal_type.as_str(), "proposal_type")?; + require_non_empty(proposal.summary.as_str(), "summary")?; + validate_event_refs(&proposal.evidence_refs, "evidence_refs")?; + validate_event_refs(&proposal.supersedes, "supersedes") +} + +pub fn validate_contribution_attestation( + attestation: &RadrootsContributionAttestation, +) -> Result<(), RadrootsKnowledgeValidationError> { + require_schema( + attestation.schema.as_str(), + attestation.schema_version, + RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, + )?; + validate_pubkey( + attestation.contributor_pubkey.as_str(), + "contributor_pubkey", + )?; + require_non_empty(attestation.contribution_type.as_str(), "contribution_type")?; + require_non_empty(attestation.summary.as_str(), "summary")?; + if attestation.subject_refs.is_empty() { + return Err(RadrootsKnowledgeValidationError::EmptyField("subject_refs")); + } + validate_event_refs(&attestation.subject_refs, "subject_refs")?; + validate_event_refs(&attestation.evidence_refs, "evidence_refs") +} + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] #[cfg_attr(feature = "dto-bindgen", dto(export))] @@ -419,16 +829,208 @@ pub struct RadrootsContributionAttestation { mod tests { use super::*; + fn hex_64(character: char) -> String { + core::iter::repeat_n(character, 64).collect() + } + fn event_ref() -> RadrootsNostrEventRef { + event_ref_with_kind(1) + } + + fn event_ref_with_kind(kind: u32) -> RadrootsNostrEventRef { RadrootsNostrEventRef { id: "0".repeat(64), author: "1".repeat(64), - kind: 1, + kind, d_tag: None, relays: None, } } + fn article_address_ref() -> RadrootsAddressableRef { + RadrootsAddressableRef { + kind: KIND_WIKI_ARTICLE, + pubkey: hex_64('a'), + d_tag: "soil-health".to_string(), + relays: Vec::new(), + } + } + + fn article_version_ref() -> RadrootsWikiArticleVersionRef { + RadrootsWikiArticleVersionRef { + event_id: hex_64('b'), + address_ref: article_address_ref(), + } + } + + fn knowledge_source() -> RadrootsKnowledgeSource { + RadrootsKnowledgeSource { + schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + d_tag: "soil-source".to_string(), + title: "Soil Source".to_string(), + source_type: "book".to_string(), + authors: vec!["Ada Example".to_string()], + publisher: None, + publication_year: Some(2026), + edition: None, + canonical_url: None, + artifact_refs: vec![event_ref()], + author_asserted_rights: None, + topics: vec!["soil".to_string()], + summary: None, + } + } + + fn citation_span() -> RadrootsKnowledgeCitationSpan { + RadrootsKnowledgeCitationSpan { + source_ref: event_ref_with_kind(crate::kinds::KIND_KNOWLEDGE_SOURCE), + artifact_ref: Some(event_ref()), + page_start: None, + page_end: None, + section_path: Vec::new(), + quote_hash: Some(hex_64('c')), + chunk_id: None, + } + } + + fn knowledge_claim() -> RadrootsKnowledgeClaim { + RadrootsKnowledgeClaim { + schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + claim_type: "practice_effect".to_string(), + text: "Cover crops improve soil structure.".to_string(), + citation_spans: vec![citation_span()], + topics: Vec::new(), + applies_to: Vec::new(), + author_asserted_confidence: None, + supersedes: vec![event_ref()], + } + } + + fn node_ref() -> RadrootsKnowledgeNodeRef { + RadrootsKnowledgeNodeRef { + node_type: "event".to_string(), + event_ref: Some(event_ref()), + address_ref: None, + external_id: None, + label: None, + } + } + + fn knowledge_relation() -> RadrootsKnowledgeRelation { + RadrootsKnowledgeRelation { + schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + subject: node_ref(), + predicate: "supports".to_string(), + object: node_ref(), + support_refs: vec![event_ref()], + author_asserted_confidence: None, + supersedes: Vec::new(), + } + } + + fn knowledge_review() -> RadrootsKnowledgeReview { + RadrootsKnowledgeReview { + schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + target: RadrootsKnowledgeReviewTarget { + event_id: hex_64('d'), + author_pubkey: hex_64('a'), + kind: crate::kinds::KIND_KNOWLEDGE_CLAIM, + address: None, + relays: Vec::new(), + review_scope: RadrootsKnowledgeReviewScope::SpecificVersion, + }, + reviewer_role: "peer".to_string(), + verdict: "needs_more_evidence".to_string(), + scores: vec![RadrootsKnowledgeReviewScore { + dimension: "evidence".to_string(), + value: "partial".to_string(), + note: None, + }], + notes: None, + evidence_refs: vec![event_ref()], + } + } + + fn field_report() -> RadrootsKnowledgeFieldReport { + RadrootsKnowledgeFieldReport { + schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + report_type: "observation".to_string(), + title: "Field observation".to_string(), + summary: None, + context: RadrootsKnowledgeFieldContext { + location_precision: RadrootsKnowledgeLocationPrecision::CoarseGeohash, + public_location: None, + private_location_ref: None, + topics: Vec::new(), + context_tags: Vec::new(), + }, + observations: vec![RadrootsKnowledgeObservation { + observation_type: "residue".to_string(), + text: "Residue was visible.".to_string(), + observed_at: None, + values: vec![RadrootsKnowledgeObservationValue { + key: "coverage".to_string(), + value: "medium".to_string(), + unit: None, + }], + }], + artifact_refs: vec![event_ref()], + related_refs: vec![event_ref()], + limitations: Vec::new(), + } + } + + fn evidence_bounty() -> RadrootsEvidenceBounty { + RadrootsEvidenceBounty { + schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + d_tag: "soil-bounty".to_string(), + title: "Soil bounty".to_string(), + summary: None, + topics: Vec::new(), + target_refs: vec![event_ref()], + reward_note: None, + closes_at: None, + } + } + + fn knowledge_change_proposal() -> RadrootsKnowledgeChangeProposal { + RadrootsKnowledgeChangeProposal { + schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + target: event_ref(), + proposal_type: "amend".to_string(), + summary: "Clarify scope".to_string(), + rationale: None, + evidence_refs: vec![event_ref()], + supersedes: Vec::new(), + } + } + + fn contribution_attestation() -> RadrootsContributionAttestation { + RadrootsContributionAttestation { + schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + contributor_pubkey: hex_64('a'), + contribution_type: "review".to_string(), + subject_refs: vec![event_ref()], + summary: "Reviewed claim".to_string(), + evidence_refs: vec![event_ref()], + } + } + + fn assert_validation_error( + result: Result<(), RadrootsKnowledgeValidationError>, + expected: RadrootsKnowledgeValidationError, + ) { + assert_eq!(result, Err(expected)); + } + #[test] fn normalizes_wiki_d_tags() { assert_eq!( @@ -599,4 +1201,168 @@ mod tests { Some("c23".to_string()) ); } + + #[test] + fn knowledge_validators_accept_valid_models() { + let article = RadrootsWikiArticle { + d_tag: "soil-health".to_string(), + title: "Soil health".to_string(), + content_djot: "# Soil health".to_string(), + summary: None, + topics: Vec::new(), + references: vec![event_ref()], + forked_from: vec![article_version_ref()], + deferred_to: Some(article_version_ref()), + }; + assert_eq!(validate_wiki_article(&article), Ok(())); + assert_eq!( + validate_wiki_redirect(&RadrootsWikiRedirect { + d_tag: "soil".to_string(), + target: article_address_ref(), + }), + Ok(()) + ); + assert_eq!( + validate_wiki_merge_request(&RadrootsWikiMergeRequest { + target_article: article_address_ref(), + destination_pubkey: hex_64('a'), + base_version_event_id: Some(hex_64('e')), + source_version_event_id: hex_64('f'), + explanation: None, + }), + Ok(()) + ); + assert_eq!(validate_knowledge_source(&knowledge_source()), Ok(())); + assert_eq!(validate_knowledge_claim(&knowledge_claim()), Ok(())); + assert_eq!(validate_knowledge_relation(&knowledge_relation()), Ok(())); + assert_eq!(validate_knowledge_review(&knowledge_review()), Ok(())); + assert_eq!(validate_knowledge_field_report(&field_report()), Ok(())); + assert_eq!(validate_evidence_bounty(&evidence_bounty()), Ok(())); + assert_eq!( + validate_knowledge_change_proposal(&knowledge_change_proposal()), + Ok(()) + ); + assert_eq!( + validate_contribution_attestation(&contribution_attestation()), + Ok(()) + ); + } + + #[test] + fn knowledge_validators_reject_representative_invalid_models() { + let mut article = RadrootsWikiArticle { + d_tag: "soil-health".to_string(), + title: "Soil health".to_string(), + content_djot: " ".to_string(), + summary: None, + topics: Vec::new(), + references: Vec::new(), + forked_from: Vec::new(), + deferred_to: None, + }; + assert_validation_error( + validate_wiki_article(&article), + RadrootsKnowledgeValidationError::EmptyField("content_djot"), + ); + article.content_djot = "# Soil health".to_string(); + article.forked_from.push(RadrootsWikiArticleVersionRef { + event_id: "bad".to_string(), + address_ref: article_address_ref(), + }); + assert_validation_error( + validate_wiki_article(&article), + RadrootsKnowledgeValidationError::InvalidField("forked_from"), + ); + + let mut redirect = RadrootsWikiRedirect { + d_tag: "soil".to_string(), + target: article_address_ref(), + }; + redirect.target.kind = 30023; + assert_validation_error( + validate_wiki_redirect(&redirect), + RadrootsKnowledgeValidationError::InvalidField("wiki_redirect.target"), + ); + + let mut merge = RadrootsWikiMergeRequest { + target_article: article_address_ref(), + destination_pubkey: "bad".to_string(), + base_version_event_id: None, + source_version_event_id: hex_64('f'), + explanation: None, + }; + assert_validation_error( + validate_wiki_merge_request(&merge), + RadrootsKnowledgeValidationError::InvalidField("destination_pubkey"), + ); + merge.destination_pubkey = hex_64('a'); + merge.source_version_event_id = String::new(); + assert_validation_error( + validate_wiki_merge_request(&merge), + RadrootsKnowledgeValidationError::EmptyField("source_version_event_id"), + ); + + let mut source = knowledge_source(); + source.authors.push(" ".to_string()); + assert_validation_error( + validate_knowledge_source(&source), + RadrootsKnowledgeValidationError::EmptyField("authors"), + ); + + let mut claim = knowledge_claim(); + claim.citation_spans[0].quote_hash = Some("bad".to_string()); + assert_validation_error( + validate_knowledge_claim(&claim), + RadrootsKnowledgeValidationError::InvalidField("citation_spans"), + ); + + let mut relation = knowledge_relation(); + relation.subject.external_id = Some("external".to_string()); + assert_validation_error( + validate_knowledge_relation(&relation), + RadrootsKnowledgeValidationError::InvalidField("subject"), + ); + + let mut review = knowledge_review(); + review.target.kind = 0; + assert_validation_error( + validate_knowledge_review(&review), + RadrootsKnowledgeValidationError::InvalidField("review_target"), + ); + + let mut report = field_report(); + report.context.location_precision = + RadrootsKnowledgeLocationPrecision::ExactPrivateReference; + assert_validation_error( + validate_knowledge_field_report(&report), + RadrootsKnowledgeValidationError::EmptyField("private_location_ref"), + ); + report.context.location_precision = RadrootsKnowledgeLocationPrecision::CoarseGeohash; + report.observations[0].values[0].value = String::new(); + assert_validation_error( + validate_knowledge_field_report(&report), + RadrootsKnowledgeValidationError::EmptyField("observation_values"), + ); + + let mut bounty = evidence_bounty(); + bounty.target_refs.clear(); + assert_validation_error( + validate_evidence_bounty(&bounty), + RadrootsKnowledgeValidationError::EmptyField("target_refs"), + ); + + let mut proposal = knowledge_change_proposal(); + proposal.summary = " ".to_string(); + assert_validation_error( + validate_knowledge_change_proposal(&proposal), + RadrootsKnowledgeValidationError::EmptyField("summary"), + ); + + let mut attestation = contribution_attestation(); + attestation.contributor_pubkey = "bad".to_string(); + assert_validation_error( + validate_contribution_attestation(&attestation), + RadrootsKnowledgeValidationError::InvalidField("contributor_pubkey"), + ); + } } diff --git a/crates/events_codec/src/knowledge/decode.rs b/crates/events_codec/src/knowledge/decode.rs @@ -14,12 +14,16 @@ use radroots_events::knowledge::{ RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA, - RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION, - RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation, - RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim, - RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, - RadrootsKnowledgeSource, RadrootsWikiArticle, RadrootsWikiArticleVersionRef, - RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_wiki_d_tag, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, + RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, + RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, + RadrootsKnowledgeReview, RadrootsKnowledgeSource, RadrootsKnowledgeValidationError, + RadrootsWikiArticle, RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, + RadrootsWikiRedirect, validate_contribution_attestation, validate_evidence_bounty, + validate_knowledge_change_proposal, validate_knowledge_claim, validate_knowledge_field_report, + validate_knowledge_relation, validate_knowledge_review, validate_knowledge_source, + validate_wiki_article, validate_wiki_d_tag, validate_wiki_merge_request, + validate_wiki_redirect, }; use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_P, TAG_SUMMARY, TAG_T}; use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventRef}; @@ -264,18 +268,17 @@ fn require_contract_tag( } } -fn require_schema( - schema: &str, - schema_version: u16, - expected: &'static str, -) -> Result<(), EventParseError> { - if schema != expected { - return Err(EventParseError::InvalidJson("schema")); - } - if schema_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION { - return Err(EventParseError::InvalidJson("schema_version")); +fn parse_validation_error(error: RadrootsKnowledgeValidationError) -> EventParseError { + match error.field() { + "d_tag" => EventParseError::InvalidTag(TAG_D), + "references" => EventParseError::InvalidTag(TAG_SOURCE), + "forked_from" | "deferred_to" | "wiki_redirect.target" | "target_article" => { + EventParseError::InvalidTag(TAG_A) + } + "destination_pubkey" => EventParseError::InvalidTag(TAG_P), + "base_version_event_id" | "source_version_event_id" => EventParseError::InvalidTag(TAG_E), + field => EventParseError::InvalidJson(field), } - Ok(()) } fn reject_private_coordinate_keys(content: &str) -> Result<(), EventParseError> { @@ -334,6 +337,7 @@ pub fn wiki_article_from_event( forked_from, deferred_to, }; + validate_wiki_article(&article).map_err(parse_validation_error)?; Ok(parsed(event, article)) } @@ -350,7 +354,9 @@ pub fn wiki_redirect_from_event( if target.kind != KIND_WIKI_ARTICLE { return Err(EventParseError::InvalidTag(TAG_A)); } - Ok(parsed(event, RadrootsWikiRedirect { d_tag, target })) + let redirect = RadrootsWikiRedirect { d_tag, target }; + validate_wiki_redirect(&redirect).map_err(parse_validation_error)?; + Ok(parsed(event, redirect)) } pub fn wiki_merge_request_from_event( @@ -376,6 +382,7 @@ pub fn wiki_merge_request_from_event( source_version_event_id, explanation, }; + validate_wiki_merge_request(&request).map_err(parse_validation_error)?; Ok(parsed(event, request)) } @@ -385,15 +392,11 @@ pub fn knowledge_source_from_event( ensure_kind(event.kind, KIND_KNOWLEDGE_SOURCE, "knowledge source")?; require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA)?; let source: RadrootsKnowledgeSource = json_content(&event.content)?; - require_schema( - &source.schema, - source.schema_version, - RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, - )?; let d_tag = required_one_value(&event.tags, TAG_D)?; if d_tag != source.d_tag { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_knowledge_source(&source).map_err(parse_validation_error)?; Ok(parsed(event, source)) } @@ -403,15 +406,11 @@ pub fn evidence_bounty_from_event( ensure_kind(event.kind, KIND_EVIDENCE_BOUNTY, "evidence bounty")?; require_contract_tag(&event.tags, RADROOTS_EVIDENCE_BOUNTY_SCHEMA)?; let bounty: RadrootsEvidenceBounty = json_content(&event.content)?; - require_schema( - &bounty.schema, - bounty.schema_version, - RADROOTS_EVIDENCE_BOUNTY_SCHEMA, - )?; let d_tag = required_one_value(&event.tags, TAG_D)?; if d_tag != bounty.d_tag { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_evidence_bounty(&bounty).map_err(parse_validation_error)?; Ok(parsed(event, bounty)) } @@ -421,11 +420,7 @@ pub fn knowledge_claim_from_event( ensure_kind(event.kind, KIND_KNOWLEDGE_CLAIM, "knowledge claim")?; require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA)?; let claim: RadrootsKnowledgeClaim = json_content(&event.content)?; - require_schema( - &claim.schema, - claim.schema_version, - RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, - )?; + validate_knowledge_claim(&claim).map_err(parse_validation_error)?; Ok(parsed(event, claim)) } @@ -435,11 +430,7 @@ pub fn knowledge_relation_from_event( ensure_kind(event.kind, KIND_KNOWLEDGE_RELATION, "knowledge relation")?; require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_RELATION_SCHEMA)?; let relation: RadrootsKnowledgeRelation = json_content(&event.content)?; - require_schema( - &relation.schema, - relation.schema_version, - RADROOTS_KNOWLEDGE_RELATION_SCHEMA, - )?; + validate_knowledge_relation(&relation).map_err(parse_validation_error)?; Ok(parsed(event, relation)) } @@ -450,11 +441,7 @@ pub fn knowledge_review_from_event( require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_REVIEW_SCHEMA)?; required_one_value(&event.tags, TAG_REVIEW_TARGET)?; let review: RadrootsKnowledgeReview = json_content(&event.content)?; - require_schema( - &review.schema, - review.schema_version, - RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, - )?; + validate_knowledge_review(&review).map_err(parse_validation_error)?; Ok(parsed(event, review)) } @@ -469,11 +456,7 @@ pub fn knowledge_field_report_from_event( require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA)?; reject_private_coordinate_keys(&event.content)?; let report: RadrootsKnowledgeFieldReport = json_content(&event.content)?; - require_schema( - &report.schema, - report.schema_version, - RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, - )?; + validate_knowledge_field_report(&report).map_err(parse_validation_error)?; Ok(parsed(event, report)) } @@ -487,11 +470,7 @@ pub fn knowledge_change_proposal_from_event( )?; require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA)?; let proposal: RadrootsKnowledgeChangeProposal = json_content(&event.content)?; - require_schema( - &proposal.schema, - proposal.schema_version, - RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, - )?; + validate_knowledge_change_proposal(&proposal).map_err(parse_validation_error)?; Ok(parsed(event, proposal)) } @@ -505,10 +484,6 @@ pub fn contribution_attestation_from_event( )?; require_contract_tag(&event.tags, RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA)?; let attestation: RadrootsContributionAttestation = json_content(&event.content)?; - require_schema( - &attestation.schema, - attestation.schema_version, - RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, - )?; + validate_contribution_attestation(&attestation).map_err(parse_validation_error)?; Ok(parsed(event, attestation)) } diff --git a/crates/events_codec/src/knowledge/encode.rs b/crates/events_codec/src/knowledge/encode.rs @@ -17,13 +17,16 @@ use radroots_events::knowledge::{ RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA, - RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION, - RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation, - RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim, - RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, - RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle, - RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect, - validate_wiki_d_tag, + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, + RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, + RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, + RadrootsKnowledgeReview, RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, + RadrootsKnowledgeValidationError, RadrootsWikiArticle, RadrootsWikiArticleVersionRef, + RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_contribution_attestation, + validate_evidence_bounty, validate_knowledge_change_proposal, validate_knowledge_claim, + validate_knowledge_field_report, validate_knowledge_relation, validate_knowledge_review, + validate_knowledge_source, validate_wiki_article, validate_wiki_merge_request, + validate_wiki_redirect, }; use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_G, TAG_P, TAG_SUMMARY, TAG_T}; use serde::Serialize; @@ -136,18 +139,15 @@ fn json_content<T: Serialize>(value: &T) -> Result<String, EventEncodeError> { serde_json::to_string(value).map_err(|_| EventEncodeError::Json) } -fn require_schema( - actual_schema: &str, - actual_version: u16, - expected_schema: &'static str, -) -> Result<(), EventEncodeError> { - if actual_schema != expected_schema { - return Err(EventEncodeError::InvalidField("schema")); - } - if actual_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION { - return Err(EventEncodeError::InvalidField("schema_version")); +fn encode_validation_error(error: RadrootsKnowledgeValidationError) -> EventEncodeError { + match error { + RadrootsKnowledgeValidationError::EmptyField(field) => { + EventEncodeError::EmptyRequiredField(field) + } + RadrootsKnowledgeValidationError::InvalidField(field) => { + EventEncodeError::InvalidField(field) + } } - Ok(()) } fn custom_tags(contract_id: &'static str) -> Vec<Vec<String>> { @@ -157,10 +157,7 @@ fn custom_tags(contract_id: &'static str) -> Vec<Vec<String>> { pub fn wiki_article_build_tags( article: &RadrootsWikiArticle, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - validate_wiki_d_tag(&article.d_tag).map_err(|_| EventEncodeError::InvalidField("d_tag"))?; - if article.title.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("title")); - } + validate_wiki_article(article).map_err(encode_validation_error)?; let mut tags = Vec::new(); tags.push(vec![TAG_D.to_string(), article.d_tag.clone()]); push_value(&mut tags, TAG_TITLE, &article.title); @@ -189,7 +186,7 @@ pub fn wiki_article_to_wire_parts( pub fn wiki_redirect_build_tags( redirect: &RadrootsWikiRedirect, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - validate_wiki_d_tag(&redirect.d_tag).map_err(|_| EventEncodeError::InvalidField("d_tag"))?; + validate_wiki_redirect(redirect).map_err(encode_validation_error)?; let mut tags = Vec::new(); tags.push(vec![TAG_D.to_string(), redirect.d_tag.clone()]); tags.push(address_tag(TAG_A, &redirect.target)); @@ -209,14 +206,7 @@ pub fn wiki_redirect_to_wire_parts( pub fn wiki_merge_request_build_tags( request: &RadrootsWikiMergeRequest, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - if request.destination_pubkey.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("destination_pubkey")); - } - if request.source_version_event_id.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField( - "source_version_event_id", - )); - } + validate_wiki_merge_request(request).map_err(encode_validation_error)?; let mut tags = Vec::new(); tags.push(address_tag(TAG_A, &request.target_article)); tags.push(vec![TAG_P.to_string(), request.destination_pubkey.clone()]); @@ -249,14 +239,7 @@ pub fn wiki_merge_request_to_wire_parts( pub fn knowledge_source_build_tags( source: &RadrootsKnowledgeSource, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - require_schema( - &source.schema, - source.schema_version, - RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, - )?; - if source.d_tag.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("d_tag")); - } + validate_knowledge_source(source).map_err(encode_validation_error)?; let mut tags = custom_tags(RADROOTS_KNOWLEDGE_SOURCE_SCHEMA); tags.push(vec![TAG_D.to_string(), source.d_tag.clone()]); push_topics(&mut tags, &source.topics); @@ -277,14 +260,7 @@ pub fn knowledge_source_to_wire_parts( pub fn evidence_bounty_build_tags( bounty: &RadrootsEvidenceBounty, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - require_schema( - &bounty.schema, - bounty.schema_version, - RADROOTS_EVIDENCE_BOUNTY_SCHEMA, - )?; - if bounty.d_tag.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("d_tag")); - } + validate_evidence_bounty(bounty).map_err(encode_validation_error)?; let mut tags = custom_tags(RADROOTS_EVIDENCE_BOUNTY_SCHEMA); tags.push(vec![TAG_D.to_string(), bounty.d_tag.clone()]); push_topics(&mut tags, &bounty.topics); @@ -305,11 +281,7 @@ pub fn evidence_bounty_to_wire_parts( pub fn knowledge_claim_build_tags( claim: &RadrootsKnowledgeClaim, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - require_schema( - &claim.schema, - claim.schema_version, - RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, - )?; + validate_knowledge_claim(claim).map_err(encode_validation_error)?; let mut tags = custom_tags(RADROOTS_KNOWLEDGE_CLAIM_SCHEMA); push_topics(&mut tags, &claim.topics); for citation in &claim.citation_spans { @@ -334,11 +306,7 @@ pub fn knowledge_claim_to_wire_parts( pub fn knowledge_relation_build_tags( relation: &RadrootsKnowledgeRelation, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - require_schema( - &relation.schema, - relation.schema_version, - RADROOTS_KNOWLEDGE_RELATION_SCHEMA, - )?; + validate_knowledge_relation(relation).map_err(encode_validation_error)?; let mut tags = custom_tags(RADROOTS_KNOWLEDGE_RELATION_SCHEMA); push_event_refs(&mut tags, TAG_SOURCE, &relation.support_refs); Ok(tags) @@ -357,11 +325,7 @@ pub fn knowledge_relation_to_wire_parts( pub fn knowledge_review_build_tags( review: &RadrootsKnowledgeReview, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - require_schema( - &review.schema, - review.schema_version, - RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, - )?; + validate_knowledge_review(review).map_err(encode_validation_error)?; let mut tags = custom_tags(RADROOTS_KNOWLEDGE_REVIEW_SCHEMA); tags.push(build_event_ref_tag( TAG_REVIEW_TARGET, @@ -384,11 +348,7 @@ pub fn knowledge_review_to_wire_parts( pub fn knowledge_field_report_build_tags( report: &RadrootsKnowledgeFieldReport, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - require_schema( - &report.schema, - report.schema_version, - RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, - )?; + validate_knowledge_field_report(report).map_err(encode_validation_error)?; let mut tags = custom_tags(RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA); push_topics(&mut tags, &report.context.topics); if let Some(location) = &report.context.public_location @@ -414,11 +374,7 @@ pub fn knowledge_field_report_to_wire_parts( pub fn knowledge_change_proposal_build_tags( proposal: &RadrootsKnowledgeChangeProposal, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - require_schema( - &proposal.schema, - proposal.schema_version, - RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, - )?; + validate_knowledge_change_proposal(proposal).map_err(encode_validation_error)?; let mut tags = custom_tags(RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA); push_event_refs(&mut tags, TAG_EVIDENCE, &proposal.evidence_refs); Ok(tags) @@ -437,11 +393,7 @@ pub fn knowledge_change_proposal_to_wire_parts( pub fn contribution_attestation_build_tags( attestation: &RadrootsContributionAttestation, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - require_schema( - &attestation.schema, - attestation.schema_version, - RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, - )?; + validate_contribution_attestation(attestation).map_err(encode_validation_error)?; let mut tags = custom_tags(RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA); push_event_refs(&mut tags, TAG_EVIDENCE, &attestation.evidence_refs); Ok(tags) diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs @@ -19,7 +19,7 @@ use radroots_events::knowledge::{ RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle, RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect, }; -use radroots_events_codec::error::EventParseError; +use radroots_events_codec::error::{EventEncodeError, EventParseError}; use radroots_events_codec::knowledge::{ contribution_attestation_from_event, contribution_attestation_to_wire_parts, evidence_bounty_from_event, evidence_bounty_to_wire_parts, @@ -82,7 +82,8 @@ fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent { fn assert_parse_error(actual: EventParseError, expected: EventParseError) { match (actual, expected) { (EventParseError::MissingTag(actual), EventParseError::MissingTag(expected)) - | (EventParseError::InvalidTag(actual), EventParseError::InvalidTag(expected)) => { + | (EventParseError::InvalidTag(actual), EventParseError::InvalidTag(expected)) + | (EventParseError::InvalidJson(actual), EventParseError::InvalidJson(expected)) => { assert_eq!(actual, expected); } ( @@ -99,6 +100,19 @@ fn assert_parse_error(actual: EventParseError, expected: EventParseError) { } } +fn assert_encode_error(actual: EventEncodeError, expected: EventEncodeError) { + match (actual, expected) { + ( + EventEncodeError::EmptyRequiredField(actual), + EventEncodeError::EmptyRequiredField(expected), + ) + | (EventEncodeError::InvalidField(actual), EventEncodeError::InvalidField(expected)) => { + assert_eq!(actual, expected); + } + (actual, expected) => panic!("expected {expected:?}, got {actual:?}"), + } +} + fn sign_parts(parts: WireEventParts) -> RadrootsNostrEvent { let tags = parts .tags @@ -642,3 +656,237 @@ fn malformed_nip54_wiki_shapes_are_rejected() { EventParseError::InvalidTag("a"), ); } + +#[test] +fn semantic_validation_rejects_invalid_encode_models() { + let mut article = wiki_article(); + article.content_djot = String::new(); + assert_encode_error( + wiki_article_to_wire_parts(&article).unwrap_err(), + EventEncodeError::EmptyRequiredField("content_djot"), + ); + + let mut redirect = RadrootsWikiRedirect { + d_tag: "soil".to_string(), + target: address_ref(), + }; + redirect.target.kind = 30023; + assert_encode_error( + wiki_redirect_to_wire_parts(&redirect).unwrap_err(), + EventEncodeError::InvalidField("wiki_redirect.target"), + ); + + let mut merge = RadrootsWikiMergeRequest { + target_article: address_ref(), + destination_pubkey: "bad".to_string(), + base_version_event_id: Some(hex_64('e')), + source_version_event_id: hex_64('f'), + explanation: None, + }; + assert_encode_error( + wiki_merge_request_to_wire_parts(&merge).unwrap_err(), + EventEncodeError::InvalidField("destination_pubkey"), + ); + merge.destination_pubkey = hex_64('a'); + merge.base_version_event_id = Some("bad".to_string()); + assert_encode_error( + wiki_merge_request_to_wire_parts(&merge).unwrap_err(), + EventEncodeError::InvalidField("base_version_event_id"), + ); + + let mut source = source(); + source.title = " ".to_string(); + assert_encode_error( + knowledge_source_to_wire_parts(&source).unwrap_err(), + EventEncodeError::EmptyRequiredField("title"), + ); + + let mut claim = claim(); + claim.citation_spans[0].quote_hash = Some("bad".to_string()); + assert_encode_error( + knowledge_claim_to_wire_parts(&claim).unwrap_err(), + EventEncodeError::InvalidField("citation_spans"), + ); + + let mut relation = relation(); + relation.subject.external_id = Some("cover-crops".to_string()); + assert_encode_error( + knowledge_relation_to_wire_parts(&relation).unwrap_err(), + EventEncodeError::InvalidField("subject"), + ); + + let mut review = review(); + review.scores[0].value = String::new(); + assert_encode_error( + knowledge_review_to_wire_parts(&review).unwrap_err(), + EventEncodeError::EmptyRequiredField("scores"), + ); + + let mut report = field_report(); + report.observations.clear(); + assert_encode_error( + knowledge_field_report_to_wire_parts(&report).unwrap_err(), + EventEncodeError::EmptyRequiredField("observations"), + ); + + let bounty = RadrootsEvidenceBounty { + schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + d_tag: "soil-bounty".to_string(), + title: "Soil bounty".to_string(), + summary: None, + topics: vec!["soil".to_string()], + target_refs: Vec::new(), + reward_note: None, + closes_at: None, + }; + assert_encode_error( + evidence_bounty_to_wire_parts(&bounty).unwrap_err(), + EventEncodeError::EmptyRequiredField("target_refs"), + ); + + let proposal = RadrootsKnowledgeChangeProposal { + schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + target: event_ref('b', KIND_KNOWLEDGE_CLAIM), + proposal_type: "amend".to_string(), + summary: String::new(), + rationale: None, + evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)], + supersedes: Vec::new(), + }; + assert_encode_error( + knowledge_change_proposal_to_wire_parts(&proposal).unwrap_err(), + EventEncodeError::EmptyRequiredField("summary"), + ); + + let attestation = RadrootsContributionAttestation { + schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + contributor_pubkey: hex_64('a'), + contribution_type: "review".to_string(), + subject_refs: Vec::new(), + summary: "Reviewed synthetic claim".to_string(), + evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)], + }; + assert_encode_error( + contribution_attestation_to_wire_parts(&attestation).unwrap_err(), + EventEncodeError::EmptyRequiredField("subject_refs"), + ); +} + +#[test] +fn semantic_validation_rejects_invalid_decoded_content() { + let mut article = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + article.content = String::new(); + assert_parse_error( + wiki_article_from_event(article).unwrap_err(), + EventParseError::InvalidJson("content_djot"), + ); + + let mut source_event = event_from_parts(knowledge_source_to_wire_parts(&source()).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&source_event.content).unwrap(); + value["title"] = serde_json::Value::String(String::new()); + source_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_source_from_event(source_event).unwrap_err(), + EventParseError::InvalidJson("title"), + ); + + let mut claim_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&claim_event.content).unwrap(); + value["citation_spans"][0]["quote_hash"] = serde_json::Value::String("bad".to_string()); + claim_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_claim_from_event(claim_event).unwrap_err(), + EventParseError::InvalidJson("citation_spans"), + ); + + let mut relation_event = + event_from_parts(knowledge_relation_to_wire_parts(&relation()).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&relation_event.content).unwrap(); + value["subject"]["external_id"] = serde_json::Value::String("cover-crops".to_string()); + relation_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_relation_from_event(relation_event).unwrap_err(), + EventParseError::InvalidJson("subject"), + ); + + let mut review_event = event_from_parts(knowledge_review_to_wire_parts(&review()).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&review_event.content).unwrap(); + value["target"]["kind"] = serde_json::Value::from(0); + review_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_review_from_event(review_event).unwrap_err(), + EventParseError::InvalidJson("review_target"), + ); + + let mut report_event = + event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&report_event.content).unwrap(); + value["observations"] = serde_json::Value::Array(Vec::new()); + report_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_field_report_from_event(report_event).unwrap_err(), + EventParseError::InvalidJson("observations"), + ); + + let bounty = RadrootsEvidenceBounty { + schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + d_tag: "soil-bounty".to_string(), + title: "Soil bounty".to_string(), + summary: None, + topics: vec!["soil".to_string()], + target_refs: vec![event_ref('a', KIND_KNOWLEDGE_CLAIM)], + reward_note: None, + closes_at: None, + }; + let mut bounty_event = event_from_parts(evidence_bounty_to_wire_parts(&bounty).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&bounty_event.content).unwrap(); + value["target_refs"] = serde_json::Value::Array(Vec::new()); + bounty_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + evidence_bounty_from_event(bounty_event).unwrap_err(), + EventParseError::InvalidJson("target_refs"), + ); + + let proposal = RadrootsKnowledgeChangeProposal { + schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + target: event_ref('b', KIND_KNOWLEDGE_CLAIM), + proposal_type: "amend".to_string(), + summary: "Clarify scope".to_string(), + rationale: None, + evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)], + supersedes: Vec::new(), + }; + let mut proposal_event = + event_from_parts(knowledge_change_proposal_to_wire_parts(&proposal).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&proposal_event.content).unwrap(); + value["summary"] = serde_json::Value::String(String::new()); + proposal_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_change_proposal_from_event(proposal_event).unwrap_err(), + EventParseError::InvalidJson("summary"), + ); + + let attestation = RadrootsContributionAttestation { + schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + contributor_pubkey: hex_64('a'), + contribution_type: "review".to_string(), + subject_refs: vec![event_ref('d', KIND_KNOWLEDGE_REVIEW)], + summary: "Reviewed synthetic claim".to_string(), + evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)], + }; + let mut attestation_event = + event_from_parts(contribution_attestation_to_wire_parts(&attestation).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&attestation_event.content).unwrap(); + value["subject_refs"] = serde_json::Value::Array(Vec::new()); + attestation_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + contribution_attestation_from_event(attestation_event).unwrap_err(), + EventParseError::InvalidJson("subject_refs"), + ); +}