//! Handle validation. Handles look like email local-parts: short, //! lowercase, restricted charset, must not collide with reserved names. use crate::error::ApiError; /// Names we never let users register (system / role / well-known). /// Conservative starter list; operators can extend. const RESERVED: &[&str] = &[ "admin", "administrator", "root", "system", "api", "internal", "kez", "support", "help", "abuse", "postmaster", "noreply", "no-reply", "mailer-daemon", "webmaster", "hostmaster", "www", "ftp", "mail", "smtp", "imap", "pop3", "everyone", "all", "anyone", "nobody", ]; pub fn validate_handle(handle: &str) -> Result<(), ApiError> { if handle.len() < 3 { return Err(ApiError::BadRequest("handle must be at least 3 chars".into())); } if handle.len() > 32 { return Err(ApiError::BadRequest("handle must be at most 32 chars".into())); } let bytes = handle.as_bytes(); let first = bytes[0]; if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { return Err(ApiError::BadRequest( "handle must start with a lowercase letter or digit".into(), )); } for &b in bytes { let ok = b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_'; if !ok { return Err(ApiError::BadRequest(format!( "handle contains invalid character: {:?}", b as char ))); } } if RESERVED.contains(&handle) { return Err(ApiError::Forbidden(format!("handle is reserved: {handle}"))); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn accepts_normal_handles() { for h in &["tudisco", "chris", "alice", "user_123", "ab-cd", "a1b2c3"] { assert!(validate_handle(h).is_ok(), "expected ok: {h}"); } } #[test] fn rejects_short_or_long() { assert!(validate_handle("ab").is_err()); assert!(validate_handle(&"a".repeat(33)).is_err()); } #[test] fn rejects_invalid_chars() { for h in &["Tudisco", "ali.ce", "user@name", "name space", "emo😀"] { assert!(validate_handle(h).is_err(), "expected err: {h}"); } } #[test] fn rejects_bad_first_char() { for h in &["-name", "_name"] { assert!(validate_handle(h).is_err(), "expected err: {h}"); } } #[test] fn rejects_reserved() { for h in &["admin", "root", "kez", "noreply"] { assert!(matches!(validate_handle(h), Err(ApiError::Forbidden(_)))); } } }