this repo has no description
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 if !local 185 .chars() 186 .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) 187 { 188 return Err(EmailValidationError::InvalidLocalPart); 189 } 190 if domain.is_empty() { 191 return Err(EmailValidationError::EmptyDomain); 192 } 193 if domain.len() > MAX_DOMAIN_LENGTH { 194 return Err(EmailValidationError::DomainTooLong); 195 } 196 if !domain.contains('.') { 197 return Err(EmailValidationError::MissingDomainDot); 198 } 199 if !domain.split('.').all(|label| { 200 !label.is_empty() 201 && label.len() <= MAX_DOMAIN_LABEL_LENGTH 202 && !label.starts_with('-') 203 && !label.ends_with('-') 204 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') 205 }) { 206 return Err(EmailValidationError::InvalidDomainLabel); 207 } 208 Ok(()) 209} 210 211#[derive(Debug, PartialEq)] 212pub enum HandleValidationError { 213 Empty, 214 TooShort, 215 TooLong, 216 InvalidCharacters, 217 StartsWithInvalidChar, 218 EndsWithInvalidChar, 219 ContainsSpaces, 220 BannedWord, 221 Reserved, 222} 223 224impl std::fmt::Display for HandleValidationError { 225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 226 match self { 227 Self::Empty => write!(f, "Handle cannot be empty"), 228 Self::TooShort => write!( 229 f, 230 "Handle must be at least {} characters", 231 MIN_HANDLE_LENGTH 232 ), 233 Self::TooLong => write!( 234 f, 235 "Handle exceeds maximum length of {} characters", 236 MAX_SERVICE_HANDLE_LOCAL_PART 237 ), 238 Self::InvalidCharacters => write!( 239 f, 240 "Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed" 241 ), 242 Self::StartsWithInvalidChar => { 243 write!(f, "Handle cannot start with a hyphen") 244 } 245 Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen"), 246 Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), 247 Self::BannedWord => write!(f, "Inappropriate language in handle"), 248 Self::Reserved => write!(f, "Reserved handle"), 249 } 250 } 251} 252 253impl std::error::Error for HandleValidationError {} 254 255pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> { 256 validate_service_handle(handle, false) 257} 258 259pub fn validate_service_handle( 260 handle: &str, 261 allow_reserved: bool, 262) -> Result<String, HandleValidationError> { 263 let handle = handle.trim(); 264 265 if handle.is_empty() { 266 return Err(HandleValidationError::Empty); 267 } 268 269 if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { 270 return Err(HandleValidationError::ContainsSpaces); 271 } 272 273 if handle.len() < MIN_HANDLE_LENGTH { 274 return Err(HandleValidationError::TooShort); 275 } 276 277 if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART { 278 return Err(HandleValidationError::TooLong); 279 } 280 281 if let Some(first_char) = handle.chars().next() 282 && first_char == '-' 283 { 284 return Err(HandleValidationError::StartsWithInvalidChar); 285 } 286 287 if let Some(last_char) = handle.chars().last() 288 && last_char == '-' 289 { 290 return Err(HandleValidationError::EndsWithInvalidChar); 291 } 292 293 if !handle 294 .chars() 295 .all(|c| c.is_ascii_alphanumeric() || c == '-') 296 { 297 return Err(HandleValidationError::InvalidCharacters); 298 } 299 300 if crate::moderation::has_explicit_slur(handle) { 301 return Err(HandleValidationError::BannedWord); 302 } 303 304 if !allow_reserved && crate::handle::reserved::is_reserved_subdomain(handle) { 305 return Err(HandleValidationError::Reserved); 306 } 307 308 Ok(handle.to_lowercase()) 309} 310 311pub fn is_valid_email(email: &str) -> bool { 312 let email = email.trim(); 313 if email.is_empty() || email.len() > MAX_EMAIL_LENGTH { 314 return false; 315 } 316 let parts: Vec<&str> = email.rsplitn(2, '@').collect(); 317 if parts.len() != 2 { 318 return false; 319 } 320 let domain = parts[0]; 321 let local = parts[1]; 322 if local.is_empty() || local.len() > MAX_LOCAL_PART_LENGTH { 323 return false; 324 } 325 if local.starts_with('.') || local.ends_with('.') { 326 return false; 327 } 328 if local.contains("..") { 329 return false; 330 } 331 if !local 332 .chars() 333 .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) 334 { 335 return false; 336 } 337 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH { 338 return false; 339 } 340 if !domain.contains('.') { 341 return false; 342 } 343 domain.split('.').all(|label| { 344 !label.is_empty() 345 && label.len() <= MAX_DOMAIN_LABEL_LENGTH 346 && !label.starts_with('-') 347 && !label.ends_with('-') 348 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') 349 }) 350} 351 352#[cfg(test)] 353mod tests { 354 use super::*; 355 356 #[test] 357 fn test_valid_handles() { 358 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); 359 assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string())); 360 assert_eq!( 361 validate_short_handle("user-name"), 362 Ok("user-name".to_string()) 363 ); 364 assert_eq!( 365 validate_short_handle("UPPERCASE"), 366 Ok("uppercase".to_string()) 367 ); 368 assert_eq!( 369 validate_short_handle("MixedCase123"), 370 Ok("mixedcase123".to_string()) 371 ); 372 assert_eq!(validate_short_handle("abc"), Ok("abc".to_string())); 373 } 374 375 #[test] 376 fn test_invalid_handles() { 377 assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty)); 378 assert_eq!( 379 validate_short_handle(" "), 380 Err(HandleValidationError::Empty) 381 ); 382 assert_eq!( 383 validate_short_handle("ab"), 384 Err(HandleValidationError::TooShort) 385 ); 386 assert_eq!( 387 validate_short_handle("a"), 388 Err(HandleValidationError::TooShort) 389 ); 390 assert_eq!( 391 validate_short_handle("test spaces"), 392 Err(HandleValidationError::ContainsSpaces) 393 ); 394 assert_eq!( 395 validate_short_handle("test\ttab"), 396 Err(HandleValidationError::ContainsSpaces) 397 ); 398 assert_eq!( 399 validate_short_handle("-starts"), 400 Err(HandleValidationError::StartsWithInvalidChar) 401 ); 402 assert_eq!( 403 validate_short_handle("_starts"), 404 Err(HandleValidationError::InvalidCharacters) 405 ); 406 assert_eq!( 407 validate_short_handle("ends-"), 408 Err(HandleValidationError::EndsWithInvalidChar) 409 ); 410 assert_eq!( 411 validate_short_handle("ends_"), 412 Err(HandleValidationError::InvalidCharacters) 413 ); 414 assert_eq!( 415 validate_short_handle("user_name"), 416 Err(HandleValidationError::InvalidCharacters) 417 ); 418 assert_eq!( 419 validate_short_handle("test@user"), 420 Err(HandleValidationError::InvalidCharacters) 421 ); 422 assert_eq!( 423 validate_short_handle("test!user"), 424 Err(HandleValidationError::InvalidCharacters) 425 ); 426 assert_eq!( 427 validate_short_handle("test.user"), 428 Err(HandleValidationError::InvalidCharacters) 429 ); 430 } 431 432 #[test] 433 fn test_handle_trimming() { 434 assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string())); 435 } 436 437 #[test] 438 fn test_handle_max_length() { 439 assert_eq!( 440 validate_short_handle("exactly18charslol"), 441 Ok("exactly18charslol".to_string()) 442 ); 443 assert_eq!( 444 validate_short_handle("exactly18charslol1"), 445 Ok("exactly18charslol1".to_string()) 446 ); 447 assert_eq!( 448 validate_short_handle("exactly19characters"), 449 Err(HandleValidationError::TooLong) 450 ); 451 assert_eq!( 452 validate_short_handle("waytoolongusername123456789"), 453 Err(HandleValidationError::TooLong) 454 ); 455 } 456 457 #[test] 458 fn test_reserved_subdomains() { 459 assert_eq!( 460 validate_short_handle("admin"), 461 Err(HandleValidationError::Reserved) 462 ); 463 assert_eq!( 464 validate_short_handle("api"), 465 Err(HandleValidationError::Reserved) 466 ); 467 assert_eq!( 468 validate_short_handle("bsky"), 469 Err(HandleValidationError::Reserved) 470 ); 471 assert_eq!( 472 validate_short_handle("barackobama"), 473 Err(HandleValidationError::Reserved) 474 ); 475 assert_eq!( 476 validate_short_handle("ADMIN"), 477 Err(HandleValidationError::Reserved) 478 ); 479 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); 480 assert_eq!( 481 validate_short_handle("notreserved"), 482 Ok("notreserved".to_string()) 483 ); 484 } 485 486 #[test] 487 fn test_allow_reserved() { 488 assert_eq!( 489 validate_service_handle("admin", true), 490 Ok("admin".to_string()) 491 ); 492 assert_eq!(validate_service_handle("api", true), Ok("api".to_string())); 493 assert_eq!( 494 validate_service_handle("admin", false), 495 Err(HandleValidationError::Reserved) 496 ); 497 } 498 499 #[test] 500 fn test_valid_emails() { 501 assert!(is_valid_email("user@example.com")); 502 assert!(is_valid_email("user.name@example.com")); 503 assert!(is_valid_email("user+tag@example.com")); 504 assert!(is_valid_email("user@sub.example.com")); 505 assert!(is_valid_email("USER@EXAMPLE.COM")); 506 assert!(is_valid_email("user123@example123.com")); 507 assert!(is_valid_email("a@b.co")); 508 } 509 #[test] 510 fn test_invalid_emails() { 511 assert!(!is_valid_email("")); 512 assert!(!is_valid_email("user")); 513 assert!(!is_valid_email("user@")); 514 assert!(!is_valid_email("@example.com")); 515 assert!(!is_valid_email("user@example")); 516 assert!(!is_valid_email("user@@example.com")); 517 assert!(!is_valid_email("user@.example.com")); 518 assert!(!is_valid_email("user@example..com")); 519 assert!(!is_valid_email(".user@example.com")); 520 assert!(!is_valid_email("user.@example.com")); 521 assert!(!is_valid_email("user..name@example.com")); 522 assert!(!is_valid_email("user@-example.com")); 523 assert!(!is_valid_email("user@example-.com")); 524 } 525 #[test] 526 fn test_trimmed_whitespace() { 527 assert!(is_valid_email(" user@example.com ")); 528 } 529}