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