lib

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

commit cabaac3e0bc778364ec8905dfcffbffcaee198b0
parent f5777f5b1864240f6e353496f90738d1153dab0d
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Jul 2026 18:01:36 +0000

transport: remove Reticulum-specific generic constants and models

- remove Reticulum constants and preview models from the generic crate root
- relocate destination and fragmentation contracts to the private preview crate
- source legacy consumers from preview-owned or versioned protocol constants
- enforce the ownership boundary with canonical and adversarial tests

Diffstat:
MCargo.lock | 2++
Mcrates/mesh_agent_client/Cargo.toml | 1+
Mcrates/mesh_agent_client/src/lib.rs | 5++---
Mcrates/mesh_agent_client/tests/client.rs | 5++---
Mcrates/outbox/Cargo.toml | 1+
Mcrates/outbox/src/store.rs | 5+++--
Mcrates/transport/src/lib.rs | 12------------
Dcrates/transport/src/reticulum.rs | 244-------------------------------------------------------------------------------
Mcrates/transport/src/target.rs | 12+++++++-----
Mcrates/transport/tests/source_boundary.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/transport/tests/transport.rs | 152+++++--------------------------------------------------------------------------
Mcrates/transport_publish_protocol/Cargo.toml | 5+----
Mcrates/transport_publish_protocol/src/lib.rs | 5++++-
Acrates/transport_reticulum/src/contract.rs | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport_reticulum/src/lib.rs | 20++++++++++++++++----
Rcrates/transport/src/message.rs -> crates/transport_reticulum/src/message.rs | 0
Mcrates/transport_reticulum/tests/reticulum.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
17 files changed, 401 insertions(+), 448 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4652,6 +4652,7 @@ dependencies = [ "radroots_mesh", "radroots_mesh_agent_proto", "radroots_transport", + "radroots_transport_reticulum", "serde", "serde_json", ] @@ -4794,6 +4795,7 @@ dependencies = [ "radroots_event_store", "radroots_identity", "radroots_nostr", + "radroots_protocol", "radroots_transport", "serde", "serde_json", diff --git a/crates/mesh_agent_client/Cargo.toml b/crates/mesh_agent_client/Cargo.toml @@ -19,6 +19,7 @@ serde = ["dep:serde", "radroots_mesh/serde"] radroots_mesh = { workspace = true, default-features = false } radroots_mesh_agent_proto = { workspace = true, default-features = false } radroots_transport = { workspace = true, default-features = false } +radroots_transport_reticulum = { workspace = true, default-features = false } serde = { workspace = true, optional = true, features = ["derive", "std"] } [dev-dependencies] diff --git a/crates/mesh_agent_client/src/lib.rs b/crates/mesh_agent_client/src/lib.rs @@ -6,9 +6,8 @@ use radroots_mesh::{ use radroots_mesh_agent_proto::{ RADROOTS_MESH_AGENT_SCHEMA_ID, RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE, schema_sha256_hex, }; -use radroots_transport::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RadrootsTransportKind, RadrootsTransportMeshScopeId, -}; +use radroots_transport::{RadrootsTransportKind, RadrootsTransportMeshScopeId}; +use radroots_transport_reticulum::RADROOTS_RETICULUM_ENDPOINT_URI; pub const RADROOTS_MESH_AGENT_CLIENT_SCHEMA_ID: &str = RADROOTS_MESH_AGENT_SCHEMA_ID; pub const RADROOTS_MESH_AGENT_CLIENT_SCHEMA_NAMESPACE: &str = RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE; diff --git a/crates/mesh_agent_client/tests/client.rs b/crates/mesh_agent_client/tests/client.rs @@ -12,9 +12,8 @@ use radroots_mesh_agent_client::{ use radroots_mesh_agent_proto::{ RADROOTS_MESH_AGENT_SCHEMA_ID, RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE, schema_sha256_hex, }; -use radroots_transport::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RadrootsTransportKind, -}; +use radroots_transport::RadrootsTransportKind; +use radroots_transport_reticulum::{RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID}; use serde_json::json; fn publish_request() -> MeshAgentPublishRequest { diff --git a/crates/outbox/Cargo.toml b/crates/outbox/Cargo.toml @@ -26,6 +26,7 @@ radroots_event_store = { workspace = true, default-features = false, features = "runtime-tokio", ] } radroots_transport = { workspace = true, default-features = false } +radroots_protocol = { workspace = true, default-features = false } hex = { workspace = true } serde = { workspace = true, features = ["std"] } serde_json = { workspace = true, features = ["std"] } diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -24,9 +24,10 @@ use radroots_event_store::{ RadrootsEventIngest, RadrootsEventStore, RadrootsTransportObservation, RadrootsTransportObservationType, }; +use radroots_protocol::radrootsd::transport_publish::v5::RETICULUM_ENDPOINT_URI as RADROOTS_RETICULUM_ENDPOINT_URI; use radroots_transport::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RadrootsTransportError, RadrootsTransportKind, - RadrootsTransportMeshScopeId, RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, + RadrootsTransportError, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, }; diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -10,11 +10,9 @@ pub mod endpoint; pub mod error; mod id; mod kind; -mod message; pub mod outcome; mod payload; pub mod policy; -mod reticulum; pub mod sink; pub mod source; mod status; @@ -32,17 +30,7 @@ pub use kind::{ RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, RadrootsTransportImplementationState, }; -pub use message::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, -}; pub use payload::RadrootsTransportPayload; -pub use reticulum::{ - ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, - ReticulumFragmentIntegrityV1, ReticulumFragmentPolicyV1, ReticulumFragmentationModeV1, - ReticulumGatewaySemanticsV1, ReticulumPayloadPolicyV1, ReticulumPrivacySemanticsV1, - ReticulumRoutingMetadataV1, -}; pub use sink::{DeliveryReceipt, DeliveryRequest, EventSink, SinkStatus}; pub use source::{BoxFuture, EventSource, FetchPage, FetchRequest, SourceStatus}; pub use status::{ diff --git a/crates/transport/src/reticulum.rs b/crates/transport/src/reticulum.rs @@ -1,244 +0,0 @@ -use crate::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RadrootsTransportError, RadrootsTransportKind, - RadrootsTransportMeshScopeId, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, - RadrootsTransportTargetLabel, RadrootsTransportTargetUri, -}; - -pub const RETICULUM_V1_MAX_PAYLOAD_BYTES: usize = 64 * 1024; - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReticulumFragmentationModeV1 { - Unsupported, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReticulumDuplicateFragmentBehaviorV1 { - Reject, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReticulumFragmentIntegrityV1 { - PayloadDigest, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReticulumFragmentPolicyV1 { - pub mode: ReticulumFragmentationModeV1, - pub max_fragment_count: u16, - pub max_reassembled_bytes: usize, - pub duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1, - pub integrity_verification: ReticulumFragmentIntegrityV1, -} - -impl ReticulumFragmentPolicyV1 { - pub const fn unsupported() -> Self { - Self { - mode: ReticulumFragmentationModeV1::Unsupported, - max_fragment_count: 1, - max_reassembled_bytes: RETICULUM_V1_MAX_PAYLOAD_BYTES, - duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1::Reject, - integrity_verification: ReticulumFragmentIntegrityV1::PayloadDigest, - } - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReticulumPayloadPolicyV1 { - pub max_payload_bytes: usize, - pub fragment_policy: ReticulumFragmentPolicyV1, -} - -impl ReticulumPayloadPolicyV1 { - pub const fn v1() -> Self { - Self { - max_payload_bytes: RETICULUM_V1_MAX_PAYLOAD_BYTES, - fragment_policy: ReticulumFragmentPolicyV1::unsupported(), - } - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReticulumGatewaySemanticsV1 { - NoGatewayForwarding, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReticulumPrivacySemanticsV1 { - CanonicalSignedEventBytesOnly, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReticulumRoutingMetadataV1 { - pub scope: RadrootsTransportMeshScopeId, - pub gateway: ReticulumGatewaySemanticsV1, - pub privacy: ReticulumPrivacySemanticsV1, -} - -impl ReticulumRoutingMetadataV1 { - pub fn local() -> Self { - Self { - scope: RadrootsTransportMeshScopeId::local_reticulum(), - gateway: ReticulumGatewaySemanticsV1::NoGatewayForwarding, - privacy: ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly, - } - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReticulumDestinationV1 { - uri: RadrootsTransportTargetUri, - routing: ReticulumRoutingMetadataV1, - label: Option<RadrootsTransportTargetLabel>, - fingerprint: RadrootsTransportTargetFingerprint, -} - -impl ReticulumDestinationV1 { - pub fn local() -> Self { - Self::new( - RADROOTS_RETICULUM_ENDPOINT_URI, - ReticulumRoutingMetadataV1::local().scope, - None, - ) - .expect("local Reticulum destination") - } - - pub fn new( - uri: impl AsRef<str>, - scope: RadrootsTransportMeshScopeId, - label: Option<RadrootsTransportTargetLabel>, - ) -> Result<Self, RadrootsTransportError> { - let target = RadrootsTransportTarget::reticulum_with_metadata( - uri.as_ref(), - Some(scope), - label.clone(), - )?; - Ok(Self { - uri: target.uri().clone(), - routing: ReticulumRoutingMetadataV1 { - scope: target - .scope() - .cloned() - .expect("Reticulum destination scope"), - gateway: ReticulumGatewaySemanticsV1::NoGatewayForwarding, - privacy: ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly, - }, - label: target.label().cloned(), - fingerprint: target.fingerprint().clone(), - }) - } - - pub fn from_target(target: &RadrootsTransportTarget) -> Result<Self, RadrootsTransportError> { - if target.kind() != &RadrootsTransportKind::Reticulum - || target.uri().as_str() != RADROOTS_RETICULUM_ENDPOINT_URI - { - return Err(RadrootsTransportError::InvalidTargetUri); - } - let Some(scope) = target.scope().cloned() else { - return Err(RadrootsTransportError::EmptyTargetScope); - }; - let destination = Self::new(target.uri().as_str(), scope, target.label().cloned())?; - if destination.fingerprint != *target.fingerprint() { - return Err(RadrootsTransportError::InvalidTargetFingerprint); - } - Ok(destination) - } - - pub fn transport_target(&self) -> Result<RadrootsTransportTarget, RadrootsTransportError> { - RadrootsTransportTarget::reticulum_with_metadata( - self.uri.as_str(), - Some(self.routing.scope.clone()), - self.label.clone(), - ) - } - - pub fn uri(&self) -> &RadrootsTransportTargetUri { - &self.uri - } - - pub fn routing(&self) -> &ReticulumRoutingMetadataV1 { - &self.routing - } - - pub fn label(&self) -> Option<&RadrootsTransportTargetLabel> { - self.label.as_ref() - } - - pub fn fingerprint(&self) -> &RadrootsTransportTargetFingerprint { - &self.fingerprint - } -} - -#[cfg(feature = "serde")] -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct ReticulumDestinationV1Wire { - uri: RadrootsTransportTargetUri, - routing: ReticulumRoutingMetadataV1, - label: Option<RadrootsTransportTargetLabel>, - fingerprint: RadrootsTransportTargetFingerprint, -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for ReticulumDestinationV1 { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - let wire = ReticulumDestinationV1Wire::deserialize(deserializer)?; - let destination = Self::new( - wire.uri.as_str(), - wire.routing.scope.clone(), - wire.label.clone(), - ) - .map_err(serde::de::Error::custom)?; - if destination.routing != wire.routing - || destination.label != wire.label - || destination.fingerprint != wire.fingerprint - { - return Err(serde::de::Error::custom( - "Reticulum destination identity does not match its canonical fields", - )); - } - Ok(destination) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReticulumCapabilityReportV1 { - pub delivery_required: bool, - pub fetch_required: bool, - pub can_deliver: bool, - pub can_fetch: bool, - pub can_discover: bool, - pub can_forward_gateway: bool, - pub can_observe_receipts: bool, - pub destination: ReticulumDestinationV1, - pub payload_policy: ReticulumPayloadPolicyV1, -} - -impl ReticulumCapabilityReportV1 { - pub fn unavailable_local() -> Self { - Self { - delivery_required: true, - fetch_required: false, - can_deliver: false, - can_fetch: false, - can_discover: false, - can_forward_gateway: false, - can_observe_receipts: false, - destination: ReticulumDestinationV1::local(), - payload_policy: ReticulumPayloadPolicyV1::v1(), - } - } -} diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs @@ -1,8 +1,10 @@ use crate::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RadrootsTransportError, - RadrootsTransportKind, + RadrootsTransportError, RadrootsTransportKind, endpoint::{ENDPOINT_URI_MAX_BYTES, TARGET_LABEL_MAX_BYTES, TARGET_SCOPE_MAX_BYTES}, }; + +const LEGACY_RETICULUM_ENDPOINT_URI: &str = "reticulum:local"; +const LEGACY_RETICULUM_SCOPE_ID: &str = "local"; use alloc::collections::BTreeSet; use alloc::format; use alloc::string::{String, ToString}; @@ -103,7 +105,7 @@ impl TargetScope { } pub fn local_reticulum() -> Self { - Self::parse(RADROOTS_RETICULUM_SCOPE_ID).expect("default Reticulum scope id") + Self::parse(LEGACY_RETICULUM_SCOPE_ID).expect("default Reticulum scope id") } pub fn as_str(&self) -> &str { @@ -341,7 +343,7 @@ impl Target { } pub fn reticulum() -> Result<Self, RadrootsTransportError> { - Self::reticulum_with_metadata(RADROOTS_RETICULUM_ENDPOINT_URI, None, None) + Self::reticulum_with_metadata(LEGACY_RETICULUM_ENDPOINT_URI, None, None) } pub fn reticulum_with_metadata( @@ -375,7 +377,7 @@ impl Target { RadrootsTransportKind::Nostr => EndpointUri::parse_nostr_relay(raw_uri)?, _ => EndpointUri::parse(raw_uri)?, }; - if kind == RadrootsTransportKind::Reticulum && raw_uri != RADROOTS_RETICULUM_ENDPOINT_URI { + if kind == RadrootsTransportKind::Reticulum && raw_uri != LEGACY_RETICULUM_ENDPOINT_URI { return Err(RadrootsTransportError::InvalidTargetUri); } let scope = scope.or_else(|| default_scope_for_kind(&kind)); diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -628,7 +628,11 @@ fn transport_target_identity_sources_reject_silent_dedupe() { ); } - let reticulum_source = read_source(crates_root.join("transport/src/reticulum.rs").as_path()); + let reticulum_source = read_source( + crates_root + .join("transport_reticulum/src/contract.rs") + .as_path(), + ); let destination_struct = source_between( reticulum_source.as_str(), "pub struct ReticulumDestinationV1 {", @@ -760,7 +764,7 @@ fn required_target_semantics_stay_fingerprint_exact() { } #[test] -fn transport_identity_is_extensible_and_reticulum_contracts_remain_explicit() { +fn transport_identity_is_extensible_and_reticulum_contracts_are_preview_owned() { let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("transport crate parent"); @@ -786,8 +790,36 @@ fn transport_identity_is_extensible_and_reticulum_contracts_remain_explicit() { assert!(!protocol_identity.contains("pub enum TransportKind")); assert!(protocol_identity.contains("MAX_TRANSPORT_KIND_BYTES")); - let transport_message_source = - read_source(crates_root.join("transport/src/message.rs").as_path()); + let transport_root_source = read_source(crates_root.join("transport/src/lib.rs").as_path()); + for forbidden in [ + "RADROOTS_RETICULUM_ENDPOINT_URI", + "RADROOTS_RETICULUM_SCOPE_ID", + "RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE", + "ReticulumCapabilityReportV1", + "ReticulumDestinationV1", + "ReticulumFragmentPolicyV1", + "ReticulumPayloadPolicyV1", + "ReticulumRoutingMetadataV1", + ] { + assert!( + !transport_root_source.contains(forbidden), + "generic transport root must not expose Reticulum-specific symbol `{forbidden}`" + ); + } + assert!( + !crates_root.join("transport/src/message.rs").exists(), + "generic transport must not retain the Reticulum message module" + ); + assert!( + !crates_root.join("transport/src/reticulum.rs").exists(), + "generic transport must not retain the Reticulum contract module" + ); + + let reticulum_message_source = read_source( + crates_root + .join("transport_reticulum/src/message.rs") + .as_path(), + ); for required in [ "RADROOTS_RETICULUM_ENDPOINT_URI", "reticulum:local", @@ -796,8 +828,8 @@ fn transport_identity_is_extensible_and_reticulum_contracts_remain_explicit() { "but this build does not implement Reticulum delivery.", ] { assert!( - transport_message_source.contains(required), - "transport message source must retain Reticulum unavailable message witness `{required}`" + reticulum_message_source.contains(required), + "private Reticulum message source must retain contract witness `{required}`" ); } for forbidden in [ @@ -808,8 +840,8 @@ fn transport_identity_is_extensible_and_reticulum_contracts_remain_explicit() { "hidden transport substitution", ] { assert!( - !transport_message_source.contains(forbidden), - "transport message source must not retain superseded Reticulum unavailable copy `{forbidden}`" + !reticulum_message_source.contains(forbidden), + "private Reticulum message source must not retain superseded copy `{forbidden}`" ); } @@ -817,15 +849,15 @@ fn transport_identity_is_extensible_and_reticulum_contracts_remain_explicit() { read_source(crates_root.join("transport_reticulum/src/lib.rs").as_path()); assert!( reticulum_source.contains("RADROOTS_RETICULUM_ENDPOINT_URI"), - "Reticulum source must consume the shared endpoint URI constant" + "Reticulum source must consume its preview-owned endpoint URI constant" ); assert!( !reticulum_source.contains(["reticulum:", "pre", "view-unavailable"].concat().as_str()), - "Reticulum source must not duplicate the shared endpoint URI" + "Reticulum source must not duplicate its endpoint URI" ); assert!( reticulum_source.contains("RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE"), - "Reticulum source must consume the shared unavailable message constant" + "Reticulum source must consume its preview-owned unavailable message constant" ); assert!( !reticulum_source.contains("Reticulum transport is configured in preview mode"), @@ -843,16 +875,17 @@ fn transport_identity_is_extensible_and_reticulum_contracts_remain_explicit() { ); let protocol_source = production_source(protocol_source_raw.as_str()); assert!( - protocol_source.contains("RADROOTS_RETICULUM_ENDPOINT_URI"), - "transport publish protocol must consume the shared Reticulum endpoint URI constant" + protocol_source.contains("RETICULUM_ENDPOINT_URI as RADROOTS_RETICULUM_ENDPOINT_URI"), + "transport publish protocol must consume the versioned protocol endpoint constant" ); assert!( !protocol_source.contains(["reticulum:", "pre", "view-unavailable"].concat().as_str()), "transport publish protocol must not duplicate the shared endpoint URI" ); assert!( - protocol_source.contains("RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE"), - "transport publish capabilities must consume the shared Reticulum unavailable message" + protocol_source + .contains("RETICULUM_UNAVAILABLE_MESSAGE as RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE"), + "transport publish capabilities must consume the versioned protocol unavailable message" ); assert!( !protocol_source.contains("Reticulum transport is configured in preview mode"), diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,5 +1,4 @@ use radroots_transport::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt, @@ -11,9 +10,7 @@ use radroots_transport::{ RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, - ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, - ReticulumFragmentIntegrityV1, ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, - ReticulumPrivacySemanticsV1, TRANSPORT_ID_MAX_BYTES, TransportId, + TRANSPORT_ID_MAX_BYTES, TransportId, }; use serde_json::Value; use std::borrow::ToOwned; @@ -37,10 +34,7 @@ fn target_fingerprints_are_stable_and_transport_scoped() { assert_eq!(nostr_upper.uri().as_str(), "wss://relay.example/Events"); assert_eq!(nostr_upper.scope(), None); - assert_eq!( - reticulum.scope().map(|scope| scope.as_str()), - Some(RADROOTS_RETICULUM_SCOPE_ID) - ); + assert_eq!(reticulum.scope().map(|scope| scope.as_str()), Some("local")); assert_eq!(nostr_upper.fingerprint(), nostr_lower.fingerprint()); assert_ne!(nostr_upper.fingerprint(), reticulum.fingerprint()); assert_eq!( @@ -50,124 +44,6 @@ fn target_fingerprints_are_stable_and_transport_scoped() { } #[test] -fn reticulum_destination_v1_is_canonical_and_stable() { - let target = RadrootsTransportTarget::reticulum().expect("reticulum target"); - let destination = radroots_transport::ReticulumDestinationV1::from_target(&target) - .expect("destination from target"); - let local = radroots_transport::ReticulumDestinationV1::local(); - - assert_eq!(destination, local); - assert_eq!(destination.uri().as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); - assert_eq!( - destination.routing().scope.as_str(), - RADROOTS_RETICULUM_SCOPE_ID - ); - assert_eq!( - destination.routing().gateway, - ReticulumGatewaySemanticsV1::NoGatewayForwarding - ); - assert_eq!( - destination.routing().privacy, - ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly - ); - assert_eq!(destination.fingerprint(), target.fingerprint()); - assert_eq!( - destination - .transport_target() - .expect("transport target") - .fingerprint(), - target.fingerprint() - ); - assert_eq!( - destination.fingerprint().as_str(), - "39142c9a79d6912655e0ad00fb5dbfbe9d2d91b4999e5d68d04a81d89a77f831" - ); - assert!( - radroots_transport::ReticulumDestinationV1::new( - "reticulum:other", - RadrootsTransportMeshScopeId::local_reticulum(), - None, - ) - .is_err() - ); -} - -#[test] -#[cfg(feature = "serde")] -fn reticulum_destination_deserialization_revalidates_canonical_identity() { - let destination = ReticulumDestinationV1::local(); - let canonical = serde_json::to_value(&destination).expect("serialize destination"); - assert_eq!( - serde_json::from_value::<ReticulumDestinationV1>(canonical.clone()) - .expect("deserialize canonical destination"), - destination - ); - - let mut forged_fingerprint = canonical.clone(); - forged_fingerprint - .as_object_mut() - .expect("destination object") - .insert("fingerprint".to_owned(), Value::String("0".repeat(64))); - assert!(serde_json::from_value::<ReticulumDestinationV1>(forged_fingerprint).is_err()); - - let mut forged_scope = canonical.clone(); - forged_scope - .get_mut("routing") - .and_then(Value::as_object_mut) - .expect("routing object") - .insert("scope".to_owned(), Value::String("remote".to_owned())); - assert!(serde_json::from_value::<ReticulumDestinationV1>(forged_scope).is_err()); - - let mut nested_unknown = canonical.clone(); - nested_unknown - .get_mut("routing") - .and_then(Value::as_object_mut) - .expect("routing object") - .insert("unexpected".to_owned(), Value::Bool(true)); - assert!(serde_json::from_value::<ReticulumDestinationV1>(nested_unknown).is_err()); - - let mut top_level_unknown = canonical; - top_level_unknown - .as_object_mut() - .expect("destination object") - .insert("unexpected".to_owned(), Value::Bool(true)); - assert!(serde_json::from_value::<ReticulumDestinationV1>(top_level_unknown).is_err()); -} - -#[test] -fn reticulum_capability_report_v1_is_explicitly_unavailable_without_fragmentation() { - let report = ReticulumCapabilityReportV1::unavailable_local(); - - assert!(report.delivery_required); - assert!(!report.fetch_required); - assert!(!report.can_deliver); - assert!(!report.can_fetch); - assert!(!report.can_discover); - assert!(!report.can_forward_gateway); - assert!(!report.can_observe_receipts); - assert_eq!( - report.payload_policy.fragment_policy.mode, - ReticulumFragmentationModeV1::Unsupported - ); - assert_eq!(report.payload_policy.fragment_policy.max_fragment_count, 1); - assert_eq!( - report.payload_policy.fragment_policy.max_reassembled_bytes, - report.payload_policy.max_payload_bytes - ); - assert_eq!( - report - .payload_policy - .fragment_policy - .duplicate_fragment_behavior, - ReticulumDuplicateFragmentBehaviorV1::Reject - ); - assert_eq!( - report.payload_policy.fragment_policy.integrity_verification, - ReticulumFragmentIntegrityV1::PayloadDigest - ); -} - -#[test] fn transport_id_round_trips_built_ins_and_custom_values() { for (raw, expected) in [ ("local", TransportId::LOCAL), @@ -773,11 +649,8 @@ fn checked_in_transport_target_uri_vectors_match_parser_behavior() { #[test] fn reticulum_transport_targets_use_default_destination_and_scope() { let target = RadrootsTransportTarget::reticulum().expect("Reticulum target"); - assert_eq!(target.uri().as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); - assert_eq!( - target.scope().map(|scope| scope.as_str()), - Some(RADROOTS_RETICULUM_SCOPE_ID) - ); + assert_eq!(target.uri().as_str(), "reticulum:local"); + assert_eq!(target.scope().map(|scope| scope.as_str()), Some("local")); let invalid_reticulum_destination = ["reticulum:", "remote"].concat(); for invalid in [ @@ -824,21 +697,21 @@ fn target_scope_participates_in_identity_and_label_does_not() { let remote_scope = RadrootsTransportMeshScopeId::parse("remote").expect("remote scope"); let local = RadrootsTransportTarget::new_with_metadata( RadrootsTransportKind::Reticulum, - RADROOTS_RETICULUM_ENDPOINT_URI, + "reticulum:local", Some(local_scope.clone()), Some(RadrootsTransportTargetLabel::parse("Local Reticulum node").expect("label")), ) .expect("local Reticulum target"); let relabeled = RadrootsTransportTarget::new_with_metadata( RadrootsTransportKind::Reticulum, - RADROOTS_RETICULUM_ENDPOINT_URI, + "reticulum:local", Some(local_scope), Some(RadrootsTransportTargetLabel::parse("Renamed node").expect("label")), ) .expect("relabeled mesh target"); let remote = RadrootsTransportTarget::new_with_metadata( RadrootsTransportKind::Reticulum, - RADROOTS_RETICULUM_ENDPOINT_URI, + "reticulum:local", Some(remote_scope), None, ) @@ -1863,7 +1736,7 @@ fn status_contract_covers_builders_and_availability_defaults() { .with_maturity(RadrootsTransportCapabilityMaturity::Preview) .with_availability(RadrootsTransportCapabilityAvailability::Degraded) .with_profile_id("reticulum.local") - .with_endpoint_uri(RADROOTS_RETICULUM_ENDPOINT_URI); + .with_endpoint_uri("reticulum:local"); assert_eq!( unavailable.availability, RadrootsTransportCapabilityAvailability::Degraded @@ -1903,15 +1776,6 @@ fn transport_id_serde_uses_the_protocol_wire_contract() { } #[test] -fn reticulum_destination_rejects_wrong_kind() { - let local = RadrootsTransportTarget::local("local:memory").expect("local target"); - assert_eq!( - ReticulumDestinationV1::from_target(&local).expect_err("wrong kind"), - RadrootsTransportError::InvalidTargetUri - ); -} - -#[test] #[cfg(feature = "serde")] fn transport_target_deserialization_rejects_forged_and_noncanonical_identity() { let target = RadrootsTransportTarget::reticulum().expect("Reticulum target"); diff --git a/crates/transport_publish_protocol/Cargo.toml b/crates/transport_publish_protocol/Cargo.toml @@ -17,12 +17,9 @@ std = [] serde = ["dep:serde", "radroots_transport/serde"] [dependencies] +radroots_protocol = { workspace = true, default-features = false } radroots_transport = { workspace = true, default-features = false } serde = { workspace = true, optional = true, features = ["alloc", "derive"] } [dev-dependencies] -radroots_protocol = { workspace = true, default-features = false, features = [ - "serde", - "std", -] } serde_json = { workspace = true, features = ["std"] } diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -10,8 +10,11 @@ use alloc::{collections::BTreeSet, string::String, vec::Vec}; use std::{collections::BTreeSet, string::String, vec::Vec}; use core::fmt; +use radroots_protocol::radrootsd::transport_publish::v5::{ + RETICULUM_ENDPOINT_URI as RADROOTS_RETICULUM_ENDPOINT_URI, + RETICULUM_UNAVAILABLE_MESSAGE as RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, +}; use radroots_transport::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportError, RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, }; diff --git a/crates/transport_reticulum/src/contract.rs b/crates/transport_reticulum/src/contract.rs @@ -0,0 +1,245 @@ +use crate::RADROOTS_RETICULUM_ENDPOINT_URI; +use radroots_transport::{ + RadrootsTransportError, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, + RadrootsTransportTargetUri, +}; + +pub const RETICULUM_V1_MAX_PAYLOAD_BYTES: usize = 64 * 1024; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReticulumFragmentationModeV1 { + Unsupported, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReticulumDuplicateFragmentBehaviorV1 { + Reject, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReticulumFragmentIntegrityV1 { + PayloadDigest, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReticulumFragmentPolicyV1 { + pub mode: ReticulumFragmentationModeV1, + pub max_fragment_count: u16, + pub max_reassembled_bytes: usize, + pub duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1, + pub integrity_verification: ReticulumFragmentIntegrityV1, +} + +impl ReticulumFragmentPolicyV1 { + pub const fn unsupported() -> Self { + Self { + mode: ReticulumFragmentationModeV1::Unsupported, + max_fragment_count: 1, + max_reassembled_bytes: RETICULUM_V1_MAX_PAYLOAD_BYTES, + duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1::Reject, + integrity_verification: ReticulumFragmentIntegrityV1::PayloadDigest, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReticulumPayloadPolicyV1 { + pub max_payload_bytes: usize, + pub fragment_policy: ReticulumFragmentPolicyV1, +} + +impl ReticulumPayloadPolicyV1 { + pub const fn v1() -> Self { + Self { + max_payload_bytes: RETICULUM_V1_MAX_PAYLOAD_BYTES, + fragment_policy: ReticulumFragmentPolicyV1::unsupported(), + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReticulumGatewaySemanticsV1 { + NoGatewayForwarding, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReticulumPrivacySemanticsV1 { + CanonicalSignedEventBytesOnly, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReticulumRoutingMetadataV1 { + pub scope: RadrootsTransportMeshScopeId, + pub gateway: ReticulumGatewaySemanticsV1, + pub privacy: ReticulumPrivacySemanticsV1, +} + +impl ReticulumRoutingMetadataV1 { + pub fn local() -> Self { + Self { + scope: RadrootsTransportMeshScopeId::local_reticulum(), + gateway: ReticulumGatewaySemanticsV1::NoGatewayForwarding, + privacy: ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReticulumDestinationV1 { + uri: RadrootsTransportTargetUri, + routing: ReticulumRoutingMetadataV1, + label: Option<RadrootsTransportTargetLabel>, + fingerprint: RadrootsTransportTargetFingerprint, +} + +impl ReticulumDestinationV1 { + pub fn local() -> Self { + Self::new( + RADROOTS_RETICULUM_ENDPOINT_URI, + ReticulumRoutingMetadataV1::local().scope, + None, + ) + .expect("local Reticulum destination") + } + + pub fn new( + uri: impl AsRef<str>, + scope: RadrootsTransportMeshScopeId, + label: Option<RadrootsTransportTargetLabel>, + ) -> Result<Self, RadrootsTransportError> { + let target = RadrootsTransportTarget::reticulum_with_metadata( + uri.as_ref(), + Some(scope), + label.clone(), + )?; + Ok(Self { + uri: target.uri().clone(), + routing: ReticulumRoutingMetadataV1 { + scope: target + .scope() + .cloned() + .expect("Reticulum destination scope"), + gateway: ReticulumGatewaySemanticsV1::NoGatewayForwarding, + privacy: ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly, + }, + label: target.label().cloned(), + fingerprint: target.fingerprint().clone(), + }) + } + + pub fn from_target(target: &RadrootsTransportTarget) -> Result<Self, RadrootsTransportError> { + if target.kind() != &RadrootsTransportKind::Reticulum + || target.uri().as_str() != RADROOTS_RETICULUM_ENDPOINT_URI + { + return Err(RadrootsTransportError::InvalidTargetUri); + } + let Some(scope) = target.scope().cloned() else { + return Err(RadrootsTransportError::EmptyTargetScope); + }; + let destination = Self::new(target.uri().as_str(), scope, target.label().cloned())?; + if destination.fingerprint != *target.fingerprint() { + return Err(RadrootsTransportError::InvalidTargetFingerprint); + } + Ok(destination) + } + + pub fn transport_target(&self) -> Result<RadrootsTransportTarget, RadrootsTransportError> { + RadrootsTransportTarget::reticulum_with_metadata( + self.uri.as_str(), + Some(self.routing.scope.clone()), + self.label.clone(), + ) + } + + pub fn uri(&self) -> &RadrootsTransportTargetUri { + &self.uri + } + + pub fn routing(&self) -> &ReticulumRoutingMetadataV1 { + &self.routing + } + + pub fn label(&self) -> Option<&RadrootsTransportTargetLabel> { + self.label.as_ref() + } + + pub fn fingerprint(&self) -> &RadrootsTransportTargetFingerprint { + &self.fingerprint + } +} + +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ReticulumDestinationV1Wire { + uri: RadrootsTransportTargetUri, + routing: ReticulumRoutingMetadataV1, + label: Option<RadrootsTransportTargetLabel>, + fingerprint: RadrootsTransportTargetFingerprint, +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ReticulumDestinationV1 { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let wire = ReticulumDestinationV1Wire::deserialize(deserializer)?; + let destination = Self::new( + wire.uri.as_str(), + wire.routing.scope.clone(), + wire.label.clone(), + ) + .map_err(serde::de::Error::custom)?; + if destination.routing != wire.routing + || destination.label != wire.label + || destination.fingerprint != wire.fingerprint + { + return Err(serde::de::Error::custom( + "Reticulum destination identity does not match its canonical fields", + )); + } + Ok(destination) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReticulumCapabilityReportV1 { + pub delivery_required: bool, + pub fetch_required: bool, + pub can_deliver: bool, + pub can_fetch: bool, + pub can_discover: bool, + pub can_forward_gateway: bool, + pub can_observe_receipts: bool, + pub destination: ReticulumDestinationV1, + pub payload_policy: ReticulumPayloadPolicyV1, +} + +impl ReticulumCapabilityReportV1 { + pub fn unavailable_local() -> Self { + Self { + delivery_required: true, + fetch_required: false, + can_deliver: false, + can_fetch: false, + can_discover: false, + can_forward_gateway: false, + can_observe_receipts: false, + destination: ReticulumDestinationV1::local(), + payload_policy: ReticulumPayloadPolicyV1::v1(), + } + } +} diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -4,21 +4,33 @@ extern crate alloc; +mod contract; +mod message; + +pub use contract::{ + RETICULUM_V1_MAX_PAYLOAD_BYTES, ReticulumCapabilityReportV1, ReticulumDestinationV1, + ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1, ReticulumFragmentPolicyV1, + ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPayloadPolicyV1, + ReticulumPrivacySemanticsV1, ReticulumRoutingMetadataV1, +}; +pub use message::{ + RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, +}; + use alloc::borrow::ToOwned; use alloc::boxed::Box; use alloc::string::String; use alloc::vec::Vec; use core::fmt; use radroots_transport::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransport, - RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability, + RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportStatus, RadrootsTransportTarget, - RadrootsTransportTargetReceipt, ReticulumCapabilityReportV1, ReticulumDestinationV1, - ReticulumPayloadPolicyV1, + RadrootsTransportTargetReceipt, }; const DEFAULT_PROFILE_ID: &str = "transport.reticulum.default"; diff --git a/crates/transport/src/message.rs b/crates/transport_reticulum/src/message.rs diff --git a/crates/transport_reticulum/tests/reticulum.rs b/crates/transport_reticulum/tests/reticulum.rs @@ -1,19 +1,21 @@ use radroots_transport::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransport, - RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, - RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportFetchRequest, RadrootsTransportImplementationState, RadrootsTransportKind, - RadrootsTransportMeshScopeId, RadrootsTransportPayload, RadrootsTransportSatisfactionClass, + RadrootsTransport, RadrootsTransportCapabilityAvailability, + RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryRequest, + RadrootsTransportDeliveryTargetStatus, RadrootsTransportFetchRequest, + RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportPayload, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet, - ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1, - ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPrivacySemanticsV1, }; use radroots_transport_reticulum::{ - RadrootsReticulumAgentEndpoint, RadrootsReticulumBehavior, RadrootsReticulumEndpoint, - RadrootsReticulumError, RadrootsReticulumFetchRequest, RadrootsReticulumProfile, - RadrootsReticulumTransport, + RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsReticulumAgentEndpoint, + RadrootsReticulumBehavior, RadrootsReticulumEndpoint, RadrootsReticulumError, + RadrootsReticulumFetchRequest, RadrootsReticulumProfile, RadrootsReticulumTransport, + ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1, + ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPrivacySemanticsV1, }; +#[cfg(feature = "serde")] +use serde_json::Value; fn reticulum_target(uri: &str) -> RadrootsTransportTarget { assert_eq!(uri, RADROOTS_RETICULUM_ENDPOINT_URI); @@ -605,6 +607,54 @@ fn public_models_round_trip_through_serde() { } #[test] +#[cfg(feature = "serde")] +fn destination_deserialization_revalidates_canonical_identity() { + let destination = ReticulumDestinationV1::local(); + let canonical = serde_json::to_value(&destination).expect("serialize destination"); + assert_eq!( + serde_json::from_value::<ReticulumDestinationV1>(canonical.clone()) + .expect("deserialize canonical destination"), + destination + ); + + let mut forged_fingerprint = canonical.clone(); + forged_fingerprint + .as_object_mut() + .expect("destination object") + .insert("fingerprint".to_owned(), Value::String("0".repeat(64))); + assert!(serde_json::from_value::<ReticulumDestinationV1>(forged_fingerprint).is_err()); + + let mut forged_scope = canonical.clone(); + forged_scope + .get_mut("routing") + .and_then(Value::as_object_mut) + .expect("routing object") + .insert("scope".to_owned(), Value::String("remote".to_owned())); + assert!(serde_json::from_value::<ReticulumDestinationV1>(forged_scope).is_err()); + + let mut nested_unknown = canonical.clone(); + nested_unknown + .get_mut("routing") + .and_then(Value::as_object_mut) + .expect("routing object") + .insert("unexpected".to_owned(), Value::Bool(true)); + assert!(serde_json::from_value::<ReticulumDestinationV1>(nested_unknown).is_err()); + + let mut top_level_unknown = canonical; + top_level_unknown + .as_object_mut() + .expect("destination object") + .insert("unexpected".to_owned(), Value::Bool(true)); + assert!(serde_json::from_value::<ReticulumDestinationV1>(top_level_unknown).is_err()); +} + +#[test] +fn destination_rejects_non_reticulum_targets() { + let local = RadrootsTransportTarget::local("local:memory").expect("local target"); + assert!(ReticulumDestinationV1::from_target(&local).is_err()); +} + +#[test] fn reticulum_source_remains_inert_without_runtime_delivery_hooks() { let source = include_str!("../src/lib.rs").to_ascii_lowercase(); for forbidden in [