lib

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

commit c1b74b3007ce9245160b4257730915a4addaeeca
parent dd0e842e020c5d673ea143c81390bac64a7284cb
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Jul 2026 16:41:33 +0000

transport: align status and Reticulum profile contracts

- replace transport status readiness fields with configured, implementation, usability, and message
- add mesh scope and label metadata to transport targets with scoped fingerprints
- expose Reticulum preview scope and inert optional agent endpoint metadata
- add focused serde, identity, Reticulum, and source-boundary coverage

Diffstat:
Mcrates/transport/src/error.rs | 8++++++++
Mcrates/transport/src/kind.rs | 28++++++++++++++++++++++++----
Mcrates/transport/src/lib.rs | 10+++++-----
Mcrates/transport/src/message.rs | 1+
Mcrates/transport/src/status.rs | 49+++++++++++++------------------------------------
Mcrates/transport/src/target.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/transport/tests/source_boundary.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/transport.rs | 136++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/transport_reticulum/src/lib.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/transport_reticulum/tests/reticulum_preview.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
10 files changed, 530 insertions(+), 96 deletions(-)

diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs @@ -6,6 +6,10 @@ pub enum RadrootsTransportError { InvalidTransportKind, EmptyTargetUri, InvalidTargetUri, + EmptyTargetScope, + InvalidTargetScope, + EmptyTargetLabel, + InvalidTargetLabel, EmptyTargetSet, DuplicateTargetFingerprint, InvalidTargetFingerprint, @@ -19,6 +23,10 @@ impl fmt::Display for RadrootsTransportError { Self::InvalidTransportKind => f.write_str("transport kind is invalid"), Self::EmptyTargetUri => f.write_str("transport target URI is empty"), Self::InvalidTargetUri => f.write_str("transport target URI is invalid"), + Self::EmptyTargetScope => f.write_str("transport target scope is empty"), + Self::InvalidTargetScope => f.write_str("transport target scope is invalid"), + Self::EmptyTargetLabel => f.write_str("transport target label is empty"), + Self::InvalidTargetLabel => f.write_str("transport target label is invalid"), Self::EmptyTargetSet => f.write_str("transport target set is empty"), Self::DuplicateTargetFingerprint => { f.write_str("transport target set contains duplicate fingerprints") diff --git a/crates/transport/src/kind.rs b/crates/transport/src/kind.rs @@ -1,7 +1,6 @@ use crate::RadrootsTransportError; use alloc::string::{String, ToString}; -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RadrootsTransportKind { Nostr, @@ -74,6 +73,27 @@ impl RadrootsTransportKind { } } +#[cfg(feature = "serde")] +impl serde::Serialize for RadrootsTransportKind { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.canonical_label().as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsTransportKind { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse_canonical(value).map_err(serde::de::Error::custom) + } +} + fn removed_first_party_kind(canonical: &str) -> bool { const RADROOTSD_PROXY_PREFIX: &str = "radrootsd"; const RADROOTSD_PROXY_SUFFIX: &str = "_proxy"; @@ -83,10 +103,10 @@ fn removed_first_party_kind(canonical: &str) -> bool { } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RadrootsTransportImplementationState { - Available, - Disabled, - Misconfigured, + Real, + Mock, PreviewUnavailable, } diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -18,13 +18,13 @@ pub use delivery::{ pub use error::RadrootsTransportError; pub use kind::{RadrootsTransportImplementationState, RadrootsTransportKind}; pub use message::{ - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, }; pub use status::{ - RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome, - RadrootsTransportReadinessState, RadrootsTransportStatus, + RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome, RadrootsTransportStatus, }; pub use target::{ - RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, - RadrootsTransportTargetUri, + RadrootsTransportMeshScopeId, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, + RadrootsTransportTargetLabel, RadrootsTransportTargetSet, RadrootsTransportTargetUri, }; diff --git a/crates/transport/src/message.rs b/crates/transport/src/message.rs @@ -1,4 +1,5 @@ pub const RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI: &str = "reticulum:preview-unavailable"; +pub const RADROOTS_RETICULUM_PREVIEW_SCOPE_ID: &str = "local_preview"; pub const RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE: &str = concat!( "Reticulum transport is configured for future compatibility, ", diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs @@ -86,42 +86,34 @@ impl RadrootsTransportOutcome { } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RadrootsTransportReadinessState { - Ready, - Disabled, - Misconfigured, - PreviewUnavailable, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportStatus { + #[cfg_attr(feature = "serde", serde(rename = "transport"))] pub kind: RadrootsTransportKind, pub profile_id: Option<String>, pub endpoint_uri: Option<String>, - pub implementation_state: RadrootsTransportImplementationState, - pub readiness: RadrootsTransportReadinessState, - pub publish_usable: bool, - pub fetch_usable: bool, - pub redacted_message: Option<String>, + pub configured: bool, + pub implementation: RadrootsTransportImplementationState, + pub usable_for_delivery: bool, + pub message: String, } impl RadrootsTransportStatus { pub fn new( kind: RadrootsTransportKind, - implementation_state: RadrootsTransportImplementationState, - readiness: RadrootsTransportReadinessState, + configured: bool, + implementation: RadrootsTransportImplementationState, + usable_for_delivery: bool, + message: impl Into<String>, ) -> Self { Self { kind, profile_id: None, endpoint_uri: None, - implementation_state, - readiness, - publish_usable: false, - fetch_usable: false, - redacted_message: None, + configured, + implementation, + usable_for_delivery, + message: message.into(), } } @@ -134,19 +126,4 @@ impl RadrootsTransportStatus { self.endpoint_uri = Some(endpoint_uri.into()); self } - - pub fn with_publish_usable(mut self, publish_usable: bool) -> Self { - self.publish_usable = publish_usable; - self - } - - pub fn with_fetch_usable(mut self, fetch_usable: bool) -> Self { - self.fetch_usable = fetch_usable; - self - } - - pub fn with_redacted_message(mut self, redacted_message: impl Into<String>) -> Self { - self.redacted_message = Some(redacted_message.into()); - self - } } diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs @@ -1,5 +1,6 @@ use crate::{ - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportError, RadrootsTransportKind, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID, + RadrootsTransportError, RadrootsTransportKind, }; use alloc::collections::BTreeSet; use alloc::format; @@ -30,14 +31,85 @@ impl core::fmt::Display for RadrootsTransportTargetUri { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsTransportMeshScopeId(String); + +impl RadrootsTransportMeshScopeId { + pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + let value = raw.as_ref(); + if value.is_empty() { + return Err(RadrootsTransportError::EmptyTargetScope); + } + if value != value.trim() + || value + .chars() + .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))) + { + return Err(RadrootsTransportError::InvalidTargetScope); + } + Ok(Self(value.to_string())) + } + + pub fn local_preview() -> Self { + Self::parse(RADROOTS_RETICULUM_PREVIEW_SCOPE_ID) + .expect("default Reticulum preview scope id") + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl core::fmt::Display for RadrootsTransportMeshScopeId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsTransportTargetLabel(String); + +impl RadrootsTransportTargetLabel { + pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + let trimmed = raw.as_ref().trim(); + if trimmed.is_empty() { + return Err(RadrootsTransportError::EmptyTargetLabel); + } + if trimmed.chars().any(char::is_control) { + return Err(RadrootsTransportError::InvalidTargetLabel); + } + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl core::fmt::Display for RadrootsTransportTargetLabel { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadrootsTransportTargetFingerprint(String); impl RadrootsTransportTargetFingerprint { - pub fn from_target(kind: &RadrootsTransportKind, uri: &RadrootsTransportTargetUri) -> Self { + pub fn from_target( + kind: &RadrootsTransportKind, + uri: &RadrootsTransportTargetUri, + scope: Option<&RadrootsTransportMeshScopeId>, + ) -> Self { let mut hasher = Sha256::new(); hasher.update(kind.canonical_label().as_bytes()); hasher.update([0]); hasher.update(uri.as_str().as_bytes()); + if let Some(scope) = scope { + hasher.update([0]); + hasher.update(scope.as_str().as_bytes()); + } let digest = hasher.finalize(); Self(hex_encode(&digest)) } @@ -76,6 +148,8 @@ impl core::fmt::Display for RadrootsTransportTargetFingerprint { pub struct RadrootsTransportTarget { pub kind: RadrootsTransportKind, pub uri: RadrootsTransportTargetUri, + pub scope: Option<RadrootsTransportMeshScopeId>, + pub label: Option<RadrootsTransportTargetLabel>, pub fingerprint: RadrootsTransportTargetFingerprint, } @@ -84,6 +158,15 @@ impl RadrootsTransportTarget { kind: RadrootsTransportKind, uri: impl AsRef<str>, ) -> Result<Self, RadrootsTransportError> { + Self::new_with_metadata(kind, uri, None, None) + } + + pub fn new_with_metadata( + kind: RadrootsTransportKind, + uri: impl AsRef<str>, + scope: Option<RadrootsTransportMeshScopeId>, + label: Option<RadrootsTransportTargetLabel>, + ) -> Result<Self, RadrootsTransportError> { let raw_uri = uri.as_ref(); if kind == RadrootsTransportKind::Reticulum && raw_uri != RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI @@ -91,15 +174,23 @@ impl RadrootsTransportTarget { return Err(RadrootsTransportError::InvalidTargetUri); } let uri = RadrootsTransportTargetUri::parse(raw_uri)?; - let fingerprint = RadrootsTransportTargetFingerprint::from_target(&kind, &uri); + let scope = scope.or_else(|| default_scope_for_kind(&kind)); + let fingerprint = + RadrootsTransportTargetFingerprint::from_target(&kind, &uri, scope.as_ref()); Ok(Self { kind, uri, + scope, + label, fingerprint, }) } } +fn default_scope_for_kind(kind: &RadrootsTransportKind) -> Option<RadrootsTransportMeshScopeId> { + (*kind == RadrootsTransportKind::Reticulum).then(RadrootsTransportMeshScopeId::local_preview) +} + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportTargetSet { diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -25,6 +25,8 @@ const GENERIC_TRANSPORT_STATUS_SOURCE_ROOTS: &[&str] = &[ "transport_reticulum/src", ]; +const CORE_STATUS_CONTRACT_SOURCE_ROOTS: &[&str] = &["transport/src", "transport_reticulum/src"]; + const FORBIDDEN_TRANSPORT_CONCEPTS: &[ForbiddenConcept] = &[ ForbiddenConcept { pattern: "\"radrootsd_proxy\"", @@ -92,6 +94,41 @@ const FORBIDDEN_TRANSPORT_CONCEPTS: &[ForbiddenConcept] = &[ }, ]; +const FORBIDDEN_CORE_STATUS_CONCEPTS: &[ForbiddenConcept] = &[ + ForbiddenConcept { + pattern: "implementation_state", + reason: "public transport status must use implementation", + }, + ForbiddenConcept { + pattern: "readiness", + reason: "public transport status must use configured, usable_for_delivery, and message", + }, + ForbiddenConcept { + pattern: "publish_usable", + reason: "public transport status must use usable_for_delivery", + }, + ForbiddenConcept { + pattern: "fetch_usable", + reason: "public transport status must use usable_for_delivery", + }, + ForbiddenConcept { + pattern: "redacted_message", + reason: "public transport status must use message", + }, + ForbiddenConcept { + pattern: "RadrootsTransportReadinessState", + reason: "readiness state is no longer a public transport status contract", + }, + ForbiddenConcept { + pattern: "Misconfigured", + reason: "configuration is modeled by configured and message", + }, + ForbiddenConcept { + pattern: "Disabled", + reason: "disabled state is modeled by configured, usable_for_delivery, and message", + }, +]; + const FORBIDDEN_GENERIC_TRANSPORT_STATUS_CONCEPTS: &[ForbiddenConcept] = &[ ForbiddenConcept { pattern: concat!("configured_nostr", "_relay", "_count"), @@ -161,6 +198,37 @@ fn transport_hardening_sources_reject_removed_protocol_identifiers() { } #[test] +fn core_status_contract_sources_reject_retired_public_status_fields() { + let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("transport crate parent"); + let mut findings = Vec::new(); + + for relative_root in CORE_STATUS_CONTRACT_SOURCE_ROOTS { + for path in rust_source_files(crates_root.join(relative_root).as_path()) { + let source_raw = read_source(path.as_path()); + let source = production_source(source_raw.as_str()); + let relative_path = relative_path(crates_root, path.as_path()); + + for concept in FORBIDDEN_CORE_STATUS_CONCEPTS { + if contains_forbidden_concept(source, concept.pattern) { + findings.push(format!( + "{} contains retired core transport status concept `{}`: {}", + relative_path, concept.pattern, concept.reason + )); + } + } + } + } + + assert!( + findings.is_empty(), + "core transport status source-boundary violations:\n{}", + findings.join("\n") + ); +} + +#[test] fn generic_transport_status_sources_reject_retired_relay_shaped_names() { let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,11 +1,12 @@ use radroots_transport::{ - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportDeliveryReceipt, - RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportError, RadrootsTransportImplementationState, RadrootsTransportKind, - RadrootsTransportOutcome, RadrootsTransportReadinessState, RadrootsTransportSatisfactionClass, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID, + RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, + RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, + RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportOutcome, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, - RadrootsTransportTargetFingerprint, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, - RadrootsTransportTargetUri, + RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, + RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, }; #[test] @@ -23,6 +24,11 @@ fn target_fingerprints_are_stable_and_transport_scoped() { .expect("reticulum target"); assert_eq!(nostr_upper.uri.as_str(), "wss://relay.example/Events"); + assert_eq!(nostr_upper.scope, None); + assert_eq!( + reticulum.scope.as_ref().map(|scope| scope.as_str()), + Some(RADROOTS_RETICULUM_PREVIEW_SCOPE_ID) + ); assert_eq!(nostr_upper.fingerprint, nostr_lower.fingerprint); assert_ne!(nostr_upper.fingerprint, reticulum.fingerprint); assert_eq!( @@ -168,17 +174,16 @@ fn satisfaction_policy_counts_target_statuses() { } #[test] -fn transport_status_models_generic_readiness_and_usability() { +fn transport_status_models_canonical_configuration_and_delivery_usability() { let status = RadrootsTransportStatus::new( RadrootsTransportKind::Nostr, - RadrootsTransportImplementationState::Available, - RadrootsTransportReadinessState::Ready, + true, + RadrootsTransportImplementationState::Real, + true, + "ready", ) .with_profile_id("transport.nostr.default") - .with_endpoint_uri("wss://relay.example") - .with_publish_usable(true) - .with_fetch_usable(true) - .with_redacted_message("ready"); + .with_endpoint_uri("wss://relay.example"); assert_eq!(status.kind, RadrootsTransportKind::Nostr); assert_eq!( @@ -186,14 +191,33 @@ fn transport_status_models_generic_readiness_and_usability() { Some("transport.nostr.default") ); assert_eq!(status.endpoint_uri.as_deref(), Some("wss://relay.example")); + assert!(status.configured); assert_eq!( - status.implementation_state, - RadrootsTransportImplementationState::Available + status.implementation, + RadrootsTransportImplementationState::Real ); - assert_eq!(status.readiness, RadrootsTransportReadinessState::Ready); - assert!(status.publish_usable); - assert!(status.fetch_usable); - assert_eq!(status.redacted_message.as_deref(), Some("ready")); + assert!(status.usable_for_delivery); + assert_eq!(status.message, "ready"); + + let json = serde_json::to_value(&status).expect("status json"); + assert_eq!(json["transport"], "nostr"); + assert_eq!(json["implementation"], "real"); + assert_eq!(json["configured"], true); + assert_eq!(json["usable_for_delivery"], true); + assert_eq!(json["message"], "ready"); + for retired in [ + "kind", + "implementation_state", + "readiness", + "publish_usable", + "fetch_usable", + "redacted_message", + ] { + assert!( + json.get(retired).is_none(), + "retired status field {retired}" + ); + } } #[test] @@ -279,6 +303,22 @@ fn transport_errors_have_stable_display_strings() { "transport target URI is invalid", ), ( + RadrootsTransportError::EmptyTargetScope, + "transport target scope is empty", + ), + ( + RadrootsTransportError::InvalidTargetScope, + "transport target scope is invalid", + ), + ( + RadrootsTransportError::EmptyTargetLabel, + "transport target label is empty", + ), + ( + RadrootsTransportError::InvalidTargetLabel, + "transport target label is invalid", + ), + ( RadrootsTransportError::EmptyTargetSet, "transport target set is empty", ), @@ -354,6 +394,10 @@ fn reticulum_transport_targets_require_exact_preview_endpoint() { ) .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()), + Some(RADROOTS_RETICULUM_PREVIEW_SCOPE_ID) + ); for invalid in [ " reticulum:preview-unavailable", @@ -398,6 +442,60 @@ fn target_fingerprints_and_sets_cover_accessors_and_validation() { } #[test] +fn target_scope_participates_in_identity_and_label_does_not() { + let local_scope = RadrootsTransportMeshScopeId::parse("local_preview").expect("local scope"); + let remote_scope = RadrootsTransportMeshScopeId::parse("remote_preview").expect("remote scope"); + let local = RadrootsTransportTarget::new_with_metadata( + RadrootsTransportKind::Mesh, + "mesh://node.example", + Some(local_scope.clone()), + Some(RadrootsTransportTargetLabel::parse("Local mesh node").expect("label")), + ) + .expect("local mesh target"); + let relabeled = RadrootsTransportTarget::new_with_metadata( + RadrootsTransportKind::Mesh, + "mesh://node.example", + Some(local_scope), + Some(RadrootsTransportTargetLabel::parse("Renamed node").expect("label")), + ) + .expect("relabeled mesh target"); + let remote = RadrootsTransportTarget::new_with_metadata( + RadrootsTransportKind::Mesh, + "mesh://node.example", + Some(remote_scope), + None, + ) + .expect("remote mesh target"); + + assert_eq!(local.fingerprint, relabeled.fingerprint); + assert_ne!(local.fingerprint, remote.fingerprint); + assert_eq!( + local.scope.as_ref().map(|scope| scope.as_str()), + Some("local_preview") + ); + assert_eq!( + local.label.as_ref().map(|label| label.as_str()), + Some("Local mesh node") + ); + assert_eq!( + RadrootsTransportMeshScopeId::parse("").expect_err("empty scope"), + RadrootsTransportError::EmptyTargetScope + ); + assert_eq!( + RadrootsTransportMeshScopeId::parse("bad scope").expect_err("invalid scope"), + RadrootsTransportError::InvalidTargetScope + ); + assert_eq!( + RadrootsTransportTargetLabel::parse(" ").expect_err("empty label"), + RadrootsTransportError::EmptyTargetLabel + ); + assert_eq!( + RadrootsTransportTargetLabel::parse("bad\nlabel").expect_err("invalid label"), + RadrootsTransportError::InvalidTargetLabel + ); +} + +#[test] fn satisfaction_and_target_status_cover_all_contract_states() { assert_eq!( RadrootsTransportSatisfactionPolicy::no_wait() diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -8,11 +8,12 @@ use alloc::string::String; use alloc::vec::Vec; use core::fmt; use radroots_transport::{ - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, - RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, - RadrootsTransportDeliveryTargetStatus, RadrootsTransportImplementationState, - RadrootsTransportKind, RadrootsTransportOutcome, RadrootsTransportReadinessState, - RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetReceipt, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportDeliveryReceipt, + RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, + RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportOutcome, RadrootsTransportStatus, RadrootsTransportTarget, + RadrootsTransportTargetReceipt, }; const DEFAULT_PROFILE_ID: &str = "transport.reticulum.preview"; @@ -74,9 +75,49 @@ impl fmt::Display for RadrootsReticulumPreviewEndpoint { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsReticulumPreviewAgentEndpoint { + uri: String, +} + +impl RadrootsReticulumPreviewAgentEndpoint { + pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsReticulumPreviewError> { + let uri = raw.as_ref(); + if uri.is_empty() + || uri != uri.trim() + || uri + .chars() + .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace()) + || uri.find(':').is_none() + { + return Err(RadrootsReticulumPreviewError::InvalidAgentEndpoint); + } + Ok(Self { + uri: uri.to_owned(), + }) + } + + pub fn as_str(&self) -> &str { + self.uri.as_str() + } + + pub fn into_string(self) -> String { + self.uri + } +} + +impl fmt::Display for RadrootsReticulumPreviewAgentEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.uri.as_str()) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsReticulumPreviewProfile { profile_id: String, endpoint: RadrootsReticulumPreviewEndpoint, + scope: RadrootsTransportMeshScopeId, + agent_endpoint: Option<RadrootsReticulumPreviewAgentEndpoint>, behavior: RadrootsReticulumPreviewBehavior, } @@ -84,6 +125,8 @@ impl RadrootsReticulumPreviewProfile { pub fn new( profile_id: impl Into<String>, endpoint: RadrootsReticulumPreviewEndpoint, + scope: RadrootsTransportMeshScopeId, + agent_endpoint: Option<RadrootsReticulumPreviewAgentEndpoint>, behavior: RadrootsReticulumPreviewBehavior, ) -> Result<Self, RadrootsReticulumPreviewError> { let profile_id = profile_id.into(); @@ -93,6 +136,8 @@ impl RadrootsReticulumPreviewProfile { Ok(Self { profile_id, endpoint, + scope, + agent_endpoint, behavior, }) } @@ -101,6 +146,8 @@ impl RadrootsReticulumPreviewProfile { Self { profile_id: DEFAULT_PROFILE_ID.to_owned(), endpoint: RadrootsReticulumPreviewEndpoint::default(), + scope: RadrootsTransportMeshScopeId::local_preview(), + agent_endpoint: None, behavior: RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts, } } @@ -118,6 +165,22 @@ impl RadrootsReticulumPreviewProfile { &self.endpoint } + pub fn scope(&self) -> &RadrootsTransportMeshScopeId { + &self.scope + } + + pub fn agent_endpoint(&self) -> Option<&RadrootsReticulumPreviewAgentEndpoint> { + self.agent_endpoint.as_ref() + } + + pub fn with_agent_endpoint( + mut self, + agent_endpoint: RadrootsReticulumPreviewAgentEndpoint, + ) -> Self { + self.agent_endpoint = Some(agent_endpoint); + self + } + pub fn behavior(&self) -> RadrootsReticulumPreviewBehavior { self.behavior } @@ -125,14 +188,17 @@ impl RadrootsReticulumPreviewProfile { pub fn status(&self) -> RadrootsReticulumPreviewStatus { RadrootsReticulumPreviewStatus { behavior: self.behavior, + scope: self.scope.clone(), + agent_endpoint: self.agent_endpoint.clone(), transport_status: RadrootsTransportStatus::new( RadrootsTransportKind::Reticulum, + true, RadrootsTransportImplementationState::PreviewUnavailable, - RadrootsTransportReadinessState::PreviewUnavailable, + false, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, ) .with_profile_id(self.profile_id.clone()) - .with_endpoint_uri(self.endpoint.as_str()) - .with_redacted_message(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE), + .with_endpoint_uri(self.endpoint.as_str()), } } } @@ -147,6 +213,8 @@ impl Default for RadrootsReticulumPreviewProfile { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsReticulumPreviewStatus { pub behavior: RadrootsReticulumPreviewBehavior, + pub scope: RadrootsTransportMeshScopeId, + pub agent_endpoint: Option<RadrootsReticulumPreviewAgentEndpoint>, pub transport_status: RadrootsTransportStatus, } @@ -197,9 +265,11 @@ impl RadrootsReticulumPreviewTransport { Ok(RadrootsReticulumPreviewFetchReceipt { request_id: request.request_id, endpoint_uri: self.profile.endpoint.as_str().to_owned(), + scope: self.profile.scope.clone(), + agent_endpoint: self.profile.agent_endpoint.clone(), outcome: preview_outcome(self.profile.behavior), observed_event_count: 0, - implementation_state: RadrootsTransportImplementationState::PreviewUnavailable, + implementation: RadrootsTransportImplementationState::PreviewUnavailable, }) } } @@ -237,14 +307,17 @@ impl RadrootsReticulumPreviewFetchRequest { pub struct RadrootsReticulumPreviewFetchReceipt { pub request_id: String, pub endpoint_uri: String, + pub scope: RadrootsTransportMeshScopeId, + pub agent_endpoint: Option<RadrootsReticulumPreviewAgentEndpoint>, pub outcome: RadrootsTransportOutcome, pub observed_event_count: usize, - pub implementation_state: RadrootsTransportImplementationState, + pub implementation: RadrootsTransportImplementationState, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsReticulumPreviewError { InvalidEndpoint, + InvalidAgentEndpoint, InvalidProfileId, InvalidFetchLimit, NonReticulumTarget, @@ -254,6 +327,7 @@ impl fmt::Display for RadrootsReticulumPreviewError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::InvalidEndpoint => "invalid Reticulum preview endpoint", + Self::InvalidAgentEndpoint => "invalid Reticulum preview agent endpoint", Self::InvalidProfileId => "invalid Reticulum preview profile id", Self::InvalidFetchLimit => "Reticulum preview fetch limit must be greater than zero", Self::NonReticulumTarget => { @@ -273,6 +347,11 @@ 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) + { + return Err(RadrootsReticulumPreviewError::InvalidEndpoint); + } } Ok(()) } diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs @@ -1,14 +1,15 @@ use radroots_transport::{ - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, - RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportReadinessState, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, - RadrootsTransportTarget, RadrootsTransportTargetSet, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportDeliveryRequest, + RadrootsTransportDeliveryTargetStatus, RadrootsTransportImplementationState, + RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet, }; use radroots_transport_reticulum::{ - RadrootsReticulumPreviewBehavior, RadrootsReticulumPreviewEndpoint, - RadrootsReticulumPreviewError, RadrootsReticulumPreviewFetchRequest, - RadrootsReticulumPreviewProfile, RadrootsReticulumPreviewTransport, + RadrootsReticulumPreviewAgentEndpoint, RadrootsReticulumPreviewBehavior, + RadrootsReticulumPreviewEndpoint, RadrootsReticulumPreviewError, + RadrootsReticulumPreviewFetchRequest, RadrootsReticulumPreviewProfile, + RadrootsReticulumPreviewTransport, }; fn reticulum_target(uri: &str) -> RadrootsTransportTarget { @@ -40,17 +41,19 @@ fn default_profile_is_configured_preview_unavailable_and_rejecting() { RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI ); assert_eq!( + profile.scope().as_str(), + RADROOTS_RETICULUM_PREVIEW_SCOPE_ID + ); + assert_eq!(profile.agent_endpoint(), None); + assert_eq!( profile.behavior(), RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts ); assert_eq!( - status.transport_status.implementation_state, + status.transport_status.implementation, RadrootsTransportImplementationState::PreviewUnavailable ); - assert_eq!( - status.transport_status.readiness, - RadrootsTransportReadinessState::PreviewUnavailable - ); + assert!(status.transport_status.configured); assert_eq!( status.transport_status.profile_id.as_deref(), Some("transport.reticulum.preview") @@ -60,11 +63,12 @@ fn default_profile_is_configured_preview_unavailable_and_rejecting() { Some(RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI) ); assert_eq!( - status.transport_status.redacted_message.as_deref(), - Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE) + status.transport_status.message, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE ); - assert!(!status.transport_status.publish_usable); - assert!(!status.transport_status.fetch_usable); + assert!(!status.transport_status.usable_for_delivery); + assert_eq!(status.scope.as_str(), RADROOTS_RETICULUM_PREVIEW_SCOPE_ID); + assert_eq!(status.agent_endpoint, None); } #[test] @@ -126,10 +130,36 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() { .expect_err("control endpoint"), RadrootsReticulumPreviewError::InvalidEndpoint ); + let agent_endpoint = + RadrootsReticulumPreviewAgentEndpoint::parse("reticulum-agent://localhost:19999") + .expect("agent endpoint"); + assert_eq!(agent_endpoint.as_str(), "reticulum-agent://localhost:19999"); + assert_eq!( + agent_endpoint.to_string(), + "reticulum-agent://localhost:19999" + ); + assert_eq!( + agent_endpoint.clone().into_string(), + "reticulum-agent://localhost:19999" + ); + for invalid_agent in [ + "", + " reticulum-agent://localhost", + "reticulum agent", + "agent", + ] { + assert_eq!( + RadrootsReticulumPreviewAgentEndpoint::parse(invalid_agent) + .expect_err("invalid agent endpoint"), + RadrootsReticulumPreviewError::InvalidAgentEndpoint + ); + } assert_eq!( RadrootsReticulumPreviewProfile::new( "transport reticulum", endpoint, + RadrootsTransportMeshScopeId::local_preview(), + None, RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts, ) .expect_err("profile id whitespace"), @@ -139,6 +169,8 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() { RadrootsReticulumPreviewProfile::new( "", RadrootsReticulumPreviewEndpoint::default(), + RadrootsTransportMeshScopeId::local_preview(), + None, RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts, ) .expect_err("empty profile id"), @@ -147,6 +179,8 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() { let profile = RadrootsReticulumPreviewProfile::new( "transport.reticulum.custom", RadrootsReticulumPreviewEndpoint::default(), + RadrootsTransportMeshScopeId::local_preview(), + Some(agent_endpoint), RadrootsReticulumPreviewBehavior::DeferDeliveryPlans, ) .expect("custom behavior profile"); @@ -159,6 +193,10 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() { profile.behavior(), RadrootsReticulumPreviewBehavior::DeferDeliveryPlans ); + assert_eq!( + profile.agent_endpoint().map(|endpoint| endpoint.as_str()), + Some("reticulum-agent://localhost:19999") + ); } #[test] @@ -262,7 +300,7 @@ fn fetch_reports_preview_unavailable_without_observed_events() { "transport.reticulum.preview" ); assert_eq!( - transport.status().transport_status.implementation_state, + transport.status().transport_status.implementation, RadrootsTransportImplementationState::PreviewUnavailable ); let receipt = transport @@ -276,9 +314,11 @@ fn fetch_reports_preview_unavailable_without_observed_events() { ); assert_eq!(receipt.observed_event_count, 0); assert_eq!( - receipt.implementation_state, + receipt.implementation, RadrootsTransportImplementationState::PreviewUnavailable ); + assert_eq!(receipt.scope.as_str(), RADROOTS_RETICULUM_PREVIEW_SCOPE_ID); + assert_eq!(receipt.agent_endpoint, None); assert_eq!( receipt.outcome.status, RadrootsTransportDeliveryTargetStatus::PreviewUnavailable @@ -310,6 +350,54 @@ fn fetch_reports_preview_unavailable_without_observed_events() { } #[test] +fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() { + let agent_endpoint = + RadrootsReticulumPreviewAgentEndpoint::parse("reticulum-agent://localhost:19999") + .expect("agent endpoint"); + let transport = RadrootsReticulumPreviewTransport::new( + RadrootsReticulumPreviewProfile::default().with_agent_endpoint(agent_endpoint), + ); + let status = transport.status(); + assert_eq!( + status + .agent_endpoint + .as_ref() + .map(|endpoint| endpoint.as_str()), + Some("reticulum-agent://localhost:19999") + ); + assert_eq!( + status.transport_status.implementation, + RadrootsTransportImplementationState::PreviewUnavailable + ); + assert!(!status.transport_status.usable_for_delivery); + + let receipt = transport + .deliver(delivery_request(vec![reticulum_target( + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, + )])) + .expect("delivery receipt"); + assert_eq!( + receipt.target_receipts[0].status, + RadrootsTransportDeliveryTargetStatus::PreviewUnavailable + ); + let fetch = transport + .fetch(RadrootsReticulumPreviewFetchRequest::new("fetch-agent", 1).expect("fetch")) + .expect("fetch receipt"); + assert_eq!( + fetch + .agent_endpoint + .as_ref() + .map(|endpoint| endpoint.as_str()), + Some("reticulum-agent://localhost:19999") + ); + assert_eq!(fetch.observed_event_count, 0); + assert_eq!( + fetch.implementation, + RadrootsTransportImplementationState::PreviewUnavailable + ); +} + +#[test] fn public_models_round_trip_through_serde() { let profile = RadrootsReticulumPreviewProfile::default() .with_behavior(RadrootsReticulumPreviewBehavior::DeferDeliveryPlans); @@ -332,6 +420,10 @@ fn reticulum_preview_errors_and_defaults_are_stable() { "invalid Reticulum preview endpoint", ), ( + RadrootsReticulumPreviewError::InvalidAgentEndpoint, + "invalid Reticulum preview agent endpoint", + ), + ( RadrootsReticulumPreviewError::InvalidProfileId, "invalid Reticulum preview profile id", ),