lib

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

v1.rs (17012B)


      1 //! Capability catalog contract generation 1.
      2 
      3 use alloc::string::{String, ToString};
      4 use core::fmt;
      5 
      6 use crate::schema::{Metadata, ModuleVersion, Registry};
      7 
      8 /// Maximum encoded length of a capability transport identity.
      9 pub const MAX_TRANSPORT_KIND_BYTES: usize = 64;
     10 
     11 /// Stable wire identity for a transport family.
     12 ///
     13 /// The representation is intentionally open so adding a transport does not
     14 /// require adding an enum variant to this versioned wire contract.
     15 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     16 pub struct TransportKind {
     17     bytes: [u8; MAX_TRANSPORT_KIND_BYTES],
     18     len: u8,
     19 }
     20 
     21 impl TransportKind {
     22     /// Process-local transport.
     23     pub const LOCAL: Self = Self::from_static(b"local");
     24     /// Nostr relay transport.
     25     pub const NOSTR: Self = Self::from_static(b"nostr");
     26     /// Reticulum mesh transport.
     27     pub const RETICULUM: Self = Self::from_static(b"reticulum");
     28     /// Daemon-mediated transport.
     29     pub const RADROOTSD: Self = Self::from_static(b"radrootsd");
     30 
     31     const fn from_static(value: &[u8]) -> Self {
     32         let mut bytes = [0; MAX_TRANSPORT_KIND_BYTES];
     33         let mut index = 0;
     34         while index < value.len() {
     35             bytes[index] = value[index];
     36             index += 1;
     37         }
     38         Self {
     39             bytes,
     40             len: value.len() as u8,
     41         }
     42     }
     43 
     44     /// Parses an exact canonical transport identity.
     45     ///
     46     /// Identities contain 1-64 lowercase ASCII bytes. They begin and end with
     47     /// an ASCII letter or digit and may use single `-` separators internally.
     48     pub fn parse(value: &str) -> Result<Self, Error> {
     49         if value.is_empty() {
     50             return Err(Error::EmptyTransportKind);
     51         }
     52         let raw = value.as_bytes();
     53         let valid_edge = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
     54         let valid = raw.len() <= MAX_TRANSPORT_KIND_BYTES
     55             && valid_edge(raw[0])
     56             && valid_edge(raw[raw.len() - 1])
     57             && raw.iter().enumerate().all(|(index, byte)| {
     58                 byte.is_ascii_lowercase()
     59                     || byte.is_ascii_digit()
     60                     || (*byte == b'-' && index > 0 && raw[index - 1] != b'-')
     61             });
     62         if !valid {
     63             return Err(Error::InvalidTransportKind {
     64                 value: value.to_string(),
     65             });
     66         }
     67 
     68         let mut bytes = [0; MAX_TRANSPORT_KIND_BYTES];
     69         bytes[..raw.len()].copy_from_slice(raw);
     70         Ok(Self {
     71             bytes,
     72             len: raw.len() as u8,
     73         })
     74     }
     75 
     76     /// Returns the validated wire identity.
     77     pub fn as_str(&self) -> &str {
     78         core::str::from_utf8(&self.bytes[..usize::from(self.len)])
     79             .expect("TransportKind stores validated ASCII")
     80     }
     81 }
     82 
     83 impl fmt::Display for TransportKind {
     84     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     85         formatter.write_str(self.as_str())
     86     }
     87 }
     88 
     89 #[cfg(feature = "serde")]
     90 impl serde::Serialize for TransportKind {
     91     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
     92     where
     93         S: serde::Serializer,
     94     {
     95         serializer.serialize_str(self.as_str())
     96     }
     97 }
     98 
     99 #[cfg(feature = "serde")]
    100 impl<'de> serde::Deserialize<'de> for TransportKind {
    101     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    102     where
    103         D: serde::Deserializer<'de>,
    104     {
    105         let value = <String as serde::Deserialize>::deserialize(deserializer)?;
    106         Self::parse(value.as_str()).map_err(serde::de::Error::custom)
    107     }
    108 }
    109 
    110 /// Product maturity of a capability.
    111 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    112 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    113 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    114 pub enum Maturity {
    115     /// Supported only as an experimental contract.
    116     Experimental,
    117     /// Supported as a preview contract.
    118     Preview,
    119     /// Supported as a stable contract.
    120     Stable,
    121 }
    122 
    123 /// Current availability of a capability.
    124 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    125 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    126 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    127 pub enum Availability {
    128     /// Fully available.
    129     Available,
    130     /// Available with reduced functionality.
    131     Degraded,
    132     /// Not currently available.
    133     Unavailable,
    134 }
    135 
    136 /// Validated mesh-scope identifier.
    137 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    138 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    139 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    140 pub struct MeshScopeId {
    141     value: String,
    142 }
    143 
    144 impl MeshScopeId {
    145     /// Parses the existing V1 mesh-scope grammar.
    146     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
    147         let value = value.into();
    148         if value.is_empty()
    149             || value != value.trim()
    150             || value.chars().any(|character| {
    151                 !(character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.'))
    152             })
    153         {
    154             return Err(Error::InvalidMeshScopeId);
    155         }
    156         Ok(Self { value })
    157     }
    158 
    159     /// Returns the validated identifier.
    160     pub fn as_str(&self) -> &str {
    161         self.value.as_str()
    162     }
    163 }
    164 
    165 /// Validated Reticulum destination.
    166 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    167 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    168 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    169 pub struct ReticulumDestination {
    170     canonical: String,
    171 }
    172 
    173 impl ReticulumDestination {
    174     /// Parses the existing V1 Reticulum destination grammar.
    175     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
    176         let value = value.into();
    177         if value.is_empty()
    178             || value != value.trim()
    179             || value
    180                 .chars()
    181                 .any(|character| character.is_ascii_control() || character.is_ascii_whitespace())
    182         {
    183             return Err(Error::InvalidReticulumDestination);
    184         }
    185         Ok(Self { canonical: value })
    186     }
    187 
    188     /// Returns the canonical destination text.
    189     pub fn as_str(&self) -> &str {
    190         self.canonical.as_str()
    191     }
    192 }
    193 
    194 /// Passive Reticulum target DTO.
    195 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    196 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    197 #[derive(Clone, Debug, Eq, PartialEq)]
    198 pub struct ReticulumTarget {
    199     /// Canonical destination.
    200     pub destination: ReticulumDestination,
    201     /// Optional mesh scope.
    202     pub mesh_scope: Option<MeshScopeId>,
    203 }
    204 
    205 /// Passive capability descriptor DTO.
    206 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    207 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    208 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    209 pub struct TransportDescriptor {
    210     /// Transport family.
    211     pub kind: TransportKind,
    212     /// Product maturity.
    213     pub maturity: Maturity,
    214     /// Current availability.
    215     pub availability: Availability,
    216     /// Whether delivery is defined.
    217     pub can_deliver: bool,
    218     /// Whether fetch is defined.
    219     pub can_fetch: bool,
    220     /// Whether discovery is defined.
    221     pub can_discover: bool,
    222     /// Whether gateway forwarding is defined.
    223     pub can_gateway_forward: bool,
    224     /// Whether delivery receipts are observable.
    225     pub can_observe_receipts: bool,
    226     /// Whether Release V1 requires the transport contract.
    227     pub required_for_v1: bool,
    228 }
    229 
    230 /// Exact Release V1 transport capability catalog.
    231 pub const CATALOG: &[TransportDescriptor] = &[
    232     TransportDescriptor {
    233         kind: TransportKind::LOCAL,
    234         maturity: Maturity::Stable,
    235         availability: Availability::Available,
    236         can_deliver: true,
    237         can_fetch: true,
    238         can_discover: false,
    239         can_gateway_forward: false,
    240         can_observe_receipts: true,
    241         required_for_v1: true,
    242     },
    243     TransportDescriptor {
    244         kind: TransportKind::NOSTR,
    245         maturity: Maturity::Stable,
    246         availability: Availability::Available,
    247         can_deliver: true,
    248         can_fetch: true,
    249         can_discover: true,
    250         can_gateway_forward: false,
    251         can_observe_receipts: true,
    252         required_for_v1: true,
    253     },
    254     TransportDescriptor {
    255         kind: TransportKind::RETICULUM,
    256         maturity: Maturity::Preview,
    257         availability: Availability::Unavailable,
    258         can_deliver: true,
    259         can_fetch: false,
    260         can_discover: true,
    261         can_gateway_forward: true,
    262         can_observe_receipts: true,
    263         required_for_v1: true,
    264     },
    265 ];
    266 
    267 /// Exact schema identities retained from the predecessor package.
    268 pub const SCHEMAS: &[Metadata] = &[
    269     Metadata {
    270         type_name: "TransportKindV1",
    271         schema_id: "radroots.protocol.transport_kind.v1",
    272         schema_version: 1,
    273     },
    274     Metadata {
    275         type_name: "TransportCapabilityDescriptorV1",
    276         schema_id: "radroots.protocol.transport_capability_descriptor.v1",
    277         schema_version: 1,
    278     },
    279     Metadata {
    280         type_name: "ReticulumTargetV1",
    281         schema_id: "radroots.protocol.reticulum_target.v1",
    282         schema_version: 1,
    283     },
    284 ];
    285 
    286 /// Validates catalog uniqueness and required V1 membership.
    287 pub fn validate_catalog(descriptors: &[TransportDescriptor]) -> Result<(), Error> {
    288     for (index, descriptor) in descriptors.iter().enumerate() {
    289         if descriptors[..index]
    290             .iter()
    291             .any(|candidate| candidate.kind == descriptor.kind)
    292         {
    293             return Err(Error::DuplicateTransportKind {
    294                 kind: descriptor.kind,
    295             });
    296         }
    297     }
    298 
    299     for kind in [
    300         TransportKind::LOCAL,
    301         TransportKind::NOSTR,
    302         TransportKind::RETICULUM,
    303     ] {
    304         if !descriptors.iter().any(|descriptor| descriptor.kind == kind) {
    305             return Err(Error::MissingRequiredTransport { kind });
    306         }
    307     }
    308     Ok(())
    309 }
    310 
    311 /// Builds the validated capability schema registry.
    312 pub fn schema_registry() -> Result<Registry, crate::schema::Error> {
    313     Registry::try_from_metadata(
    314         SCHEMAS
    315             .iter()
    316             .copied()
    317             .map(|metadata| (metadata, ModuleVersion::CapabilityV1)),
    318     )
    319 }
    320 
    321 /// Capability V1 validation failure.
    322 #[derive(Clone, Debug, Eq, PartialEq)]
    323 #[non_exhaustive]
    324 pub enum Error {
    325     /// The transport identity is empty.
    326     EmptyTransportKind,
    327     /// The transport identity is not canonical or exceeds its bound.
    328     InvalidTransportKind {
    329         /// Rejected transport identity.
    330         value: String,
    331     },
    332     /// A mesh-scope identifier is malformed.
    333     InvalidMeshScopeId,
    334     /// A Reticulum destination is malformed.
    335     InvalidReticulumDestination,
    336     /// A transport appears more than once in a catalog.
    337     DuplicateTransportKind {
    338         /// Duplicated transport family.
    339         kind: TransportKind,
    340     },
    341     /// A required V1 transport is absent.
    342     MissingRequiredTransport {
    343         /// Missing transport family.
    344         kind: TransportKind,
    345     },
    346 }
    347 
    348 impl fmt::Display for Error {
    349     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    350         match self {
    351             Self::EmptyTransportKind => formatter.write_str("transport kind is empty"),
    352             Self::InvalidTransportKind { value } => {
    353                 write!(formatter, "invalid transport kind {value}")
    354             }
    355             Self::InvalidMeshScopeId => formatter.write_str("invalid mesh scope id"),
    356             Self::InvalidReticulumDestination => {
    357                 formatter.write_str("invalid Reticulum destination")
    358             }
    359             Self::DuplicateTransportKind { kind } => {
    360                 write!(formatter, "duplicate transport kind {}", kind.as_str())
    361             }
    362             Self::MissingRequiredTransport { kind } => {
    363                 write!(formatter, "missing required transport {}", kind.as_str())
    364             }
    365         }
    366     }
    367 }
    368 
    369 #[cfg(feature = "std")]
    370 impl std::error::Error for Error {}
    371 
    372 #[cfg(test)]
    373 mod tests {
    374     use super::*;
    375 
    376     #[test]
    377     fn catalog_and_schema_registry_validate() {
    378         validate_catalog(CATALOG).expect("catalog");
    379         let registry = schema_registry().expect("schema registry");
    380         assert_eq!(registry.len(), SCHEMAS.len());
    381         assert!(
    382             registry
    383                 .descriptors()
    384                 .iter()
    385                 .all(|descriptor| descriptor.module() == ModuleVersion::CapabilityV1)
    386         );
    387     }
    388 
    389     #[test]
    390     fn transport_kind_accepts_built_ins_and_forward_compatible_values() {
    391         for (value, expected) in [
    392             ("local", TransportKind::LOCAL),
    393             ("nostr", TransportKind::NOSTR),
    394             ("reticulum", TransportKind::RETICULUM),
    395             ("radrootsd", TransportKind::RADROOTSD),
    396         ] {
    397             assert_eq!(TransportKind::parse(value), Ok(expected));
    398             assert_eq!(expected.as_str(), value);
    399         }
    400         assert_eq!(
    401             TransportKind::parse("fieldbus-v2").unwrap().as_str(),
    402             "fieldbus-v2"
    403         );
    404         for invalid in ["", "NOSTR", " fieldbus", "fieldbus_2", "fieldbus--2"] {
    405             assert!(
    406                 TransportKind::parse(invalid).is_err(),
    407                 "accepted {invalid:?}"
    408             );
    409         }
    410         assert!(TransportKind::parse(&"a".repeat(MAX_TRANSPORT_KIND_BYTES + 1)).is_err());
    411     }
    412 
    413     #[test]
    414     fn other_capability_parsers_preserve_v1_diagnostics() {
    415         let scope = MeshScopeId::parse("farm.eu-1").expect("scope");
    416         assert_eq!(scope.as_str(), "farm.eu-1");
    417         let destination = ReticulumDestination::parse("reticulum:local").expect("destination");
    418         assert_eq!(destination.as_str(), "reticulum:local");
    419         for invalid in ["", " scope", "scope ", "scope/name", "scope\nname"] {
    420             assert_eq!(MeshScopeId::parse(invalid), Err(Error::InvalidMeshScopeId));
    421         }
    422         for invalid in [
    423             "",
    424             " destination",
    425             "destination ",
    426             "dest ination",
    427             "dest\nination",
    428         ] {
    429             assert_eq!(
    430                 ReticulumDestination::parse(invalid),
    431                 Err(Error::InvalidReticulumDestination)
    432             );
    433         }
    434         assert_eq!(
    435             MeshScopeId::parse("local/scope")
    436                 .expect_err("invalid scope")
    437                 .to_string(),
    438             "invalid mesh scope id"
    439         );
    440         assert_eq!(
    441             ReticulumDestination::parse("reticulum:\nlocal")
    442                 .expect_err("invalid destination")
    443                 .to_string(),
    444             "invalid Reticulum destination"
    445         );
    446     }
    447 
    448     #[test]
    449     fn catalog_rejects_duplicates_and_missing_required_transports() {
    450         assert_eq!(
    451             validate_catalog(&[CATALOG[0], CATALOG[0]]),
    452             Err(Error::DuplicateTransportKind {
    453                 kind: TransportKind::LOCAL,
    454             })
    455         );
    456         assert_eq!(
    457             validate_catalog(&[CATALOG[1], CATALOG[2]]),
    458             Err(Error::MissingRequiredTransport {
    459                 kind: TransportKind::LOCAL,
    460             })
    461         );
    462         assert_eq!(
    463             validate_catalog(&[CATALOG[0], CATALOG[2]]),
    464             Err(Error::MissingRequiredTransport {
    465                 kind: TransportKind::NOSTR
    466             })
    467         );
    468         assert_eq!(
    469             validate_catalog(&[CATALOG[0], CATALOG[1]]),
    470             Err(Error::MissingRequiredTransport {
    471                 kind: TransportKind::RETICULUM
    472             })
    473         );
    474 
    475         let errors = [
    476             Error::EmptyTransportKind,
    477             Error::InvalidTransportKind {
    478                 value: "BAD".to_owned(),
    479             },
    480             Error::InvalidMeshScopeId,
    481             Error::InvalidReticulumDestination,
    482             Error::DuplicateTransportKind {
    483                 kind: TransportKind::LOCAL,
    484             },
    485             Error::MissingRequiredTransport {
    486                 kind: TransportKind::NOSTR,
    487             },
    488         ];
    489         for error in errors {
    490             assert!(!error.to_string().is_empty());
    491         }
    492     }
    493 
    494     #[cfg(feature = "serde")]
    495     #[test]
    496     fn capability_identifiers_round_trip_through_json() {
    497         let custom = TransportKind::parse("fieldbus-v2").expect("custom");
    498         assert_eq!(custom.to_string(), "fieldbus-v2");
    499         let encoded = serde_json::to_string(&custom).expect("encode");
    500         assert_eq!(
    501             serde_json::from_str::<TransportKind>(&encoded).expect("decode"),
    502             custom
    503         );
    504         assert!(serde_json::from_str::<TransportKind>("\"BAD\"").is_err());
    505 
    506         let target = ReticulumTarget {
    507             destination: ReticulumDestination::parse("reticulum:local").expect("destination"),
    508             mesh_scope: Some(MeshScopeId::parse("farm-1").expect("scope")),
    509         };
    510         let value = serde_json::to_value(&target).expect("target JSON");
    511         assert_eq!(
    512             serde_json::from_value::<ReticulumTarget>(value).expect("target decode"),
    513             target
    514         );
    515     }
    516 }