circus/crates/common/src/validation.rs
NotAShelf 73919f2f9e
treewide: format with nightly rustfmt; auto-fix Clippy lints
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I15d9215ab506b37954468d99746098326a6a6964
2026-02-08 22:23:20 +03:00

206 lines
5.3 KiB
Rust

//! Input validation utilities for FC
use std::sync::LazyLock;
use regex::Regex;
/// Username validation: 3-32 chars, alphanumeric + underscore + hyphen
static USERNAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-zA-Z0-9_-]{3,32}$").expect("Invalid username regex pattern")
});
/// Email validation (basic RFC 5322 compliant)
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
.expect("Invalid email regex pattern")
});
/// Validation errors
#[derive(Debug, Clone)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.field, self.message)
}
}
impl std::error::Error for ValidationError {}
/// Validate username format
/// Requirements:
/// - 3-32 characters
/// - Alphanumeric, underscore, hyphen only
pub fn validate_username(username: &str) -> Result<(), ValidationError> {
if username.is_empty() {
return Err(ValidationError {
field: "username".to_string(),
message: "Username is required".to_string(),
});
}
if !USERNAME_REGEX.is_match(username) {
return Err(ValidationError {
field: "username".to_string(),
message: "Username must be 3-32 characters and contain only letters, \
numbers, underscores, and hyphens"
.to_string(),
});
}
Ok(())
}
/// Validate email format
pub fn validate_email(email: &str) -> Result<(), ValidationError> {
if email.is_empty() {
return Err(ValidationError {
field: "email".to_string(),
message: "Email is required".to_string(),
});
}
if !EMAIL_REGEX.is_match(email) {
return Err(ValidationError {
field: "email".to_string(),
message: "Invalid email format".to_string(),
});
}
Ok(())
}
/// Validate password strength
/// Requirements:
/// - At least 12 characters
/// - At least one uppercase letter
/// - At least one lowercase letter
/// - At least one number
/// - At least one special character
pub fn validate_password(password: &str) -> Result<(), ValidationError> {
if password.len() < 12 {
return Err(ValidationError {
field: "password".to_string(),
message: "Password must be at least 12 characters".to_string(),
});
}
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_special = password.chars().any(|c| !c.is_ascii_alphanumeric());
if !has_upper {
return Err(ValidationError {
field: "password".to_string(),
message: "Password must contain at least one uppercase letter"
.to_string(),
});
}
if !has_lower {
return Err(ValidationError {
field: "password".to_string(),
message: "Password must contain at least one lowercase letter"
.to_string(),
});
}
if !has_digit {
return Err(ValidationError {
field: "password".to_string(),
message: "Password must contain at least one number".to_string(),
});
}
if !has_special {
return Err(ValidationError {
field: "password".to_string(),
message: "Password must contain at least one special character"
.to_string(),
});
}
Ok(())
}
/// Validate role against allowed roles
pub fn validate_role(
role: &str,
allowed: &[&str],
) -> Result<(), ValidationError> {
if role.is_empty() {
return Err(ValidationError {
field: "role".to_string(),
message: "Role is required".to_string(),
});
}
if !allowed.contains(&role) {
return Err(ValidationError {
field: "role".to_string(),
message: format!("Invalid role. Must be one of: {}", allowed.join(", ")),
});
}
Ok(())
}
/// Validate full name (optional field)
/// - Max 255 characters
/// - Must not contain control characters
pub fn validate_full_name(name: &str) -> Result<(), ValidationError> {
if name.len() > 255 {
return Err(ValidationError {
field: "full_name".to_string(),
message: "Full name must be 255 characters or less".to_string(),
});
}
if name.chars().any(char::is_control) {
return Err(ValidationError {
field: "full_name".to_string(),
message: "Full name cannot contain control characters".to_string(),
});
}
Ok(())
}
/// Validate job name
/// Requirements:
/// - 1-255 characters
/// - Alphanumeric + common path characters
pub fn validate_job_name(name: &str) -> Result<(), ValidationError> {
if name.is_empty() {
return Err(ValidationError {
field: "job_name".to_string(),
message: "Job name is required".to_string(),
});
}
if name.len() > 255 {
return Err(ValidationError {
field: "job_name".to_string(),
message: "Job name must be 255 characters or less".to_string(),
});
}
// Allow alphanumeric, hyphen, underscore, dot, and path separators
let valid_chars: std::collections::HashSet<char> =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-./"
.chars()
.collect();
if !name.chars().all(|c| valid_chars.contains(&c)) {
return Err(ValidationError {
field: "job_name".to_string(),
message: "Job name contains invalid characters".to_string(),
});
}
Ok(())
}