Files
tranquil-pds/crates/tranquil-pds/src/api/validation.rs
T
2026-07-12 08:03:14 +02:00

502 lines
16 KiB
Rust

use crate::types::Handle;
use std::fmt;
pub const MAX_EMAIL_LENGTH: usize = 254;
pub const MAX_LOCAL_PART_LENGTH: usize = 64;
pub const MAX_DOMAIN_LENGTH: usize = 253;
pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;
const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
pub const MIN_HANDLE_LENGTH: usize = 3;
pub const MAX_HANDLE_LENGTH: usize = 253;
pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmailValidationError {
Empty,
TooLong,
MissingAtSign,
EmptyLocalPart,
LocalPartTooLong,
InvalidLocalPart,
EmptyDomain,
DomainTooLong,
MissingDomainDot,
InvalidDomainLabel,
}
impl fmt::Display for EmailValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "Email cannot be empty"),
Self::TooLong => write!(
f,
"Email exceeds maximum length of {} characters",
MAX_EMAIL_LENGTH
),
Self::MissingAtSign => write!(f, "Email must contain @"),
Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"),
Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"),
Self::InvalidLocalPart => write!(f, "Email local part contains invalid characters"),
Self::EmptyDomain => write!(f, "Email domain cannot be empty"),
Self::DomainTooLong => write!(f, "Email domain exceeds maximum length"),
Self::MissingDomainDot => write!(f, "Email domain must contain a dot"),
Self::InvalidDomainLabel => write!(f, "Email domain contains invalid label"),
}
}
}
impl std::error::Error for EmailValidationError {}
fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> {
if email.is_empty() {
return Err(EmailValidationError::Empty);
}
if email.len() > MAX_EMAIL_LENGTH {
return Err(EmailValidationError::TooLong);
}
let parts: Vec<&str> = email.rsplitn(2, '@').collect();
if parts.len() != 2 {
return Err(EmailValidationError::MissingAtSign);
}
let domain = parts[0];
let local = parts[1];
if local.is_empty() {
return Err(EmailValidationError::EmptyLocalPart);
}
if local.len() > MAX_LOCAL_PART_LENGTH {
return Err(EmailValidationError::LocalPartTooLong);
}
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
return Err(EmailValidationError::InvalidLocalPart);
}
if !local
.chars()
.all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c))
{
return Err(EmailValidationError::InvalidLocalPart);
}
if domain.is_empty() {
return Err(EmailValidationError::EmptyDomain);
}
if domain.len() > MAX_DOMAIN_LENGTH {
return Err(EmailValidationError::DomainTooLong);
}
if !domain.contains('.') {
return Err(EmailValidationError::MissingDomainDot);
}
if !domain.split('.').all(|label| {
!label.is_empty()
&& label.len() <= MAX_DOMAIN_LABEL_LENGTH
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}) {
return Err(EmailValidationError::InvalidDomainLabel);
}
Ok(())
}
#[derive(Debug, PartialEq)]
pub enum HandleValidationError {
Empty,
TooShort,
TooLong,
InvalidCharacters,
StartsWithInvalidChar,
EndsWithInvalidChar,
ContainsSpaces,
BannedWord,
Reserved,
}
impl std::fmt::Display for HandleValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "Handle cannot be empty"),
Self::TooShort => write!(
f,
"Handle must be at least {} characters",
MIN_HANDLE_LENGTH
),
Self::TooLong => write!(
f,
"Handle exceeds maximum length of {} characters",
MAX_SERVICE_HANDLE_LOCAL_PART
),
Self::InvalidCharacters => write!(
f,
"Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed"
),
Self::StartsWithInvalidChar => {
write!(f, "Handle cannot start with a hyphen")
}
Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen"),
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
Self::BannedWord => write!(f, "Inappropriate language in handle"),
Self::Reserved => write!(f, "Reserved handle"),
}
}
}
impl std::error::Error for HandleValidationError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReservedHandlePolicy {
Allow,
Reject,
}
pub fn validate_full_domain_handle(handle: &str) -> Result<Handle, HandleValidationError> {
let handle = handle.trim();
if handle.is_empty() {
return Err(HandleValidationError::Empty);
}
if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') {
return Err(HandleValidationError::ContainsSpaces);
}
if handle.len() > MAX_HANDLE_LENGTH {
return Err(HandleValidationError::TooLong);
}
if handle
.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '.' && c != '-')
{
return Err(HandleValidationError::InvalidCharacters);
}
if !handle.contains('.') {
return Err(HandleValidationError::InvalidCharacters);
}
let labels: Vec<&str> = handle.split('.').collect();
let has_invalid_label = labels.iter().any(|label| {
label.is_empty()
|| label.len() > MAX_DOMAIN_LABEL_LENGTH
|| label.starts_with('-')
|| label.ends_with('-')
});
if has_invalid_label {
return Err(HandleValidationError::InvalidCharacters);
}
let handle_lower = handle.to_lowercase();
if crate::moderation::has_explicit_slur(&handle_lower) {
return Err(HandleValidationError::BannedWord);
}
Ok(Handle::from(handle_lower))
}
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
validate_service_handle(handle, ReservedHandlePolicy::Reject)
}
pub fn resolve_handle_input(input: &str) -> Result<Handle, HandleValidationError> {
let available_domains = tranquil_config::get().server.available_user_domain_list();
let matched_domain = available_domains
.iter()
.filter(|d| input.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
if !input.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input.strip_suffix(&format!(".{}", domain)).unwrap_or(input),
None => input,
};
let validated = validate_short_handle(handle_to_validate)?;
Ok(Handle::from(format!(
"{}.{}",
validated,
matched_domain.unwrap_or(&available_domains[0])
)))
} else {
validate_full_domain_handle(input)
}
}
pub fn validate_service_handle(
handle: &str,
reserved_policy: ReservedHandlePolicy,
) -> Result<String, HandleValidationError> {
let handle = handle.trim();
if handle.is_empty() {
return Err(HandleValidationError::Empty);
}
if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') {
return Err(HandleValidationError::ContainsSpaces);
}
if handle.len() < MIN_HANDLE_LENGTH {
return Err(HandleValidationError::TooShort);
}
if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART {
return Err(HandleValidationError::TooLong);
}
if let Some(first_char) = handle.chars().next()
&& first_char == '-'
{
return Err(HandleValidationError::StartsWithInvalidChar);
}
if let Some(last_char) = handle.chars().last()
&& last_char == '-'
{
return Err(HandleValidationError::EndsWithInvalidChar);
}
if !handle
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(HandleValidationError::InvalidCharacters);
}
if crate::moderation::has_explicit_slur(handle) {
return Err(HandleValidationError::BannedWord);
}
if reserved_policy == ReservedHandlePolicy::Reject
&& crate::handle::reserved::is_reserved_subdomain(handle)
{
return Err(HandleValidationError::Reserved);
}
Ok(handle.to_lowercase())
}
pub fn is_valid_email(email: &str) -> bool {
validate_email_detailed(email.trim()).is_ok()
}
pub fn is_valid_telegram_username(username: &str) -> bool {
let clean = username.strip_prefix('@').unwrap_or(username);
(5..=32).contains(&clean.len()) && clean.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn is_valid_discord_username(username: &str) -> bool {
(2..=32).contains(&username.len())
&& username
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
&& !username.contains("..")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_handles() {
assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string()));
assert_eq!(
validate_short_handle("user-name"),
Ok("user-name".to_string())
);
assert_eq!(
validate_short_handle("UPPERCASE"),
Ok("uppercase".to_string())
);
assert_eq!(
validate_short_handle("MixedCase123"),
Ok("mixedcase123".to_string())
);
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
}
#[test]
fn test_invalid_handles() {
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
assert_eq!(
validate_short_handle(" "),
Err(HandleValidationError::Empty)
);
assert_eq!(
validate_short_handle("ab"),
Err(HandleValidationError::TooShort)
);
assert_eq!(
validate_short_handle("a"),
Err(HandleValidationError::TooShort)
);
assert_eq!(
validate_short_handle("test spaces"),
Err(HandleValidationError::ContainsSpaces)
);
assert_eq!(
validate_short_handle("test\ttab"),
Err(HandleValidationError::ContainsSpaces)
);
assert_eq!(
validate_short_handle("-starts"),
Err(HandleValidationError::StartsWithInvalidChar)
);
assert_eq!(
validate_short_handle("_starts"),
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("ends-"),
Err(HandleValidationError::EndsWithInvalidChar)
);
assert_eq!(
validate_short_handle("ends_"),
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("user_name"),
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("test@user"),
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("test!user"),
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("test.user"),
Err(HandleValidationError::InvalidCharacters)
);
}
#[test]
fn test_handle_trimming() {
assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string()));
}
#[test]
fn test_handle_max_length() {
assert_eq!(
validate_short_handle("exactly18charslol"),
Ok("exactly18charslol".to_string())
);
assert_eq!(
validate_short_handle("exactly18charslol1"),
Ok("exactly18charslol1".to_string())
);
assert_eq!(
validate_short_handle("exactly19characters"),
Err(HandleValidationError::TooLong)
);
assert_eq!(
validate_short_handle("waytoolongusername123456789"),
Err(HandleValidationError::TooLong)
);
}
#[test]
fn test_reserved_subdomains() {
assert_eq!(
validate_short_handle("admin"),
Err(HandleValidationError::Reserved)
);
assert_eq!(
validate_short_handle("api"),
Err(HandleValidationError::Reserved)
);
assert_eq!(
validate_short_handle("bsky"),
Err(HandleValidationError::Reserved)
);
assert_eq!(
validate_short_handle("barackobama"),
Err(HandleValidationError::Reserved)
);
assert_eq!(
validate_short_handle("ADMIN"),
Err(HandleValidationError::Reserved)
);
assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
assert_eq!(
validate_short_handle("notreserved"),
Ok("notreserved".to_string())
);
}
#[test]
fn test_allow_reserved() {
assert_eq!(
validate_service_handle("admin", ReservedHandlePolicy::Allow),
Ok("admin".to_string())
);
assert_eq!(
validate_service_handle("api", ReservedHandlePolicy::Allow),
Ok("api".to_string())
);
assert_eq!(
validate_service_handle("admin", ReservedHandlePolicy::Reject),
Err(HandleValidationError::Reserved)
);
}
#[test]
fn test_valid_emails() {
assert!(is_valid_email("user@example.com"));
assert!(is_valid_email("user.name@example.com"));
assert!(is_valid_email("user+tag@example.com"));
assert!(is_valid_email("user@sub.example.com"));
assert!(is_valid_email("USER@EXAMPLE.COM"));
assert!(is_valid_email("user123@example123.com"));
assert!(is_valid_email("a@b.co"));
}
#[test]
fn test_invalid_emails() {
assert!(!is_valid_email(""));
assert!(!is_valid_email("user"));
assert!(!is_valid_email("user@"));
assert!(!is_valid_email("@example.com"));
assert!(!is_valid_email("user@example"));
assert!(!is_valid_email("user@@example.com"));
assert!(!is_valid_email("user@.example.com"));
assert!(!is_valid_email("user@example..com"));
assert!(!is_valid_email(".user@example.com"));
assert!(!is_valid_email("user.@example.com"));
assert!(!is_valid_email("user..name@example.com"));
assert!(!is_valid_email("user@-example.com"));
assert!(!is_valid_email("user@example-.com"));
}
#[test]
fn test_trimmed_whitespace() {
assert!(is_valid_email(" user@example.com "));
}
#[test]
fn test_valid_discord_usernames() {
assert!(is_valid_discord_username("ab"));
assert!(is_valid_discord_username("alice"));
assert!(is_valid_discord_username("user_name"));
assert!(is_valid_discord_username("user.name"));
assert!(is_valid_discord_username("user123"));
assert!(is_valid_discord_username("a_b.c_d"));
assert!(is_valid_discord_username(
"12345678901234567890123456789012"
));
}
#[test]
fn test_invalid_discord_usernames() {
assert!(!is_valid_discord_username(""));
assert!(!is_valid_discord_username("a"));
assert!(!is_valid_discord_username("Alice"));
assert!(!is_valid_discord_username("ALICE"));
assert!(!is_valid_discord_username("user-name"));
assert!(!is_valid_discord_username("user..name"));
assert!(!is_valid_discord_username("user name"));
assert!(!is_valid_discord_username(
"123456789012345678901234567890123"
));
}
}