PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.
at main 16 kB view raw
1use serde::{Deserialize, Serialize}; 2use std::fmt; 3use std::ops::Deref; 4 5pub const MAX_EMAIL_LENGTH: usize = 254; 6pub const MAX_LOCAL_PART_LENGTH: usize = 64; 7pub const MAX_DOMAIN_LENGTH: usize = 253; 8pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63; 9const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-"; 10 11pub const MIN_HANDLE_LENGTH: usize = 3; 12pub const MAX_HANDLE_LENGTH: usize = 253; 13pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18; 14 15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 16#[serde(try_from = "String", into = "String")] 17pub struct ValidatedLocalHandle(String); 18 19impl ValidatedLocalHandle { 20 pub fn new(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> { 21 let validated = validate_short_handle(handle.as_ref())?; 22 Ok(Self(validated)) 23 } 24 25 pub fn new_allow_reserved(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> { 26 let validated = validate_service_handle(handle.as_ref(), true)?; 27 Ok(Self(validated)) 28 } 29 30 pub fn as_str(&self) -> &str { 31 &self.0 32 } 33 34 pub fn into_inner(self) -> String { 35 self.0 36 } 37} 38 39impl Deref for ValidatedLocalHandle { 40 type Target = str; 41 fn deref(&self) -> &Self::Target { 42 &self.0 43 } 44} 45 46impl fmt::Display for ValidatedLocalHandle { 47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 48 write!(f, "{}", self.0) 49 } 50} 51 52impl TryFrom<String> for ValidatedLocalHandle { 53 type Error = HandleValidationError; 54 fn try_from(value: String) -> Result<Self, Self::Error> { 55 Self::new(value) 56 } 57} 58 59impl From<ValidatedLocalHandle> for String { 60 fn from(handle: ValidatedLocalHandle) -> Self { 61 handle.0 62 } 63} 64 65#[derive(Debug, Clone, PartialEq, Eq)] 66pub enum EmailValidationError { 67 Empty, 68 TooLong, 69 MissingAtSign, 70 EmptyLocalPart, 71 LocalPartTooLong, 72 InvalidLocalPart, 73 EmptyDomain, 74 DomainTooLong, 75 MissingDomainDot, 76 InvalidDomainLabel, 77} 78 79impl fmt::Display for EmailValidationError { 80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 81 match self { 82 Self::Empty => write!(f, "Email cannot be empty"), 83 Self::TooLong => write!( 84 f, 85 "Email exceeds maximum length of {} characters", 86 MAX_EMAIL_LENGTH 87 ), 88 Self::MissingAtSign => write!(f, "Email must contain @"), 89 Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"), 90 Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"), 91 Self::InvalidLocalPart => write!(f, "Email local part contains invalid characters"), 92 Self::EmptyDomain => write!(f, "Email domain cannot be empty"), 93 Self::DomainTooLong => write!(f, "Email domain exceeds maximum length"), 94 Self::MissingDomainDot => write!(f, "Email domain must contain a dot"), 95 Self::InvalidDomainLabel => write!(f, "Email domain contains invalid label"), 96 } 97 } 98} 99 100impl std::error::Error for EmailValidationError {} 101 102#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 103#[serde(try_from = "String", into = "String")] 104pub struct ValidatedEmail(String); 105 106impl ValidatedEmail { 107 pub fn new(email: impl AsRef<str>) -> Result<Self, EmailValidationError> { 108 let email = email.as_ref().trim(); 109 validate_email_detailed(email)?; 110 Ok(Self(email.to_string())) 111 } 112 113 pub fn as_str(&self) -> &str { 114 &self.0 115 } 116 117 pub fn into_inner(self) -> String { 118 self.0 119 } 120 121 pub fn local_part(&self) -> &str { 122 self.0 123 .rsplit_once('@') 124 .map(|(local, _)| local) 125 .unwrap_or("") 126 } 127 128 pub fn domain(&self) -> &str { 129 self.0 130 .rsplit_once('@') 131 .map(|(_, domain)| domain) 132 .unwrap_or("") 133 } 134} 135 136impl Deref for ValidatedEmail { 137 type Target = str; 138 fn deref(&self) -> &Self::Target { 139 &self.0 140 } 141} 142 143impl fmt::Display for ValidatedEmail { 144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 145 write!(f, "{}", self.0) 146 } 147} 148 149impl TryFrom<String> for ValidatedEmail { 150 type Error = EmailValidationError; 151 fn try_from(value: String) -> Result<Self, Self::Error> { 152 Self::new(value) 153 } 154} 155 156impl From<ValidatedEmail> for String { 157 fn from(email: ValidatedEmail) -> Self { 158 email.0 159 } 160} 161 162fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> { 163 if email.is_empty() { 164 return Err(EmailValidationError::Empty); 165 } 166 if email.len() > MAX_EMAIL_LENGTH { 167 return Err(EmailValidationError::TooLong); 168 } 169 let parts: Vec<&str> = email.rsplitn(2, '@').collect(); 170 if parts.len() != 2 { 171 return Err(EmailValidationError::MissingAtSign); 172 } 173 let domain = parts[0]; 174 let local = parts[1]; 175 if local.is_empty() { 176 return Err(EmailValidationError::EmptyLocalPart); 177 } 178 if local.len() > MAX_LOCAL_PART_LENGTH { 179 return Err(EmailValidationError::LocalPartTooLong); 180 } 181 if local.starts_with('.') || local.ends_with('.') || local.contains("..") { 182 return Err(EmailValidationError::InvalidLocalPart); 183 } 184 for c in local.chars() { 185 if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { 186 return Err(EmailValidationError::InvalidLocalPart); 187 } 188 } 189 if domain.is_empty() { 190 return Err(EmailValidationError::EmptyDomain); 191 } 192 if domain.len() > MAX_DOMAIN_LENGTH { 193 return Err(EmailValidationError::DomainTooLong); 194 } 195 if !domain.contains('.') { 196 return Err(EmailValidationError::MissingDomainDot); 197 } 198 for label in domain.split('.') { 199 if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH { 200 return Err(EmailValidationError::InvalidDomainLabel); 201 } 202 if label.starts_with('-') || label.ends_with('-') { 203 return Err(EmailValidationError::InvalidDomainLabel); 204 } 205 for c in label.chars() { 206 if !c.is_ascii_alphanumeric() && c != '-' { 207 return Err(EmailValidationError::InvalidDomainLabel); 208 } 209 } 210 } 211 Ok(()) 212} 213 214#[derive(Debug, PartialEq)] 215pub enum HandleValidationError { 216 Empty, 217 TooShort, 218 TooLong, 219 InvalidCharacters, 220 StartsWithInvalidChar, 221 EndsWithInvalidChar, 222 ContainsSpaces, 223 BannedWord, 224 Reserved, 225} 226 227impl std::fmt::Display for HandleValidationError { 228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 229 match self { 230 Self::Empty => write!(f, "Handle cannot be empty"), 231 Self::TooShort => write!( 232 f, 233 "Handle must be at least {} characters", 234 MIN_HANDLE_LENGTH 235 ), 236 Self::TooLong => write!( 237 f, 238 "Handle exceeds maximum length of {} characters", 239 MAX_SERVICE_HANDLE_LOCAL_PART 240 ), 241 Self::InvalidCharacters => write!( 242 f, 243 "Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed" 244 ), 245 Self::StartsWithInvalidChar => { 246 write!(f, "Handle cannot start with a hyphen") 247 } 248 Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen"), 249 Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), 250 Self::BannedWord => write!(f, "Inappropriate language in handle"), 251 Self::Reserved => write!(f, "Reserved handle"), 252 } 253 } 254} 255 256impl std::error::Error for HandleValidationError {} 257 258pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> { 259 validate_service_handle(handle, false) 260} 261 262pub fn validate_service_handle( 263 handle: &str, 264 allow_reserved: bool, 265) -> Result<String, HandleValidationError> { 266 let handle = handle.trim(); 267 268 if handle.is_empty() { 269 return Err(HandleValidationError::Empty); 270 } 271 272 if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { 273 return Err(HandleValidationError::ContainsSpaces); 274 } 275 276 if handle.len() < MIN_HANDLE_LENGTH { 277 return Err(HandleValidationError::TooShort); 278 } 279 280 if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART { 281 return Err(HandleValidationError::TooLong); 282 } 283 284 if let Some(first_char) = handle.chars().next() 285 && first_char == '-' 286 { 287 return Err(HandleValidationError::StartsWithInvalidChar); 288 } 289 290 if let Some(last_char) = handle.chars().last() 291 && last_char == '-' 292 { 293 return Err(HandleValidationError::EndsWithInvalidChar); 294 } 295 296 for c in handle.chars() { 297 if !c.is_ascii_alphanumeric() && c != '-' { 298 return Err(HandleValidationError::InvalidCharacters); 299 } 300 } 301 302 if crate::moderation::has_explicit_slur(handle) { 303 return Err(HandleValidationError::BannedWord); 304 } 305 306 if !allow_reserved && crate::handle::reserved::is_reserved_subdomain(handle) { 307 return Err(HandleValidationError::Reserved); 308 } 309 310 Ok(handle.to_lowercase()) 311} 312 313pub fn is_valid_email(email: &str) -> bool { 314 let email = email.trim(); 315 if email.is_empty() || email.len() > MAX_EMAIL_LENGTH { 316 return false; 317 } 318 let parts: Vec<&str> = email.rsplitn(2, '@').collect(); 319 if parts.len() != 2 { 320 return false; 321 } 322 let domain = parts[0]; 323 let local = parts[1]; 324 if local.is_empty() || local.len() > MAX_LOCAL_PART_LENGTH { 325 return false; 326 } 327 if local.starts_with('.') || local.ends_with('.') { 328 return false; 329 } 330 if local.contains("..") { 331 return false; 332 } 333 for c in local.chars() { 334 if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { 335 return false; 336 } 337 } 338 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH { 339 return false; 340 } 341 if !domain.contains('.') { 342 return false; 343 } 344 for label in domain.split('.') { 345 if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH { 346 return false; 347 } 348 if label.starts_with('-') || label.ends_with('-') { 349 return false; 350 } 351 for c in label.chars() { 352 if !c.is_ascii_alphanumeric() && c != '-' { 353 return false; 354 } 355 } 356 } 357 true 358} 359 360#[cfg(test)] 361mod tests { 362 use super::*; 363 364 #[test] 365 fn test_valid_handles() { 366 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); 367 assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string())); 368 assert_eq!( 369 validate_short_handle("user-name"), 370 Ok("user-name".to_string()) 371 ); 372 assert_eq!( 373 validate_short_handle("UPPERCASE"), 374 Ok("uppercase".to_string()) 375 ); 376 assert_eq!( 377 validate_short_handle("MixedCase123"), 378 Ok("mixedcase123".to_string()) 379 ); 380 assert_eq!(validate_short_handle("abc"), Ok("abc".to_string())); 381 } 382 383 #[test] 384 fn test_invalid_handles() { 385 assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty)); 386 assert_eq!( 387 validate_short_handle(" "), 388 Err(HandleValidationError::Empty) 389 ); 390 assert_eq!( 391 validate_short_handle("ab"), 392 Err(HandleValidationError::TooShort) 393 ); 394 assert_eq!( 395 validate_short_handle("a"), 396 Err(HandleValidationError::TooShort) 397 ); 398 assert_eq!( 399 validate_short_handle("test spaces"), 400 Err(HandleValidationError::ContainsSpaces) 401 ); 402 assert_eq!( 403 validate_short_handle("test\ttab"), 404 Err(HandleValidationError::ContainsSpaces) 405 ); 406 assert_eq!( 407 validate_short_handle("-starts"), 408 Err(HandleValidationError::StartsWithInvalidChar) 409 ); 410 assert_eq!( 411 validate_short_handle("_starts"), 412 Err(HandleValidationError::InvalidCharacters) 413 ); 414 assert_eq!( 415 validate_short_handle("ends-"), 416 Err(HandleValidationError::EndsWithInvalidChar) 417 ); 418 assert_eq!( 419 validate_short_handle("ends_"), 420 Err(HandleValidationError::InvalidCharacters) 421 ); 422 assert_eq!( 423 validate_short_handle("user_name"), 424 Err(HandleValidationError::InvalidCharacters) 425 ); 426 assert_eq!( 427 validate_short_handle("test@user"), 428 Err(HandleValidationError::InvalidCharacters) 429 ); 430 assert_eq!( 431 validate_short_handle("test!user"), 432 Err(HandleValidationError::InvalidCharacters) 433 ); 434 assert_eq!( 435 validate_short_handle("test.user"), 436 Err(HandleValidationError::InvalidCharacters) 437 ); 438 } 439 440 #[test] 441 fn test_handle_trimming() { 442 assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string())); 443 } 444 445 #[test] 446 fn test_handle_max_length() { 447 assert_eq!( 448 validate_short_handle("exactly18charslol"), 449 Ok("exactly18charslol".to_string()) 450 ); 451 assert_eq!( 452 validate_short_handle("exactly18charslol1"), 453 Ok("exactly18charslol1".to_string()) 454 ); 455 assert_eq!( 456 validate_short_handle("exactly19characters"), 457 Err(HandleValidationError::TooLong) 458 ); 459 assert_eq!( 460 validate_short_handle("waytoolongusername123456789"), 461 Err(HandleValidationError::TooLong) 462 ); 463 } 464 465 #[test] 466 fn test_reserved_subdomains() { 467 assert_eq!( 468 validate_short_handle("admin"), 469 Err(HandleValidationError::Reserved) 470 ); 471 assert_eq!( 472 validate_short_handle("api"), 473 Err(HandleValidationError::Reserved) 474 ); 475 assert_eq!( 476 validate_short_handle("bsky"), 477 Err(HandleValidationError::Reserved) 478 ); 479 assert_eq!( 480 validate_short_handle("barackobama"), 481 Err(HandleValidationError::Reserved) 482 ); 483 assert_eq!( 484 validate_short_handle("ADMIN"), 485 Err(HandleValidationError::Reserved) 486 ); 487 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); 488 assert_eq!( 489 validate_short_handle("notreserved"), 490 Ok("notreserved".to_string()) 491 ); 492 } 493 494 #[test] 495 fn test_allow_reserved() { 496 assert_eq!( 497 validate_service_handle("admin", true), 498 Ok("admin".to_string()) 499 ); 500 assert_eq!(validate_service_handle("api", true), Ok("api".to_string())); 501 assert_eq!( 502 validate_service_handle("admin", false), 503 Err(HandleValidationError::Reserved) 504 ); 505 } 506 507 #[test] 508 fn test_valid_emails() { 509 assert!(is_valid_email("user@example.com")); 510 assert!(is_valid_email("user.name@example.com")); 511 assert!(is_valid_email("user+tag@example.com")); 512 assert!(is_valid_email("user@sub.example.com")); 513 assert!(is_valid_email("USER@EXAMPLE.COM")); 514 assert!(is_valid_email("user123@example123.com")); 515 assert!(is_valid_email("a@b.co")); 516 } 517 #[test] 518 fn test_invalid_emails() { 519 assert!(!is_valid_email("")); 520 assert!(!is_valid_email("user")); 521 assert!(!is_valid_email("user@")); 522 assert!(!is_valid_email("@example.com")); 523 assert!(!is_valid_email("user@example")); 524 assert!(!is_valid_email("user@@example.com")); 525 assert!(!is_valid_email("user@.example.com")); 526 assert!(!is_valid_email("user@example..com")); 527 assert!(!is_valid_email(".user@example.com")); 528 assert!(!is_valid_email("user.@example.com")); 529 assert!(!is_valid_email("user..name@example.com")); 530 assert!(!is_valid_email("user@-example.com")); 531 assert!(!is_valid_email("user@example-.com")); 532 } 533 #[test] 534 fn test_trimmed_whitespace() { 535 assert!(is_valid_email(" user@example.com ")); 536 } 537}