lib

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

identifier.rs (11094B)


      1 //! Validated identifiers for canonical service-instance paths.
      2 
      3 use core::{fmt, str::FromStr};
      4 
      5 use serde::{Deserialize, Deserializer, Serialize, Serializer};
      6 use thiserror::Error;
      7 
      8 /// Maximum encoded length of a service identifier.
      9 pub const SERVICE_ID_MAX_BYTES: usize = 128;
     10 
     11 /// Maximum encoded length of an instance identifier.
     12 pub const INSTANCE_ID_MAX_BYTES: usize = 128;
     13 
     14 /// Identifies which service-instance path component failed validation.
     15 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     16 pub enum ServiceIdentityKind {
     17     Service,
     18     Instance,
     19 }
     20 
     21 impl fmt::Display for ServiceIdentityKind {
     22     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     23         match self {
     24             Self::Service => formatter.write_str("service"),
     25             Self::Instance => formatter.write_str("instance"),
     26         }
     27     }
     28 }
     29 
     30 /// Validation failure for a service or instance identifier.
     31 #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
     32 pub enum ServiceIdentityError {
     33     #[error("{kind} identifier must not be empty")]
     34     Empty { kind: ServiceIdentityKind },
     35     #[error("{kind} identifier exceeds its {maximum}-byte limit")]
     36     TooLong {
     37         kind: ServiceIdentityKind,
     38         maximum: usize,
     39     },
     40     #[error("{kind} identifier must start and end with a lowercase ASCII letter or digit")]
     41     InvalidBoundary { kind: ServiceIdentityKind },
     42     #[error("{kind} identifier contains a forbidden character")]
     43     InvalidCharacter { kind: ServiceIdentityKind },
     44 }
     45 
     46 fn validate(
     47     value: &str,
     48     kind: ServiceIdentityKind,
     49     maximum: usize,
     50 ) -> Result<(), ServiceIdentityError> {
     51     if value.is_empty() {
     52         return Err(ServiceIdentityError::Empty { kind });
     53     }
     54     if value.len() > maximum {
     55         return Err(ServiceIdentityError::TooLong { kind, maximum });
     56     }
     57 
     58     let is_alphanumeric = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
     59     let bytes = value.as_bytes();
     60     if !is_alphanumeric(bytes[0]) || !is_alphanumeric(bytes[bytes.len() - 1]) {
     61         return Err(ServiceIdentityError::InvalidBoundary { kind });
     62     }
     63     if !bytes
     64         .iter()
     65         .all(|byte| is_alphanumeric(*byte) || matches!(*byte, b'-' | b'_'))
     66     {
     67         return Err(ServiceIdentityError::InvalidCharacter { kind });
     68     }
     69 
     70     Ok(())
     71 }
     72 
     73 macro_rules! service_identity {
     74     ($name:ident, $kind:expr, $maximum:ident) => {
     75         #[doc = concat!("A validated canonical ", stringify!($name), " path component.")]
     76         #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
     77         pub struct $name(String);
     78 
     79         impl $name {
     80             /// Parses and validates a canonical identifier.
     81             pub fn new(value: impl AsRef<str>) -> Result<Self, ServiceIdentityError> {
     82                 let value = value.as_ref();
     83                 validate(value, $kind, $maximum)?;
     84                 Ok(Self(value.to_owned()))
     85             }
     86 
     87             fn from_string(value: String) -> Result<Self, ServiceIdentityError> {
     88                 validate(&value, $kind, $maximum)?;
     89                 Ok(Self(value))
     90             }
     91 
     92             /// Returns the canonical identifier text.
     93             #[must_use]
     94             pub fn as_str(&self) -> &str {
     95                 self.0.as_str()
     96             }
     97 
     98             /// Consumes the identifier and returns its canonical text.
     99             #[must_use]
    100             pub fn into_string(self) -> String {
    101                 self.0
    102             }
    103         }
    104 
    105         impl AsRef<str> for $name {
    106             fn as_ref(&self) -> &str {
    107                 self.as_str()
    108             }
    109         }
    110 
    111         impl fmt::Display for $name {
    112             fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    113                 formatter.write_str(self.as_str())
    114             }
    115         }
    116 
    117         impl FromStr for $name {
    118             type Err = ServiceIdentityError;
    119 
    120             fn from_str(value: &str) -> Result<Self, Self::Err> {
    121                 Self::new(value)
    122             }
    123         }
    124 
    125         impl TryFrom<String> for $name {
    126             type Error = ServiceIdentityError;
    127 
    128             fn try_from(value: String) -> Result<Self, Self::Error> {
    129                 Self::from_string(value)
    130             }
    131         }
    132 
    133         impl From<$name> for String {
    134             fn from(value: $name) -> Self {
    135                 value.into_string()
    136             }
    137         }
    138 
    139         impl Serialize for $name {
    140             fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    141             where
    142                 S: Serializer,
    143             {
    144                 serializer.serialize_str(self.as_str())
    145             }
    146         }
    147 
    148         impl<'de> Deserialize<'de> for $name {
    149             fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    150             where
    151                 D: Deserializer<'de>,
    152             {
    153                 struct Visitor;
    154 
    155                 impl<'de> serde::de::Visitor<'de> for Visitor {
    156                     type Value = $name;
    157 
    158                     fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    159                         formatter.write_str("a bounded canonical service identity")
    160                     }
    161 
    162                     fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
    163                     where
    164                         E: serde::de::Error,
    165                     {
    166                         $name::new(value).map_err(E::custom)
    167                     }
    168 
    169                     fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
    170                     where
    171                         E: serde::de::Error,
    172                     {
    173                         $name::from_string(value).map_err(E::custom)
    174                     }
    175                 }
    176 
    177                 deserializer.deserialize_str(Visitor)
    178             }
    179         }
    180     };
    181 }
    182 
    183 service_identity!(
    184     ServiceId,
    185     ServiceIdentityKind::Service,
    186     SERVICE_ID_MAX_BYTES
    187 );
    188 service_identity!(
    189     InstanceId,
    190     ServiceIdentityKind::Instance,
    191     INSTANCE_ID_MAX_BYTES
    192 );
    193 
    194 #[cfg(test)]
    195 mod tests {
    196     use super::*;
    197 
    198     #[test]
    199     fn identifiers_accept_exact_boundaries_and_display_canonically() {
    200         for service in ["a", "myc", "farm_service", "service-01"] {
    201             let id = ServiceId::new(service).expect("valid service id");
    202             assert_eq!(id.as_str(), service);
    203             assert_eq!(id.to_string(), service);
    204         }
    205         for instance in ["0", "default", "north_farm", "west-01"] {
    206             let id = InstanceId::new(instance).expect("valid instance id");
    207             assert_eq!(id.as_str(), instance);
    208             assert_eq!(id.to_string(), instance);
    209         }
    210 
    211         assert!(ServiceId::new("a".repeat(SERVICE_ID_MAX_BYTES)).is_ok());
    212         assert!(InstanceId::new("a".repeat(INSTANCE_ID_MAX_BYTES)).is_ok());
    213     }
    214 
    215     #[test]
    216     fn identifiers_reject_empty_overlong_and_noncanonical_text() {
    217         assert_eq!(
    218             ServiceId::new(""),
    219             Err(ServiceIdentityError::Empty {
    220                 kind: ServiceIdentityKind::Service
    221             })
    222         );
    223         assert_eq!(
    224             InstanceId::new(""),
    225             Err(ServiceIdentityError::Empty {
    226                 kind: ServiceIdentityKind::Instance
    227             })
    228         );
    229         assert_eq!(
    230             ServiceId::new("a".repeat(SERVICE_ID_MAX_BYTES + 1)),
    231             Err(ServiceIdentityError::TooLong {
    232                 kind: ServiceIdentityKind::Service,
    233                 maximum: SERVICE_ID_MAX_BYTES,
    234             })
    235         );
    236         assert_eq!(
    237             InstanceId::new("a".repeat(INSTANCE_ID_MAX_BYTES + 1)),
    238             Err(ServiceIdentityError::TooLong {
    239                 kind: ServiceIdentityKind::Instance,
    240                 maximum: INSTANCE_ID_MAX_BYTES,
    241             })
    242         );
    243         let very_large = "a".repeat(4 * 1024 * 1024);
    244         assert!(matches!(
    245             ServiceId::new(&very_large),
    246             Err(ServiceIdentityError::TooLong {
    247                 kind: ServiceIdentityKind::Service,
    248                 maximum: SERVICE_ID_MAX_BYTES,
    249             })
    250         ));
    251         assert!(matches!(
    252             InstanceId::new(&very_large),
    253             Err(ServiceIdentityError::TooLong {
    254                 kind: ServiceIdentityKind::Instance,
    255                 maximum: INSTANCE_ID_MAX_BYTES,
    256             })
    257         ));
    258 
    259         let service_json = serde_json::to_string(&very_large).expect("large service JSON");
    260         assert!(serde_json::from_str::<ServiceId>(&service_json).is_err());
    261         let instance_json = serde_json::to_string(&very_large).expect("large instance JSON");
    262         assert!(serde_json::from_str::<InstanceId>(&instance_json).is_err());
    263 
    264         for invalid in ["Myc", "café", "a.b", "a:b", "a b", "a%b", "a/b", r"a\b"] {
    265             assert!(ServiceId::new(invalid).is_err(), "accepted `{invalid}`");
    266             assert!(InstanceId::new(invalid).is_err(), "accepted `{invalid}`");
    267         }
    268         for invalid in ["-a", "a-", "_a", "a_"] {
    269             assert!(ServiceId::new(invalid).is_err(), "accepted `{invalid}`");
    270             assert!(InstanceId::new(invalid).is_err(), "accepted `{invalid}`");
    271         }
    272     }
    273 
    274     #[test]
    275     fn identifiers_reject_traversal_and_separators() {
    276         for invalid in [".", "..", "../a", "a/../b", r"..\a", "%2e%2e", "a//b"] {
    277             assert!(ServiceId::new(invalid).is_err(), "accepted `{invalid}`");
    278             assert!(InstanceId::new(invalid).is_err(), "accepted `{invalid}`");
    279         }
    280     }
    281 
    282     #[test]
    283     fn serde_round_trips_revalidate_identifiers() {
    284         let service = ServiceId::new("myc").expect("service id");
    285         let encoded = serde_json::to_string(&service).expect("serialize service id");
    286         assert_eq!(encoded, "\"myc\"");
    287         assert_eq!(
    288             serde_json::from_str::<ServiceId>(&encoded).expect("deserialize service id"),
    289             service
    290         );
    291 
    292         let instance = InstanceId::new("default-01").expect("instance id");
    293         let encoded = serde_json::to_string(&instance).expect("serialize instance id");
    294         assert_eq!(
    295             serde_json::from_str::<InstanceId>(&encoded).expect("deserialize instance id"),
    296             instance
    297         );
    298 
    299         assert!(serde_json::from_str::<ServiceId>("\"../myc\"").is_err());
    300         assert!(serde_json::from_str::<InstanceId>("\"UPPER\"").is_err());
    301     }
    302 
    303     #[test]
    304     fn identifier_conversion_traits_preserve_validated_text() {
    305         let service = "myc"
    306             .parse::<ServiceId>()
    307             .expect("parse service identifier");
    308         assert_eq!(service.as_ref(), "myc");
    309         assert_eq!(String::from(service), "myc");
    310 
    311         let service =
    312             ServiceId::try_from(String::from("rhi")).expect("convert owned service identifier");
    313         assert_eq!(service.into_string(), "rhi");
    314 
    315         let instance = "primary"
    316             .parse::<InstanceId>()
    317             .expect("parse instance identifier");
    318         assert_eq!(instance.as_ref(), "primary");
    319         assert_eq!(String::from(instance), "primary");
    320 
    321         let instance = InstanceId::try_from(String::from("secondary"))
    322             .expect("convert owned instance identifier");
    323         assert_eq!(instance.into_string(), "secondary");
    324     }
    325 }