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:
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