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 }