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 }