lib

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

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:
MCargo.lock | 1+
Mcrates/transport/src/kind.rs | 36+++++++++++++++++++++++++++++++++---
Mcrates/transport/tests/transport.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport_publish_protocol/Cargo.toml | 1+
Mcrates/transport_publish_protocol/src/lib.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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()],