commit 75f07b7bc0ca2afc276ab279651ce1cf2c46a75a
parent 937d8ba5200dc7c3fe6757ba5ae5651d7eccc1f4
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Jul 2026 08:33:50 +0000
transport: enforce canonical Reticulum preview endpoint
Diffstat:
3 files changed, 79 insertions(+), 37 deletions(-)
diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs
@@ -17,6 +17,7 @@ pub const METHOD_CAPABILITIES: &str = "transport.publish.capabilities";
pub const METHOD_EVENT: &str = "transport.publish.event";
pub const METHOD_JOB_GET: &str = "transport.publish.job.get";
pub const METHOD_JOB_LIST: &str = "transport.publish.job.list";
+pub const RETICULUM_PREVIEW_ENDPOINT_URI: &str = "reticulum:preview-unavailable";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TransportPublishProtocolError {
@@ -35,6 +36,9 @@ pub enum TransportPublishProtocolError {
EmptyEndpointUri {
index: usize,
},
+ InvalidReticulumPreviewEndpoint {
+ index: usize,
+ },
TargetLimitExceeded {
max: usize,
actual: usize,
@@ -63,6 +67,10 @@ impl fmt::Display for TransportPublishProtocolError {
Self::EmptyEndpointUri { index } => {
write!(f, "transport target {index} endpoint_uri must not be empty")
}
+ Self::InvalidReticulumPreviewEndpoint { index } => write!(
+ f,
+ "transport target {index} Reticulum preview endpoint must be {RETICULUM_PREVIEW_ENDPOINT_URI}"
+ ),
Self::TargetLimitExceeded { max, actual } => {
write!(f, "transport target count {actual} exceeds limit {max}")
}
@@ -143,13 +151,10 @@ impl TransportPublishTarget {
}
}
- pub fn reticulum_preview(
- endpoint_uri: impl Into<String>,
- behavior: TransportPublishPreviewBehavior,
- ) -> Self {
+ pub fn reticulum_preview(behavior: TransportPublishPreviewBehavior) -> Self {
Self {
transport_kind: "reticulum".to_owned(),
- endpoint_uri: endpoint_uri.into(),
+ endpoint_uri: RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(),
preview_behavior: Some(behavior),
}
}
@@ -161,6 +166,11 @@ impl TransportPublishTarget {
if self.endpoint_uri.trim().is_empty() {
return Err(TransportPublishProtocolError::EmptyEndpointUri { index });
}
+ if self.transport_kind.trim() == "reticulum"
+ && self.endpoint_uri.trim() != RETICULUM_PREVIEW_ENDPOINT_URI
+ {
+ return Err(TransportPublishProtocolError::InvalidReticulumPreviewEndpoint { index });
+ }
Ok(())
}
}
@@ -707,7 +717,6 @@ mod tests {
too_many.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![
TransportPublishTarget::nostr("wss://relay.example.com"),
TransportPublishTarget::reticulum_preview(
- "reticulum:preview-unavailable",
TransportPublishPreviewBehavior::RejectDeliveryAttempts,
),
]);
@@ -726,6 +735,18 @@ mod tests {
Err(TransportPublishProtocolError::EmptyEndpointUri { index: 0 })
));
+ let mut invalid_reticulum_endpoint = request.clone();
+ invalid_reticulum_endpoint.target_policy =
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
+ transport_kind: "reticulum".to_owned(),
+ endpoint_uri: "reticulum:preview-unavailable-alt".to_owned(),
+ preview_behavior: Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts),
+ }]);
+ assert_eq!(
+ invalid_reticulum_endpoint.validate(1),
+ Err(TransportPublishProtocolError::InvalidReticulumPreviewEndpoint { index: 0 })
+ );
+
let mut empty_key = request.clone();
empty_key.idempotency_key = Some(" ".to_owned());
assert_eq!(
@@ -787,7 +808,6 @@ mod tests {
event: event(),
target_policy: TransportPublishTargetPolicy::explicit_targets(vec![
TransportPublishTarget::reticulum_preview(
- "reticulum:preview-unavailable",
TransportPublishPreviewBehavior::DeferDeliveryPlans,
),
]),
@@ -834,6 +854,10 @@ mod tests {
"transport target 3 endpoint_uri must not be empty",
),
(
+ TransportPublishProtocolError::InvalidReticulumPreviewEndpoint { index: 4 },
+ "transport target 4 Reticulum preview endpoint must be reticulum:preview-unavailable",
+ ),
+ (
TransportPublishProtocolError::TargetLimitExceeded { max: 1, actual: 2 },
"transport target count 2 exceeds limit 1",
),
@@ -912,7 +936,6 @@ mod tests {
let explicit = TransportPublishTargetPolicy::explicit_targets(vec![
TransportPublishTarget::nostr("wss://relay.example"),
TransportPublishTarget::reticulum_preview(
- "reticulum:preview-unavailable",
TransportPublishPreviewBehavior::DeferDeliveryPlans,
),
]);
diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs
@@ -15,7 +15,7 @@ use radroots_transport::{
};
const DEFAULT_PROFILE_ID: &str = "transport.reticulum.preview";
-const DEFAULT_ENDPOINT_URI: &str = "reticulum:preview-unavailable";
+pub const RETICULUM_PREVIEW_ENDPOINT_URI: &str = "reticulum:preview-unavailable";
const UNAVAILABLE_CODE: &str = "transport_unavailable";
const DEFERRED_CODE: &str = "deferred_until_implemented";
const PREVIEW_UNAVAILABLE_MESSAGE: &str =
@@ -49,17 +49,13 @@ pub struct RadrootsReticulumPreviewEndpoint {
impl RadrootsReticulumPreviewEndpoint {
pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsReticulumPreviewError> {
- let uri = raw.as_ref().trim().to_ascii_lowercase();
- if uri.is_empty() || !uri.starts_with("reticulum:") || uri == "reticulum:" {
+ let uri = raw.as_ref().trim();
+ if uri != RETICULUM_PREVIEW_ENDPOINT_URI {
return Err(RadrootsReticulumPreviewError::InvalidEndpoint);
}
- if uri
- .chars()
- .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
- {
- return Err(RadrootsReticulumPreviewError::InvalidEndpoint);
- }
- Ok(Self { uri })
+ Ok(Self {
+ uri: RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(),
+ })
}
pub fn as_str(&self) -> &str {
@@ -73,7 +69,7 @@ impl RadrootsReticulumPreviewEndpoint {
impl Default for RadrootsReticulumPreviewEndpoint {
fn default() -> Self {
- Self::parse(DEFAULT_ENDPOINT_URI).expect("default Reticulum preview endpoint")
+ Self::parse(RETICULUM_PREVIEW_ENDPOINT_URI).expect("default Reticulum preview endpoint")
}
}
@@ -276,13 +272,15 @@ impl fmt::Display for RadrootsReticulumPreviewError {
fn ensure_reticulum_targets(
targets: &[RadrootsTransportTarget],
) -> Result<(), RadrootsReticulumPreviewError> {
- if targets
- .iter()
- .all(|target| target.kind == RadrootsTransportKind::Reticulum)
- {
- return Ok(());
+ for target in targets {
+ if target.kind != RadrootsTransportKind::Reticulum {
+ return Err(RadrootsReticulumPreviewError::NonReticulumTarget);
+ }
+ if target.uri.as_str() != RETICULUM_PREVIEW_ENDPOINT_URI {
+ return Err(RadrootsReticulumPreviewError::InvalidEndpoint);
+ }
}
- Err(RadrootsReticulumPreviewError::NonReticulumTarget)
+ Ok(())
}
fn preview_outcome(behavior: RadrootsReticulumPreviewBehavior) -> RadrootsTransportOutcome {
diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs
@@ -53,10 +53,13 @@ fn default_profile_is_configured_preview_unavailable_and_rejecting() {
#[test]
fn endpoint_and_profile_validation_are_strict_and_canonical() {
let endpoint =
- RadrootsReticulumPreviewEndpoint::parse(" RETICULUM:Preview-Target ").expect("endpoint");
- assert_eq!(endpoint.as_str(), "reticulum:preview-target");
- assert_eq!(endpoint.to_string(), "reticulum:preview-target");
- assert_eq!(endpoint.clone().into_string(), "reticulum:preview-target");
+ RadrootsReticulumPreviewEndpoint::parse("reticulum:preview-unavailable").expect("endpoint");
+ assert_eq!(endpoint.as_str(), "reticulum:preview-unavailable");
+ assert_eq!(endpoint.to_string(), "reticulum:preview-unavailable");
+ assert_eq!(
+ endpoint.clone().into_string(),
+ "reticulum:preview-unavailable"
+ );
assert_eq!(
RadrootsReticulumPreviewEndpoint::default().as_str(),
"reticulum:preview-unavailable"
@@ -75,6 +78,15 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
RadrootsReticulumPreviewError::InvalidEndpoint
);
assert_eq!(
+ RadrootsReticulumPreviewEndpoint::parse("RETICULUM:Preview-Unavailable")
+ .expect_err("case drift endpoint"),
+ RadrootsReticulumPreviewError::InvalidEndpoint
+ );
+ assert_eq!(
+ RadrootsReticulumPreviewEndpoint::parse("reticulum:custom").expect_err("custom endpoint"),
+ RadrootsReticulumPreviewError::InvalidEndpoint
+ );
+ assert_eq!(
RadrootsReticulumPreviewEndpoint::parse("reticulum:bad target")
.expect_err("whitespace endpoint"),
RadrootsReticulumPreviewError::InvalidEndpoint
@@ -104,12 +116,12 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
);
let profile = RadrootsReticulumPreviewProfile::new(
"transport.reticulum.custom",
- RadrootsReticulumPreviewEndpoint::parse("reticulum:custom").expect("custom endpoint"),
+ RadrootsReticulumPreviewEndpoint::default(),
RadrootsReticulumPreviewBehavior::DeferDeliveryPlans,
)
- .expect("custom profile");
+ .expect("custom behavior profile");
assert_eq!(profile.profile_id(), "transport.reticulum.custom");
- assert_eq!(profile.endpoint().as_str(), "reticulum:custom");
+ assert_eq!(profile.endpoint().as_str(), "reticulum:preview-unavailable");
assert_eq!(
profile.behavior(),
RadrootsReticulumPreviewBehavior::DeferDeliveryPlans
@@ -119,13 +131,10 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
#[test]
fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing() {
let transport = RadrootsReticulumPreviewTransport::default();
- let request = delivery_request(vec![
- reticulum_target("reticulum:preview-unavailable"),
- reticulum_target("reticulum:preview-unavailable-alt"),
- ]);
+ let request = delivery_request(vec![reticulum_target("reticulum:preview-unavailable")]);
let receipt = transport.deliver(request).expect("delivery receipt");
- assert_eq!(receipt.target_receipts.len(), 2);
+ assert_eq!(receipt.target_receipts.len(), 1);
assert_eq!(
receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted),
0
@@ -144,6 +153,18 @@ fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing
}
#[test]
+fn noncanonical_reticulum_preview_targets_are_rejected() {
+ let transport = RadrootsReticulumPreviewTransport::default();
+ let err = transport
+ .deliver(delivery_request(vec![reticulum_target(
+ "reticulum:preview-unavailable-alt",
+ )]))
+ .expect_err("noncanonical Reticulum endpoint");
+
+ assert_eq!(err, RadrootsReticulumPreviewError::InvalidEndpoint);
+}
+
+#[test]
fn deferred_delivery_plan_mode_never_counts_as_satisfied() {
let transport = RadrootsReticulumPreviewTransport::new(
RadrootsReticulumPreviewProfile::default()