lib

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

commit cfd552f8b23a9cef8f70a865aa2f07b1216c5563
parent d9724b8fbfab3c36f9c9f491dc29cd71c2857c0d
Author: triesap <tyson@radroots.org>
Date:   Sun,  5 Jul 2026 19:23:46 +0000

events-codec: harden NIP-54 wiki wire formats

- Model wiki redirects as addressable article targets and wiki fork/defer references as paired event/address references.

- Encode merge-request explanations as plaintext and reconstruct merge requests from NIP-54 tags.

- Add exact wire-shape and malformed decoder coverage for NIP-54 wiki articles, redirects, and merge requests.

- 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/contract.rs | 2+-
Mcrates/events/src/generated/dto_roots.rs | 2++
Mcrates/events/src/knowledge.rs | 15++++++++++++---
Mcrates/events_codec/src/knowledge/decode.rs | 199++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/events_codec/src/knowledge/encode.rs | 75++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/events_codec/tests/knowledge.rs | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/test_fixtures/src/knowledge.rs | 11+++++++++--
7 files changed, 407 insertions(+), 112 deletions(-)

diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs @@ -2384,7 +2384,7 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ RadrootsEventClass::Regular, RadrootsEventPrivacy::Public, RadrootsActorRole::Any, - RadrootsContentSchema::JsonObject, + RadrootsContentSchema::PlainText, RadrootsEventDiscriminator::KindOnly, WIKI_MERGE_REQUEST_TAGS, KNOWLEDGE_REDUCERS diff --git a/crates/events/src/generated/dto_roots.rs b/crates/events/src/generated/dto_roots.rs @@ -69,6 +69,8 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescrip ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeSource>(), ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsRightsAssertion>(), ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiArticle>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiArticleVersionRef>( + ), ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiMergeRequest>(), ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiRedirect>(), ::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsList>(), diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs @@ -134,8 +134,17 @@ pub struct RadrootsWikiArticle { pub summary: Option<String>, pub topics: Vec<String>, pub references: Vec<RadrootsNostrEventRef>, - pub forked_from: Vec<RadrootsNostrEventRef>, - pub deferred_to: Option<RadrootsNostrEventRef>, + pub forked_from: Vec<RadrootsWikiArticleVersionRef>, + pub deferred_to: Option<RadrootsWikiArticleVersionRef>, +} + +#[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))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsWikiArticleVersionRef { + pub event_id: String, + pub address_ref: RadrootsAddressableRef, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -144,7 +153,7 @@ pub struct RadrootsWikiArticle { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsWikiRedirect { pub d_tag: String, - pub target: RadrootsNostrEventRef, + pub target: RadrootsAddressableRef, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/crates/events_codec/src/knowledge/decode.rs b/crates/events_codec/src/knowledge/decode.rs @@ -18,10 +18,10 @@ use radroots_events::knowledge::{ RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, - RadrootsKnowledgeSource, RadrootsWikiArticle, RadrootsWikiMergeRequest, RadrootsWikiRedirect, - validate_wiki_d_tag, + RadrootsKnowledgeSource, RadrootsWikiArticle, RadrootsWikiArticleVersionRef, + RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_wiki_d_tag, }; -use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_SUMMARY, TAG_T}; +use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_P, TAG_SUMMARY, TAG_T}; use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventRef}; use serde::de::DeserializeOwned; @@ -32,8 +32,8 @@ use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; const TAG_TITLE: &str = "title"; const TAG_SOURCE: &str = "source"; const TAG_REVIEW_TARGET: &str = "review_target"; -const TAG_FORK: &str = "fork"; -const TAG_DEFERRED_TO: &str = "deferred_to"; +const MARKER_FORK: &str = "fork"; +const MARKER_DEFER: &str = "defer"; const E_MARKER_SOURCE: &str = "source"; fn ensure_kind(kind: u32, expected: u32, name: &'static str) -> Result<(), EventParseError> { @@ -102,28 +102,23 @@ fn event_refs( .collect() } -fn first_event_ref( - tags: &[Vec<String>], - name: &'static str, -) -> Result<Option<RadrootsNostrEventRef>, EventParseError> { - let matches = matching_tags(tags, name); - if matches.len() > 1 { - return Err(EventParseError::InvalidTag(name)); - } - matches - .first() - .map(|tag| parse_event_ref_tag(tag, name)) - .transpose() +fn marker(tag: &[String]) -> Option<&str> { + tag.last() + .map(|value| value.as_str()) + .filter(|value| matches!(*value, MARKER_FORK | MARKER_DEFER | E_MARKER_SOURCE)) } -fn address_from_a_tag( - tags: &[Vec<String>], +fn unmarked_tags<'a>(tags: &'a [Vec<String>], name: &'static str) -> Vec<&'a Vec<String>> { + matching_tags(tags, name) + .into_iter() + .filter(|tag| marker(tag).is_none()) + .collect() +} + +fn address_from_tag( + tag: &[String], name: &'static str, ) -> Result<RadrootsAddressableRef, EventParseError> { - let tag = matching_tags(tags, name) - .into_iter() - .next() - .ok_or(EventParseError::MissingTag(name))?; let value = tag.get(1).ok_or(EventParseError::InvalidTag(name))?; let mut parts = value.splitn(3, ':'); let kind = parts @@ -139,33 +134,107 @@ fn address_from_a_tag( .next() .filter(|value| !value.trim().is_empty()) .ok_or(EventParseError::InvalidTag(name))?; + let relays_end = if marker(tag).is_some() { + tag.len().saturating_sub(1) + } else { + tag.len() + }; Ok(RadrootsAddressableRef { kind, pubkey: pubkey.to_string(), d_tag: d_tag.to_string(), relays: tag - .get(2..) - .map(|values| values.to_vec()) + .get(2..relays_end) + .map(|values| { + values + .iter() + .filter(|value| !value.is_empty()) + .cloned() + .collect() + }) .unwrap_or_default(), }) } -fn event_ref_from_a_tag( +fn address_from_a_tag( tags: &[Vec<String>], name: &'static str, -) -> Result<RadrootsNostrEventRef, EventParseError> { - let address = address_from_a_tag(tags, name)?; - Ok(RadrootsNostrEventRef { - id: String::new(), - author: address.pubkey, - kind: address.kind, - d_tag: Some(address.d_tag), - relays: if address.relays.is_empty() { - None - } else { - Some(address.relays) - }, - }) +) -> Result<RadrootsAddressableRef, EventParseError> { + let matches = unmarked_tags(tags, name); + if matches.is_empty() { + return Err(EventParseError::MissingTag(name)); + } + if matches.len() > 1 { + return Err(EventParseError::InvalidTag(name)); + } + address_from_tag(matches[0], name) +} + +fn wiki_version_refs( + tags: &[Vec<String>], + marker_name: &'static str, +) -> Result<Vec<RadrootsWikiArticleVersionRef>, EventParseError> { + let address_tags = matching_tags(tags, TAG_A) + .into_iter() + .filter(|tag| marker(tag) == Some(marker_name)) + .collect::<Vec<_>>(); + let event_tags = matching_tags(tags, TAG_E) + .into_iter() + .filter(|tag| marker(tag) == Some(marker_name)) + .collect::<Vec<_>>(); + if address_tags.len() != event_tags.len() { + return Err(EventParseError::InvalidTag(TAG_A)); + } + address_tags + .into_iter() + .zip(event_tags) + .map(|(address_tag, event_tag)| { + let address_ref = address_from_tag(address_tag, TAG_A)?; + if address_ref.kind != KIND_WIKI_ARTICLE { + return Err(EventParseError::InvalidTag(TAG_A)); + } + let event_id = event_tag + .get(1) + .filter(|value| !value.trim().is_empty()) + .ok_or(EventParseError::InvalidTag(TAG_E))? + .clone(); + Ok(RadrootsWikiArticleVersionRef { + event_id, + address_ref, + }) + }) + .collect() +} + +fn wiki_merge_source_event_id(tags: &[Vec<String>]) -> Result<String, EventParseError> { + let source_tags = matching_tags(tags, TAG_E) + .into_iter() + .filter(|tag| marker(tag) == Some(E_MARKER_SOURCE)) + .collect::<Vec<_>>(); + if source_tags.len() != 1 { + return Err(EventParseError::InvalidTag(TAG_E)); + } + source_tags[0] + .get(1) + .filter(|value| !value.trim().is_empty()) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_E)) +} + +fn wiki_merge_base_event_id(tags: &[Vec<String>]) -> Result<Option<String>, EventParseError> { + let base_tags = unmarked_tags(tags, TAG_E); + if base_tags.len() > 1 { + return Err(EventParseError::InvalidTag(TAG_E)); + } + base_tags + .first() + .map(|tag| { + tag.get(1) + .filter(|value| !value.trim().is_empty()) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_E)) + }) + .transpose() } fn json_content<T: DeserializeOwned>(content: &str) -> Result<T, EventParseError> { @@ -209,22 +278,6 @@ fn require_schema( Ok(()) } -fn require_source_marker( - tags: &[Vec<String>], - source_event_id: &str, -) -> Result<(), EventParseError> { - let has_source = tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_E) - && tag.get(1).map(|value| value.as_str()) == Some(source_event_id) - && tag.iter().skip(2).any(|value| value == E_MARKER_SOURCE) - }); - if has_source { - Ok(()) - } else { - Err(EventParseError::InvalidTag(TAG_E)) - } -} - fn reject_private_coordinate_keys(content: &str) -> Result<(), EventParseError> { let value: serde_json::Value = serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; @@ -265,8 +318,12 @@ pub fn wiki_article_from_event( let summary = optional_one_value(&event.tags, TAG_SUMMARY)?; let topics = values(&event.tags, TAG_T); let references = event_refs(&event.tags, TAG_SOURCE)?; - let forked_from = event_refs(&event.tags, TAG_FORK)?; - let deferred_to = first_event_ref(&event.tags, TAG_DEFERRED_TO)?; + let forked_from = wiki_version_refs(&event.tags, MARKER_FORK)?; + let mut deferred_refs = wiki_version_refs(&event.tags, MARKER_DEFER)?; + if deferred_refs.len() > 1 { + return Err(EventParseError::InvalidTag(TAG_A)); + } + let deferred_to = deferred_refs.pop(); let article = RadrootsWikiArticle { d_tag, title, @@ -289,7 +346,10 @@ pub fn wiki_redirect_from_event( } let d_tag = required_one_value(&event.tags, TAG_D)?; validate_wiki_d_tag(&d_tag).map_err(|_| EventParseError::InvalidTag(TAG_D))?; - let target = event_ref_from_a_tag(&event.tags, TAG_A)?; + let target = address_from_a_tag(&event.tags, TAG_A)?; + if target.kind != KIND_WIKI_ARTICLE { + return Err(EventParseError::InvalidTag(TAG_A)); + } Ok(parsed(event, RadrootsWikiRedirect { d_tag, target })) } @@ -297,10 +357,25 @@ pub fn wiki_merge_request_from_event( event: RadrootsNostrEvent, ) -> Result<RadrootsParsedEvent<RadrootsWikiMergeRequest>, EventParseError> { ensure_kind(event.kind, KIND_WIKI_MERGE_REQUEST, "wiki merge request")?; - let request: RadrootsWikiMergeRequest = json_content(&event.content)?; - address_from_a_tag(&event.tags, TAG_A)?; - required_one_value(&event.tags, "p")?; - require_source_marker(&event.tags, &request.source_version_event_id)?; + let target_article = address_from_a_tag(&event.tags, TAG_A)?; + if target_article.kind != KIND_WIKI_ARTICLE { + return Err(EventParseError::InvalidTag(TAG_A)); + } + let destination_pubkey = required_one_value(&event.tags, TAG_P)?; + let base_version_event_id = wiki_merge_base_event_id(&event.tags)?; + let source_version_event_id = wiki_merge_source_event_id(&event.tags)?; + let explanation = if event.content.is_empty() { + None + } else { + Some(event.content.clone()) + }; + let request = RadrootsWikiMergeRequest { + target_article, + destination_pubkey, + base_version_event_id, + source_version_event_id, + explanation, + }; Ok(parsed(event, request)) } diff --git a/crates/events_codec/src/knowledge/encode.rs b/crates/events_codec/src/knowledge/encode.rs @@ -22,7 +22,8 @@ use radroots_events::knowledge::{ RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle, - RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_wiki_d_tag, + RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect, + validate_wiki_d_tag, }; use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_G, TAG_P, TAG_SUMMARY, TAG_T}; use serde::Serialize; @@ -36,10 +37,9 @@ const TAG_SOURCE: &str = "source"; const TAG_CITATION: &str = "citation"; const TAG_REVIEW_TARGET: &str = "review_target"; const TAG_EVIDENCE: &str = "evidence"; -const TAG_FORK: &str = "fork"; -const TAG_DEFERRED_TO: &str = "deferred_to"; +const MARKER_FORK: &str = "fork"; +const MARKER_DEFER: &str = "defer"; const E_MARKER_SOURCE: &str = "source"; -const E_MARKER_BASE: &str = "base"; fn push_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { if !value.trim().is_empty() { @@ -77,22 +77,42 @@ fn address_tag(tag_name: &str, address: &RadrootsAddressableRef) -> Vec<String> tag } -fn event_ref_address_tag( - tag_name: &str, - event_ref: &RadrootsNostrEventRef, -) -> Result<Vec<String>, EventEncodeError> { - let d_tag = event_ref - .d_tag - .as_ref() - .filter(|value| !value.trim().is_empty()) - .ok_or(EventEncodeError::EmptyRequiredField("target.d_tag"))?; - let mut tag = Vec::with_capacity(2 + event_ref.relays.as_ref().map(|v| v.len()).unwrap_or(0)); - tag.push(tag_name.to_string()); - tag.push(format!("{}:{}:{}", event_ref.kind, event_ref.author, d_tag)); - if let Some(relays) = &event_ref.relays { - tag.extend(relays.iter().cloned()); +fn marker_address_tag(address: &RadrootsAddressableRef, marker: &'static str) -> Vec<String> { + let mut tag = Vec::with_capacity(4 + address.relays.len()); + tag.push(TAG_A.to_string()); + tag.push(address_coordinate(address)); + if address.relays.is_empty() { + tag.push(String::new()); + } else { + tag.extend(address.relays.iter().cloned()); + } + tag.push(marker.to_string()); + tag +} + +fn marker_event_tag( + version_ref: &RadrootsWikiArticleVersionRef, + marker: &'static str, +) -> Vec<String> { + let mut tag = Vec::with_capacity(4 + version_ref.address_ref.relays.len()); + tag.push(TAG_E.to_string()); + tag.push(version_ref.event_id.clone()); + if version_ref.address_ref.relays.is_empty() { + tag.push(String::new()); + } else { + tag.extend(version_ref.address_ref.relays.iter().cloned()); } - Ok(tag) + tag.push(marker.to_string()); + tag +} + +fn push_wiki_version_ref_tags( + tags: &mut Vec<Vec<String>>, + version_ref: &RadrootsWikiArticleVersionRef, + marker: &'static str, +) { + tags.push(marker_address_tag(&version_ref.address_ref, marker)); + tags.push(marker_event_tag(version_ref, marker)); } fn review_target_ref(target: &RadrootsKnowledgeReviewTarget) -> RadrootsNostrEventRef { @@ -147,9 +167,11 @@ pub fn wiki_article_build_tags( push_optional_value(&mut tags, TAG_SUMMARY, article.summary.as_deref()); push_topics(&mut tags, &article.topics); push_event_refs(&mut tags, TAG_SOURCE, &article.references); - push_event_refs(&mut tags, TAG_FORK, &article.forked_from); + for forked_from in &article.forked_from { + push_wiki_version_ref_tags(&mut tags, forked_from, MARKER_FORK); + } if let Some(deferred_to) = &article.deferred_to { - tags.push(build_event_ref_tag(TAG_DEFERRED_TO, deferred_to)); + push_wiki_version_ref_tags(&mut tags, deferred_to, MARKER_DEFER); } Ok(tags) } @@ -170,7 +192,7 @@ pub fn wiki_redirect_build_tags( validate_wiki_d_tag(&redirect.d_tag).map_err(|_| EventEncodeError::InvalidField("d_tag"))?; let mut tags = Vec::new(); tags.push(vec![TAG_D.to_string(), redirect.d_tag.clone()]); - tags.push(event_ref_address_tag(TAG_A, &redirect.target)?); + tags.push(address_tag(TAG_A, &redirect.target)); Ok(tags) } @@ -203,12 +225,7 @@ pub fn wiki_merge_request_build_tags( .as_ref() .filter(|value| !value.trim().is_empty()) { - tags.push(vec![ - TAG_E.to_string(), - base.clone(), - String::new(), - E_MARKER_BASE.to_string(), - ]); + tags.push(vec![TAG_E.to_string(), base.clone(), String::new()]); } tags.push(vec![ TAG_E.to_string(), @@ -224,7 +241,7 @@ pub fn wiki_merge_request_to_wire_parts( ) -> Result<WireEventParts, EventEncodeError> { Ok(WireEventParts { kind: KIND_WIKI_MERGE_REQUEST, - content: json_content(request)?, + content: request.explanation.clone().unwrap_or_default(), tags: wiki_merge_request_build_tags(request)?, }) } diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs @@ -17,8 +17,9 @@ use radroots_events::knowledge::{ RadrootsKnowledgeObservation, RadrootsKnowledgeObservationValue, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, RadrootsKnowledgeReviewScope, RadrootsKnowledgeReviewScore, RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle, - RadrootsWikiMergeRequest, RadrootsWikiRedirect, + RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect, }; +use radroots_events_codec::error::EventParseError; use radroots_events_codec::knowledge::{ contribution_attestation_from_event, contribution_attestation_to_wire_parts, evidence_bounty_from_event, evidence_bounty_to_wire_parts, @@ -50,16 +51,6 @@ fn event_ref(character: char, kind: u32) -> radroots_events::RadrootsNostrEventR } } -fn article_ref() -> radroots_events::RadrootsNostrEventRef { - radroots_events::RadrootsNostrEventRef { - id: hex_64('b'), - author: hex_64('a'), - kind: KIND_WIKI_ARTICLE, - d_tag: Some("soil-health".to_string()), - relays: Some(vec!["wss://relay.radroots.example".to_string()]), - } -} - fn address_ref() -> RadrootsAddressableRef { RadrootsAddressableRef { kind: KIND_WIKI_ARTICLE, @@ -69,6 +60,13 @@ fn address_ref() -> RadrootsAddressableRef { } } +fn article_version_ref() -> RadrootsWikiArticleVersionRef { + RadrootsWikiArticleVersionRef { + event_id: hex_64('b'), + address_ref: address_ref(), + } +} + fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent { RadrootsNostrEvent { id: hex_64('0'), @@ -81,6 +79,26 @@ 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)) => { + assert_eq!(actual, expected); + } + ( + EventParseError::InvalidKind { + expected: actual_expected, + got: actual_got, + }, + EventParseError::InvalidKind { expected, got }, + ) => { + assert_eq!(actual_expected, expected); + assert_eq!(actual_got, got); + } + (actual, expected) => panic!("expected {expected:?}, got {actual:?}"), + } +} + fn sign_parts(parts: WireEventParts) -> RadrootsNostrEvent { let tags = parts .tags @@ -120,8 +138,8 @@ fn wiki_article() -> RadrootsWikiArticle { summary: Some("Living soil basics".to_string()), topics: vec!["soil".to_string(), "health".to_string()], references: vec![event_ref('2', KIND_KNOWLEDGE_SOURCE)], - forked_from: Vec::new(), - deferred_to: None, + forked_from: vec![article_version_ref()], + deferred_to: Some(article_version_ref()), } } @@ -252,6 +270,34 @@ fn field_report() -> RadrootsKnowledgeFieldReport { fn knowledge_codecs_roundtrip_all_contracts() { let article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); validate_event_contract_shape(&article_event, "radroots.wiki.article.v1").unwrap(); + assert!(article_event.tags.iter().any(|tag| tag + == &vec![ + "a".to_string(), + format!("30818:{}:soil-health", hex_64('a')), + "wss://relay.radroots.example".to_string(), + "fork".to_string() + ])); + assert!(article_event.tags.iter().any(|tag| tag + == &vec![ + "e".to_string(), + hex_64('b'), + "wss://relay.radroots.example".to_string(), + "fork".to_string() + ])); + assert!(article_event.tags.iter().any(|tag| tag + == &vec![ + "a".to_string(), + format!("30818:{}:soil-health", hex_64('a')), + "wss://relay.radroots.example".to_string(), + "defer".to_string() + ])); + assert!(article_event.tags.iter().any(|tag| tag + == &vec![ + "e".to_string(), + hex_64('b'), + "wss://relay.radroots.example".to_string(), + "defer".to_string() + ])); assert_eq!( wiki_article_from_event(article_event) .unwrap() @@ -263,10 +309,16 @@ fn knowledge_codecs_roundtrip_all_contracts() { let redirect = RadrootsWikiRedirect { d_tag: "soil".to_string(), - target: article_ref(), + target: address_ref(), }; let redirect_event = event_from_parts(wiki_redirect_to_wire_parts(&redirect).unwrap()); validate_event_contract_shape(&redirect_event, "radroots.wiki.redirect.v1").unwrap(); + assert!(redirect_event.tags.iter().any(|tag| tag + == &vec![ + "a".to_string(), + format!("30818:{}:soil-health", hex_64('a')), + "wss://relay.radroots.example".to_string() + ])); assert_eq!( wiki_redirect_from_event(redirect_event) .unwrap() @@ -274,7 +326,7 @@ fn knowledge_codecs_roundtrip_all_contracts() { .data .target .d_tag, - Some("soil-health".to_string()) + "soil-health" ); let merge = RadrootsWikiMergeRequest { @@ -286,6 +338,20 @@ fn knowledge_codecs_roundtrip_all_contracts() { }; let merge_event = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap()); validate_event_contract_shape(&merge_event, "radroots.wiki.merge_request.v1").unwrap(); + assert_eq!(merge_event.content, "Merge synthetic source"); + assert!( + merge_event + .tags + .iter() + .any(|tag| tag == &vec!["e".to_string(), hex_64('e'), String::new()]) + ); + assert!(merge_event.tags.iter().any(|tag| tag + == &vec![ + "e".to_string(), + hex_64('f'), + String::new(), + "source".to_string() + ])); assert_eq!( wiki_merge_request_from_event(merge_event) .unwrap() @@ -457,3 +523,122 @@ fn malformed_knowledge_events_return_stable_decode_codes() { let parsed_error = knowledge_field_report_from_event(report).unwrap_err(); assert_eq!(parsed_error.code(), "invalid_json"); } + +#[test] +fn malformed_nip54_wiki_shapes_are_rejected() { + let mut redirect = event_from_parts( + wiki_redirect_to_wire_parts(&RadrootsWikiRedirect { + d_tag: "soil".to_string(), + target: address_ref(), + }) + .unwrap(), + ); + for tag in &mut redirect.tags { + if tag.first().map(|value| value.as_str()) == Some("a") { + tag[1] = format!("30023:{}:soil-health", hex_64('a')); + } + } + assert_parse_error( + wiki_redirect_from_event(redirect).unwrap_err(), + EventParseError::InvalidTag("a"), + ); + + let merge = RadrootsWikiMergeRequest { + target_article: address_ref(), + destination_pubkey: hex_64('a'), + base_version_event_id: Some(hex_64('e')), + source_version_event_id: hex_64('f'), + explanation: Some("Merge synthetic source".to_string()), + }; + let mut missing_target = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap()); + missing_target + .tags + .retain(|tag| tag.first().map(|value| value.as_str()) != Some("a")); + assert_parse_error( + wiki_merge_request_from_event(missing_target).unwrap_err(), + EventParseError::MissingTag("a"), + ); + + let mut missing_destination = + event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap()); + missing_destination + .tags + .retain(|tag| tag.first().map(|value| value.as_str()) != Some("p")); + assert_parse_error( + wiki_merge_request_from_event(missing_destination).unwrap_err(), + EventParseError::MissingTag("p"), + ); + + let mut missing_source = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap()); + missing_source.tags.retain(|tag| { + !(tag.first().map(|value| value.as_str()) == Some("e") + && tag.last().map(|value| value.as_str()) == Some("source")) + }); + assert_parse_error( + wiki_merge_request_from_event(missing_source).unwrap_err(), + EventParseError::InvalidTag("e"), + ); + + let mut duplicate_source = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap()); + duplicate_source.tags.push(vec![ + "e".to_string(), + hex_64('a'), + String::new(), + "source".to_string(), + ]); + assert_parse_error( + wiki_merge_request_from_event(duplicate_source).unwrap_err(), + EventParseError::InvalidTag("e"), + ); + + let mut wrong_merge_target = + event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap()); + for tag in &mut wrong_merge_target.tags { + if tag.first().map(|value| value.as_str()) == Some("a") { + tag[1] = format!("30023:{}:soil-health", hex_64('a')); + } + } + assert_parse_error( + wiki_merge_request_from_event(wrong_merge_target).unwrap_err(), + EventParseError::InvalidTag("a"), + ); + + let mut orphan_fork = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let mut removed_fork_event = false; + orphan_fork.tags.retain(|tag| { + if !removed_fork_event + && tag.first().map(|value| value.as_str()) == Some("e") + && tag.last().map(|value| value.as_str()) == Some("fork") + { + removed_fork_event = true; + false + } else { + true + } + }); + assert_parse_error( + wiki_article_from_event(orphan_fork).unwrap_err(), + EventParseError::InvalidTag("a"), + ); + + let mut duplicate_defer = + event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + duplicate_defer.tags.extend([ + vec![ + "a".to_string(), + format!("30818:{}:compost", hex_64('a')), + String::new(), + "defer".to_string(), + ], + vec![ + "e".to_string(), + hex_64('c'), + String::new(), + "defer".to_string(), + ], + ]); + assert_parse_error( + wiki_article_from_event(duplicate_defer).unwrap_err(), + EventParseError::InvalidTag("a"), + ); +} diff --git a/crates/test_fixtures/src/knowledge.rs b/crates/test_fixtures/src/knowledge.rs @@ -17,7 +17,7 @@ use radroots_events::knowledge::{ RadrootsKnowledgeObservation, RadrootsKnowledgeObservationValue, RadrootsKnowledgeRelation, RadrootsKnowledgeReview, RadrootsKnowledgeReviewScope, RadrootsKnowledgeReviewScore, RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle, - RadrootsWikiMergeRequest, RadrootsWikiRedirect, + RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect, }; use crate::RELAY_PRIMARY_WSS; @@ -144,6 +144,13 @@ pub fn address_ref() -> RadrootsAddressableRef { } } +pub fn wiki_article_version_ref() -> RadrootsWikiArticleVersionRef { + RadrootsWikiArticleVersionRef { + event_id: hex_64('b'), + address_ref: address_ref(), + } +} + pub fn wiki_article() -> RadrootsWikiArticle { RadrootsWikiArticle { d_tag: "soil-health".to_string(), @@ -161,7 +168,7 @@ pub fn wiki_article() -> RadrootsWikiArticle { pub fn wiki_redirect() -> RadrootsWikiRedirect { RadrootsWikiRedirect { d_tag: "soil".to_string(), - target: wiki_article_ref(), + target: address_ref(), } }