lib

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

commit d2d2b7ba342c3215dbf40fbfa7132cd895a5594c
parent a1f46d6bd6a632971de82903f6395da93688836e
Author: triesap <tyson@radroots.org>
Date:   Fri, 12 Jun 2026 21:39:47 -0700

events: add protocol identifier value types

Diffstat:
Acrates/events/src/ids.rs | 400+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/lib.rs | 1+
2 files changed, 401 insertions(+), 0 deletions(-)

diff --git a/crates/events/src/ids.rs b/crates/events/src/ids.rs @@ -0,0 +1,400 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, string::ToString, vec::Vec}; + +#[cfg(feature = "std")] +use std::{string::String, vec::Vec}; + +use core::{borrow::Borrow, fmt, str::FromStr}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsIdParseError { + Empty, + InvalidFormat, + InvalidLength { expected: usize, actual: usize }, + InvalidCharacter, + TooLong { max: usize, actual: usize }, +} + +impl fmt::Display for RadrootsIdParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "identifier is empty"), + Self::InvalidFormat => write!(f, "identifier has invalid format"), + Self::InvalidLength { expected, actual } => { + write!( + f, + "identifier length {actual} does not match required length {expected}" + ) + } + Self::InvalidCharacter => write!(f, "identifier contains an invalid character"), + Self::TooLong { max, actual } => { + write!(f, "identifier length {actual} exceeds maximum length {max}") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsIdParseError {} + +macro_rules! validated_string_id { + ($name:ident, $validator:ident) => { + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(String); + + impl $name { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsIdParseError> { + $validator(value.as_ref()).map(Self) + } + + #[inline] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + #[inline] + pub fn into_string(self) -> String { + self.0 + } + } + + impl AsRef<str> for $name { + #[inline] + fn as_ref(&self) -> &str { + self.as_str() + } + } + + impl Borrow<str> for $name { + #[inline] + fn borrow(&self) -> &str { + self.as_str() + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } + } + + impl FromStr for $name { + type Err = RadrootsIdParseError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } + } + + impl TryFrom<&str> for $name { + type Error = RadrootsIdParseError; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::parse(value) + } + } + + impl TryFrom<String> for $name { + type Error = RadrootsIdParseError; + + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::parse(value) + } + } + + #[cfg(feature = "serde")] + impl serde::Serialize for $name { + 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 $name { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } + } + }; +} + +validated_string_id!(RadrootsPublicKey, validate_hex_64); +validated_string_id!(RadrootsEventId, validate_hex_64); +validated_string_id!(RadrootsEventSignature, validate_hex_128); +validated_string_id!(RadrootsDTag, validate_d_tag); +validated_string_id!( + RadrootsAddressableCoordinate, + validate_addressable_coordinate +); +validated_string_id!(RadrootsListingAddress, validate_addressable_coordinate); +validated_string_id!(RadrootsOrderId, validate_commercial_id); +validated_string_id!(RadrootsOrderRevisionId, validate_commercial_id); +validated_string_id!(RadrootsOrderQuoteId, validate_commercial_id); +validated_string_id!(RadrootsInventoryBinId, validate_commercial_id); +validated_string_id!(RadrootsEconomicsDigest, validate_economics_digest); +validated_string_id!(RadrootsEventPointer, validate_hex_64); + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsNostrEventPointer { + pub event_id: RadrootsEventId, + pub relays: Vec<String>, +} + +impl RadrootsNostrEventPointer { + pub fn new<I, S>(event_id: RadrootsEventId, relays: I) -> Result<Self, RadrootsIdParseError> + where + I: IntoIterator<Item = S>, + S: Into<String>, + { + let mut canonical_relays = Vec::new(); + for relay in relays { + let relay = relay.into(); + if relay.is_empty() + || relay.trim() != relay + || relay.chars().any(|character| character.is_control()) + { + return Err(RadrootsIdParseError::InvalidCharacter); + } + canonical_relays.push(relay); + } + Ok(Self { + event_id, + relays: canonical_relays, + }) + } +} + +fn validate_hex_64(value: &str) -> Result<String, RadrootsIdParseError> { + validate_hex(value, 64) +} + +fn validate_hex_128(value: &str) -> Result<String, RadrootsIdParseError> { + validate_hex(value, 128) +} + +fn validate_hex(value: &str, expected_len: usize) -> Result<String, RadrootsIdParseError> { + if value.len() != expected_len { + return Err(RadrootsIdParseError::InvalidLength { + expected: expected_len, + actual: value.len(), + }); + } + + let mut canonical = String::with_capacity(expected_len); + for byte in value.bytes() { + match byte { + b'0'..=b'9' => canonical.push(byte as char), + b'a'..=b'f' => canonical.push(byte as char), + b'A'..=b'F' => canonical.push((byte + 32) as char), + _ => return Err(RadrootsIdParseError::InvalidCharacter), + } + } + Ok(canonical) +} + +fn validate_d_tag(value: &str) -> Result<String, RadrootsIdParseError> { + validate_visible_token(value, 512) +} + +fn validate_commercial_id(value: &str) -> Result<String, RadrootsIdParseError> { + validate_visible_token(value, 128) +} + +fn validate_economics_digest(value: &str) -> Result<String, RadrootsIdParseError> { + if let Some(hex) = value.strip_prefix("sha256:") { + validate_hex(hex, 64)?; + return Ok(value.to_string()); + } + validate_visible_token(value, 128) +} + +fn validate_addressable_coordinate(value: &str) -> Result<String, RadrootsIdParseError> { + let (kind, remainder) = value + .split_once(':') + .ok_or(RadrootsIdParseError::InvalidFormat)?; + let (pubkey, d_tag) = remainder + .split_once(':') + .ok_or(RadrootsIdParseError::InvalidFormat)?; + kind.parse::<u32>() + .map_err(|_| RadrootsIdParseError::InvalidFormat)?; + validate_hex_64(pubkey)?; + validate_d_tag(d_tag)?; + Ok(value.to_string()) +} + +fn validate_visible_token(value: &str, max_len: usize) -> Result<String, RadrootsIdParseError> { + if value.is_empty() { + return Err(RadrootsIdParseError::Empty); + } + if value.len() > max_len { + return Err(RadrootsIdParseError::TooLong { + max: max_len, + actual: value.len(), + }); + } + if value.trim() != value + || value + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(RadrootsIdParseError::InvalidCharacter); + } + Ok(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex_64(character: char) -> String { + core::iter::repeat_n(character, 64).collect() + } + + fn hex_128(character: char) -> String { + core::iter::repeat_n(character, 128).collect() + } + + #[test] + fn public_keys_and_event_ids_require_64_hex_chars() { + let upper = "A".repeat(64); + let public_key = RadrootsPublicKey::parse(&upper).expect("public key"); + assert_eq!(public_key.as_str(), "a".repeat(64)); + + let event_id = RadrootsEventId::parse(hex_64('f')).expect("event id"); + assert_eq!(event_id.as_str(), hex_64('f')); + assert_eq!( + RadrootsEventId::parse(" ".repeat(64)).unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); + assert_eq!( + RadrootsEventId::parse("a".repeat(63)).unwrap_err(), + RadrootsIdParseError::InvalidLength { + expected: 64, + actual: 63 + } + ); + } + + #[test] + fn signatures_require_128_hex_chars() { + let signature = RadrootsEventSignature::parse(hex_128('B')).expect("signature"); + assert_eq!(signature.as_str(), "b".repeat(128)); + assert_eq!( + RadrootsEventSignature::parse(hex_64('b')).unwrap_err(), + RadrootsIdParseError::InvalidLength { + expected: 128, + actual: 64 + } + ); + } + + #[test] + fn d_tags_reject_empty_control_and_whitespace() { + assert_eq!( + RadrootsDTag::parse("").unwrap_err(), + RadrootsIdParseError::Empty + ); + assert_eq!( + RadrootsDTag::parse(" listing").unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); + assert_eq!( + RadrootsDTag::parse("listing\none").unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); + assert_eq!( + RadrootsDTag::parse("farm:farm-1:members") + .expect("d tag") + .as_str(), + "farm:farm-1:members" + ); + } + + #[test] + fn addressable_coordinates_validate_kind_pubkey_and_d_tag() { + let addr = format!("30402:{}:listing-1", hex_64('0')); + assert_eq!( + RadrootsAddressableCoordinate::parse(&addr) + .expect("coordinate") + .as_str(), + addr + ); + assert_eq!( + RadrootsListingAddress::parse("30402:not_hex:listing-1").unwrap_err(), + RadrootsIdParseError::InvalidLength { + expected: 64, + actual: 7 + } + ); + } + + #[test] + fn commercial_ids_reject_empty_whitespace_control_and_long_values() { + assert_eq!( + RadrootsOrderId::parse("order-1") + .expect("order id") + .as_str(), + "order-1" + ); + assert_eq!( + RadrootsOrderRevisionId::parse("rev 1").unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); + assert_eq!( + RadrootsInventoryBinId::parse("a".repeat(129)).unwrap_err(), + RadrootsIdParseError::TooLong { + max: 128, + actual: 129 + } + ); + } + + #[test] + fn economics_digest_accepts_sha256_and_existing_wire_tokens() { + let digest = format!("sha256:{}", hex_64('c')); + assert_eq!( + RadrootsEconomicsDigest::parse(&digest) + .expect("digest") + .as_str(), + digest + ); + assert_eq!( + RadrootsEconomicsDigest::parse("digest-1") + .expect("wire v1 digest") + .as_str(), + "digest-1" + ); + } + + #[test] + fn validated_types_do_not_offer_infallible_string_conversion() { + let id = RadrootsOrderQuoteId::try_from(String::from("quote-1")).expect("quote id"); + assert_eq!(id.as_ref(), "quote-1"); + let parsed: RadrootsEventPointer = hex_64('d').parse().expect("event pointer"); + assert_eq!(parsed.as_str(), hex_64('d')); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialization_validates_identifiers() { + let encoded = format!("\"{}\"", hex_64('E')); + let event_id: RadrootsEventId = serde_json::from_str(&encoded).expect("event id"); + assert_eq!(event_id.as_str(), hex_64('e')); + + let invalid = serde_json::from_str::<RadrootsOrderId>("\"bad id\""); + assert!(invalid.is_err()); + assert_eq!( + serde_json::to_string(&event_id).expect("json"), + format!("\"{}\"", hex_64('e')) + ); + } +} diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs @@ -24,6 +24,7 @@ pub mod geochat; pub mod gift_wrap; pub mod group; pub mod http_auth; +pub mod ids; pub mod job; pub mod job_feedback; pub mod job_request;