lib

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

commit ff893f0207fef17a6a2a406503bba475d50e41a9
parent 727f97fbe7bcdd12e7854b4c31bd8a577cd06c00
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Jul 2026 15:44:00 +0000

transport: replace the closed transport kind enum

- add a bounded extensible TransportId with canonical built-in identities
- delegate transport validation and serde to the versioned protocol value
- keep generic target reconstruction forward compatible for custom transports
- refresh protocol inventories and cover syntax conversion and compatibility

Diffstat:
Mcontracts/codegen/protocol_v1.inventory.json | 6+++---
Mcontracts/codegen/protocol_v1.inventory.sha256 | 2+-
Mcrates/outbox/src/store.rs | 6++++++
Mcrates/protocol/src/capability/v1.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/protocol/src/runtime/v1.rs | 11++++++-----
Mcrates/protocol_contract_v1/tests/successor_equivalence.rs | 6+++---
Acrates/transport/src/id.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/src/kind.rs | 66------------------------------------------------------------------
Mcrates/transport/src/lib.rs | 4+++-
Mcrates/transport/tests/source_boundary.rs | 35++++++++++++++++++-----------------
Mcrates/transport/tests/transport.rs | 88++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/transport_publish_protocol/src/lib.rs | 1+
Mdocs/api/radroots_protocol.txt | 28+++++++++++++++++++---------
13 files changed, 377 insertions(+), 199 deletions(-)

