lib

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

commit d428fe1abf0339ecd6e40409e4e1abeb331eb406
parent 5f136f7a29d00ab53676d7f3cd7b9f144a97326d
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 07:03:19 +0000

local-events: add relay evidence contracts

Diffstat:
Mcrates/local_events/src/lib.rs | 4++++
Acrates/local_events/src/relay_delivery.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/local_events/src/relay_url.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/local_events/tests/relay_delivery.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/local_events/tests/relay_url.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/local_events/tests/store.rs | 41++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/src/config.rs | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/config.rs | 44++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 758 insertions(+), 6 deletions(-)

diff --git a/crates/local_events/src/lib.rs b/crates/local_events/src/lib.rs @@ -4,7 +4,9 @@ mod error; mod migrations; mod models; mod order_work; +mod relay_delivery; mod relay_set; +mod relay_url; mod store; pub use error::LocalEventsError; @@ -22,5 +24,7 @@ pub use order_work::{ validate_supported_buyer_order_request_local_work_payload, validate_unsupported_buyer_order_request_local_work_payload, }; +pub use relay_delivery::{RelayDeliveryEvidence, RelayDeliveryFailure, RelayDeliveryState}; pub use relay_set::{CANONICAL_RELAY_SET_FINGERPRINT_VERSION, canonical_relay_set_fingerprint}; +pub use relay_url::{RelayUrlValidationError, normalize_relay_url, normalize_relay_urls}; pub use store::LocalEventsStore; diff --git a/crates/local_events/src/relay_delivery.rs b/crates/local_events/src/relay_delivery.rs @@ -0,0 +1,262 @@ +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + LocalEventsError, canonical_relay_set_fingerprint, relay_url::RelayUrlValidationError, + relay_url::normalize_relay_urls, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayDeliveryState { + Pending, + Acknowledged, + Failed, +} + +impl RelayDeliveryState { + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Acknowledged => "acknowledged", + Self::Failed => "failed", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayDeliveryFailure { + pub relay_url: String, + pub error: String, +} + +impl RelayDeliveryFailure { + pub fn new( + relay_url: impl AsRef<str>, + error: impl AsRef<str>, + ) -> Result<Self, LocalEventsError> { + let relay_url = normalize_relay_url_for_evidence("failed_relays.relay_url", relay_url)?; + let error = normalize_non_empty_text("failed_relays.error", error.as_ref())?; + Ok(Self { relay_url, error }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayDeliveryEvidence { + pub state: RelayDeliveryState, + pub target_relays: Vec<String>, + pub connected_relays: Vec<String>, + pub acknowledged_relays: Vec<String>, + pub failed_relays: Vec<RelayDeliveryFailure>, +} + +impl RelayDeliveryEvidence { + pub fn pending<I, S>(target_relays: I) -> Result<Self, LocalEventsError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + Self::build( + RelayDeliveryState::Pending, + target_relays, + Vec::<String>::new(), + Vec::<String>::new(), + Vec::new(), + ) + } + + pub fn acknowledged<I, S, J, T, K, U>( + target_relays: I, + connected_relays: J, + acknowledged_relays: K, + failed_relays: Vec<RelayDeliveryFailure>, + ) -> Result<Self, LocalEventsError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + J: IntoIterator<Item = T>, + T: AsRef<str>, + K: IntoIterator<Item = U>, + U: AsRef<str>, + { + Self::build( + RelayDeliveryState::Acknowledged, + target_relays, + connected_relays, + acknowledged_relays, + failed_relays, + ) + } + + pub fn failed<I, S, J, T>( + target_relays: I, + connected_relays: J, + failed_relays: Vec<RelayDeliveryFailure>, + ) -> Result<Self, LocalEventsError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + J: IntoIterator<Item = T>, + T: AsRef<str>, + { + Self::build( + RelayDeliveryState::Failed, + target_relays, + connected_relays, + Vec::<String>::new(), + failed_relays, + ) + } + + pub fn validate(&self) -> Result<(), LocalEventsError> { + validate_relay_set("target_relays", &self.target_relays, true)?; + validate_relay_set("connected_relays", &self.connected_relays, false)?; + validate_relay_set("acknowledged_relays", &self.acknowledged_relays, false)?; + for failure in &self.failed_relays { + let normalized = + normalize_relay_url_for_evidence("failed_relays.relay_url", &failure.relay_url)?; + if normalized != failure.relay_url { + return Err(invalid_evidence( + "failed_relays.relay_url must be normalized and deduplicated", + )); + } + let normalized_error = normalize_non_empty_text("failed_relays.error", &failure.error)?; + if normalized_error != failure.error { + return Err(invalid_evidence("failed_relays.error must be trimmed")); + } + } + match self.state { + RelayDeliveryState::Pending => { + if !self.acknowledged_relays.is_empty() || !self.failed_relays.is_empty() { + return Err(invalid_evidence( + "pending delivery evidence must not include acknowledged or failed relays", + )); + } + } + RelayDeliveryState::Acknowledged => { + if self.acknowledged_relays.is_empty() { + return Err(invalid_evidence( + "acknowledged delivery evidence requires acknowledged_relays", + )); + } + } + RelayDeliveryState::Failed => { + if !self.acknowledged_relays.is_empty() || self.failed_relays.is_empty() { + return Err(invalid_evidence( + "failed delivery evidence requires failed_relays and no acknowledged_relays", + )); + } + } + } + Ok(()) + } + + pub fn relay_set_fingerprint(&self) -> Option<String> { + canonical_relay_set_fingerprint(&self.target_relays) + } + + pub fn to_json_value(&self) -> Result<Value, LocalEventsError> { + self.validate()?; + serde_json::to_value(self).map_err(LocalEventsError::from) + } + + pub fn from_json_value(value: &Value) -> Result<Self, LocalEventsError> { + let evidence: Self = serde_json::from_value(value.clone())?; + evidence.validate()?; + Ok(evidence) + } + + fn build<I, S, J, T, K, U>( + state: RelayDeliveryState, + target_relays: I, + connected_relays: J, + acknowledged_relays: K, + failed_relays: Vec<RelayDeliveryFailure>, + ) -> Result<Self, LocalEventsError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + J: IntoIterator<Item = T>, + T: AsRef<str>, + K: IntoIterator<Item = U>, + U: AsRef<str>, + { + let evidence = Self { + state, + target_relays: normalize_required_relay_set("target_relays", target_relays)?, + connected_relays: normalize_relay_set("connected_relays", connected_relays)?, + acknowledged_relays: normalize_relay_set("acknowledged_relays", acknowledged_relays)?, + failed_relays, + }; + evidence.validate()?; + Ok(evidence) + } +} + +fn normalize_relay_url_for_evidence( + field: &str, + value: impl AsRef<str>, +) -> Result<String, LocalEventsError> { + crate::relay_url::normalize_relay_url(value.as_ref()).map_err(|error| relay_error(field, error)) +} + +fn normalize_required_relay_set<I, S>( + field: &str, + values: I, +) -> Result<Vec<String>, LocalEventsError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let relays = normalize_relay_set(field, values)?; + if relays.is_empty() { + return Err(invalid_evidence(format!("{field} must not be empty"))); + } + Ok(relays) +} + +fn normalize_relay_set<I, S>(field: &str, values: I) -> Result<Vec<String>, LocalEventsError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + normalize_relay_urls(values).map_err(|error| relay_error(field, error)) +} + +fn validate_relay_set( + field: &str, + relays: &[String], + require_non_empty: bool, +) -> Result<(), LocalEventsError> { + let normalized = normalize_relay_set(field, relays)?; + if require_non_empty && normalized.is_empty() { + return Err(invalid_evidence(format!("{field} must not be empty"))); + } + if normalized != relays { + return Err(invalid_evidence(format!( + "{field} must be normalized and deduplicated" + ))); + } + Ok(()) +} + +fn normalize_non_empty_text(field: &str, value: &str) -> Result<String, LocalEventsError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(invalid_evidence(format!("{field} must not be empty"))); + } + Ok(trimmed.to_owned()) +} + +fn relay_error(field: &str, error: RelayUrlValidationError) -> LocalEventsError { + invalid_evidence(format!("{field}: {error}")) +} + +fn invalid_evidence(message: impl Into<String>) -> LocalEventsError { + LocalEventsError::InvalidRecord(format!( + "invalid relay delivery evidence: {}", + message.into() + )) +} diff --git a/crates/local_events/src/relay_url.rs b/crates/local_events/src/relay_url.rs @@ -0,0 +1,142 @@ +#![forbid(unsafe_code)] + +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayUrlValidationError { + Empty, + UnsupportedScheme(String), + MissingHost(String), + InvalidAuthority(String), + InvalidPort(String), +} + +impl fmt::Display for RelayUrlValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("relay url must not be empty"), + Self::UnsupportedScheme(value) => { + write!(f, "relay url must use ws or wss, got `{value}`") + } + Self::MissingHost(value) => write!(f, "relay url must include a host, got `{value}`"), + Self::InvalidAuthority(value) => { + write!(f, "relay url authority is invalid, got `{value}`") + } + Self::InvalidPort(value) => write!(f, "relay url port is invalid, got `{value}`"), + } + } +} + +impl std::error::Error for RelayUrlValidationError {} + +pub fn normalize_relay_url(value: &str) -> Result<String, RelayUrlValidationError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(RelayUrlValidationError::Empty); + } + + let rest = if let Some(rest) = trimmed.strip_prefix("ws://") { + rest + } else if let Some(rest) = trimmed.strip_prefix("wss://") { + rest + } else { + return Err(RelayUrlValidationError::UnsupportedScheme( + trimmed.to_owned(), + )); + }; + + validate_relay_authority(trimmed, rest)?; + Ok(trimmed.to_owned()) +} + +pub fn normalize_relay_urls<I, S>(values: I) -> Result<Vec<String>, RelayUrlValidationError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let mut normalized = Vec::new(); + for value in values { + let relay = normalize_relay_url(value.as_ref())?; + if !normalized.iter().any(|existing| existing == &relay) { + normalized.push(relay); + } + } + Ok(normalized) +} + +fn validate_relay_authority(original: &str, rest: &str) -> Result<(), RelayUrlValidationError> { + let authority_end = rest + .char_indices() + .find(|(_, ch)| matches!(ch, '/' | '?' | '#')) + .map(|(index, _)| index) + .unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + + if authority.is_empty() { + return Err(RelayUrlValidationError::MissingHost(original.to_owned())); + } + if authority.chars().any(char::is_whitespace) || authority.contains('@') { + return Err(RelayUrlValidationError::InvalidAuthority( + original.to_owned(), + )); + } + + if let Some(after_open) = authority.strip_prefix('[') { + let Some(close_index) = after_open.find(']') else { + return Err(RelayUrlValidationError::InvalidAuthority( + original.to_owned(), + )); + }; + let host = &after_open[..close_index]; + let after_host = &after_open[close_index + 1..]; + if host.is_empty() { + return Err(RelayUrlValidationError::MissingHost(original.to_owned())); + } + validate_optional_port(original, after_host)?; + return Ok(()); + } + + let colon_count = authority.bytes().filter(|byte| *byte == b':').count(); + match colon_count { + 0 => { + if authority.is_empty() { + Err(RelayUrlValidationError::MissingHost(original.to_owned())) + } else { + Ok(()) + } + } + 1 => { + let Some((host, port)) = authority.split_once(':') else { + return Err(RelayUrlValidationError::InvalidAuthority( + original.to_owned(), + )); + }; + if host.is_empty() { + return Err(RelayUrlValidationError::MissingHost(original.to_owned())); + } + validate_port(original, port) + } + _ => Err(RelayUrlValidationError::InvalidAuthority( + original.to_owned(), + )), + } +} + +fn validate_optional_port(original: &str, after_host: &str) -> Result<(), RelayUrlValidationError> { + if after_host.is_empty() { + return Ok(()); + } + let Some(port) = after_host.strip_prefix(':') else { + return Err(RelayUrlValidationError::InvalidAuthority( + original.to_owned(), + )); + }; + validate_port(original, port) +} + +fn validate_port(original: &str, port: &str) -> Result<(), RelayUrlValidationError> { + if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(RelayUrlValidationError::InvalidPort(original.to_owned())); + } + Ok(()) +} diff --git a/crates/local_events/tests/relay_delivery.rs b/crates/local_events/tests/relay_delivery.rs @@ -0,0 +1,102 @@ +use radroots_local_events::{ + RelayDeliveryEvidence, RelayDeliveryFailure, RelayDeliveryState, + canonical_relay_set_fingerprint, +}; +use serde_json::json; + +#[test] +fn pending_delivery_evidence_uses_canonical_json_shape() { + let evidence = RelayDeliveryEvidence::pending([ + " wss://relay-b.example ", + "wss://relay-a.example", + "wss://relay-b.example", + ]) + .expect("pending evidence"); + + assert_eq!(evidence.state, RelayDeliveryState::Pending); + assert_eq!( + evidence.target_relays, + vec![ + "wss://relay-b.example".to_owned(), + "wss://relay-a.example".to_owned() + ] + ); + assert_eq!( + evidence.to_json_value().expect("json"), + json!({ + "state": "pending", + "target_relays": ["wss://relay-b.example", "wss://relay-a.example"], + "connected_relays": [], + "acknowledged_relays": [], + "failed_relays": [] + }) + ); +} + +#[test] +fn acknowledged_delivery_evidence_uses_canonical_failure_fields() { + let evidence = RelayDeliveryEvidence::acknowledged( + ["wss://relay-a.example", "wss://relay-b.example"], + [" wss://relay-a.example "], + ["wss://relay-a.example"], + vec![RelayDeliveryFailure::new(" wss://relay-b.example ", " timeout ").expect("failure")], + ) + .expect("acknowledged evidence"); + + assert_eq!( + evidence.to_json_value().expect("json"), + json!({ + "state": "acknowledged", + "target_relays": ["wss://relay-a.example", "wss://relay-b.example"], + "connected_relays": ["wss://relay-a.example"], + "acknowledged_relays": ["wss://relay-a.example"], + "failed_relays": [ + {"relay_url": "wss://relay-b.example", "error": "timeout"} + ] + }) + ); +} + +#[test] +fn failed_delivery_evidence_requires_failures_without_acknowledgements() { + let evidence = RelayDeliveryEvidence::failed( + ["wss://relay-a.example"], + ["wss://relay-a.example"], + vec![RelayDeliveryFailure::new("wss://relay-a.example", "closed").expect("failure")], + ) + .expect("failed evidence"); + + assert_eq!(evidence.state, RelayDeliveryState::Failed); + assert!(evidence.acknowledged_relays.is_empty()); + assert_eq!(evidence.failed_relays.len(), 1); +} + +#[test] +fn delivery_evidence_fingerprint_uses_target_relays() { + let evidence = RelayDeliveryEvidence::acknowledged( + ["wss://relay-b.example", "wss://relay-a.example"], + ["wss://relay-a.example"], + ["wss://relay-a.example"], + Vec::new(), + ) + .expect("evidence"); + + assert_eq!( + evidence.relay_set_fingerprint(), + canonical_relay_set_fingerprint(["wss://relay-a.example", "wss://relay-b.example"]) + ); +} + +#[test] +fn delivery_evidence_rejects_invalid_json_shape() { + let err = RelayDeliveryEvidence::from_json_value(&json!({ + "state": "acknowledged", + "target_relays": ["wss://relay-a.example"], + "connected_relays": [], + "acknowledged_relays": [], + "failed_relays": [] + })) + .expect_err("invalid evidence"); + + assert!(err.to_string().contains("acknowledged_relays")); +} diff --git a/crates/local_events/tests/relay_url.rs b/crates/local_events/tests/relay_url.rs @@ -0,0 +1,103 @@ +use radroots_local_events::{RelayUrlValidationError, normalize_relay_url, normalize_relay_urls}; + +#[test] +fn relay_url_normalization_trims_and_dedupes() { + let relays = normalize_relay_urls([ + " wss://relay-a.example ", + "wss://relay-a.example", + "ws://127.0.0.1:8080/nostr", + ]) + .expect("normalize relays"); + + assert_eq!( + relays, + vec![ + "wss://relay-a.example".to_owned(), + "ws://127.0.0.1:8080/nostr".to_owned() + ] + ); +} + +#[test] +fn relay_url_validation_rejects_empty_values() { + assert_eq!( + normalize_relay_url(" "), + Err(RelayUrlValidationError::Empty) + ); +} + +#[test] +fn relay_url_validation_rejects_non_websocket_schemes() { + assert_eq!( + normalize_relay_url("https://relay.example"), + Err(RelayUrlValidationError::UnsupportedScheme( + "https://relay.example".to_owned() + )) + ); +} + +#[test] +fn relay_url_validation_rejects_hostless_values() { + assert_eq!( + normalize_relay_url("wss://"), + Err(RelayUrlValidationError::MissingHost("wss://".to_owned())) + ); + assert_eq!( + normalize_relay_url("wss:///relay"), + Err(RelayUrlValidationError::MissingHost( + "wss:///relay".to_owned() + )) + ); + assert_eq!( + normalize_relay_url("ws://:8080"), + Err(RelayUrlValidationError::MissingHost( + "ws://:8080".to_owned() + )) + ); +} + +#[test] +fn relay_url_validation_rejects_malformed_authority() { + assert_eq!( + normalize_relay_url("wss://user@relay.example"), + Err(RelayUrlValidationError::InvalidAuthority( + "wss://user@relay.example".to_owned() + )) + ); + assert_eq!( + normalize_relay_url("wss://relay example"), + Err(RelayUrlValidationError::InvalidAuthority( + "wss://relay example".to_owned() + )) + ); + assert_eq!( + normalize_relay_url("wss://2001:db8::1"), + Err(RelayUrlValidationError::InvalidAuthority( + "wss://2001:db8::1".to_owned() + )) + ); +} + +#[test] +fn relay_url_validation_rejects_invalid_ports() { + assert_eq!( + normalize_relay_url("wss://relay.example:abc"), + Err(RelayUrlValidationError::InvalidPort( + "wss://relay.example:abc".to_owned() + )) + ); + assert_eq!( + normalize_relay_url("wss://[2001:db8::1]:abc"), + Err(RelayUrlValidationError::InvalidPort( + "wss://[2001:db8::1]:abc".to_owned() + )) + ); +} + +#[test] +fn relay_url_validation_accepts_bracketed_ipv6() { + assert_eq!( + normalize_relay_url("wss://[2001:db8::1]:8080/nostr").expect("ipv6 relay"), + "wss://[2001:db8::1]:8080/nostr" + ); +} diff --git a/crates/local_events/tests/store.rs b/crates/local_events/tests/store.rs @@ -1,6 +1,6 @@ use radroots_local_events::{ LocalEventRecordInput, LocalEventRecordUpdate, LocalEventsStore, LocalRecordFamily, - LocalRecordStatus, MIGRATIONS, PublishOutboxStatus, SourceRuntime, + LocalRecordStatus, MIGRATIONS, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, }; use radroots_sql_core::migrations::migrations_run_all_up; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; @@ -63,7 +63,12 @@ fn signed_event(record_id: &str) -> LocalEventRecordInput { raw_event_json: Some(json!({"id":"event-a","kind":3421})), outbox_status: PublishOutboxStatus::Pending, relay_set_fingerprint: Some("relay-set-a".to_owned()), - relay_delivery_json: Some(json!({"pending":["ws://127.0.0.1:8080"]})), + relay_delivery_json: Some( + RelayDeliveryEvidence::pending(["ws://127.0.0.1:8080"]) + .expect("pending delivery") + .to_json_value() + .expect("pending delivery json"), + ), } } @@ -124,7 +129,17 @@ fn outbox_status_updates_signed_event_records() { status: LocalRecordStatus::Published, outbox_status: PublishOutboxStatus::Acknowledged, relay_set_fingerprint: Some("relay-set-a".to_owned()), - relay_delivery_json: Some(json!({"acked":["ws://127.0.0.1:8080"]})), + relay_delivery_json: Some( + RelayDeliveryEvidence::acknowledged( + ["ws://127.0.0.1:8080"], + ["ws://127.0.0.1:8080"], + ["ws://127.0.0.1:8080"], + Vec::new(), + ) + .expect("acknowledged delivery") + .to_json_value() + .expect("acknowledged delivery json"), + ), updated_at_ms: 3000, }) .expect("update outbox"); @@ -133,7 +148,13 @@ fn outbox_status_updates_signed_event_records() { assert_eq!(updated.outbox_status, PublishOutboxStatus::Acknowledged); assert_eq!( updated.relay_delivery_json, - Some(json!({"acked":["ws://127.0.0.1:8080"]})) + Some(json!({ + "state": "acknowledged", + "target_relays": ["ws://127.0.0.1:8080"], + "connected_relays": ["ws://127.0.0.1:8080"], + "acknowledged_relays": ["ws://127.0.0.1:8080"], + "failed_relays": [] + })) ); } @@ -157,7 +178,17 @@ fn changed_after_uses_change_seq_for_appends_and_outbox_updates() { status: LocalRecordStatus::Published, outbox_status: PublishOutboxStatus::Acknowledged, relay_set_fingerprint: Some("relay-set-a".to_owned()), - relay_delivery_json: Some(json!({"acked":["ws://127.0.0.1:8080"]})), + relay_delivery_json: Some( + RelayDeliveryEvidence::acknowledged( + ["ws://127.0.0.1:8080"], + ["ws://127.0.0.1:8080"], + ["ws://127.0.0.1:8080"], + Vec::new(), + ) + .expect("acknowledged delivery") + .to_json_value() + .expect("acknowledged delivery json"), + ), updated_at_ms: 3000, }) .expect("update outbox"); diff --git a/crates/sdk/src/config.rs b/crates/sdk/src/config.rs @@ -277,12 +277,76 @@ fn normalize_relay_url(value: &str) -> Result<String, SdkConfigError> { if trimmed.is_empty() { return Err(SdkConfigError::EmptyRelayUrl); } - if !(trimmed.starts_with("ws://") || trimmed.starts_with("wss://")) { + + let rest = if let Some(rest) = trimmed.strip_prefix("ws://") { + rest + } else if let Some(rest) = trimmed.strip_prefix("wss://") { + rest + } else { + return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned())); + }; + + if relay_authority_is_invalid(rest) { return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned())); } + Ok(trimmed.to_owned()) } +fn relay_authority_is_invalid(rest: &str) -> bool { + let authority_end = rest + .char_indices() + .find(|(_, ch)| matches!(ch, '/' | '?' | '#')) + .map(|(index, _)| index) + .unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + + if authority.is_empty() || authority.chars().any(char::is_whitespace) { + return true; + } + if authority.contains('@') { + return true; + } + + if let Some(after_open) = authority.strip_prefix('[') { + let Some(close_index) = after_open.find(']') else { + return true; + }; + let host = &after_open[..close_index]; + let after_host = &after_open[close_index + 1..]; + if host.is_empty() { + return true; + } + return relay_port_suffix_is_invalid(after_host); + } + + let colon_count = authority.bytes().filter(|byte| *byte == b':').count(); + match colon_count { + 0 => false, + 1 => { + let Some((host, port)) = authority.split_once(':') else { + return true; + }; + host.is_empty() || relay_port_is_invalid(port) + } + _ => true, + } +} + +fn relay_port_suffix_is_invalid(after_host: &str) -> bool { + if after_host.is_empty() { + return false; + } + let Some(port) = after_host.strip_prefix(':') else { + return true; + }; + relay_port_is_invalid(port) +} + +fn relay_port_is_invalid(port: &str) -> bool { + port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) +} + fn normalize_radrootsd_endpoint(value: &str) -> Result<String, SdkConfigError> { let trimmed = value.trim(); if trimmed.is_empty() { diff --git a/crates/sdk/tests/config.rs b/crates/sdk/tests/config.rs @@ -209,6 +209,50 @@ fn invalid_coordinate_schemes_fail_loudly() { } #[test] +fn invalid_relay_authorities_fail_loudly() { + let invalid_relays = [ + "wss://", + "wss:///relay", + "ws://:8080", + "wss://relay example", + "wss://user@relay.example", + "wss://relay.example:abc", + "wss://2001:db8::1", + ]; + + for relay_url in invalid_relays { + let mut config = RadrootsSdkConfig::production(); + config.relay.urls = vec![relay_url.to_owned()]; + + assert_eq!( + config + .resolved_relay_urls() + .expect_err("relay authority error"), + SdkConfigError::InvalidRelayUrl(relay_url.to_owned()) + ); + } +} + +#[test] +fn valid_relay_authorities_still_resolve() { + let mut config = RadrootsSdkConfig::production(); + config.relay.urls = vec![ + " wss://relay.example/nostr ".to_owned(), + "ws://127.0.0.1:8080".to_owned(), + "wss://[2001:db8::1]:443/relay".to_owned(), + ]; + + assert_eq!( + config.resolved_relay_urls().expect("valid relays"), + vec![ + "wss://relay.example/nostr".to_owned(), + "ws://127.0.0.1:8080".to_owned(), + "wss://[2001:db8::1]:443/relay".to_owned() + ] + ); +} + +#[test] fn sdk_config_debug_redacts_bearer_tokens() { let mut config = RadrootsSdkConfig::production(); config.radrootsd.auth = RadrootsdAuth::BearerToken("sdk-secret-token".to_owned());