lib

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

commit 2c0911a490f2bcfcba63505477a0171872286f86
parent 141026247e2f8815635188b2edc542c536e99afa
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Jul 2026 06:30:52 +0000

events: enforce canonical event ref relays

- add a shared Radroots relay URL validator and typed relay URL id
- route event contract relay checks and event pointers through the shared validator
- make generic event-ref parsing require the canonical five-field minimum shape
- replace permissive relay-in-slot-four coverage with strict rejection tests

Diffstat:
Mcrates/events/src/contract.rs | 12++++--------
Mcrates/events/src/ids.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/events_codec/src/event_ref.rs | 36++++++++++++++++++++++++------------
Mcrates/events_codec/tests/event_ref.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
4 files changed, 193 insertions(+), 66 deletions(-)

diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs @@ -5,7 +5,10 @@ use alloc::{borrow::ToOwned, string::String, vec::Vec}; use crate::{ RadrootsNostrEvent, - ids::{RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey}, + ids::{ + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey, + relay_url_is_valid, + }, kinds::*, }; @@ -3408,13 +3411,6 @@ fn visible_text_is_valid(value: &str) -> bool { !value.trim().is_empty() && !value.chars().any(char::is_control) } -fn relay_url_is_valid(value: &str) -> bool { - (value.starts_with("ws://") || value.starts_with("wss://")) - && value.len() > "ws://".len() - && value.trim() == value - && !value.chars().any(char::is_control) -} - fn url_is_valid(value: &str) -> bool { (value.starts_with("http://") || value.starts_with("https://")) && value.len() > "http://".len() diff --git a/crates/events/src/ids.rs b/crates/events/src/ids.rs @@ -188,6 +188,7 @@ validated_string_id!(RadrootsOrderQuoteId, validate_commercial_id); validated_string_id!(RadrootsInventoryBinId, validate_commercial_id); validated_string_id!(RadrootsEconomicsDigest, validate_economics_digest); validated_string_id!(RadrootsEventPointer, validate_hex_64); +validated_string_id!(RadrootsRelayUrl, validate_relay_url); #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadrootsAddressableCoordinateParts { @@ -217,12 +218,7 @@ impl RadrootsNostrEventPointer { let mut canonical_relays = Vec::new(); for relay in relays { let relay = relay.into(); - if relay.is_empty() - || relay.trim() != relay - || relay.chars().any(|character| character.is_control()) - { - return Err(RadrootsIdParseError::InvalidCharacter); - } + RadrootsRelayUrl::parse(relay.as_str())?; canonical_relays.push(relay); } Ok(Self { @@ -322,6 +318,29 @@ fn validate_visible_token(value: &str, max_len: usize) -> Result<String, Radroot Ok(value.to_string()) } +pub fn relay_url_is_valid(value: &str) -> bool { + validate_relay_url(value).is_ok() +} + +fn validate_relay_url(value: &str) -> Result<String, RadrootsIdParseError> { + if value.is_empty() { + return Err(RadrootsIdParseError::Empty); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err(RadrootsIdParseError::InvalidCharacter); + } + if !(value.starts_with("ws://") || value.starts_with("wss://")) { + return Err(RadrootsIdParseError::InvalidFormat); + } + if value.len() <= "ws://".len() { + return Err(RadrootsIdParseError::InvalidLength { + expected: "ws://".len() + 1, + actual: value.len(), + }); + } + Ok(value.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -564,6 +583,47 @@ mod tests { assert_identifier_impls!(RadrootsInventoryBinId, "bin-1"); assert_identifier_impls!(RadrootsEconomicsDigest, "digest-1"); assert_identifier_impls!(RadrootsEventPointer, hex_64('d').as_str()); + assert_identifier_impls!(RadrootsRelayUrl, "wss://relay.example.com"); + } + + #[test] + fn relay_urls_require_websocket_scheme_and_visible_boundaries() { + assert_eq!( + RadrootsRelayUrl::parse("ws://relay.example.com") + .expect("relay url") + .as_str(), + "ws://relay.example.com" + ); + assert_eq!( + RadrootsRelayUrl::parse("wss://relay.example.com") + .expect("relay url") + .as_str(), + "wss://relay.example.com" + ); + assert!(relay_url_is_valid("wss://relay.example.com")); + assert_eq!( + RadrootsRelayUrl::parse("").unwrap_err(), + RadrootsIdParseError::Empty + ); + assert_eq!( + RadrootsRelayUrl::parse("http://relay.example.com").unwrap_err(), + RadrootsIdParseError::InvalidFormat + ); + assert_eq!( + RadrootsRelayUrl::parse("ws://").unwrap_err(), + RadrootsIdParseError::InvalidLength { + expected: 6, + actual: 5 + } + ); + assert_eq!( + RadrootsRelayUrl::parse(" wss://relay.example.com").unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); + assert_eq!( + RadrootsRelayUrl::parse("wss://relay.example.com\nmiddle").unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); } #[test] @@ -584,21 +644,38 @@ mod tests { ] ); - for relay in [ - "", - " wss://relay.example", - "wss://relay.example\n", - "wss://relay.example/\u{7}", - ] { - assert_eq!( - RadrootsNostrEventPointer::new( - RadrootsEventId::parse(hex_64('e')).expect("event id"), - [relay], - ) - .unwrap_err(), - RadrootsIdParseError::InvalidCharacter - ); - } + assert_eq!( + RadrootsNostrEventPointer::new( + RadrootsEventId::parse(hex_64('e')).expect("event id"), + [""], + ) + .unwrap_err(), + RadrootsIdParseError::Empty + ); + assert_eq!( + RadrootsNostrEventPointer::new( + RadrootsEventId::parse(hex_64('e')).expect("event id"), + ["http://relay.example"], + ) + .unwrap_err(), + RadrootsIdParseError::InvalidFormat + ); + assert_eq!( + RadrootsNostrEventPointer::new( + RadrootsEventId::parse(hex_64('e')).expect("event id"), + [" wss://relay.example"], + ) + .unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); + assert_eq!( + RadrootsNostrEventPointer::new( + RadrootsEventId::parse(hex_64('e')).expect("event id"), + ["wss://relay.example\n"], + ) + .unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); } #[cfg(feature = "serde")] diff --git a/crates/events_codec/src/event_ref.rs b/crates/events_codec/src/event_ref.rs @@ -5,14 +5,13 @@ use alloc::{ vec::Vec, }; -use radroots_events::RadrootsNostrEventRef; +use radroots_events::{ + RadrootsNostrEventRef, + ids::{RadrootsDTag, RadrootsEventId, RadrootsPublicKey, RadrootsRelayUrl}, +}; use crate::error::EventParseError; -fn looks_like_relay_url(s: &str) -> bool { - s.starts_with("ws://") || s.starts_with("wss://") -} - pub fn build_event_ref_tag(tag: &str, event: &RadrootsNostrEventRef) -> Vec<String> { let relays_len = event.relays.as_ref().map(|r| r.len()).unwrap_or(0); let mut out = Vec::with_capacity(5 + relays_len); @@ -34,22 +33,35 @@ pub fn parse_event_ref_tag( if tag.first().map(|s| s.as_str()) != Some(tag_name) { return Err(EventParseError::InvalidTag(tag_name)); } + if tag.len() < 5 { + return Err(EventParseError::InvalidTag(tag_name)); + } let id = tag.get(1).ok_or(EventParseError::InvalidTag(tag_name))?; + RadrootsEventId::parse(id).map_err(|_| EventParseError::InvalidTag(tag_name))?; let author = tag.get(2).ok_or(EventParseError::InvalidTag(tag_name))?; + RadrootsPublicKey::parse(author).map_err(|_| EventParseError::InvalidTag(tag_name))?; let kind_s = tag.get(3).ok_or(EventParseError::InvalidTag(tag_name))?; let kind: u32 = kind_s .parse() .map_err(|e| EventParseError::InvalidNumber(tag_name, e))?; - let (d_tag, relays_start) = match tag.get(4) { - Some(v) if tag.len() == 5 && looks_like_relay_url(v) => (None, 4), - Some(v) if v.is_empty() => (None, 5), - Some(v) => (Some(v.clone()), 5), - None => (None, 4), + let d_tag = match tag.get(4) { + Some(value) if value.is_empty() => None, + Some(value) => { + if RadrootsRelayUrl::parse(value).is_ok() { + return Err(EventParseError::InvalidTag(tag_name)); + } + RadrootsDTag::parse(value).map_err(|_| EventParseError::InvalidTag(tag_name))?; + Some(value.clone()) + } + None => return Err(EventParseError::InvalidTag(tag_name)), }; - let relays = if tag.len() > relays_start { - Some(tag[relays_start..].to_vec()) + let relays = if tag.len() > 5 { + for relay in &tag[5..] { + RadrootsRelayUrl::parse(relay).map_err(|_| EventParseError::InvalidTag(tag_name))?; + } + Some(tag[5..].to_vec()) } else { None }; diff --git a/crates/events_codec/tests/event_ref.rs b/crates/events_codec/tests/event_ref.rs @@ -10,11 +10,15 @@ use radroots_events_codec::event_ref::{ }; use test_fixtures::{RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS}; +fn hex_64(character: char) -> String { + core::iter::repeat_n(character, 64).collect() +} + #[test] fn build_and_parse_roundtrip_with_d_tag_and_relays() { let event = common::event_ref_with_d( - "id", - "author", + &hex_64('1'), + &hex_64('2'), 42, "d-tag", Some(vec!["wss://relay".to_string()]), @@ -32,7 +36,7 @@ fn build_and_parse_roundtrip_with_d_tag_and_relays() { #[test] fn build_and_parse_roundtrip_without_d_tag_or_relays() { - let event = common::event_ref("id", "author", KIND_POST); + let event = common::event_ref(&hex_64('3'), &hex_64('4'), KIND_POST); let tag = build_event_ref_tag("e", &event); assert_eq!(tag.len(), 5); @@ -47,38 +51,76 @@ fn build_and_parse_roundtrip_without_d_tag_or_relays() { } #[test] -fn parse_event_ref_tag_allows_relay_only_fifth_entry() { +fn parse_event_ref_tag_rejects_noncanonical_event_pointer_shapes() { let tag = vec![ "e".to_string(), - "id".to_string(), - "author".to_string(), + hex_64('5'), + hex_64('6'), KIND_POST.to_string(), "wss://relay".to_string(), ]; + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("e"))); - let parsed = parse_event_ref_tag(&tag, "e").unwrap(); - assert!(parsed.d_tag.is_none()); - assert_eq!(parsed.relays, Some(vec!["wss://relay".to_string()])); + let tag = vec![ + "e".to_string(), + hex_64('5'), + hex_64('6'), + KIND_POST.to_string(), + ]; + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("e"))); - let ws_tag = vec![ + let tag = vec![ "e".to_string(), - "id".to_string(), - "author".to_string(), + "not-hex".to_string(), + hex_64('6'), KIND_POST.to_string(), - "ws://relay".to_string(), + String::new(), ]; - let parsed = parse_event_ref_tag(&ws_tag, "e").unwrap(); - assert!(parsed.d_tag.is_none()); - assert_eq!(parsed.relays, Some(vec!["ws://relay".to_string()])); + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("e"))); + + let tag = vec![ + "e".to_string(), + hex_64('5'), + "not-hex".to_string(), + KIND_POST.to_string(), + String::new(), + ]; + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("e"))); + + let tag = vec![ + "e".to_string(), + hex_64('5'), + hex_64('6'), + KIND_POST.to_string(), + "bad d-tag".to_string(), + ]; + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("e"))); + + let tag = vec![ + "e".to_string(), + hex_64('5'), + hex_64('6'), + KIND_POST.to_string(), + String::new(), + "https://relay".to_string(), + ]; + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("e"))); } #[test] fn parse_event_ref_tag_rejects_invalid_kind() { let tag = vec![ "e".to_string(), - "id".to_string(), - "author".to_string(), + hex_64('7'), + hex_64('8'), "bad".to_string(), + String::new(), ]; let err = parse_event_ref_tag(&tag, "e").unwrap_err(); @@ -93,13 +135,12 @@ fn parse_event_ref_tag_rejects_wrong_tag_name_and_missing_fields() { let tag = vec![ "e".to_string(), - "id".to_string(), - "author".to_string(), + hex_64('9'), + hex_64('a'), KIND_POST.to_string(), ]; - let parsed = parse_event_ref_tag(&tag, "e").unwrap(); - assert!(parsed.d_tag.is_none()); - assert!(parsed.relays.is_none()); + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("e"))); } #[test] @@ -120,7 +161,8 @@ fn parse_event_ref_tag_rejects_missing_required_values() { #[test] fn find_event_ref_tag_locates_first_match() { - let event = common::event_ref("id", "author", KIND_POST); + let id = hex_64('b'); + let event = common::event_ref(&id, &hex_64('c'), KIND_POST); let tags = vec![ vec!["p".to_string(), "pubkey".to_string()], build_event_ref_tag("e", &event), @@ -128,7 +170,7 @@ fn find_event_ref_tag_locates_first_match() { let found = find_event_ref_tag(&tags, "e").unwrap(); assert_eq!(found[0], "e"); - assert_eq!(found[1], "id"); + assert_eq!(found[1], id); } #[test]