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