lib

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

commit 5596c0d670682bba73d8c63b7015394266276a10
parent fa2fcb6804bb0c433c093399cb7dc089fae18ced
Author: triesap <tyson@radroots.org>
Date:   Mon, 13 Jul 2026 07:10:54 +0000

transport: add canonical target constructors

Diffstat:
Mcrates/outbox/src/store.rs | 15++++++---------
Mcrates/transport/src/target.rs | 177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/transport/tests/transport.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/transport_nostr/src/outbox.rs | 27++++++++++++++-------------
Mcrates/transport_nostr/src/publish.rs | 14++++++++++----
Mcrates/transport_nostr/tests/transport.rs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/transport_publish_protocol/src/lib.rs | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/transport_reticulum/tests/reticulum_preview.rs | 10++++------
8 files changed, 411 insertions(+), 100 deletions(-)

diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -2699,12 +2699,11 @@ mod tests { } fn nostr_target(uri: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, uri).expect("nostr target") + RadrootsTransportTarget::nostr_relay(uri).expect("nostr target") } fn scoped_nostr_target(uri: &str, scope: &str, label: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new_with_metadata( - RadrootsTransportKind::Nostr, + RadrootsTransportTarget::nostr_relay_with_metadata( uri, Some(RadrootsTransportMeshScopeId::parse(scope).expect("target scope")), Some(RadrootsTransportTargetLabel::parse(label).expect("target label")), @@ -2713,9 +2712,7 @@ mod tests { } fn scoped_reticulum_target(scope: &str, label: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new_with_metadata( - RadrootsTransportKind::Reticulum, - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, + RadrootsTransportTarget::reticulum_preview_with_metadata( Some(RadrootsTransportMeshScopeId::parse(scope).expect("target scope")), Some(RadrootsTransportTargetLabel::parse(label).expect("target label")), ) @@ -2723,12 +2720,12 @@ mod tests { } fn reticulum_target(uri: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, uri) - .expect("reticulum target") + assert_eq!(uri, RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI); + RadrootsTransportTarget::reticulum_preview().expect("reticulum target") } fn proxy_target(uri: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new(RadrootsTransportKind::Proxy, uri).expect("proxy target") + RadrootsTransportTarget::proxy(uri).expect("proxy target") } #[test] diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs @@ -18,6 +18,11 @@ impl RadrootsTransportTargetUri { Ok(Self(canonical)) } + fn parse_nostr_relay(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + let canonical = canonicalize_nostr_relay_uri(raw.as_ref())?; + Ok(Self(canonical)) + } + pub fn as_str(&self) -> &str { &self.0 } @@ -161,6 +166,58 @@ impl RadrootsTransportTarget { Self::new_with_metadata(kind, uri, None, None) } + pub fn nostr_relay(uri: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + Self::nostr_relay_with_metadata(uri, None, None) + } + + pub fn nostr_relay_with_metadata( + uri: impl AsRef<str>, + scope: Option<RadrootsTransportMeshScopeId>, + label: Option<RadrootsTransportTargetLabel>, + ) -> Result<Self, RadrootsTransportError> { + Self::new_with_metadata(RadrootsTransportKind::Nostr, uri, scope, label) + } + + pub fn reticulum_preview() -> Result<Self, RadrootsTransportError> { + Self::reticulum_preview_with_metadata(None, None) + } + + pub fn reticulum_preview_with_metadata( + scope: Option<RadrootsTransportMeshScopeId>, + label: Option<RadrootsTransportTargetLabel>, + ) -> Result<Self, RadrootsTransportError> { + Self::new_with_metadata( + RadrootsTransportKind::Reticulum, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, + scope, + label, + ) + } + + pub fn local(uri: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + Self::local_with_metadata(uri, None, None) + } + + pub fn local_with_metadata( + uri: impl AsRef<str>, + scope: Option<RadrootsTransportMeshScopeId>, + label: Option<RadrootsTransportTargetLabel>, + ) -> Result<Self, RadrootsTransportError> { + Self::new_with_metadata(RadrootsTransportKind::Local, uri, scope, label) + } + + pub fn proxy(uri: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + Self::proxy_with_metadata(uri, None, None) + } + + pub fn proxy_with_metadata( + uri: impl AsRef<str>, + scope: Option<RadrootsTransportMeshScopeId>, + label: Option<RadrootsTransportTargetLabel>, + ) -> Result<Self, RadrootsTransportError> { + Self::new_with_metadata(RadrootsTransportKind::Proxy, uri, scope, label) + } + pub fn new_with_metadata( kind: RadrootsTransportKind, uri: impl AsRef<str>, @@ -173,7 +230,10 @@ impl RadrootsTransportTarget { { return Err(RadrootsTransportError::InvalidTargetUri); } - let uri = RadrootsTransportTargetUri::parse(raw_uri)?; + let uri = match kind { + RadrootsTransportKind::Nostr => RadrootsTransportTargetUri::parse_nostr_relay(raw_uri)?, + _ => RadrootsTransportTargetUri::parse(raw_uri)?, + }; let scope = scope.or_else(|| default_scope_for_kind(&kind)); let fingerprint = RadrootsTransportTargetFingerprint::from_target(&kind, &uri, scope.as_ref()); @@ -264,3 +324,118 @@ fn is_valid_scheme(value: &str) -> bool { matches!(chars.next(), Some(first) if first.is_ascii_alphabetic()) && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) } + +fn canonicalize_nostr_relay_uri(raw: &str) -> Result<String, RadrootsTransportError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(RadrootsTransportError::EmptyTargetUri); + } + if trimmed + .chars() + .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace()) + { + return Err(RadrootsTransportError::InvalidTargetUri); + } + if trimmed.contains('?') || trimmed.contains('#') { + return Err(RadrootsTransportError::InvalidTargetUri); + } + let Some(scheme_end) = trimmed.find("://") else { + return Err(RadrootsTransportError::InvalidTargetUri); + }; + let scheme = trimmed[..scheme_end].to_ascii_lowercase(); + if !matches!(scheme.as_str(), "wss" | "ws") { + return Err(RadrootsTransportError::InvalidTargetUri); + } + let endpoint = &trimmed[scheme_end + 3..]; + let authority_end = endpoint.find('/').unwrap_or(endpoint.len()); + let authority = &endpoint[..authority_end]; + let path = &endpoint[authority_end..]; + let authority = canonicalize_nostr_relay_authority(authority, scheme.as_str())?; + if path == "/" { + return Ok(format!("{scheme}://{authority}")); + } + Ok(format!("{scheme}://{authority}{path}")) +} + +fn canonicalize_nostr_relay_authority( + authority: &str, + scheme: &str, +) -> Result<String, RadrootsTransportError> { + if authority.is_empty() || authority.contains('@') { + return Err(RadrootsTransportError::InvalidTargetUri); + } + let (host, port) = if let Some(rest) = authority.strip_prefix('[') { + let Some(host_end) = rest.find(']') else { + return Err(RadrootsTransportError::InvalidTargetUri); + }; + let host = &rest[..host_end]; + let suffix = &rest[host_end + 1..]; + if host.is_empty() + || host + .chars() + .any(|ch| matches!(ch, '[' | ']' | '/' | '?' | '#' | '@')) + { + return Err(RadrootsTransportError::InvalidTargetUri); + } + ( + format!("[{}]", host.to_ascii_lowercase()), + parse_nostr_relay_port(suffix)?, + ) + } else { + if authority.contains(['[', ']']) { + return Err(RadrootsTransportError::InvalidTargetUri); + } + let mut parts = authority.splitn(2, ':'); + let host = parts.next().unwrap_or_default(); + let port = parts + .next() + .map(|port| parse_nostr_relay_port_with_prefix(port)) + .transpose()?; + if host.is_empty() || !is_valid_nostr_relay_host(host) { + return Err(RadrootsTransportError::InvalidTargetUri); + } + (host.to_ascii_lowercase(), port) + }; + if scheme == "ws" && !is_local_ws_relay_host(host.as_str()) { + return Err(RadrootsTransportError::InvalidTargetUri); + } + Ok(match port { + Some(port) => format!("{host}:{port}"), + None => host, + }) +} + +fn parse_nostr_relay_port(suffix: &str) -> Result<Option<String>, RadrootsTransportError> { + if suffix.is_empty() { + return Ok(None); + } + let Some(port) = suffix.strip_prefix(':') else { + return Err(RadrootsTransportError::InvalidTargetUri); + }; + parse_nostr_relay_port_with_prefix(port).map(Some) +} + +fn parse_nostr_relay_port_with_prefix(port: &str) -> Result<String, RadrootsTransportError> { + if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(RadrootsTransportError::InvalidTargetUri); + } + let value = port + .parse::<u32>() + .map_err(|_| RadrootsTransportError::InvalidTargetUri)?; + if value > u16::MAX as u32 { + return Err(RadrootsTransportError::InvalidTargetUri); + } + Ok(port.to_string()) +} + +fn is_valid_nostr_relay_host(host: &str) -> bool { + if host.contains("..") || host.starts_with('.') || host.ends_with('.') { + return false; + } + host.bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.')) +} + +fn is_local_ws_relay_host(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "[::1]") +} diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -19,16 +19,10 @@ fn opaque_payload() -> RadrootsTransportPayload { #[test] fn target_fingerprints_are_stable_and_transport_scoped() { let nostr_upper = - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, " WSS://Relay.Example/Events ") - .expect("nostr target"); + RadrootsTransportTarget::nostr_relay(" WSS://Relay.Example/Events ").expect("nostr target"); let nostr_lower = - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example/Events") - .expect("nostr target"); - let reticulum = RadrootsTransportTarget::new( - RadrootsTransportKind::Reticulum, - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, - ) - .expect("reticulum target"); + RadrootsTransportTarget::nostr_relay("wss://relay.example/Events").expect("nostr target"); + let reticulum = RadrootsTransportTarget::reticulum_preview().expect("reticulum target"); assert_eq!(nostr_upper.uri.as_str(), "wss://relay.example/Events"); assert_eq!(nostr_upper.scope, None); @@ -132,11 +126,10 @@ fn removed_proxy_kind() -> String { #[test] fn target_set_rejects_duplicate_fingerprints() { - let first = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example/a") - .expect("first target"); + let first = + RadrootsTransportTarget::nostr_relay("wss://relay.example/a").expect("first target"); let duplicate = - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "WSS://RELAY.EXAMPLE/a") - .expect("duplicate target"); + RadrootsTransportTarget::nostr_relay("WSS://RELAY.EXAMPLE/a").expect("duplicate target"); let err = RadrootsTransportTargetSet::new(vec![first, duplicate]) .expect_err("duplicate fingerprints must fail"); @@ -144,6 +137,57 @@ fn target_set_rejects_duplicate_fingerprints() { } #[test] +fn nostr_relay_targets_use_canonical_endpoint_identity() { + let root = RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("root target"); + let root_slash = + RadrootsTransportTarget::nostr_relay("WSS://RELAY.EXAMPLE/").expect("root slash target"); + let path = + RadrootsTransportTarget::nostr_relay("wss://relay.example/path").expect("path target"); + let generic = + RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example/") + .expect("generic nostr target"); + + assert_eq!(root.uri.as_str(), "wss://relay.example"); + assert_eq!(root_slash.uri.as_str(), "wss://relay.example"); + assert_eq!(root.fingerprint, root_slash.fingerprint); + assert_eq!(root.fingerprint, generic.fingerprint); + assert_ne!(root.fingerprint, path.fingerprint); + assert_eq!(path.uri.as_str(), "wss://relay.example/path"); + assert_eq!( + RadrootsTransportTargetSet::new(vec![root, root_slash]) + .expect_err("canonical-equivalent roots collide"), + RadrootsTransportError::DuplicateTargetFingerprint + ); +} + +#[test] +fn nostr_relay_targets_reject_noncanonical_or_unsupported_endpoint_forms() { + for invalid in [ + "https://relay.example", + "wss://user@relay.example", + "wss://user:password@relay.example", + "wss://relay.example?subscription=1", + "wss://relay.example#fragment", + "wss://", + "wss://relay.example:bad", + "wss://relay.example:65536", + "ws://relay.example", + ] { + assert_eq!( + RadrootsTransportTarget::nostr_relay(invalid).expect_err("invalid Nostr relay target"), + RadrootsTransportError::InvalidTargetUri + ); + } + + let local_ws = + RadrootsTransportTarget::nostr_relay("ws://LOCALHOST:7777/").expect("local ws relay"); + assert_eq!(local_ws.uri.as_str(), "ws://localhost:7777"); + let local_ipv6 = + RadrootsTransportTarget::nostr_relay("ws://[::1]:7777").expect("local ipv6 relay"); + assert_eq!(local_ipv6.uri.as_str(), "ws://[::1]:7777"); +} + +#[test] fn satisfaction_policy_counts_target_statuses() { let no_wait = RadrootsTransportSatisfactionPolicy::no_wait(); let all = RadrootsTransportSatisfactionPolicy::all_accepted(); @@ -292,11 +336,7 @@ fn transport_status_models_canonical_configuration_and_delivery_usability() { #[test] fn deferred_transport_outcomes_are_terminal_but_not_satisfied() { - let target = RadrootsTransportTarget::new( - RadrootsTransportKind::Reticulum, - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, - ) - .expect("target"); + let target = RadrootsTransportTarget::reticulum_preview().expect("target"); let receipt = RadrootsTransportDeliveryReceipt { request_id: "reticulum-preview".to_owned(), target_receipts: vec![RadrootsTransportTargetReceipt::new( @@ -323,8 +363,7 @@ fn deferred_transport_outcomes_are_terminal_but_not_satisfied() { #[test] #[cfg(feature = "serde")] fn request_models_round_trip_with_serde() { - let target = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example") - .expect("target"); + let target = RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("target"); let target_set = RadrootsTransportTargetSet::new(vec![target]).expect("target set"); let request = RadrootsTransportDeliveryRequest::new( "req-1", @@ -558,11 +597,8 @@ fn transport_kind_and_target_parsers_cover_negative_edges() { #[test] fn reticulum_transport_targets_require_exact_preview_endpoint() { - let target = RadrootsTransportTarget::new( - RadrootsTransportKind::Reticulum, - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, - ) - .expect("exact Reticulum preview endpoint"); + let target = + RadrootsTransportTarget::reticulum_preview().expect("exact Reticulum preview endpoint"); assert_eq!(target.uri.as_str(), RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI); assert_eq!( target.scope.as_ref().map(|scope| scope.as_str()), @@ -913,10 +949,10 @@ fn typed_outcome_kinds_drive_status_and_satisfaction_semantics() { #[test] fn required_target_satisfaction_uses_fingerprints_not_target_counts() { - let required = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://one.example") - .expect("required target"); - let optional = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://two.example") - .expect("optional target"); + let required = + RadrootsTransportTarget::nostr_relay("wss://one.example").expect("required target"); + let optional = + RadrootsTransportTarget::nostr_relay("wss://two.example").expect("optional target"); let policy = RadrootsTransportSatisfactionPolicy::required_targets( RadrootsTransportSatisfactionClass::Accepted, vec![required.fingerprint.clone()], @@ -1065,8 +1101,7 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { } } - let target = RadrootsTransportTarget::new(RadrootsTransportKind::Local, "local:memory") - .expect("local target"); + let target = RadrootsTransportTarget::local("local:memory").expect("local target"); let target_set = RadrootsTransportTargetSet::new(vec![target.clone()]).expect("target set"); let transport = MemoryTransport { target }; assert_eq!(transport.transport_kind(), RadrootsTransportKind::Local); diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -408,9 +408,14 @@ impl PublishableRelays { &'a self, relay_url: &'a str, ) -> impl Iterator<Item = &'a PublishableRelay> + 'a { - self.relays - .iter() - .filter(move |target| target.relay_url == relay_url) + let canonical_relay_url = RadrootsTransportTarget::nostr_relay(relay_url) + .ok() + .map(|target| target.uri.as_str().to_owned()); + self.relays.iter().filter(move |target| { + canonical_relay_url + .as_deref() + .is_some_and(|relay_url| target.relay_url == relay_url) + }) } fn satisfied_count_after_receipts( @@ -723,8 +728,7 @@ fn publishable_transport_targets( .relays .iter() .map(|relay| { - RadrootsTransportTarget::new_with_metadata( - RadrootsTransportKind::Nostr, + RadrootsTransportTarget::nostr_relay_with_metadata( relay.relay_url.as_str(), relay .target_scope @@ -1057,9 +1061,9 @@ mod tests { use crate::{RadrootsRelayOutcome, RadrootsRelayPublishRelayReceipt}; use radroots_transport::{ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportKind, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, - RadrootsTransportTarget, RadrootsTransportTargetReceipt, + RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, + RadrootsTransportTargetReceipt, }; #[test] @@ -1095,8 +1099,7 @@ mod tests { RadrootsTransportSatisfactionPolicy::quorum_delivered(2) ); let required_target = - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example") - .expect("required target"); + RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("required target"); assert_eq!( satisfaction_policy_for_remaining_count( RadrootsTransportSatisfactionClass::Delivered, @@ -1124,9 +1127,7 @@ mod tests { #[test] fn outbox_publish_satisfaction_counts_use_active_transport_class() { - let target = - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example") - .expect("target"); + let target = RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("target"); let publishable = PublishableRelays { active_delivery_plan_id: 7, relays: vec![PublishableRelay { diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -309,10 +309,9 @@ fn target_receipts_from_relay_receipts( .iter() .cloned() .map(|target| { - let relay_url = target.uri.as_str().trim_end_matches('/'); let outcome = relay_receipts .iter() - .find(|receipt| receipt.relay_url.trim_end_matches('/') == relay_url) + .find(|receipt| relay_receipt_matches_target(receipt, &target)) .map(|receipt| receipt.outcome.to_transport_outcome()) .unwrap_or_else(|| { RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::RouteUnavailable) @@ -404,8 +403,7 @@ fn relay_publish_satisfies_policy( policy.required_target_count(target_count)?; let mut satisfied_required_targets = BTreeSet::new(); for receipt in relays { - let target = - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, &receipt.relay_url)?; + let target = RadrootsTransportTarget::nostr_relay(&receipt.relay_url)?; if targets.contains(&target.fingerprint) && receipt .outcome @@ -423,6 +421,14 @@ fn relay_publish_satisfies_policy( } } +fn relay_receipt_matches_target( + receipt: &RadrootsRelayPublishRelayReceipt, + target: &RadrootsTransportTarget, +) -> bool { + RadrootsTransportTarget::nostr_relay(receipt.relay_url.as_str()) + .is_ok_and(|receipt_target| receipt_target.uri == target.uri) +} + #[derive(Clone, Default)] pub struct RadrootsMockRelayPublishAdapter { outcomes: BTreeMap<String, RadrootsRelayOutcome>, diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -76,6 +76,23 @@ impl RadrootsRelayPublishAdapter for PartialPublishAdapter { } } +struct SlashSpelledRelayReceiptPublishAdapter; + +impl RadrootsRelayPublishAdapter for SlashSpelledRelayReceiptPublishAdapter { + fn publish<'a>( + &'a self, + _request: RadrootsRelayPublishRequest, + ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>> + { + Box::pin(async { + Ok(vec![RadrootsRelayPublishRelayReceipt::attempted( + format!("{RELAY_PRIMARY_WSS}/"), + RadrootsRelayOutcome::accepted(), + )]) + }) + } +} + struct NostrJsonFailurePublishAdapter; impl RadrootsRelayPublishAdapter for NostrJsonFailurePublishAdapter { @@ -207,12 +224,11 @@ async fn complete_claimed_signing( } fn nostr_target(relay_url: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, relay_url).expect("nostr target") + RadrootsTransportTarget::nostr_relay(relay_url).expect("nostr target") } fn scoped_nostr_target(relay_url: &str, scope: &str, label: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new_with_metadata( - RadrootsTransportKind::Nostr, + RadrootsTransportTarget::nostr_relay_with_metadata( relay_url, Some(RadrootsTransportMeshScopeId::parse(scope).expect("target scope")), Some(RadrootsTransportTargetLabel::parse(label).expect("target label")), @@ -771,11 +787,7 @@ async fn nostr_transport_facade_rejects_unsupported_payloads_and_targets() { .expect_err("payload rejected"); assert_eq!(payload_error, RadrootsTransportError::InvalidPayloadBytes); - let non_nostr_target = RadrootsTransportTarget::new( - RadrootsTransportKind::Reticulum, - radroots_transport::RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, - ) - .expect("reticulum target"); + let non_nostr_target = RadrootsTransportTarget::reticulum_preview().expect("reticulum target"); let target_error = transport .deliver(RadrootsTransportDeliveryRequest::new( "facade-request-target", @@ -790,6 +802,50 @@ async fn nostr_transport_facade_rejects_unsupported_payloads_and_targets() { } #[tokio::test] +async fn nostr_transport_facade_matches_canonical_equivalent_relay_receipts() { + let signed = signed_post("facade canonical receipt"); + let target = nostr_target(RELAY_PRIMARY_WSS); + let policy = RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + vec![target.fingerprint.clone()], + ) + .expect("required target policy"); + let transport = RadrootsNostrTransport::new(SlashSpelledRelayReceiptPublishAdapter); + let receipt = transport + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-canonical-receipt", + RadrootsTransportPayload::signed_event_json(signed.id.clone(), signed.raw_json.clone()) + .expect("payload"), + RadrootsTransportTargetSet::new(vec![target.clone()]).expect("target set"), + policy.clone(), + )) + .await + .expect("delivery"); + + assert_eq!(receipt.target_receipts.len(), 1); + assert_eq!(receipt.target_receipts[0].target, target); + assert_eq!( + receipt.target_receipts[0].status, + radroots_transport::RadrootsTransportDeliveryTargetStatus::Accepted + ); + assert!(receipt.is_satisfied_by(&policy).expect("satisfaction")); + + let relay_receipt = publish_signed_event( + &SlashSpelledRelayReceiptPublishAdapter, + RadrootsRelayPublishRequest::new( + signed, + RadrootsRelayTargetSet::new(vec![RELAY_PRIMARY_WSS], RadrootsRelayUrlPolicy::Public) + .expect("targets"), + 1_070, + ) + .with_satisfaction_policy(policy), + ) + .await + .expect("relay publish"); + assert!(relay_receipt.quorum_met); +} + +#[tokio::test] async fn nostr_transport_facade_preserves_scoped_duplicate_target_metadata() { let signed = signed_post("facade scoped duplicate"); let adapter = RadrootsMockRelayPublishAdapter::new(); @@ -873,8 +929,7 @@ async fn publish_receipts_track_terminal_skipped_and_adapter_errors() { async fn publish_required_target_policy_uses_relay_fingerprints() { let signed = signed_post("required relay"); let required_target = - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, RELAY_PRIMARY_WSS) - .expect("required target"); + RadrootsTransportTarget::nostr_relay(RELAY_PRIMARY_WSS).expect("required target"); let targets = RadrootsRelayTargetSet::new( vec![RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS], RadrootsRelayUrlPolicy::Public, @@ -2348,11 +2403,7 @@ async fn outbox_publish_skips_non_nostr_targets() { RadrootsTransportSatisfactionPolicy::all_accepted(), vec![ nostr_target(RELAY_PRIMARY_WSS), - RadrootsTransportTarget::new( - RadrootsTransportKind::Reticulum, - "reticulum:preview-unavailable", - ) - .expect("reticulum target"), + RadrootsTransportTarget::reticulum_preview().expect("reticulum target"), ], ), 1_000, diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -398,20 +398,24 @@ impl TransportPublishTarget { .map(RadrootsTransportTargetLabel::parse) .transpose() .map_err(|error| target_metadata_error(error, index))?; - let target = RadrootsTransportTarget::new_with_metadata( - transport_kind, - self.endpoint_uri.as_str(), - scope, - label, - ) - .map_err(|error| target_fingerprint_error(error, index))?; + let target = + transport_target_from_parts(transport_kind, self.endpoint_uri.as_str(), scope, label) + .map_err(|error| target_fingerprint_error(error, index))?; Ok(target.fingerprint) } - fn identity_eq(&self, outcome: &TransportPublishTargetOutcome) -> bool { - self.transport_kind == outcome.transport_kind - && self.endpoint_uri == outcome.endpoint_uri - && self.target_scope == outcome.target_scope + fn identity_eq( + &self, + target_index: usize, + outcome: &TransportPublishTargetOutcome, + outcome_index: usize, + ) -> Result<bool, TransportPublishProtocolError> { + if self.transport_kind != outcome.transport_kind + || self.target_scope != outcome.target_scope + { + return Ok(false); + } + Ok(self.fingerprint(target_index)? == target_outcome_fingerprint(outcome, outcome_index)?) } } @@ -1199,16 +1203,40 @@ fn target_outcome_fingerprint( .map(RadrootsTransportTargetLabel::parse) .transpose() .map_err(|error| target_metadata_error(error, index))?; - let target = RadrootsTransportTarget::new_with_metadata( - transport_kind, - target.endpoint_uri.as_str(), - scope, - label, - ) - .map_err(|error| target_fingerprint_error(error, index))?; + let target = + transport_target_from_parts(transport_kind, target.endpoint_uri.as_str(), scope, label) + .map_err(|error| target_fingerprint_error(error, index))?; Ok(target.fingerprint) } +fn transport_target_from_parts( + transport_kind: RadrootsTransportKind, + endpoint_uri: &str, + scope: Option<RadrootsTransportMeshScopeId>, + label: Option<RadrootsTransportTargetLabel>, +) -> Result<RadrootsTransportTarget, RadrootsTransportError> { + match transport_kind { + RadrootsTransportKind::Nostr => { + RadrootsTransportTarget::nostr_relay_with_metadata(endpoint_uri, scope, label) + } + RadrootsTransportKind::Reticulum => { + if endpoint_uri != RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI { + return Err(RadrootsTransportError::InvalidTargetUri); + } + RadrootsTransportTarget::reticulum_preview_with_metadata(scope, label) + } + RadrootsTransportKind::Local => { + RadrootsTransportTarget::local_with_metadata(endpoint_uri, scope, label) + } + RadrootsTransportKind::Proxy => { + RadrootsTransportTarget::proxy_with_metadata(endpoint_uri, scope, label) + } + RadrootsTransportKind::Mesh | RadrootsTransportKind::Custom(_) => { + RadrootsTransportTarget::new_with_metadata(transport_kind, endpoint_uri, scope, label) + } + } +} + fn required_policy_outcomes<'a>( required_targets: &[RadrootsTransportTargetFingerprint], outcomes: &'a [TransportPublishTargetOutcome], @@ -1253,9 +1281,16 @@ fn validate_job_target_policy_outcomes( let mut matched_targets = Vec::new(); matched_targets.resize(targets.len(), false); for (outcome_index, outcome) in outcomes.iter().enumerate() { - let Some((target_index, _)) = targets.iter().enumerate().find(|(target_index, target)| { - !matched_targets[*target_index] && target.identity_eq(outcome) - }) else { + let mut matched_target_index = None; + for (target_index, target) in targets.iter().enumerate() { + if !matched_targets[target_index] + && target.identity_eq(target_index, outcome, outcome_index)? + { + matched_target_index = Some(target_index); + break; + } + } + let Some(target_index) = matched_target_index else { return Err( TransportPublishProtocolError::InvalidExplicitTargetOutcome { index: outcome_index, @@ -1926,6 +1961,19 @@ mod tests { .validate() .expect("explicit target outcomes match regardless of order"); + job_from_targets( + TransportPublishJobStatus::DeliverySatisfied, + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( + "wss://relay-a.example.com", + )]), + vec![nostr_outcome_for( + "wss://relay-a.example.com/", + TransportPublishOutcomeKind::Accepted, + )], + ) + .validate() + .expect("explicit target outcome matches canonical-equivalent endpoint"); + let mismatched_endpoint = job_from_targets( TransportPublishJobStatus::DeliverySatisfied, TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs @@ -14,13 +14,12 @@ use radroots_transport_reticulum::{ }; fn reticulum_target(uri: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, uri).expect("reticulum target") + assert_eq!(uri, RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI); + RadrootsTransportTarget::reticulum_preview().expect("reticulum target") } fn scoped_reticulum_target(scope: &str) -> RadrootsTransportTarget { - RadrootsTransportTarget::new_with_metadata( - RadrootsTransportKind::Reticulum, - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, + RadrootsTransportTarget::reticulum_preview_with_metadata( Some(RadrootsTransportMeshScopeId::parse(scope).expect("scope")), None, ) @@ -28,8 +27,7 @@ fn scoped_reticulum_target(scope: &str) -> RadrootsTransportTarget { } fn nostr_target() -> RadrootsTransportTarget { - RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example") - .expect("nostr target") + RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("nostr target") } fn delivery_request(targets: Vec<RadrootsTransportTarget>) -> RadrootsTransportDeliveryRequest {