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:
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();