diff --git a/contracts/codegen/protocol_v1.inventory.json b/contracts/codegen/protocol_v1.inventory.json @@ -7,7 +7,7 @@ { "module": "capability::v1", "path": "crates/protocol/src/capability/v1.rs", - "sha256": "8a50dc841ced8e5f4fea18cd248f2a170b8e4e51175511a82ac5c6213c768ba3", + "sha256": "416f3ae2a5f97c46b56b0c24095f6f6813d2851b7856c84cfbdc251bdd30a846", "types": [ { "rust_path": "radroots_protocol::capability::v1::Availability", @@ -35,7 +35,7 @@ }, { "rust_path": "radroots_protocol::capability::v1::TransportKind", - "kind": "enum" + "kind": "struct" } ] }, @@ -203,7 +203,7 @@ { "module": "runtime::v1", "path": "crates/protocol/src/runtime/v1.rs", - "sha256": "94157d512a1ea152685529f9a3e5cb23da95db6e2271b09f1c9f835c14703743", + "sha256": "3ef493bd3d2e18f52f2efa0c508fe1bcdaced26d7f53674f130ccf38dd1b1e36", "types": [ { "rust_path": "radroots_protocol::runtime::v1::ApprovalRequirement", diff --git a/contracts/codegen/protocol_v1.inventory.sha256 b/contracts/codegen/protocol_v1.inventory.sha256 @@ -1 +1 @@ -86a317bf2eb502ca0c89f627a823c3df9652b95a4b1a71169b03ffb9e2f9b4ce +6f9eab5185f426fed8a9e2fab514dbf19a4eecd9fdff6756db60634396023b29 diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -2897,6 +2897,12 @@ fn delivery_target_from_row( target_scope.clone(), target_label.clone(), ), + _ => RadrootsTransportTarget::new_with_metadata( + transport_kind, + endpoint_uri_raw.as_str(), + target_scope.clone(), + target_label.clone(), + ), } .map_err(|_| invalid_identity("canonical_fields"))?; if target.uri().as_str() != endpoint_uri_raw diff --git a/crates/protocol/src/capability/v1.rs b/crates/protocol/src/capability/v1.rs @@ -5,39 +5,105 @@ use core::fmt; use crate::schema::{Metadata, ModuleVersion, Registry}; -/// Stable wire identity for a supported transport family. -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +/// Maximum encoded length of a capability transport identity. +pub const MAX_TRANSPORT_KIND_BYTES: usize = 64; + +/// Stable wire identity for a transport family. +/// +/// The representation is intentionally open so adding a transport does not +/// require adding an enum variant to this versioned wire contract. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub enum TransportKind { +pub struct TransportKind { + bytes: [u8; MAX_TRANSPORT_KIND_BYTES], + len: u8, +} + +impl TransportKind { /// Process-local transport. - Local, + pub const LOCAL: Self = Self::from_static(b"local"); /// Nostr relay transport. - Nostr, + pub const NOSTR: Self = Self::from_static(b"nostr"); /// Reticulum mesh transport. - Reticulum, -} + pub const RETICULUM: Self = Self::from_static(b"reticulum"); + /// Daemon-mediated transport. + pub const RADROOTSD: Self = Self::from_static(b"radrootsd"); -impl TransportKind { - /// Returns the stable serialized identity. - pub const fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Nostr => "nostr", - Self::Reticulum => "reticulum", + const fn from_static(value: &[u8]) -> Self { + let mut bytes = [0; MAX_TRANSPORT_KIND_BYTES]; + let mut index = 0; + while index < value.len() { + bytes[index] = value[index]; + index += 1; + } + Self { + bytes, + len: value.len() as u8, } } - /// Parses an exact stable transport identity. + /// Parses an exact canonical transport identity. + /// + /// Identities contain 1-64 lowercase ASCII bytes. They begin and end with + /// an ASCII letter or digit and may use single `-` separators internally. pub fn parse(value: &str) -> Result<Self, Error> { - match value { - "local" => Ok(Self::Local), - "nostr" => Ok(Self::Nostr), - "reticulum" => Ok(Self::Reticulum), - _ => Err(Error::UnknownTransportKind { + if value.is_empty() { + return Err(Error::EmptyTransportKind); + } + let raw = value.as_bytes(); + let valid_edge = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + let valid = raw.len() <= MAX_TRANSPORT_KIND_BYTES + && valid_edge(raw[0]) + && valid_edge(raw[raw.len() - 1]) + && raw.iter().enumerate().all(|(index, byte)| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || (*byte == b'-' && index > 0 && raw[index - 1] != b'-') + }); + if !valid { + return Err(Error::InvalidTransportKind { value: value.to_string(), - }), + }); } + + let mut bytes = [0; MAX_TRANSPORT_KIND_BYTES]; + bytes[..raw.len()].copy_from_slice(raw); + Ok(Self { + bytes, + len: raw.len() as u8, + }) + } + + /// Returns the validated wire identity. + pub fn as_str(&self) -> &str { + core::str::from_utf8(&self.bytes[..usize::from(self.len)]) + .expect("TransportKind stores validated ASCII") + } +} + +impl fmt::Display for TransportKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for TransportKind { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for TransportKind { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse(value.as_str()).map_err(serde::de::Error::custom) } } @@ -162,7 +228,7 @@ pub struct TransportDescriptor { /// Exact Release V1 transport capability catalog. pub const CATALOG: &[TransportDescriptor] = &[ TransportDescriptor { - kind: TransportKind::Local, + kind: TransportKind::LOCAL, maturity: Maturity::Stable, availability: Availability::Available, can_deliver: true, @@ -173,7 +239,7 @@ pub const CATALOG: &[TransportDescriptor] = &[ required_for_v1: true, }, TransportDescriptor { - kind: TransportKind::Nostr, + kind: TransportKind::NOSTR, maturity: Maturity::Stable, availability: Availability::Available, can_deliver: true, @@ -184,7 +250,7 @@ pub const CATALOG: &[TransportDescriptor] = &[ required_for_v1: true, }, TransportDescriptor { - kind: TransportKind::Reticulum, + kind: TransportKind::RETICULUM, maturity: Maturity::Preview, availability: Availability::Unavailable, can_deliver: true, @@ -217,30 +283,23 @@ pub const SCHEMAS: &[Metadata] = &[ /// Validates catalog uniqueness and required V1 membership. pub fn validate_catalog(descriptors: &[TransportDescriptor]) -> Result<(), Error> { - let mut seen = [false; 3]; - for descriptor in descriptors { - let index = match descriptor.kind { - TransportKind::Local => 0, - TransportKind::Nostr => 1, - TransportKind::Reticulum => 2, - }; - if seen[index] { + for (index, descriptor) in descriptors.iter().enumerate() { + if descriptors[..index] + .iter() + .any(|candidate| candidate.kind == descriptor.kind) + { return Err(Error::DuplicateTransportKind { kind: descriptor.kind, }); } - seen[index] = true; } - for (index, kind) in [ - TransportKind::Local, - TransportKind::Nostr, - TransportKind::Reticulum, - ] - .into_iter() - .enumerate() - { - if !seen[index] { + for kind in [ + TransportKind::LOCAL, + TransportKind::NOSTR, + TransportKind::RETICULUM, + ] { + if !descriptors.iter().any(|descriptor| descriptor.kind == kind) { return Err(Error::MissingRequiredTransport { kind }); } } @@ -261,8 +320,10 @@ pub fn schema_registry() -> Result<Registry, crate::schema::Error> { #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum Error { - /// The transport identity is unknown. - UnknownTransportKind { + /// The transport identity is empty. + EmptyTransportKind, + /// The transport identity is not canonical or exceeds its bound. + InvalidTransportKind { /// Rejected transport identity. value: String, }, @@ -285,8 +346,9 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::UnknownTransportKind { value } => { - write!(formatter, "unknown transport kind {value}") + Self::EmptyTransportKind => formatter.write_str("transport kind is empty"), + Self::InvalidTransportKind { value } => { + write!(formatter, "invalid transport kind {value}") } Self::InvalidMeshScopeId => formatter.write_str("invalid mesh scope id"), Self::InvalidReticulumDestination => { @@ -323,21 +385,31 @@ mod tests { } #[test] - fn parsers_preserve_v1_acceptance_and_diagnostics() { + fn transport_kind_accepts_built_ins_and_forward_compatible_values() { for (value, expected) in [ - ("local", TransportKind::Local), - ("nostr", TransportKind::Nostr), - ("reticulum", TransportKind::Reticulum), + ("local", TransportKind::LOCAL), + ("nostr", TransportKind::NOSTR), + ("reticulum", TransportKind::RETICULUM), + ("radrootsd", TransportKind::RADROOTSD), ] { assert_eq!(TransportKind::parse(value), Ok(expected)); assert_eq!(expected.as_str(), value); } assert_eq!( - TransportKind::parse("mesh") - .expect_err("unknown") - .to_string(), - "unknown transport kind mesh" + TransportKind::parse("fieldbus-v2").unwrap().as_str(), + "fieldbus-v2" ); + for invalid in ["", "NOSTR", " fieldbus", "fieldbus_2", "fieldbus--2"] { + assert!( + TransportKind::parse(invalid).is_err(), + "accepted {invalid:?}" + ); + } + assert!(TransportKind::parse(&"a".repeat(MAX_TRANSPORT_KIND_BYTES + 1)).is_err()); + } + + #[test] + fn other_capability_parsers_preserve_v1_diagnostics() { assert_eq!( MeshScopeId::parse("local/scope") .expect_err("invalid scope") @@ -357,13 +429,13 @@ mod tests { assert_eq!( validate_catalog(&[CATALOG[0], CATALOG[0]]), Err(Error::DuplicateTransportKind { - kind: TransportKind::Local, + kind: TransportKind::LOCAL, }) ); assert_eq!( validate_catalog(&[CATALOG[1], CATALOG[2]]), Err(Error::MissingRequiredTransport { - kind: TransportKind::Local, + kind: TransportKind::LOCAL, }) ); } diff --git a/crates/protocol/src/runtime/v1.rs b/crates/protocol/src/runtime/v1.rs @@ -296,9 +296,10 @@ impl TransportRoute { pub fn includes_transport(self, kind: TransportKind) -> bool { match kind { - TransportKind::Local => self.local, - TransportKind::Nostr => self.nostr, - TransportKind::Reticulum => self.reticulum, + TransportKind::LOCAL => self.local, + TransportKind::NOSTR => self.nostr, + TransportKind::RETICULUM => self.reticulum, + _ => false, } } } @@ -956,10 +957,10 @@ pub fn validate_catalog(descriptors: &[OperationDescriptor]) -> Result<(), Error if !descriptor.transport_capability.deliver || !descriptor .transport_capability - .includes_transport(TransportKind::Nostr) + .includes_transport(TransportKind::NOSTR) || !descriptor .transport_capability - .includes_transport(TransportKind::Reticulum) + .includes_transport(TransportKind::RETICULUM) { return Err(Error::CatalogInvalid { message: format!( diff --git a/crates/protocol_contract_v1/tests/successor_equivalence.rs b/crates/protocol_contract_v1/tests/successor_equivalence.rs @@ -30,15 +30,15 @@ fn capability_value_json_is_byte_identical() { for (predecessor, successor) in [ ( predecessor::TransportKindV1::Local, - capability::TransportKind::Local, + capability::TransportKind::LOCAL, ), ( predecessor::TransportKindV1::Nostr, - capability::TransportKind::Nostr, + capability::TransportKind::NOSTR, ), ( predecessor::TransportKindV1::Reticulum, - capability::TransportKind::Reticulum, + capability::TransportKind::RETICULUM, ), ] { assert_eq!( diff --git a/crates/transport/src/id.rs b/crates/transport/src/id.rs @@ -0,0 +1,137 @@ +use crate::RadrootsTransportError; +use alloc::string::{String, ToString}; +use core::{fmt, str::FromStr}; +use radroots_protocol::capability::v1::{ + Error as ProtocolError, MAX_TRANSPORT_KIND_BYTES, TransportKind as ProtocolTransportKind, +}; + +/// Maximum encoded length of a transport identity. +pub const TRANSPORT_ID_MAX_BYTES: usize = MAX_TRANSPORT_KIND_BYTES; + +/// Validated, extensible transport identity. +/// +/// Identities contain 1-64 canonical lowercase ASCII bytes. They begin and +/// end with a letter or digit and may use single `-` separators internally. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct TransportId(ProtocolTransportKind); + +impl TransportId { + /// Process-local transport. + pub const LOCAL: Self = Self(ProtocolTransportKind::LOCAL); + /// Nostr relay transport. + pub const NOSTR: Self = Self(ProtocolTransportKind::NOSTR); + /// Reticulum mesh transport. + pub const RETICULUM: Self = Self(ProtocolTransportKind::RETICULUM); + /// Daemon-mediated transport. + pub const RADROOTSD: Self = Self(ProtocolTransportKind::RADROOTSD); + + // Compatibility spellings retained until the planned consumer cutover. + #[allow(non_upper_case_globals)] + pub const Local: Self = Self::LOCAL; + #[allow(non_upper_case_globals)] + pub const Nostr: Self = Self::NOSTR; + #[allow(non_upper_case_globals)] + pub const Reticulum: Self = Self::RETICULUM; + + /// Parses an exact canonical identity. + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + ProtocolTransportKind::parse(value.as_ref()) + .map(Self) + .map_err(map_protocol_error) + } + + /// Parses an exact canonical identity. + pub fn parse_canonical(value: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + Self::parse(value) + } + + /// Returns the canonical identity text. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Returns an owned canonical identity for compatibility with predecessor APIs. + pub fn canonical_label(&self) -> String { + self.as_str().to_string() + } +} + +fn map_protocol_error(error: ProtocolError) -> RadrootsTransportError { + match error { + ProtocolError::EmptyTransportKind => RadrootsTransportError::EmptyTransportKind, + ProtocolError::InvalidTransportKind { .. } => RadrootsTransportError::InvalidTransportKind, + _ => RadrootsTransportError::InvalidTransportKind, + } +} + +impl AsRef<str> for TransportId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for TransportId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for TransportId { + type Err = RadrootsTransportError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl TryFrom<&str> for TransportId { + type Error = RadrootsTransportError; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +impl TryFrom<String> for TransportId { + type Error = RadrootsTransportError; + + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +impl From<ProtocolTransportKind> for TransportId { + fn from(value: ProtocolTransportKind) -> Self { + Self(value) + } +} + +impl From<TransportId> for ProtocolTransportKind { + fn from(value: TransportId) -> Self { + value.0 + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for TransportId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for TransportId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + <ProtocolTransportKind as serde::Deserialize>::deserialize(deserializer).map(Self) + } +} + +/// Compatibility name retained until the planned workspace consumer cutover. +#[doc(hidden)] +pub type RadrootsTransportKind = TransportId; diff --git a/crates/transport/src/kind.rs b/crates/transport/src/kind.rs @@ -1,69 +1,3 @@ -use crate::RadrootsTransportError; -use alloc::string::{String, ToString}; - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RadrootsTransportKind { - Nostr, - Reticulum, - Local, -} - -impl RadrootsTransportKind { - pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { - let canonical = value.as_ref().trim().to_ascii_lowercase(); - Self::from_canonical_str(canonical.as_str()) - } - - pub fn parse_canonical(value: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { - let raw = value.as_ref(); - if raw.is_empty() { - return Err(RadrootsTransportError::EmptyTransportKind); - } - if raw != raw.trim() || raw != raw.to_ascii_lowercase() { - return Err(RadrootsTransportError::InvalidTransportKind); - } - Self::from_canonical_str(raw) - } - - fn from_canonical_str(canonical: &str) -> Result<Self, RadrootsTransportError> { - match canonical { - "nostr" => Ok(Self::Nostr), - "reticulum" => Ok(Self::Reticulum), - "local" => Ok(Self::Local), - _ => Err(RadrootsTransportError::InvalidTransportKind), - } - } - - pub fn canonical_label(&self) -> String { - match self { - Self::Nostr => "nostr".to_string(), - Self::Reticulum => "reticulum".to_string(), - Self::Local => "local".to_string(), - } - } -} - -#[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) - } -} - #[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)] diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -8,6 +8,7 @@ pub mod capability; mod delivery; pub mod endpoint; pub mod error; +mod id; mod kind; mod message; pub mod outcome; @@ -26,9 +27,10 @@ pub use delivery::{ RadrootsTransportSatisfactionPolicy, RadrootsTransportTargetReceipt, }; pub use error::RadrootsTransportError; +pub use id::{RadrootsTransportKind, TRANSPORT_ID_MAX_BYTES, TransportId}; pub use kind::{ RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, - RadrootsTransportImplementationState, RadrootsTransportKind, + RadrootsTransportImplementationState, }; pub use message::{ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -759,30 +759,31 @@ fn required_target_semantics_stay_fingerprint_exact() { } #[test] -fn transport_hardening_sources_reject_removed_execution_kind_and_keep_reticulum_contracts() { +fn transport_identity_is_extensible_and_reticulum_contracts_remain_explicit() { let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("transport crate parent"); - let transport_kind = read_source(crates_root.join("transport/src/kind.rs").as_path()); - for required in ["Nostr,", "Reticulum,", "Local,"] { - assert!( - transport_kind.contains(required), - "transport kind source must retain first-wave transport witness `{required}`" - ); - } - let removed_kind_variant = ["Pro", "xy,"].concat(); - let removed_parse_arm = [r#""#, "pro", "xy", r#"" => Ok(Self::"#, "Pro", "xy)"].concat(); - let removed_label_arm = [r#"Self::"#, "Pro", "xy => \"", "pro", "xy\".to_string()"].concat(); - for forbidden in [ - &removed_kind_variant, - &removed_parse_arm, - &removed_label_arm, + let transport_id = read_source(crates_root.join("transport/src/id.rs").as_path()); + for required in [ + "pub struct TransportId(", + "pub const LOCAL:", + "pub const NOSTR:", + "pub const RETICULUM:", + "pub const RADROOTSD:", + "ProtocolTransportKind::parse", ] { assert!( - !transport_kind.contains(forbidden.as_str()), - "transport kind source must not retain removed radrootsd execution transport witness `{forbidden}`" + transport_id.contains(required), + "transport identity source must retain extensible identity witness `{required}`" ); } + assert!(!transport_id.contains("pub enum TransportId")); + + let protocol_identity = + read_source(crates_root.join("protocol/src/capability/v1.rs").as_path()); + assert!(protocol_identity.contains("pub struct TransportKind")); + 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()); diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -13,12 +13,12 @@ use radroots_transport::{ RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1, ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, - ReticulumPrivacySemanticsV1, + ReticulumPrivacySemanticsV1, TRANSPORT_ID_MAX_BYTES, TransportId, }; use serde_json::Value; use std::borrow::ToOwned; use std::boxed::Box; -use std::string::{String, ToString}; +use std::string::ToString; use std::vec; use std::vec::Vec; @@ -168,39 +168,32 @@ fn reticulum_capability_report_v1_is_explicitly_unavailable_without_fragmentatio } #[test] -fn transport_kind_parser_round_trips_first_wave_canonical_labels() { - assert_eq!( - RadrootsTransportKind::parse(" NOSTR ").expect("nostr kind"), - RadrootsTransportKind::Nostr - ); - assert_eq!( - RadrootsTransportKind::parse("reticulum").expect("reticulum kind"), - RadrootsTransportKind::Reticulum - ); - assert_eq!( - RadrootsTransportKind::parse("local").expect("local kind"), - RadrootsTransportKind::Local - ); +fn transport_id_round_trips_built_ins_and_custom_values() { + for (raw, expected) in [ + ("local", TransportId::LOCAL), + ("nostr", TransportId::NOSTR), + ("reticulum", TransportId::RETICULUM), + ("radrootsd", TransportId::RADROOTSD), + ] { + let parsed = TransportId::parse(raw).expect("built-in transport id"); + assert_eq!(parsed, expected); + assert_eq!(parsed.as_str(), raw); + assert_eq!(parsed.to_string(), raw); + } + + let custom = TransportId::parse("fieldbus-v2").expect("custom transport id"); + assert_eq!(custom.as_str(), "fieldbus-v2"); + let custom_target = + RadrootsTransportTarget::new(custom, "fieldbus:node-7").expect("custom target"); + assert_eq!(custom_target.kind(), &custom); assert_eq!( RadrootsTransportKind::Local.canonical_label(), "local".to_owned() ); - for retired in [ - "mesh".to_owned(), - ["pro", "xy"].concat(), - ["hy", "brid"].concat(), - ["reticulum", "_preview"].concat(), - "fieldbus".to_owned(), - ] { - assert_eq!( - RadrootsTransportKind::parse(retired).expect_err("retired or unknown kind"), - RadrootsTransportError::InvalidTransportKind - ); - } } #[test] -fn canonical_transport_kind_parser_rejects_noncanonical_public_values() { +fn transport_id_parser_enforces_canonical_syntax_and_bound() { assert_eq!( RadrootsTransportKind::parse_canonical("nostr").expect("nostr kind"), RadrootsTransportKind::Nostr @@ -214,22 +207,22 @@ fn canonical_transport_kind_parser_rejects_noncanonical_public_values() { RadrootsTransportError::InvalidTransportKind ); assert_eq!( - RadrootsTransportKind::parse_canonical(removed_radrootsd_execution_transport_kind()) - .expect_err("removed radrootsd execution kind"), + RadrootsTransportKind::parse_canonical("radrootsd_proxy") + .expect_err("underscore separator"), RadrootsTransportError::InvalidTransportKind ); assert_eq!( - RadrootsTransportKind::parse_canonical("fieldbus").expect_err("custom kind"), - RadrootsTransportError::InvalidTransportKind + RadrootsTransportKind::parse_canonical("fieldbus").expect("custom kind"), + TransportId::parse("fieldbus").expect("same custom kind") ); assert_eq!( RadrootsTransportKind::parse_canonical("").expect_err("empty kind"), RadrootsTransportError::EmptyTransportKind ); -} - -fn removed_radrootsd_execution_transport_kind() -> String { - ["radrootsd", "_", "pro", "xy"].concat() + assert_eq!( + TransportId::parse("a".repeat(TRANSPORT_ID_MAX_BYTES + 1)).expect_err("overlong kind"), + RadrootsTransportError::InvalidTransportKind + ); } #[test] @@ -679,7 +672,15 @@ fn transport_errors_have_stable_display_strings() { #[test] fn transport_kind_and_target_parsers_cover_negative_edges() { - for invalid in ["bad kind", "bad:kind", "bad/kind", "bad\nkind", "fieldbus"] { + for invalid in [ + "bad kind", + "bad:kind", + "bad/kind", + "bad\nkind", + "-fieldbus", + "fieldbus-", + "fieldbus--v2", + ] { assert_eq!( RadrootsTransportKind::parse(invalid).expect_err("invalid kind"), RadrootsTransportError::InvalidTransportKind @@ -1885,6 +1886,19 @@ fn transport_kind_deserializer_rejects_non_string_values() { } #[test] +#[cfg(feature = "serde")] +fn transport_id_serde_uses_the_protocol_wire_contract() { + let id = TransportId::parse("future-mesh-v3").expect("future transport id"); + let encoded = serde_json::to_string(&id).expect("serialize transport id"); + assert_eq!(encoded, "\"future-mesh-v3\""); + assert_eq!(serde_json::from_str::<TransportId>(&encoded).unwrap(), id); + + let protocol: radroots_protocol::capability::v1::TransportKind = id.into(); + assert_eq!(protocol.as_str(), id.as_str()); + assert_eq!(TransportId::from(protocol), id); +} + +#[test] fn reticulum_destination_rejects_wrong_kind() { let local = RadrootsTransportTarget::local("local:memory").expect("local target"); assert_eq!( diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -1222,6 +1222,7 @@ fn transport_target_from_parts( RadrootsTransportKind::Local => { RadrootsTransportTarget::local_with_metadata(endpoint_uri, scope, label) } + _ => RadrootsTransportTarget::new_with_metadata(transport_kind, endpoint_uri, scope, label), } } diff --git a/docs/api/radroots_protocol.txt b/docs/api/radroots_protocol.txt @@ -8,25 +8,19 @@ pub radroots_protocol::capability::v1::Availability::Unavailable #[non_exhaustive] pub enum radroots_protocol::capability::v1::Error pub radroots_protocol::capability::v1::Error::DuplicateTransportKind pub radroots_protocol::capability::v1::Error::DuplicateTransportKind::kind: radroots_protocol::capability::v1::TransportKind +pub radroots_protocol::capability::v1::Error::EmptyTransportKind pub radroots_protocol::capability::v1::Error::InvalidMeshScopeId pub radroots_protocol::capability::v1::Error::InvalidReticulumDestination +pub radroots_protocol::capability::v1::Error::InvalidTransportKind +pub radroots_protocol::capability::v1::Error::InvalidTransportKind::value: alloc::string::String pub radroots_protocol::capability::v1::Error::MissingRequiredTransport pub radroots_protocol::capability::v1::Error::MissingRequiredTransport::kind: radroots_protocol::capability::v1::TransportKind -pub radroots_protocol::capability::v1::Error::UnknownTransportKind -pub radroots_protocol::capability::v1::Error::UnknownTransportKind::value: alloc::string::String impl core::error::Error for radroots_protocol::capability::v1::Error impl core::fmt::Display for radroots_protocol::capability::v1::Error pub fn radroots_protocol::capability::v1::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub enum radroots_protocol::capability::v1::Maturity pub radroots_protocol::capability::v1::Maturity::Preview pub radroots_protocol::capability::v1::Maturity::Stable -pub enum radroots_protocol::capability::v1::TransportKind -pub radroots_protocol::capability::v1::TransportKind::Local -pub radroots_protocol::capability::v1::TransportKind::Nostr -pub radroots_protocol::capability::v1::TransportKind::Reticulum -impl radroots_protocol::capability::v1::TransportKind -pub const fn radroots_protocol::capability::v1::TransportKind::as_str(self) -> &'static str -pub fn radroots_protocol::capability::v1::TransportKind::parse(&str) -> core::result::Result<Self, radroots_protocol::capability::v1::Error> pub struct radroots_protocol::capability::v1::MeshScopeId impl radroots_protocol::capability::v1::MeshScopeId pub fn radroots_protocol::capability::v1::MeshScopeId::as_str(&self) -> &str @@ -48,7 +42,22 @@ pub radroots_protocol::capability::v1::TransportDescriptor::can_observe_receipts pub radroots_protocol::capability::v1::TransportDescriptor::kind: radroots_protocol::capability::v1::TransportKind pub radroots_protocol::capability::v1::TransportDescriptor::maturity: radroots_protocol::capability::v1::Maturity pub radroots_protocol::capability::v1::TransportDescriptor::required_for_v1: bool +pub struct radroots_protocol::capability::v1::TransportKind +impl radroots_protocol::capability::v1::TransportKind +pub const radroots_protocol::capability::v1::TransportKind::LOCAL: Self +pub const radroots_protocol::capability::v1::TransportKind::NOSTR: Self +pub const radroots_protocol::capability::v1::TransportKind::RADROOTSD: Self +pub const radroots_protocol::capability::v1::TransportKind::RETICULUM: Self +pub fn radroots_protocol::capability::v1::TransportKind::as_str(&self) -> &str +pub fn radroots_protocol::capability::v1::TransportKind::parse(&str) -> core::result::Result<Self, radroots_protocol::capability::v1::Error> +impl core::fmt::Display for radroots_protocol::capability::v1::TransportKind +pub fn radroots_protocol::capability::v1::TransportKind::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl serde_core::ser::Serialize for radroots_protocol::capability::v1::TransportKind +pub fn radroots_protocol::capability::v1::TransportKind::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer +impl<'de> serde_core::de::Deserialize<'de> for radroots_protocol::capability::v1::TransportKind +pub fn radroots_protocol::capability::v1::TransportKind::deserialize<D>(D) -> core::result::Result<Self, <D as serde_core::de::Deserializer>::Error> where D: serde_core::de::Deserializer<'de> pub const radroots_protocol::capability::v1::CATALOG: &[radroots_protocol::capability::v1::TransportDescriptor] +pub const radroots_protocol::capability::v1::MAX_TRANSPORT_KIND_BYTES: usize pub const radroots_protocol::capability::v1::SCHEMAS: &[radroots_protocol::schema::Metadata] pub fn radroots_protocol::capability::v1::schema_registry() -> core::result::Result<radroots_protocol::schema::Registry, radroots_protocol::schema::Error> pub fn radroots_protocol::capability::v1::validate_catalog(&[radroots_protocol::capability::v1::TransportDescriptor]) -> core::result::Result<(), radroots_protocol::capability::v1::Error> @@ -139,6 +148,7 @@ pub radroots_protocol::error::v1::KnownCode::RevisionRequired pub radroots_protocol::error::v1::KnownCode::SchemaTooNew pub radroots_protocol::error::v1::KnownCode::SignerCancelled pub radroots_protocol::error::v1::KnownCode::SignerCapabilityMissing +pub radroots_protocol::error::v1::KnownCode::SignerOutputInvalid pub radroots_protocol::error::v1::KnownCode::SignerRejected pub radroots_protocol::error::v1::KnownCode::SignerTimeout pub radroots_protocol::error::v1::KnownCode::SignerUnavailable