commit 5596c0d670682bba73d8c63b7015394266276a10
parent fa2fcb6804bb0c433c093399cb7dc089fae18ced
Author: triesap <tyson@radroots.org>
Date: Mon, 13 Jul 2026 07:10:54 +0000
transport: add canonical target constructors
Diffstat:
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 {