use ammonia::clean;
use regex::Regex;
#[cfg(test)]
use unicode_normalization::is_nfc;
use unicode_normalization::UnicodeNormalization;
use validator::ValidationError;
static PHONE_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"^\+?[1-9]\d{1,14}$").unwrap()
});
static USERNAME_BLACKLIST: std::sync::LazyLock<Vec<&'static str>> =
std::sync::LazyLock::new(|| vec!["admin", "root", "administrator", "support", "superuser"]);
#[must_use]
pub fn sanitize_text(input: &str) -> String {
clean(input)
}
#[must_use]
pub fn normalize_and_sanitize(text: &str) -> String {
let normalized: String = text.nfc().collect();
sanitize_text(&normalized)
}
pub fn validate_password_strength(password: &str) -> Result<(), ValidationError> {
let has_lowercase = Regex::new(r"[a-z]").unwrap().is_match(password);
let has_uppercase = Regex::new(r"[A-Z]").unwrap().is_match(password);
let has_digit = Regex::new(r"\d").unwrap().is_match(password);
let has_symbol = Regex::new(r"[^\da-zA-Z]").unwrap().is_match(password);
let is_long_enough = password.len() >= 8;
if !(has_lowercase && has_uppercase && has_digit && has_symbol && is_long_enough) {
let mut err = ValidationError::new("password_policy");
err.add_param(
"reason".into(),
&"Password must contain at least one lowercase, uppercase, digit, and special character, and be at least 8 characters long."
);
return Err(err);
}
Ok(())
}
pub fn validate_username(username: &str) -> Result<(), ValidationError> {
if username.len() < 3 || username.len() > 24 {
return Err(ValidationError::new("invalid_username_length"));
}
if USERNAME_BLACKLIST
.iter()
.any(|&name| username.eq_ignore_ascii_case(name))
{
return Err(ValidationError::new("blacklisted_username"));
}
Ok(())
}
pub fn validate_phone_number(phone: &str) -> Result<(), ValidationError> {
if !PHONE_RE.is_match(phone) {
return Err(ValidationError::new("invalid_phone_number_format"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_text_removes_scripts() {
let malicious_input = "Hello, <script>alert('XSS');</script> world!";
let sanitized = sanitize_text(malicious_input);
assert_eq!(sanitized, "Hello, world!");
}
#[test]
fn test_sanitize_text_removes_html_tags() {
let html_input = "<b>Bold</b> and <i>italic</i> text. <style>p { color: red; }</style>";
let sanitized = sanitize_text(html_input);
assert_eq!(sanitized, "<b>Bold</b> and <i>italic</i> text. ");
}
#[test]
fn test_sanitize_text_keeps_safe_text() {
let safe_input = "This is a normal, safe sentence with numbers 123 and symbols !@#$.";
let sanitized = sanitize_text(safe_input);
assert_eq!(sanitized, safe_input);
}
#[test]
fn test_password_strength_validator() {
assert!(validate_password_strength("StrongP@ss1").is_ok());
assert!(validate_password_strength("weak").is_err());
assert!(validate_password_strength("weakpass").is_err());
assert!(validate_password_strength("Weakpass1").is_err()); assert!(validate_password_strength("weakpass!").is_err()); assert!(validate_password_strength("WEAKPASS1!").is_err()); }
#[test]
fn test_normalization() {
let homoglyph_input = "pаypal"; let normalized = normalize_and_sanitize(homoglyph_input);
if is_nfc(homoglyph_input) {
assert_eq!(normalized, homoglyph_input);
} else {
assert_ne!(normalized, homoglyph_input);
}
let malicious_normalized = "pаypal<script>alert(1)</script>";
let sanitized_normalized = normalize_and_sanitize(malicious_normalized);
assert!(!sanitized_normalized.contains("<script>"));
}
#[test]
fn test_username_validator() {
assert!(validate_username("good_user").is_ok());
assert!(validate_username("ad").is_err()); assert!(validate_username("a_very_very_long_username_that_is_not_allowed").is_err()); assert!(validate_username("admin").is_err()); assert!(validate_username("Support").is_err()); }
#[test]
fn test_phone_number_validator() {
assert!(validate_phone_number("+15551234567").is_ok());
assert!(validate_phone_number("12345").is_ok()); assert!(validate_phone_number("+1-555-123-4567").is_err()); assert!(validate_phone_number("not a phone").is_err());
}
}