tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

tenant.rs (10199B)


      1 #![forbid(unsafe_code)]
      2 
      3 use crate::errors::BaseRelayError;
      4 use std::fmt;
      5 
      6 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
      7 pub struct TenantId(String);
      8 
      9 impl TenantId {
     10     pub const MAX_LENGTH: usize = 64;
     11 
     12     pub fn new(value: impl Into<String>) -> Result<Self, BaseRelayError> {
     13         let value = value.into();
     14         validate_identifier(
     15             "tenant_id",
     16             &value,
     17             Self::MAX_LENGTH,
     18             IdentifierAlphabet::TenantId,
     19         )?;
     20         Ok(Self(value))
     21     }
     22 
     23     pub fn as_str(&self) -> &str {
     24         &self.0
     25     }
     26 }
     27 
     28 impl fmt::Display for TenantId {
     29     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     30         formatter.write_str(&self.0)
     31     }
     32 }
     33 
     34 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
     35 pub struct TenantSchema(String);
     36 
     37 impl TenantSchema {
     38     pub const MAX_LENGTH: usize = 64;
     39 
     40     pub fn new(value: impl Into<String>) -> Result<Self, BaseRelayError> {
     41         let value = value.into();
     42         validate_identifier(
     43             "tenant_schema",
     44             &value,
     45             Self::MAX_LENGTH,
     46             IdentifierAlphabet::TenantSchema,
     47         )?;
     48         Ok(Self(value))
     49     }
     50 
     51     pub fn as_str(&self) -> &str {
     52         &self.0
     53     }
     54 }
     55 
     56 impl fmt::Display for TenantSchema {
     57     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     58         formatter.write_str(&self.0)
     59     }
     60 }
     61 
     62 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
     63 pub struct CanonicalHost(String);
     64 
     65 impl CanonicalHost {
     66     pub fn new(value: impl AsRef<str>) -> Result<Self, BaseRelayError> {
     67         let raw = value.as_ref();
     68         let trimmed = raw.trim();
     69         if trimmed.is_empty() {
     70             return Err(BaseRelayError::invalid("host must not be empty"));
     71         }
     72         if trimmed != raw {
     73             return Err(BaseRelayError::invalid(
     74                 "host must not contain leading or trailing whitespace",
     75             ));
     76         }
     77         if trimmed.contains("://") {
     78             return Err(BaseRelayError::invalid(
     79                 "host must not include a URL scheme",
     80             ));
     81         }
     82         if trimmed.chars().any(|character| {
     83             character.is_whitespace() || matches!(character, '/' | '\\' | '?' | '#' | '@')
     84         }) {
     85             return Err(BaseRelayError::invalid(
     86                 "host must not contain whitespace, path, query, fragment, or credentials",
     87             ));
     88         }
     89         if trimmed.contains('[') || trimmed.contains(']') {
     90             return Err(BaseRelayError::invalid(
     91                 "host must use a DNS name or IPv4 address with optional port",
     92             ));
     93         }
     94         let lowercase = trimmed.to_ascii_lowercase();
     95         let (host, port) = split_host_port(&lowercase)?;
     96         validate_host_name(host)?;
     97         if let Some(port) = port {
     98             validate_port(port)?;
     99         }
    100         Ok(Self(lowercase))
    101     }
    102 
    103     pub fn as_str(&self) -> &str {
    104         &self.0
    105     }
    106 }
    107 
    108 impl fmt::Display for CanonicalHost {
    109     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    110         formatter.write_str(&self.0)
    111     }
    112 }
    113 
    114 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
    115 pub struct TenantRelayUrl(String);
    116 
    117 impl TenantRelayUrl {
    118     pub fn new(value: impl AsRef<str>) -> Result<Self, BaseRelayError> {
    119         let raw = value.as_ref();
    120         let trimmed = raw.trim();
    121         if trimmed.is_empty() {
    122             return Err(BaseRelayError::invalid("relay_url must not be empty"));
    123         }
    124         if trimmed != raw {
    125             return Err(BaseRelayError::invalid(
    126                 "relay_url must not contain leading or trailing whitespace",
    127             ));
    128         }
    129         if trimmed.chars().any(char::is_whitespace) {
    130             return Err(BaseRelayError::invalid(
    131                 "relay_url must not contain whitespace",
    132             ));
    133         }
    134         let (scheme, rest) = trimmed
    135             .split_once("://")
    136             .ok_or_else(|| BaseRelayError::invalid("relay_url must include ws:// or wss://"))?;
    137         let scheme = match scheme {
    138             "ws" => "ws",
    139             "wss" => "wss",
    140             _ => {
    141                 return Err(BaseRelayError::invalid(
    142                     "relay_url must start with ws:// or wss://",
    143                 ));
    144             }
    145         };
    146         let authority = rest.split('/').next().unwrap_or_default();
    147         if authority.is_empty() {
    148             return Err(BaseRelayError::invalid("relay_url host must not be empty"));
    149         }
    150         let host = CanonicalHost::new(authority)?;
    151         let suffix = rest
    152             .strip_prefix(authority)
    153             .expect("authority came from rest prefix");
    154         Ok(Self(format!("{scheme}://{}{}", host.as_str(), suffix)))
    155     }
    156 
    157     pub fn as_str(&self) -> &str {
    158         &self.0
    159     }
    160 }
    161 
    162 impl fmt::Display for TenantRelayUrl {
    163     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    164         formatter.write_str(&self.0)
    165     }
    166 }
    167 
    168 #[derive(Clone, Copy)]
    169 enum IdentifierAlphabet {
    170     TenantId,
    171     TenantSchema,
    172 }
    173 
    174 fn validate_identifier(
    175     field: &str,
    176     value: &str,
    177     max_length: usize,
    178     alphabet: IdentifierAlphabet,
    179 ) -> Result<(), BaseRelayError> {
    180     if value.is_empty() {
    181         return Err(BaseRelayError::invalid(format!(
    182             "{field} must not be empty"
    183         )));
    184     }
    185     if value.len() > max_length {
    186         return Err(BaseRelayError::invalid(format!(
    187             "{field} must be {max_length} bytes or less"
    188         )));
    189     }
    190     if value.trim() != value {
    191         return Err(BaseRelayError::invalid(format!(
    192             "{field} must not contain leading or trailing whitespace"
    193         )));
    194     }
    195     let Some(first) = value.as_bytes().first().copied() else {
    196         return Err(BaseRelayError::invalid(format!(
    197             "{field} must not be empty"
    198         )));
    199     };
    200     if !first.is_ascii_lowercase() {
    201         return Err(BaseRelayError::invalid(format!(
    202             "{field} must start with a lowercase ASCII letter"
    203         )));
    204     }
    205     let valid = value.bytes().all(|byte| match alphabet {
    206         IdentifierAlphabet::TenantId => {
    207             byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-'
    208         }
    209         IdentifierAlphabet::TenantSchema => {
    210             byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
    211         }
    212     });
    213     if !valid {
    214         return Err(BaseRelayError::invalid(format!(
    215             "{field} must contain only lowercase ASCII letters, digits, and approved separators"
    216         )));
    217     }
    218     Ok(())
    219 }
    220 
    221 fn split_host_port(host: &str) -> Result<(&str, Option<&str>), BaseRelayError> {
    222     if host.matches(':').count() > 1 {
    223         return Err(BaseRelayError::invalid(
    224             "host must not contain multiple port separators",
    225         ));
    226     }
    227     if let Some((name, port)) = host.rsplit_once(':') {
    228         if name.is_empty() || port.is_empty() {
    229             return Err(BaseRelayError::invalid(
    230                 "host and port must both be present",
    231             ));
    232         }
    233         Ok((name, Some(port)))
    234     } else {
    235         Ok((host, None))
    236     }
    237 }
    238 
    239 fn validate_host_name(host: &str) -> Result<(), BaseRelayError> {
    240     if host.is_empty() {
    241         return Err(BaseRelayError::invalid("host name must not be empty"));
    242     }
    243     for label in host.split('.') {
    244         if label.is_empty() {
    245             return Err(BaseRelayError::invalid("host labels must not be empty"));
    246         }
    247         if label.starts_with('-') || label.ends_with('-') {
    248             return Err(BaseRelayError::invalid(
    249                 "host labels must not start or end with hyphen",
    250             ));
    251         }
    252         if !label
    253             .bytes()
    254             .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
    255         {
    256             return Err(BaseRelayError::invalid(
    257                 "host labels must contain only lowercase ASCII letters, digits, and hyphen",
    258             ));
    259         }
    260     }
    261     Ok(())
    262 }
    263 
    264 fn validate_port(port: &str) -> Result<(), BaseRelayError> {
    265     if !port.bytes().all(|byte| byte.is_ascii_digit()) {
    266         return Err(BaseRelayError::invalid("host port must be numeric"));
    267     }
    268     let parsed = port
    269         .parse::<u16>()
    270         .map_err(|_| BaseRelayError::invalid("host port must be between 1 and 65535"))?;
    271     if parsed == 0 {
    272         return Err(BaseRelayError::invalid(
    273             "host port must be between 1 and 65535",
    274         ));
    275     }
    276     Ok(())
    277 }
    278 
    279 #[cfg(test)]
    280 mod tests {
    281     use super::{CanonicalHost, TenantId, TenantRelayUrl, TenantSchema};
    282 
    283     #[test]
    284     fn tenant_identity_types_accept_canonical_values() {
    285         assert_eq!(
    286             TenantId::new("farmers-market").expect("id").as_str(),
    287             "farmers-market"
    288         );
    289         assert_eq!(
    290             TenantSchema::new("farmers_market")
    291                 .expect("schema")
    292                 .as_str(),
    293             "farmers_market"
    294         );
    295         assert_eq!(
    296             CanonicalHost::new("Relay.Example.TEST:8083")
    297                 .expect("host")
    298                 .as_str(),
    299             "relay.example.test:8083"
    300         );
    301         assert_eq!(
    302             TenantRelayUrl::new("wss://Relay.Example.TEST:443/groups")
    303                 .expect("url")
    304                 .as_str(),
    305             "wss://relay.example.test:443/groups"
    306         );
    307     }
    308 
    309     #[test]
    310     fn tenant_identity_types_reject_noncanonical_values() {
    311         for value in ["", "Farm", "farm space", "farm.market"] {
    312             assert!(TenantId::new(value).is_err());
    313         }
    314         for value in ["", "farm-market", "Farm", "_farm"] {
    315             assert!(TenantSchema::new(value).is_err());
    316         }
    317         for value in [
    318             "",
    319             " https://relay.example.test",
    320             "https://relay.example.test",
    321             "relay.example.test/path",
    322             "user@relay.example.test",
    323         ] {
    324             assert!(CanonicalHost::new(value).is_err());
    325         }
    326         for value in [
    327             "",
    328             "http://relay.example.test",
    329             "wss://",
    330             "wss://user@relay.example.test",
    331             "wss://relay example.test",
    332         ] {
    333             assert!(TenantRelayUrl::new(value).is_err());
    334         }
    335     }
    336 }