lib

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

commit 5953aea9812bdfda4d0eae96b38801abb2d1443d
parent 2c0911a490f2bcfcba63505477a0171872286f86
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Jul 2026 06:35:38 +0000

knowledge: enforce canonical relay validation

- route knowledge relay validation through the shared Radroots relay URL type
- cover event refs, address refs, wiki version refs, review targets, and field private refs
- add encode and typed decode rejection tests for non-websocket relay values
- confirm knowledge manifest generation remains unchanged

Diffstat:
Mcrates/events/src/knowledge.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/events_codec/tests/knowledge.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 244 insertions(+), 18 deletions(-)

diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs @@ -6,7 +6,10 @@ use alloc::{string::String, vec::Vec}; use core::fmt; use crate::RadrootsNostrEventRef; -use crate::ids::{RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey}; +use crate::ids::{ + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey, + RadrootsRelayUrl, +}; use crate::kinds::KIND_WIKI_ARTICLE; pub const RADROOTS_KNOWLEDGE_SCHEMA_VERSION: u16 = 1; @@ -192,12 +195,9 @@ fn validate_relays( 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)); - } + RadrootsRelayUrl::parse(relay) + .map(|_| ()) + .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))?; } Ok(()) } @@ -876,6 +876,23 @@ mod tests { } } + fn wiki_article() -> RadrootsWikiArticle { + RadrootsWikiArticle { + d_tag: "soil-health".to_string(), + title: Some("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()), + } + } + + fn invalid_relay() -> String { + "http://relay.radroots.example".to_string() + } + fn knowledge_source() -> RadrootsKnowledgeSource { RadrootsKnowledgeSource { schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(), @@ -1217,17 +1234,7 @@ mod tests { #[test] fn knowledge_validators_accept_valid_models() { - let article = RadrootsWikiArticle { - d_tag: "soil-health".to_string(), - title: Some("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_article(&wiki_article()), Ok(())); assert_eq!( validate_wiki_redirect(&RadrootsWikiRedirect { d_tag: "soil".to_string(), @@ -1262,6 +1269,118 @@ mod tests { } #[test] + fn knowledge_validators_reject_noncanonical_relay_values() { + let mut article = wiki_article(); + article.references[0].relays = Some(vec![invalid_relay()]); + assert_validation_error( + validate_wiki_article(&article), + RadrootsKnowledgeValidationError::InvalidField("references"), + ); + + let mut article = wiki_article(); + article.forked_from[0].address_ref.relays = vec![invalid_relay()]; + assert_validation_error( + validate_wiki_article(&article), + RadrootsKnowledgeValidationError::InvalidField("forked_from"), + ); + + let mut article = wiki_article(); + article + .deferred_to + .as_mut() + .expect("deferred") + .address_ref + .relays = vec![invalid_relay()]; + assert_validation_error( + validate_wiki_article(&article), + RadrootsKnowledgeValidationError::InvalidField("deferred_to"), + ); + + let mut redirect = RadrootsWikiRedirect { + d_tag: "soil".to_string(), + target: article_address_ref(), + }; + redirect.target.relays = vec![invalid_relay()]; + assert_validation_error( + validate_wiki_redirect(&redirect), + RadrootsKnowledgeValidationError::InvalidField("wiki_redirect.target"), + ); + + let mut merge = 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, + }; + merge.target_article.relays = vec![invalid_relay()]; + assert_validation_error( + validate_wiki_merge_request(&merge), + RadrootsKnowledgeValidationError::InvalidField("target_article"), + ); + + let mut source = knowledge_source(); + source.artifact_refs[0].relays = Some(vec![invalid_relay()]); + assert_validation_error( + validate_knowledge_source(&source), + RadrootsKnowledgeValidationError::InvalidField("artifact_refs"), + ); + + let mut claim = knowledge_claim(); + claim.citation_spans[0].source_ref.relays = Some(vec![invalid_relay()]); + assert_validation_error( + validate_knowledge_claim(&claim), + RadrootsKnowledgeValidationError::InvalidField("citation_spans"), + ); + + let mut relation = knowledge_relation(); + relation.support_refs[0].relays = Some(vec![invalid_relay()]); + assert_validation_error( + validate_knowledge_relation(&relation), + RadrootsKnowledgeValidationError::InvalidField("support_refs"), + ); + + let mut review = knowledge_review(); + review.target.relays = vec![invalid_relay()]; + assert_validation_error( + validate_knowledge_review(&review), + RadrootsKnowledgeValidationError::InvalidField("review_target"), + ); + + let mut report = field_report(); + report.context.location_precision = + RadrootsKnowledgeLocationPrecision::ExactPrivateReference; + let mut private_location_ref = event_ref(); + private_location_ref.relays = Some(vec![invalid_relay()]); + report.context.private_location_ref = Some(private_location_ref); + assert_validation_error( + validate_knowledge_field_report(&report), + RadrootsKnowledgeValidationError::InvalidField("private_location_ref"), + ); + + let mut bounty = evidence_bounty(); + bounty.target_refs[0].relays = Some(vec![invalid_relay()]); + assert_validation_error( + validate_evidence_bounty(&bounty), + RadrootsKnowledgeValidationError::InvalidField("target_refs"), + ); + + let mut proposal = knowledge_change_proposal(); + proposal.target.relays = Some(vec![invalid_relay()]); + assert_validation_error( + validate_knowledge_change_proposal(&proposal), + RadrootsKnowledgeValidationError::InvalidField("target"), + ); + + let mut attestation = contribution_attestation(); + attestation.subject_refs[0].relays = Some(vec![invalid_relay()]); + assert_validation_error( + validate_contribution_attestation(&attestation), + RadrootsKnowledgeValidationError::InvalidField("subject_refs"), + ); + } + + #[test] fn wiki_article_title_is_optional_but_not_blank() { let mut article = RadrootsWikiArticle { d_tag: "soil-health".to_string(), diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs @@ -141,6 +141,10 @@ fn assert_encode_error(actual: EventEncodeError, expected: EventEncodeError) { } } +fn invalid_relay() -> String { + "http://relay.radroots.example".to_string() +} + fn sign_parts(parts: WireEventParts) -> RadrootsNostrEvent { let tags = parts .tags @@ -1036,6 +1040,43 @@ fn semantic_validation_rejects_invalid_encode_models() { } #[test] +fn semantic_validation_rejects_invalid_relay_models() { + let mut source = source(); + source.artifact_refs[0].relays = Some(vec![invalid_relay()]); + assert_encode_error( + knowledge_source_to_wire_parts(&source).unwrap_err(), + EventEncodeError::InvalidField("artifact_refs"), + ); + + let mut redirect = RadrootsWikiRedirect { + d_tag: "soil".to_string(), + target: address_ref(), + }; + redirect.target.relays = vec![invalid_relay()]; + assert_encode_error( + wiki_redirect_to_wire_parts(&redirect).unwrap_err(), + EventEncodeError::InvalidField("wiki_redirect.target"), + ); + + let mut review = review(); + review.target.relays = vec![invalid_relay()]; + assert_encode_error( + knowledge_review_to_wire_parts(&review).unwrap_err(), + EventEncodeError::InvalidField("review_target"), + ); + + let mut report = field_report(); + report.context.location_precision = RadrootsKnowledgeLocationPrecision::ExactPrivateReference; + let mut private_location_ref = event_ref('f', KIND_KNOWLEDGE_SOURCE); + private_location_ref.relays = Some(vec![invalid_relay()]); + report.context.private_location_ref = Some(private_location_ref); + assert_encode_error( + knowledge_field_report_to_wire_parts(&report).unwrap_err(), + EventEncodeError::InvalidField("private_location_ref"), + ); +} + +#[test] fn knowledge_claim_encode_enforces_citation_rules() { let mut model = claim(); model.citation_spans.clear(); @@ -1179,6 +1220,72 @@ fn semantic_validation_rejects_invalid_decoded_content() { } #[test] +fn knowledge_decode_rejects_invalid_relay_values() { + let mut article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let source_tag = article_event + .tags + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some("source")) + .expect("source tag"); + source_tag[5] = invalid_relay(); + assert_parse_error( + wiki_article_from_event(article_event).unwrap_err(), + EventParseError::InvalidTag("source"), + ); + + let mut redirect_event = event_from_parts( + wiki_redirect_to_wire_parts(&RadrootsWikiRedirect { + d_tag: "soil".to_string(), + target: address_ref(), + }) + .unwrap(), + ); + let target_tag = redirect_event + .tags + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some("a")) + .expect("target tag"); + target_tag[2] = invalid_relay(); + assert_parse_error( + wiki_redirect_from_event(redirect_event).unwrap_err(), + EventParseError::InvalidTag("a"), + ); + + 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["artifact_refs"][0]["relays"] = + serde_json::Value::Array(vec![serde_json::Value::String(invalid_relay())]); + source_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_source_from_event(source_event).unwrap_err(), + EventParseError::InvalidJson("artifact_refs"), + ); + + 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"]["relays"] = + serde_json::Value::Array(vec![serde_json::Value::String(invalid_relay())]); + 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 = field_report(); + report.context.location_precision = RadrootsKnowledgeLocationPrecision::ExactPrivateReference; + report.context.private_location_ref = Some(event_ref('f', KIND_KNOWLEDGE_SOURCE)); + let mut report_event = event_from_parts(knowledge_field_report_to_wire_parts(&report).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&report_event.content).unwrap(); + value["context"]["private_location_ref"]["relays"] = + serde_json::Value::Array(vec![serde_json::Value::String(invalid_relay())]); + report_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_field_report_from_event(report_event).unwrap_err(), + EventParseError::InvalidJson("private_location_ref"), + ); +} + +#[test] fn knowledge_claim_decode_enforces_citation_rules() { 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();