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:
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",
),