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