commit bf3c971e77fe18bf31ba48d3aa0b5a22a662c6af
parent ebc707fc8a09777ab0a637cba429e18a0aaaaac5
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Jul 2026 19:03:53 +0000
transport: enforce canonical publish target kinds
- reject removed first-party transport kind values before custom parsing
- add strict canonical transport kind parsing for public protocol DTOs
- validate publish targets through the shared transport kind parser
- cover removed proxy and noncanonical Reticulum inputs in focused tests
Diffstat:
5 files changed, 146 insertions(+), 5 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4874,6 +4874,7 @@ dependencies = [
name = "radroots_transport_publish_protocol"
version = "0.1.0-alpha.2"
dependencies = [
+ "radroots_transport",
"serde",
"serde_json",
]
diff --git a/crates/transport/src/kind.rs b/crates/transport/src/kind.rs
@@ -15,29 +15,51 @@ pub enum RadrootsTransportKind {
impl RadrootsTransportKind {
pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsTransportError> {
let canonical = value.as_ref().trim().to_ascii_lowercase();
- match canonical.as_str() {
+ Self::from_canonical_str(canonical.as_str())
+ }
+
+ pub fn parse_canonical(value: impl AsRef<str>) -> Result<Self, RadrootsTransportError> {
+ let raw = value.as_ref();
+ if raw.is_empty() {
+ return Err(RadrootsTransportError::EmptyTransportKind);
+ }
+ if raw != raw.trim() || raw != raw.to_ascii_lowercase() {
+ return Err(RadrootsTransportError::InvalidTransportKind);
+ }
+ Self::from_canonical_str(raw)
+ }
+
+ fn from_canonical_str(canonical: &str) -> Result<Self, RadrootsTransportError> {
+ match canonical {
"nostr" => Ok(Self::Nostr),
"reticulum" => Ok(Self::Reticulum),
"mesh" => Ok(Self::Mesh),
"local" => Ok(Self::Local),
"proxy" => Ok(Self::Proxy),
- _ => Self::custom(canonical),
+ _ => Self::custom_canonical(canonical),
}
}
pub fn custom(value: impl Into<String>) -> Result<Self, RadrootsTransportError> {
let value = value.into();
let canonical = value.trim().to_ascii_lowercase();
+ Self::custom_canonical(canonical.as_str())
+ }
+
+ fn custom_canonical(canonical: &str) -> Result<Self, RadrootsTransportError> {
if canonical.is_empty() {
return Err(RadrootsTransportError::EmptyTransportKind);
}
+ if removed_first_party_kind(canonical) {
+ return Err(RadrootsTransportError::InvalidTransportKind);
+ }
if canonical
.chars()
.any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace() || ch == ':' || ch == '/')
{
return Err(RadrootsTransportError::InvalidTransportKind);
}
- Ok(Self::Custom(canonical))
+ Ok(Self::Custom(canonical.to_string()))
}
pub fn canonical_label(&self) -> String {
@@ -52,6 +74,14 @@ impl RadrootsTransportKind {
}
}
+fn removed_first_party_kind(canonical: &str) -> bool {
+ const RADROOTSD_PROXY_PREFIX: &str = "radrootsd";
+ const RADROOTSD_PROXY_SUFFIX: &str = "_proxy";
+ canonical.len() == RADROOTSD_PROXY_PREFIX.len() + RADROOTSD_PROXY_SUFFIX.len()
+ && canonical.starts_with(RADROOTSD_PROXY_PREFIX)
+ && canonical.ends_with(RADROOTSD_PROXY_SUFFIX)
+}
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RadrootsTransportImplementationState {
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -65,6 +65,15 @@ fn transport_kind_parser_round_trips_canonical_labels_and_custom_values() {
RadrootsTransportKind::Custom("fieldbus".to_owned())
);
assert_eq!(
+ RadrootsTransportKind::parse(removed_proxy_kind()).expect_err("removed proxy kind"),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
+ RadrootsTransportKind::custom(removed_proxy_kind().to_ascii_uppercase())
+ .expect_err("removed proxy custom kind"),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
RadrootsTransportKind::Custom("fieldbus".to_owned()).canonical_label(),
"fieldbus".to_owned()
);
@@ -75,6 +84,39 @@ fn transport_kind_parser_round_trips_canonical_labels_and_custom_values() {
}
#[test]
+fn canonical_transport_kind_parser_rejects_noncanonical_public_values() {
+ assert_eq!(
+ RadrootsTransportKind::parse_canonical("nostr").expect("nostr kind"),
+ RadrootsTransportKind::Nostr
+ );
+ assert_eq!(
+ RadrootsTransportKind::parse_canonical("fieldbus").expect("custom kind"),
+ RadrootsTransportKind::Custom("fieldbus".to_owned())
+ );
+ assert_eq!(
+ RadrootsTransportKind::parse_canonical("NOSTR").expect_err("uppercase kind"),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
+ RadrootsTransportKind::parse_canonical(" nostr ").expect_err("trimmed kind"),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
+ RadrootsTransportKind::parse_canonical(removed_proxy_kind())
+ .expect_err("removed proxy kind"),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
+ RadrootsTransportKind::parse_canonical("").expect_err("empty kind"),
+ RadrootsTransportError::EmptyTransportKind
+ );
+}
+
+fn removed_proxy_kind() -> String {
+ ["radrootsd", "_proxy"].concat()
+}
+
+#[test]
fn target_set_rejects_duplicate_fingerprints() {
let first = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example/a")
.expect("first target");
diff --git a/crates/transport_publish_protocol/Cargo.toml b/crates/transport_publish_protocol/Cargo.toml
@@ -17,6 +17,7 @@ std = []
serde = ["dep:serde"]
[dependencies]
+radroots_transport = { workspace = true, default-features = false }
serde = { workspace = true, optional = true, features = ["alloc", "derive"] }
[dev-dependencies]
diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs
@@ -10,6 +10,7 @@ use alloc::{string::String, vec::Vec};
use std::{string::String, vec::Vec};
use core::fmt;
+use radroots_transport::{RadrootsTransportError, RadrootsTransportKind};
pub const API_VERSION: &str = "radrootsd.transport_publish.v2";
pub const DAEMON_NAME: &str = "radrootsd";
@@ -33,6 +34,9 @@ pub enum TransportPublishProtocolError {
EmptyTransportKind {
index: usize,
},
+ InvalidTransportKind {
+ index: usize,
+ },
EmptyEndpointUri {
index: usize,
},
@@ -64,6 +68,12 @@ impl fmt::Display for TransportPublishProtocolError {
Self::EmptyTransportKind { index } => {
write!(f, "transport target {index} kind must not be empty")
}
+ Self::InvalidTransportKind { index } => {
+ write!(
+ f,
+ "transport target {index} kind must be canonical lowercase"
+ )
+ }
Self::EmptyEndpointUri { index } => {
write!(f, "transport target {index} endpoint_uri must not be empty")
}
@@ -163,11 +173,13 @@ impl TransportPublishTarget {
if self.transport_kind.trim().is_empty() {
return Err(TransportPublishProtocolError::EmptyTransportKind { index });
}
+ let transport_kind = RadrootsTransportKind::parse_canonical(self.transport_kind.as_str())
+ .map_err(|error| transport_kind_error(error, index))?;
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
+ if transport_kind == RadrootsTransportKind::Reticulum
+ && self.endpoint_uri != RETICULUM_PREVIEW_ENDPOINT_URI
{
return Err(TransportPublishProtocolError::InvalidReticulumPreviewEndpoint { index });
}
@@ -175,6 +187,18 @@ impl TransportPublishTarget {
}
}
+fn transport_kind_error(
+ error: RadrootsTransportError,
+ index: usize,
+) -> TransportPublishProtocolError {
+ match error {
+ RadrootsTransportError::EmptyTransportKind => {
+ TransportPublishProtocolError::EmptyTransportKind { index }
+ }
+ _ => TransportPublishProtocolError::InvalidTransportKind { index },
+ }
+}
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -747,6 +771,30 @@ mod tests {
Err(TransportPublishProtocolError::InvalidReticulumPreviewEndpoint { index: 0 })
);
+ let mut noncanonical_reticulum_kind = request.clone();
+ noncanonical_reticulum_kind.target_policy =
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
+ transport_kind: "Reticulum".to_owned(),
+ endpoint_uri: RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(),
+ preview_behavior: Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts),
+ }]);
+ assert_eq!(
+ noncanonical_reticulum_kind.validate(1),
+ Err(TransportPublishProtocolError::InvalidTransportKind { index: 0 })
+ );
+
+ let mut removed_proxy_kind = request.clone();
+ removed_proxy_kind.target_policy =
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
+ transport_kind: removed_proxy_kind_string(),
+ endpoint_uri: "radrootsd-proxy:publish".to_owned(),
+ preview_behavior: None,
+ }]);
+ assert_eq!(
+ removed_proxy_kind.validate(1),
+ Err(TransportPublishProtocolError::InvalidTransportKind { index: 0 })
+ );
+
let mut empty_key = request.clone();
empty_key.idempotency_key = Some(" ".to_owned());
assert_eq!(
@@ -755,6 +803,10 @@ mod tests {
);
}
+ fn removed_proxy_kind_string() -> String {
+ ["radrootsd", "_proxy"].concat()
+ }
+
#[test]
fn outcome_kinds_classify_satisfaction_retry_and_terminal() {
assert!(TransportPublishOutcomeKind::Accepted.counts_toward_satisfaction());
@@ -850,6 +902,10 @@ mod tests {
"transport target 1 kind must not be empty",
),
(
+ TransportPublishProtocolError::InvalidTransportKind { index: 2 },
+ "transport target 2 kind must be canonical lowercase",
+ ),
+ (
TransportPublishProtocolError::EmptyEndpointUri { index: 3 },
"transport target 3 endpoint_uri must not be empty",
),
@@ -969,6 +1025,17 @@ mod tests {
Err(TransportPublishProtocolError::EmptyTransportKind { index: 0 })
);
+ empty_targets.target_policy =
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
+ transport_kind: "Nostr".to_owned(),
+ endpoint_uri: "wss://relay.example".to_owned(),
+ preview_behavior: None,
+ }]);
+ assert_eq!(
+ empty_targets.validate(10),
+ Err(TransportPublishProtocolError::InvalidTransportKind { index: 0 })
+ );
+
empty_targets.target_policy = TransportPublishTargetPolicy::nostr(
NostrPublishTargetSourcePolicy::ExplicitOnly,
vec![" ".to_owned()],