lib

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

commit 6ddb2358f360fdfa03178855ee028709c2ed09c4
parent 7b4339e1942cdd4bb0482a88dd79c2e57d604c6d
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Jul 2026 04:42:56 +0000

events-codec: require adjacent wiki version pairs

- replace grouped wiki fork/defer parsing with adjacent a/e pair decoding
- reject reordered, unpaired, misplaced-marker, mismatched-relay, and non-article version refs
- preserve valid fork and defer round trips including ordered multiple fork refs
- keep wiki codec, events wiki, and contract validation lanes green

Diffstat:
Mcrates/events_codec/src/knowledge/decode.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/events_codec/tests/knowledge.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 194 insertions(+), 31 deletions(-)

diff --git a/crates/events_codec/src/knowledge/decode.rs b/crates/events_codec/src/knowledge/decode.rs @@ -187,6 +187,43 @@ fn marker(tag: &[String]) -> Option<&str> { .filter(|value| matches!(*value, MARKER_FORK | MARKER_DEFER | E_MARKER_SOURCE)) } +fn wiki_version_marker(value: &str) -> bool { + matches!(value, MARKER_FORK | MARKER_DEFER) +} + +fn validate_wiki_version_marker_position( + tag: &[String], + name: &'static str, +) -> Result<(), EventParseError> { + if tag.len() > 2 + && tag[2..tag.len().saturating_sub(1)] + .iter() + .any(|value| wiki_version_marker(value.as_str())) + { + Err(EventParseError::InvalidTag(name)) + } else { + Ok(()) + } +} + +fn validate_wiki_version_marker_position_for_tag(tag: &[String]) -> Result<(), EventParseError> { + match tag.first().map(String::as_str) { + Some(TAG_A) => validate_wiki_version_marker_position(tag, TAG_A), + Some(TAG_E) => validate_wiki_version_marker_position(tag, TAG_E), + _ => Ok(()), + } +} + +fn marked_wiki_version_relay_entries<'a>( + tag: &'a [String], + name: &'static str, +) -> Result<&'a [String], EventParseError> { + if tag.len() < 4 { + return Err(EventParseError::InvalidTag(name)); + } + Ok(&tag[2..tag.len() - 1]) +} + fn unmarked_tags<'a>(tags: &'a [Vec<String>], name: &'static str) -> Vec<&'a Vec<String>> { matching_tags(tags, name) .into_iter() @@ -253,36 +290,54 @@ 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 mut refs = Vec::new(); + let mut index = 0; + while let Some(tag) = tags.get(index) { + validate_wiki_version_marker_position_for_tag(tag)?; + + if marker(tag) != Some(marker_name) { + index += 1; + continue; + } + + match tag.first().map(String::as_str) { + Some(TAG_A) => { + let event_tag = tags + .get(index + 1) + .ok_or(EventParseError::InvalidTag(TAG_A))?; + validate_wiki_version_marker_position_for_tag(event_tag)?; + if event_tag.first().map(String::as_str) != Some(TAG_E) + || marker(event_tag) != Some(marker_name) + { + return Err(EventParseError::InvalidTag(TAG_A)); + } + let address_ref = address_from_tag(tag, TAG_A)?; + if address_ref.kind != KIND_WIKI_ARTICLE { + return Err(EventParseError::InvalidTag(TAG_A)); + } + if marked_wiki_version_relay_entries(tag, TAG_A)? + != marked_wiki_version_relay_entries(event_tag, TAG_E)? + { + return Err(EventParseError::InvalidTag(TAG_E)); + } + let event_id = event_tag + .get(1) + .filter(|value| !value.trim().is_empty()) + .ok_or(EventParseError::InvalidTag(TAG_E))? + .clone(); + refs.push(RadrootsWikiArticleVersionRef { + event_id, + address_ref, + }); + index += 2; } - 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() + Some(TAG_E) => return Err(EventParseError::InvalidTag(TAG_E)), + _ => { + index += 1; + } + } + } + Ok(refs) } fn wiki_merge_source_event_id(tags: &[Vec<String>]) -> Result<String, EventParseError> { diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs @@ -61,9 +61,15 @@ fn address_ref() -> RadrootsAddressableRef { } fn article_version_ref() -> RadrootsWikiArticleVersionRef { + article_version_ref_for('b', "soil-health") +} + +fn article_version_ref_for(event_id_character: char, d_tag: &str) -> RadrootsWikiArticleVersionRef { + let mut address_ref = address_ref(); + address_ref.d_tag = d_tag.to_string(); RadrootsWikiArticleVersionRef { - event_id: hex_64('b'), - address_ref: address_ref(), + event_id: hex_64(event_id_character), + address_ref, } } @@ -89,6 +95,18 @@ fn replace_first_tag_value(event: &mut RadrootsNostrEvent, name: &str, value: St *tag_value = value; } +fn marked_tag(tag: &[String], name: &str, marker: &str) -> bool { + tag.first().map(String::as_str) == Some(name) && tag.last().map(String::as_str) == Some(marker) +} + +fn marked_tag_index(event: &RadrootsNostrEvent, name: &str, marker: &str) -> usize { + event + .tags + .iter() + .position(|tag| marked_tag(tag, name, marker)) + .expect("marked tag") +} + fn assert_parse_error(actual: EventParseError, expected: EventParseError) { match (actual, expected) { (EventParseError::MissingTag(actual), EventParseError::MissingTag(expected)) @@ -795,6 +813,96 @@ fn malformed_nip54_wiki_shapes_are_rejected() { } #[test] +fn wiki_article_version_refs_require_adjacent_marked_pairs() { + let article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let decoded = wiki_article_from_event(article_event).unwrap(); + assert_eq!(decoded.data.data.forked_from[0], article_version_ref()); + assert_eq!(decoded.data.data.deferred_to, Some(article_version_ref())); + + let mut two_forks = wiki_article(); + two_forks.forked_from = vec![ + article_version_ref_for('b', "soil-health"), + article_version_ref_for('c', "compost"), + ]; + two_forks.deferred_to = None; + let two_forks_event = event_from_parts(wiki_article_to_wire_parts(&two_forks).unwrap()); + let decoded = wiki_article_from_event(two_forks_event.clone()).unwrap(); + assert_eq!( + decoded.data.data.forked_from, + vec![ + article_version_ref_for('b', "soil-health"), + article_version_ref_for('c', "compost") + ] + ); + + let mut grouped = two_forks_event; + let fork_addresses = grouped + .tags + .iter() + .filter(|tag| marked_tag(tag, "a", "fork")) + .cloned() + .collect::<Vec<_>>(); + let fork_events = grouped + .tags + .iter() + .filter(|tag| marked_tag(tag, "e", "fork")) + .cloned() + .collect::<Vec<_>>(); + grouped + .tags + .retain(|tag| !marked_tag(tag, "a", "fork") && !marked_tag(tag, "e", "fork")); + grouped.tags.extend(fork_addresses); + grouped.tags.extend(fork_events); + assert_parse_error( + wiki_article_from_event(grouped).unwrap_err(), + EventParseError::InvalidTag("a"), + ); + + let mut reversed = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let fork_address_index = marked_tag_index(&reversed, "a", "fork"); + let fork_event_index = marked_tag_index(&reversed, "e", "fork"); + reversed.tags.swap(fork_address_index, fork_event_index); + assert_parse_error( + wiki_article_from_event(reversed).unwrap_err(), + EventParseError::InvalidTag("e"), + ); + + let mut relay_mismatch = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let fork_event_index = marked_tag_index(&relay_mismatch, "e", "fork"); + relay_mismatch.tags[fork_event_index][2] = "wss://other.radroots.example".to_string(); + assert_parse_error( + wiki_article_from_event(relay_mismatch).unwrap_err(), + EventParseError::InvalidTag("e"), + ); + + let mut missing_partner = + event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let fork_event_index = marked_tag_index(&missing_partner, "e", "fork"); + missing_partner.tags.remove(fork_event_index); + assert_parse_error( + wiki_article_from_event(missing_partner).unwrap_err(), + EventParseError::InvalidTag("a"), + ); + + let mut misplaced_marker = + event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let fork_address_index = marked_tag_index(&misplaced_marker, "a", "fork"); + misplaced_marker.tags[fork_address_index].insert(2, "fork".to_string()); + assert_parse_error( + wiki_article_from_event(misplaced_marker).unwrap_err(), + EventParseError::InvalidTag("a"), + ); + + let mut wrong_kind = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let fork_address_index = marked_tag_index(&wrong_kind, "a", "fork"); + wrong_kind.tags[fork_address_index][1] = format!("30023:{}:soil-health", hex_64('a')); + assert_parse_error( + wiki_article_from_event(wrong_kind).unwrap_err(), + EventParseError::InvalidTag("a"), + ); +} + +#[test] fn wiki_article_codec_accepts_missing_title_tag() { let mut article = wiki_article(); article.title = None;