commit 6fe7f07b6e6c11b5a1520f0c651decac5a09030e
parent ea0d5df8de604fbd1a946cd8a264c2acdeb145c0
Author: triesap <tyson@radroots.org>
Date: Thu, 9 Jul 2026 20:22:59 +0000
transport: harden reticulum mesh validation
Diffstat:
7 files changed, 172 insertions(+), 22 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4391,6 +4391,7 @@ dependencies = [
name = "radroots_mesh"
version = "0.1.0-alpha.2"
dependencies = [
+ "radroots_transport",
"serde",
]
diff --git a/crates/mesh/Cargo.toml b/crates/mesh/Cargo.toml
@@ -16,6 +16,7 @@ default = ["serde"]
serde = ["dep:serde"]
[dependencies]
+radroots_transport = { workspace = true, default-features = false }
serde = { workspace = true, optional = true }
[lints.rust]
diff --git a/crates/mesh/src/error.rs b/crates/mesh/src/error.rs
@@ -3,6 +3,7 @@ use core::fmt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsMeshError {
EmptyCustomScope,
+ InvalidCustomScope,
EmptyMessageId,
InvalidTtl,
PayloadTransmissionForbidden,
@@ -17,6 +18,7 @@ impl fmt::Display for RadrootsMeshError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyCustomScope => f.write_str("mesh custom scope is empty"),
+ Self::InvalidCustomScope => f.write_str("mesh custom scope is invalid"),
Self::EmptyMessageId => f.write_str("mesh message id is empty"),
Self::InvalidTtl => f.write_str("mesh frame TTL is invalid"),
Self::PayloadTransmissionForbidden => {
diff --git a/crates/mesh/src/model.rs b/crates/mesh/src/model.rs
@@ -1,6 +1,7 @@
use crate::RadrootsMeshError;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
+use radroots_transport::{RadrootsTransportError, RadrootsTransportMeshScopeId};
pub const RADROOTS_MESH_FRAME_VERSION: u16 = 1;
@@ -60,13 +61,14 @@ pub enum RadrootsMeshScope {
}
impl RadrootsMeshScope {
- pub fn custom(value: impl Into<String>) -> Result<Self, RadrootsMeshError> {
- let value = value.into();
- let canonical = value.trim().to_ascii_lowercase();
- if canonical.is_empty() {
- return Err(RadrootsMeshError::EmptyCustomScope);
- }
- Ok(Self::Custom(canonical))
+ pub fn custom(value: impl AsRef<str>) -> Result<Self, RadrootsMeshError> {
+ let scope =
+ RadrootsTransportMeshScopeId::parse(value.as_ref()).map_err(|err| match err {
+ RadrootsTransportError::EmptyTargetScope => RadrootsMeshError::EmptyCustomScope,
+ RadrootsTransportError::InvalidTargetScope => RadrootsMeshError::InvalidCustomScope,
+ _ => RadrootsMeshError::InvalidCustomScope,
+ })?;
+ Ok(Self::Custom(scope.as_str().to_string()))
}
pub fn label(&self) -> &str {
diff --git a/crates/mesh/tests/mesh.rs b/crates/mesh/tests/mesh.rs
@@ -77,8 +77,8 @@ fn all_frame_types_round_trip_with_stable_codes_and_labels() {
#[test]
fn custom_scope_has_explicit_namespace() {
- let scope = RadrootsMeshScope::custom(" Farm-North ").expect("custom scope");
- assert_eq!(scope.label(), "farm-north");
+ let scope = RadrootsMeshScope::custom("farm-north.preview_1").expect("custom scope");
+ assert_eq!(scope.label(), "farm-north.preview_1");
let frame = RadrootsMeshFrame::new(
RadrootsMeshFrameType::RouteProbe,
scope,
@@ -89,15 +89,38 @@ fn custom_scope_has_explicit_namespace() {
let encoded = encode_mesh_frame_cbor(&frame).expect("encode custom scope");
let decoded = decode_mesh_frame_cbor(&encoded).expect("decode custom scope");
- assert_eq!(decoded.scope_id.cbor_label(), "custom:farm-north");
+ assert_eq!(decoded.scope_id.cbor_label(), "custom:farm-north.preview_1");
+ assert_eq!(encode_mesh_frame_cbor(&decoded).expect("reencode"), encoded);
}
#[test]
-fn mesh_parsers_and_validation_reject_unknown_or_empty_values() {
+fn mesh_parsers_and_validation_reject_unknown_empty_or_invalid_values() {
assert_eq!(
- RadrootsMeshScope::custom(" ").expect_err("empty custom scope"),
+ RadrootsMeshScope::custom("").expect_err("empty custom scope"),
RadrootsMeshError::EmptyCustomScope
);
+ for invalid in [
+ " ",
+ " farm-north",
+ "farm-north ",
+ "farm north",
+ "farm/north",
+ "farm:north",
+ "farm\nnorth",
+ ] {
+ assert_eq!(
+ RadrootsMeshScope::custom(invalid).expect_err("invalid custom scope"),
+ RadrootsMeshError::InvalidCustomScope
+ );
+ }
+ assert_eq!(
+ RadrootsMeshScope::parse("custom:").expect_err("empty parsed custom scope"),
+ RadrootsMeshError::EmptyCustomScope
+ );
+ assert_eq!(
+ RadrootsMeshScope::parse("custom:farm north").expect_err("invalid parsed custom scope"),
+ RadrootsMeshError::InvalidCustomScope
+ );
assert_eq!(
RadrootsMeshScope::parse("unscoped").expect_err("unknown scope"),
RadrootsMeshError::UnknownScope
@@ -142,6 +165,10 @@ fn mesh_errors_have_stable_display_strings() {
"mesh custom scope is empty",
),
(
+ RadrootsMeshError::InvalidCustomScope,
+ "mesh custom scope is invalid",
+ ),
+ (
RadrootsMeshError::EmptyMessageId,
"mesh message id is empty",
),
@@ -272,6 +299,19 @@ fn decoder_rejects_previous_five_field_frame_shape() {
#[test]
fn decoder_rejects_malformed_cbor_shapes() {
let encoded = encode_mesh_frame_cbor(&default_frame()).expect("encode default");
+ let invalid_custom_scope = default_encoded_with_replacement(
+ 6,
+ 12,
+ [
+ 0x70, b'c', b'u', b's', b't', b'o', b'm', b':', b'f', b'a', b'r', b'm', b' ', b'n',
+ b'o', b'r', b't',
+ ],
+ );
+ assert_eq!(
+ decode_mesh_frame_cbor(&invalid_custom_scope).expect_err("invalid custom scope"),
+ RadrootsMeshError::InvalidCustomScope
+ );
+
let mut unsupported_version = encoded.clone();
unsupported_version[2] = 2;
assert_eq!(
diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs
@@ -8,15 +8,16 @@ use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;
use radroots_transport::{
- RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID,
- RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportDeliveryReceipt,
- RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
- RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId,
- RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportStatus,
- RadrootsTransportTarget, RadrootsTransportTargetReceipt,
+ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE,
+ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
+ RadrootsTransportDeliveryTargetStatus, RadrootsTransportImplementationState,
+ RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome,
+ RadrootsTransportOutcomeKind, RadrootsTransportStatus, RadrootsTransportTarget,
+ RadrootsTransportTargetReceipt,
};
const DEFAULT_PROFILE_ID: &str = "transport.reticulum.preview";
+const RETICULUM_AGENT_ENDPOINT_PREFIX: &str = "reticulum-agent:";
const UNAVAILABLE_CODE: &str = "transport_unavailable";
const DEFERRED_CODE: &str = "deferred_until_implemented";
const DEFERRED_MESSAGE: &str = "Reticulum preview delivery is deferred until implementation";
@@ -87,7 +88,8 @@ impl RadrootsReticulumPreviewAgentEndpoint {
|| uri
.chars()
.any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
- || uri.find(':').is_none()
+ || !uri.starts_with(RETICULUM_AGENT_ENDPOINT_PREFIX)
+ || uri.len() == RETICULUM_AGENT_ENDPOINT_PREFIX.len()
{
return Err(RadrootsReticulumPreviewError::InvalidAgentEndpoint);
}
@@ -347,9 +349,7 @@ fn ensure_reticulum_targets(
if target.uri.as_str() != RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI {
return Err(RadrootsReticulumPreviewError::InvalidEndpoint);
}
- if target.scope.as_ref().map(|scope| scope.as_str())
- != Some(RADROOTS_RETICULUM_PREVIEW_SCOPE_ID)
- {
+ if target.scope.is_none() {
return Err(RadrootsReticulumPreviewError::InvalidEndpoint);
}
}
diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs
@@ -16,6 +16,16 @@ fn reticulum_target(uri: &str) -> RadrootsTransportTarget {
RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, uri).expect("reticulum target")
}
+fn scoped_reticulum_target(scope: &str) -> RadrootsTransportTarget {
+ RadrootsTransportTarget::new_with_metadata(
+ RadrootsTransportKind::Reticulum,
+ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI,
+ Some(RadrootsTransportMeshScopeId::parse(scope).expect("scope")),
+ None,
+ )
+ .expect("scoped reticulum target")
+}
+
fn nostr_target() -> RadrootsTransportTarget {
RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example")
.expect("nostr target")
@@ -142,11 +152,23 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
agent_endpoint.clone().into_string(),
"reticulum-agent://localhost:19999"
);
+ let local_agent_endpoint =
+ RadrootsReticulumPreviewAgentEndpoint::parse("reticulum-agent:local-controller")
+ .expect("local agent endpoint");
+ assert_eq!(
+ local_agent_endpoint.as_str(),
+ "reticulum-agent:local-controller"
+ );
for invalid_agent in [
"",
" reticulum-agent://localhost",
+ "reticulum-agent:",
"reticulum agent",
"agent",
+ "https://localhost:19999",
+ "ws://localhost:19999",
+ "reticulum://localhost:19999",
+ "RETICULUM-AGENT://localhost:19999",
] {
assert_eq!(
RadrootsReticulumPreviewAgentEndpoint::parse(invalid_agent)
@@ -200,6 +222,57 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
}
#[test]
+fn direct_preview_delivery_accepts_any_typed_reticulum_scope_as_inert_metadata() {
+ let transport = RadrootsReticulumPreviewTransport::default();
+ let request = delivery_request(vec![scoped_reticulum_target("farm-north.preview_1")]);
+ let receipt = transport.deliver(request).expect("delivery receipt");
+
+ assert_eq!(receipt.target_receipts.len(), 1);
+ assert_eq!(
+ receipt.target_receipts[0]
+ .target
+ .scope
+ .as_ref()
+ .map(|scope| scope.as_str()),
+ Some("farm-north.preview_1")
+ );
+ assert_eq!(
+ receipt.target_receipts[0].status,
+ RadrootsTransportDeliveryTargetStatus::PreviewUnavailable
+ );
+ assert_eq!(
+ receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted),
+ 0
+ );
+
+ let deferred_transport = RadrootsReticulumPreviewTransport::new(
+ RadrootsReticulumPreviewProfile::default()
+ .with_behavior(RadrootsReticulumPreviewBehavior::DeferDeliveryPlans),
+ );
+ let deferred = deferred_transport
+ .deliver(delivery_request(vec![scoped_reticulum_target(
+ "farm-south.preview_2",
+ )]))
+ .expect("deferred delivery receipt");
+ assert_eq!(
+ deferred.target_receipts[0]
+ .target
+ .scope
+ .as_ref()
+ .map(|scope| scope.as_str()),
+ Some("farm-south.preview_2")
+ );
+ assert_eq!(
+ deferred.target_receipts[0].status,
+ RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented
+ );
+ assert_eq!(
+ deferred.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted),
+ 0
+ );
+}
+
+#[test]
fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing() {
let transport = RadrootsReticulumPreviewTransport::default();
let request = delivery_request(vec![reticulum_target(
@@ -293,6 +366,18 @@ fn non_reticulum_targets_are_rejected_without_nostr_routing() {
}
#[test]
+fn malformed_reticulum_target_without_typed_scope_is_rejected() {
+ let transport = RadrootsReticulumPreviewTransport::default();
+ let mut target = reticulum_target(RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI);
+ target.scope = None;
+ let err = transport
+ .deliver(delivery_request(vec![target]))
+ .expect_err("missing typed scope");
+
+ assert_eq!(err, RadrootsReticulumPreviewError::InvalidEndpoint);
+}
+
+#[test]
fn fetch_reports_preview_unavailable_without_observed_events() {
let transport = RadrootsReticulumPreviewTransport::default();
assert_eq!(
@@ -409,6 +494,25 @@ fn public_models_round_trip_through_serde() {
}
#[test]
+fn preview_source_remains_inert_without_runtime_delivery_hooks() {
+ let source = include_str!("../src/lib.rs").to_ascii_lowercase();
+ for forbidden in [
+ "socket",
+ "rnsd",
+ "python",
+ "identity",
+ "send_mesh",
+ "fallback",
+ "nostr",
+ ] {
+ assert!(
+ !source.contains(forbidden),
+ "Reticulum preview source contains forbidden runtime hook {forbidden}"
+ );
+ }
+}
+
+#[test]
fn reticulum_preview_errors_and_defaults_are_stable() {
assert_eq!(
RadrootsReticulumPreviewBehavior::default(),