use regex::{Regex, RegexBuilder};
use std::{
collections::{HashMap, HashSet},
fmt::Display,
net::Ipv6Addr,
path::Path,
sync::OnceLock,
};
use url::{Host, Url};
const DEFAULT_INVALID_CODE: &str = "invalid";
const DEFAULT_VALUE_MESSAGE: &str = "Enter a valid value.";
const DEFAULT_URL_MESSAGE: &str = "Enter a valid URL.";
const DEFAULT_EMAIL_MESSAGE: &str = "Enter a valid email address.";
const DEFAULT_MAX_URL_LENGTH: usize = 2_048;
const DEFAULT_URL_SCHEMES: [&str; 4] = ["http", "https", "ftp", "ftps"];
pub trait Validator<T> {
fn validate(&self, value: &T) -> Result<(), ValidationError>;
}
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
#[error("{message}")]
pub struct ValidationError {
pub message: String,
pub code: String,
pub params: HashMap<String, String>,
}
impl ValidationError {
#[must_use]
pub fn new(message: impl Into<String>, code: impl Into<String>) -> Self {
Self {
message: message.into(),
code: code.into(),
params: HashMap::new(),
}
}
#[must_use]
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.params.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone)]
pub struct RegexValidator {
regex: Regex,
message: String,
code: String,
inverse_match: bool,
}
impl Default for RegexValidator {
fn default() -> Self {
Self::new("").expect("default regex is valid")
}
}
impl RegexValidator {
pub fn new(pattern: &str) -> Result<Self, regex::Error> {
Self::with_options(
pattern,
DEFAULT_VALUE_MESSAGE,
DEFAULT_INVALID_CODE,
false,
false,
)
}
pub fn with_options(
pattern: &str,
message: impl Into<String>,
code: impl Into<String>,
inverse_match: bool,
case_insensitive: bool,
) -> Result<Self, regex::Error> {
let regex = RegexBuilder::new(pattern)
.case_insensitive(case_insensitive)
.build()?;
Ok(Self {
regex,
message: message.into(),
code: code.into(),
inverse_match,
})
}
}
impl<T> Validator<T> for RegexValidator
where
T: AsRef<str>,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
let value = value.as_ref();
let matches = self.regex.is_match(value);
let invalid = if self.inverse_match {
matches
} else {
!matches
};
if invalid {
return Err(
ValidationError::new(self.message.clone(), self.code.clone())
.with_param("value", value),
);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct URLValidator {
schemes: HashSet<String>,
message: String,
code: String,
max_length: usize,
}
impl Default for URLValidator {
fn default() -> Self {
Self {
schemes: DEFAULT_URL_SCHEMES
.into_iter()
.map(std::string::ToString::to_string)
.collect(),
message: DEFAULT_URL_MESSAGE.to_string(),
code: DEFAULT_INVALID_CODE.to_string(),
max_length: DEFAULT_MAX_URL_LENGTH,
}
}
}
impl URLValidator {
#[must_use]
pub fn with_schemes<I, S>(schemes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
schemes: schemes.into_iter().map(Into::into).collect(),
..Self::default()
}
}
}
impl<T> Validator<T> for URLValidator
where
T: AsRef<str>,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
let value = value.as_ref();
let invalid = || {
ValidationError::new(self.message.clone(), self.code.clone()).with_param("value", value)
};
if value.is_empty()
|| value.len() > self.max_length
|| value.chars().any(char::is_whitespace)
{
return Err(invalid());
}
let (scheme, _) = value.split_once("://").ok_or_else(invalid)?;
if !self.schemes.contains(&scheme.to_ascii_lowercase()) {
return Err(invalid());
}
let _parsed = Url::parse(value).map_err(|_| invalid())?;
let authority = extract_authority(value).ok_or_else(invalid)?;
let (userinfo, host_port) = split_userinfo(authority).ok_or_else(invalid)?;
if let Some(userinfo) = userinfo
&& !is_valid_userinfo(userinfo)
{
return Err(invalid());
}
let (host, port) = split_host_and_port(host_port).ok_or_else(invalid)?;
if let Some(port) = port
&& !is_valid_port(port)
{
return Err(invalid());
}
if host.starts_with('[') || host.ends_with(']') {
return Err(invalid());
}
if validate_ipv4_address(host).is_ok() || validate_ipv6_address(host).is_ok() {
return Ok(());
}
if host.eq_ignore_ascii_case("localhost") {
return Ok(());
}
if !is_valid_domain_name(host, true, true) {
return Err(invalid());
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct EmailValidator {
message: String,
code: String,
domain_allowlist: HashSet<String>,
}
impl Default for EmailValidator {
fn default() -> Self {
Self {
message: DEFAULT_EMAIL_MESSAGE.to_string(),
code: DEFAULT_INVALID_CODE.to_string(),
domain_allowlist: HashSet::from(["localhost".to_string()]),
}
}
}
impl EmailValidator {
#[must_use]
pub fn with_allowlist<I, S>(allowlist: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
domain_allowlist: allowlist
.into_iter()
.map(Into::into)
.map(|item: String| item.to_ascii_lowercase())
.collect(),
..Self::default()
}
}
fn validate_domain_part(&self, domain_part: &str) -> bool {
if self
.domain_allowlist
.contains(&domain_part.to_ascii_lowercase())
{
return true;
}
if is_valid_domain_name(domain_part, true, false) {
return true;
}
let literal_regex = email_literal_regex();
literal_regex
.captures(domain_part)
.and_then(|captures| captures.get(1))
.is_some_and(|matched| validate_ipv46_address(matched.as_str()).is_ok())
}
}
impl<T> Validator<T> for EmailValidator
where
T: AsRef<str>,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
let value = value.as_ref();
let invalid = || {
ValidationError::new(self.message.clone(), self.code.clone()).with_param("value", value)
};
if value.is_empty() || !value.contains('@') || value.len() > 320 {
return Err(invalid());
}
let (user_part, domain_part) = value.rsplit_once('@').ok_or_else(invalid)?;
if user_part.is_empty() || !email_user_regex().is_match(user_part) {
return Err(invalid());
}
if !self.validate_domain_part(domain_part) {
return Err(invalid());
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct MinValueValidator<T> {
limit_value: T,
message: String,
code: String,
}
impl<T> MinValueValidator<T> {
#[must_use]
pub fn new(limit_value: T) -> Self {
Self {
limit_value,
message: "Ensure this value is greater than or equal to {limit_value}.".to_string(),
code: "min_value".to_string(),
}
}
}
impl<T> Validator<T> for MinValueValidator<T>
where
T: PartialOrd + Display,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
if value < &self.limit_value {
return Err(
ValidationError::new(self.message.clone(), self.code.clone())
.with_param("limit_value", self.limit_value.to_string())
.with_param("show_value", value.to_string())
.with_param("value", value.to_string()),
);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct MaxValueValidator<T> {
limit_value: T,
message: String,
code: String,
}
impl<T> MaxValueValidator<T> {
#[must_use]
pub fn new(limit_value: T) -> Self {
Self {
limit_value,
message: "Ensure this value is less than or equal to {limit_value}.".to_string(),
code: "max_value".to_string(),
}
}
}
impl<T> Validator<T> for MaxValueValidator<T>
where
T: PartialOrd + Display,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
if value > &self.limit_value {
return Err(
ValidationError::new(self.message.clone(), self.code.clone())
.with_param("limit_value", self.limit_value.to_string())
.with_param("show_value", value.to_string())
.with_param("value", value.to_string()),
);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct MinLengthValidator {
limit_value: usize,
message: String,
code: String,
}
impl MinLengthValidator {
#[must_use]
pub fn new(limit_value: usize) -> Self {
Self {
limit_value,
message: "Ensure this value has at least {limit_value} characters.".to_string(),
code: "min_length".to_string(),
}
}
}
impl<T> Validator<T> for MinLengthValidator
where
T: AsRef<str>,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
let value = value.as_ref();
let length = value.chars().count();
if length < self.limit_value {
return Err(
ValidationError::new(self.message.clone(), self.code.clone())
.with_param("limit_value", self.limit_value.to_string())
.with_param("show_value", length.to_string())
.with_param("value", value),
);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct MaxLengthValidator {
limit_value: usize,
message: String,
code: String,
}
impl MaxLengthValidator {
#[must_use]
pub fn new(limit_value: usize) -> Self {
Self {
limit_value,
message: "Ensure this value has at most {limit_value} characters.".to_string(),
code: "max_length".to_string(),
}
}
}
impl<T> Validator<T> for MaxLengthValidator
where
T: AsRef<str>,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
let value = value.as_ref();
let length = value.chars().count();
if length > self.limit_value {
return Err(
ValidationError::new(self.message.clone(), self.code.clone())
.with_param("limit_value", self.limit_value.to_string())
.with_param("show_value", length.to_string())
.with_param("value", value),
);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct DecimalValidator {
max_digits: Option<usize>,
decimal_places: Option<usize>,
}
impl DecimalValidator {
#[must_use]
pub fn new(max_digits: usize, decimal_places: usize) -> Self {
Self {
max_digits: Some(max_digits),
decimal_places: Some(decimal_places),
}
}
#[must_use]
pub fn with_limits(max_digits: Option<usize>, decimal_places: Option<usize>) -> Self {
Self {
max_digits,
decimal_places,
}
}
}
impl<T> Validator<T> for DecimalValidator
where
T: AsRef<str>,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
let value = value.as_ref();
let stats = DecimalStats::parse(value).ok_or_else(|| {
ValidationError::new("Enter a number.", DEFAULT_INVALID_CODE).with_param("value", value)
})?;
if let Some(max_digits) = self.max_digits
&& stats.digits > max_digits
{
return Err(ValidationError::new(
"Ensure that there are no more than {max} digits in total.",
"max_digits",
)
.with_param("max", max_digits.to_string())
.with_param("value", value));
}
if let Some(decimal_places) = self.decimal_places
&& stats.decimals > decimal_places
{
return Err(ValidationError::new(
"Ensure that there are no more than {max} decimal places.",
"max_decimal_places",
)
.with_param("max", decimal_places.to_string())
.with_param("value", value));
}
if let (Some(max_digits), Some(decimal_places)) = (self.max_digits, self.decimal_places) {
let max_whole_digits = max_digits.saturating_sub(decimal_places);
if stats.whole_digits > max_whole_digits {
return Err(ValidationError::new(
"Ensure that there are no more than {max} digits before the decimal point.",
"max_whole_digits",
)
.with_param("max", max_whole_digits.to_string())
.with_param("value", value));
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct FileExtensionValidator {
allowed_extensions: Option<HashSet<String>>,
message: String,
code: String,
}
impl Default for FileExtensionValidator {
fn default() -> Self {
Self {
allowed_extensions: None,
message:
"File extension \"{extension}\" is not allowed. Allowed extensions are: {allowed_extensions}."
.to_string(),
code: "invalid_extension".to_string(),
}
}
}
impl FileExtensionValidator {
#[must_use]
pub fn new<I, S>(allowed_extensions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allowed_extensions: Some(
allowed_extensions
.into_iter()
.map(Into::into)
.map(|item: String| item.to_ascii_lowercase())
.collect(),
),
..Self::default()
}
}
}
impl<T> Validator<T> for FileExtensionValidator
where
T: AsRef<Path>,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
let path = value.as_ref();
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if let Some(allowed_extensions) = &self.allowed_extensions
&& !allowed_extensions.contains(&extension)
{
let allowed = sorted_csv(allowed_extensions.iter().cloned());
return Err(
ValidationError::new(self.message.clone(), self.code.clone())
.with_param("extension", extension)
.with_param("allowed_extensions", allowed)
.with_param("value", path.display().to_string()),
);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ProhibitNullCharactersValidator {
message: String,
code: String,
}
impl Default for ProhibitNullCharactersValidator {
fn default() -> Self {
Self {
message: "Null characters are not allowed.".to_string(),
code: "null_characters_not_allowed".to_string(),
}
}
}
impl<T> Validator<T> for ProhibitNullCharactersValidator
where
T: ToString,
{
fn validate(&self, value: &T) -> Result<(), ValidationError> {
let rendered = value.to_string();
if rendered.contains('\0') {
return Err(
ValidationError::new(self.message.clone(), self.code.clone())
.with_param("value", rendered),
);
}
Ok(())
}
}
pub fn validate_slug(value: &str) -> Result<(), ValidationError> {
static VALIDATOR: OnceLock<RegexValidator> = OnceLock::new();
VALIDATOR
.get_or_init(|| {
RegexValidator::with_options(
r"^[-a-zA-Z0-9_]+\z",
"Enter a valid slug consisting of letters, numbers, underscores or hyphens.",
DEFAULT_INVALID_CODE,
false,
false,
)
.expect("slug regex is valid")
})
.validate(&value)
}
pub fn validate_unicode_slug(value: &str) -> Result<(), ValidationError> {
static VALIDATOR: OnceLock<RegexValidator> = OnceLock::new();
VALIDATOR
.get_or_init(|| {
RegexValidator::with_options(
r"^[-\w]+\z",
"Enter a valid Unicode slug consisting of letters, numbers, underscores or hyphens.",
DEFAULT_INVALID_CODE,
false,
false,
)
.expect("unicode slug regex is valid")
})
.validate(&value)
}
pub fn validate_ipv4_address(value: impl Display) -> Result<(), ValidationError> {
let value = value.to_string();
let parts: Vec<_> = value.split('.').collect();
let valid = parts.len() == 4
&& parts.iter().all(|part| {
!part.is_empty()
&& part.bytes().all(|byte| byte.is_ascii_digit())
&& (part.len() == 1 || !part.starts_with('0'))
&& part.parse::<u8>().is_ok()
});
if valid {
return Ok(());
}
Err(
ValidationError::new("Enter a valid IPv4 address.", DEFAULT_INVALID_CODE)
.with_param("protocol", "IPv4")
.with_param("value", value),
)
}
pub fn validate_ipv6_address(value: impl Display) -> Result<(), ValidationError> {
let value = value.to_string();
if value.parse::<Ipv6Addr>().is_ok() {
return Ok(());
}
Err(
ValidationError::new("Enter a valid IPv6 address.", DEFAULT_INVALID_CODE)
.with_param("protocol", "IPv6")
.with_param("value", value),
)
}
pub fn validate_ipv46_address(value: impl Display) -> Result<(), ValidationError> {
let value = value.to_string();
if validate_ipv4_address(&value).is_ok() || validate_ipv6_address(&value).is_ok() {
return Ok(());
}
Err(
ValidationError::new("Enter a valid IPv4 or IPv6 address.", DEFAULT_INVALID_CODE)
.with_param("protocol", "IPv4 or IPv6")
.with_param("value", value),
)
}
fn extract_authority(url: &str) -> Option<&str> {
let after_scheme = url.split_once("://")?.1;
let end = after_scheme
.find(['/', '?', '#'])
.unwrap_or(after_scheme.len());
Some(&after_scheme[..end])
}
fn split_userinfo(authority: &str) -> Option<(Option<&str>, &str)> {
let (userinfo, host_port) = authority
.rsplit_once('@')
.map_or((None, authority), |(userinfo, host_port)| {
(Some(userinfo), host_port)
});
if host_port.is_empty() {
return None;
}
Some((userinfo, host_port))
}
fn split_host_and_port(host_port: &str) -> Option<(&str, Option<&str>)> {
if let Some(stripped) = host_port.strip_prefix('[') {
let closing = stripped.find(']')?;
let host = &stripped[..closing];
if host.is_empty() {
return None;
}
let remainder = &stripped[closing + 1..];
if remainder.is_empty() {
return Some((host, None));
}
let port = remainder.strip_prefix(':')?;
return Some((host, Some(port)));
}
if host_port.contains('[') || host_port.contains(']') {
return None;
}
match host_port.match_indices(':').count() {
0 => Some((host_port, None)),
1 => {
let (host, port) = host_port.rsplit_once(':')?;
if host.is_empty() {
None
} else {
Some((host, Some(port)))
}
}
_ => None,
}
}
fn is_valid_userinfo(userinfo: &str) -> bool {
if userinfo.is_empty() || userinfo.starts_with(':') || userinfo.contains('@') {
return false;
}
let mut parts = userinfo.split(':');
let username = parts.next().unwrap_or_default();
if username.is_empty()
|| username
.chars()
.any(|ch| ch.is_whitespace() || matches!(ch, '@' | '/'))
{
return false;
}
match (parts.next(), parts.next()) {
(None, None) => true,
(Some(password), None) => !password
.chars()
.any(|ch| ch.is_whitespace() || matches!(ch, '@' | '/' | ':')),
_ => false,
}
}
fn is_valid_port(port: &str) -> bool {
!port.is_empty()
&& port.len() <= 5
&& port.bytes().all(|byte| byte.is_ascii_digit())
&& port.parse::<u16>().is_ok()
}
fn is_valid_domain_name(value: &str, allow_localhost: bool, allow_trailing_dot: bool) -> bool {
if value.is_empty() || value.chars().any(char::is_whitespace) {
return false;
}
let trimmed = if let Some(candidate) = value.strip_suffix('.') {
if !allow_trailing_dot {
return false;
}
candidate
} else {
value
};
if trimmed.is_empty() {
return false;
}
let ascii_domain = match Host::parse(trimmed) {
Ok(Host::Domain(domain)) => domain.to_string(),
_ => return false,
};
if ascii_domain.len() > 253 {
return false;
}
if allow_localhost && ascii_domain.eq_ignore_ascii_case("localhost") {
return true;
}
let labels: Vec<_> = ascii_domain.split('.').collect();
if labels.len() < 2 {
return false;
}
for label in &labels[..labels.len() - 1] {
if !is_valid_domain_label(label) {
return false;
}
}
is_valid_tld(labels.last().copied().unwrap_or_default())
}
fn is_valid_domain_label(label: &str) -> bool {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
fn is_valid_tld(label: &str) -> bool {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& ((label.len() >= 2
&& label
.bytes()
.all(|byte| byte.is_ascii_alphabetic() || byte == b'-'))
|| label.strip_prefix("xn--").is_some_and(|rest| {
!rest.is_empty()
&& rest.len() <= 59
&& rest.bytes().all(|byte| byte.is_ascii_alphanumeric())
}))
}
fn sorted_csv(values: impl IntoIterator<Item = String>) -> String {
let mut values: Vec<_> = values.into_iter().collect();
values.sort();
values.join(", ")
}
fn email_user_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r#"(?i)^(?:[-!#$%&'*+/=?^_`{}|~0-9a-z]+(?:\.[-!#$%&'*+/=?^_`{}|~0-9a-z]+)*|\"(?:[^\"\\\r\n]|\\.)*\")\z"#,
)
.expect("email user regex is valid")
})
}
fn email_literal_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?i)^\[([A-F0-9:.]+)\]\z").expect("email literal regex is valid")
})
}
#[derive(Debug, Clone, Copy)]
struct DecimalStats {
digits: usize,
decimals: usize,
whole_digits: usize,
}
impl DecimalStats {
fn parse(value: &str) -> Option<Self> {
let captures = decimal_regex().captures(value)?;
let integer = captures.name("integer")?.as_str();
let fraction = captures
.name("fraction")
.map_or("", |match_| match_.as_str());
let exponent = captures
.name("exponent")
.map_or(Ok(0_i32), |match_| match_.as_str().parse::<i32>())
.ok()?;
let combined = format!("{integer}{fraction}");
let significant = combined.trim_start_matches('0');
let digits_tuple = if significant.is_empty() {
"0"
} else {
significant
};
let exponent = exponent - i32::try_from(fraction.len()).ok()?;
let digits_tuple_len = digits_tuple.len();
let (digits, decimals) = if exponent >= 0 {
let digits = if digits_tuple == "0" {
digits_tuple_len
} else {
digits_tuple_len + usize::try_from(exponent).ok()?
};
(digits, 0)
} else {
let decimals = usize::try_from(exponent.unsigned_abs()).ok()?;
if decimals > digits_tuple_len {
(decimals, decimals)
} else {
(digits_tuple_len, decimals)
}
};
Some(Self {
digits,
decimals,
whole_digits: digits.saturating_sub(decimals),
})
}
}
fn decimal_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r"^[+-]?(?:(?P<integer>\d+)(?:\.(?P<fraction>\d*))?|\.(?P<fraction_only>\d+))(?:[eE](?P<exponent>[+-]?\d+))?\z",
)
.expect("decimal regex is valid")
})
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_valid<T, V>(validator: &V, value: &T)
where
V: Validator<T>,
{
assert!(
validator.validate(value).is_ok(),
"expected value to be valid"
);
}
fn assert_invalid<T, V>(validator: &V, value: &T)
where
V: Validator<T>,
{
assert!(
validator.validate(value).is_err(),
"expected value to be invalid"
);
}
#[test]
fn regex_validator_matches_and_inverts() {
let validator = RegexValidator::new("[0-9]+").expect("regex compiles");
assert_valid(&validator, &"1234");
assert_invalid(&validator, &"xxxxxx");
let inverse = RegexValidator::with_options(
"x",
DEFAULT_VALUE_MESSAGE,
DEFAULT_INVALID_CODE,
true,
false,
)
.expect("regex compiles");
assert_valid(&inverse, &"y");
assert_invalid(&inverse, &"x");
}
#[test]
fn slug_validators_cover_ascii_and_unicode_cases() {
let valid_ascii = [
"slug-ok",
"longer-slug-still-ok",
"--------",
"nohyphensoranything",
"a",
"1",
"a1",
];
for value in valid_ascii {
assert!(
validate_slug(value).is_ok(),
"{value} should be a valid ASCII slug"
);
}
let invalid_ascii = [
"",
" text ",
" ",
"some@mail.com",
"你好",
"你 好",
"\n",
"trailing-newline\n",
];
for value in invalid_ascii {
assert!(
validate_slug(value).is_err(),
"{value:?} should be an invalid ASCII slug"
);
}
let valid_unicode = [
"slug-ok",
"longer-slug-still-ok",
"--------",
"nohyphensoranything",
"a",
"1",
"a1",
"你好",
];
for value in valid_unicode {
assert!(
validate_unicode_slug(value).is_ok(),
"{value} should be a valid unicode slug"
);
}
let invalid_unicode = [
"",
" text ",
" ",
"some@mail.com",
"\n",
"你 好",
"trailing-newline\n",
];
for value in invalid_unicode {
assert!(
validate_unicode_slug(value).is_err(),
"{value:?} should be an invalid unicode slug"
);
}
}
#[test]
fn ip_validators_handle_strict_ipv4_and_ipv6() {
assert!(validate_ipv4_address("1.1.1.1").is_ok());
assert!(validate_ipv4_address("255.0.0.0").is_ok());
assert!(validate_ipv4_address("000.000.000.000").is_err());
assert!(validate_ipv4_address("1.1.1.1\n").is_err());
assert!(validate_ipv6_address("fe80::1").is_ok());
assert!(validate_ipv6_address("::1").is_ok());
assert!(validate_ipv6_address("12345::").is_err());
assert!(validate_ipv46_address("1.1.1.1").is_ok());
assert!(validate_ipv46_address("fe80::1").is_ok());
assert!(validate_ipv46_address("25.1 .1.1").is_err());
}
#[test]
fn value_validators_enforce_bounds_and_expose_params() {
let min = MinValueValidator::new(-10);
for value in [-10, 0, 10] {
assert_valid(&min, &value);
}
let error = min.validate(&-11).expect_err("min validator should fail");
assert_eq!(error.code, "min_value");
assert_eq!(
error.params.get("limit_value").map(String::as_str),
Some("-10")
);
assert_eq!(
error.params.get("show_value").map(String::as_str),
Some("-11")
);
let max = MaxValueValidator::new(10);
for value in [-10, 0, 10] {
assert_valid(&max, &value);
}
let error = max.validate(&11).expect_err("max validator should fail");
assert_eq!(error.code, "max_value");
assert_eq!(
error.params.get("limit_value").map(String::as_str),
Some("10")
);
assert_eq!(
error.params.get("show_value").map(String::as_str),
Some("11")
);
}
#[test]
fn length_validators_count_unicode_codepoints() {
let min = MinLengthValidator::new(2);
assert_valid(&min, &"你好");
assert_invalid(&min, &"a");
let max = MaxLengthValidator::new(10);
for value in ["", "xxxxxxxxxx"] {
assert_valid(&max, &value);
}
let error = max
.validate(&"xxxxxxxxxxxxxxx")
.expect_err("max length should fail");
assert_eq!(error.code, "max_length");
assert_eq!(
error.params.get("show_value").map(String::as_str),
Some("15")
);
let min = MinLengthValidator::new(10);
for value in ["xxxxxxxxxxxxxxx", "xxxxxxxxxx"] {
assert_valid(&min, &value);
}
let error = min.validate(&"").expect_err("min length should fail");
assert_eq!(error.code, "min_length");
assert_eq!(
error.params.get("show_value").map(String::as_str),
Some("0")
);
}
#[test]
fn url_validator_accepts_common_django_cases() {
let validator = URLValidator::default();
let valid_urls = [
"http://www.djangoproject.com/",
"HTTP://WWW.DJANGOPROJECT.COM/",
"http://localhost/",
"http://example.com:65535/",
"http://example.com./",
"http://foo.com/blah_blah_(wikipedia)",
"http://userid:password@example.com:8080/",
"http://例子.测试",
"http://[::1]:8080/",
"https://example.com/?something=value",
"ftp://example.com/",
];
for value in valid_urls {
assert!(
validator.validate(&value).is_ok(),
"{value} should be valid"
);
}
}
#[test]
fn url_validator_rejects_invalid_django_cases() {
let validator = URLValidator::default();
let invalid_urls = [
"no_scheme",
"foo.com",
"http://",
"http://example",
"http://example.",
"http://example.com:000000080/",
"http://.com",
"rdar://1234",
"http://foo.bar?q=Spaces should be encoded",
"http://[]:8080",
"http://@example.com",
"http://:bar@example.com",
"http://foo:bar:baz@example.com",
"http://www.djangoproject.com/\n",
];
for value in invalid_urls {
assert!(
validator.validate(&value).is_err(),
"{value} should be invalid"
);
}
}
#[test]
fn url_validator_supports_extended_schemes() {
let validator =
URLValidator::with_schemes(["http", "https", "ftp", "ftps", "git", "file", "git+ssh"]);
assert_valid(&validator, &"file://localhost/path");
assert_valid(&validator, &"git://example.com/");
assert_valid(&validator, &"git+ssh://git@github.com/example/hg-git.git");
assert_invalid(&validator, &"git://-invalid.com");
}
#[test]
fn email_validator_accepts_valid_addresses() {
let validator = EmailValidator::default();
let valid_addresses = [
"email@here.com",
"weirder-email@here.and.there.com",
"email@[127.0.0.1]",
"email@[2001:dB8::1]",
"email@[::fffF:127.0.0.1]",
"example@valid-----hyphens.com",
"test@domain.with.idn.tld.उदाहरण.परीक्षा",
"email@localhost",
"\"test@test\"@example.com",
"email@xn--4ca9at.com",
"email@öäü.com",
"email@漢字.example.com",
];
for value in valid_addresses {
assert!(
validator.validate(&value).is_ok(),
"{value} should be valid"
);
}
let custom_allowlist = EmailValidator::with_allowlist(["localdomain"]);
assert_valid(&custom_allowlist, &"email@localdomain");
}
#[test]
fn email_validator_rejects_invalid_addresses() {
let validator = EmailValidator::default();
let invalid_addresses = [
"",
"abc",
"abc@",
"abc@bar",
"a @x.cz",
"something@@somewhere.com",
"email@127.0.0.1",
"email@[127.0.0.256]",
"email@[2001:db8::12345]",
"email@[::ffff:127.0.0.256]",
"@domain.com",
"email.domain.com",
"email@domain@domain.com",
"example@invalid-.com",
"email@domain..com",
"trailingdot@shouldfail.com.",
"a@b.com\n",
"test@example.com\n\n<script src=\"x.js\">",
];
for value in invalid_addresses {
assert!(
validator.validate(&value).is_err(),
"{value:?} should be invalid"
);
}
}
#[test]
fn decimal_validator_enforces_digit_precision_and_rejects_non_numbers() {
let validator = DecimalValidator::new(2, 2);
assert_valid(&validator, &"0.99");
let validator = DecimalValidator::new(2, 1);
let error = validator
.validate(&"0.99")
.expect_err("too many decimal places should fail");
assert_eq!(error.code, "max_decimal_places");
assert_valid(&validator, &"0E+1");
let validator = DecimalValidator::new(3, 1);
let error = validator
.validate(&"999")
.expect_err("too many whole digits should fail");
assert_eq!(error.code, "max_whole_digits");
let validator = DecimalValidator::new(4, 1);
assert_valid(&validator, &"999");
assert_invalid(&validator, &"99.99");
let validator = DecimalValidator::new(5, 2);
assert_valid(&validator, &"7304E-1");
let error = validator
.validate(&"7304E-3")
.expect_err("too many decimals");
assert_eq!(error.code, "max_decimal_places");
let validator = DecimalValidator::new(5, 5);
assert_valid(&validator, &"70E-5");
assert_invalid(&validator, &"70E-6");
let validator = DecimalValidator::new(10, 2);
assert_invalid(&validator, &"NaN");
assert_invalid(&validator, &"+Infinity");
}
#[test]
fn file_extension_validator_normalizes_case_and_handles_missing_extensions() {
let validator = FileExtensionValidator::new(["txt"]);
assert_valid(&validator, &Path::new("file.txt"));
assert_valid(&validator, &Path::new("file.TXT"));
assert_invalid(&validator, &Path::new("file.jpg"));
assert_invalid(&validator, &Path::new("fileWithNoExtension"));
let uppercase = FileExtensionValidator::new(["TXT"]);
assert_valid(&uppercase, &Path::new("file.txt"));
let no_extension = FileExtensionValidator::new([""]);
assert_valid(&no_extension, &Path::new("fileWithNoExtension"));
assert_invalid(&no_extension, &Path::new("fileWithAnExtension.txt"));
let empty_allowlist = FileExtensionValidator::new(Vec::<&str>::new());
assert_invalid(&empty_allowlist, &Path::new("file.txt"));
let default_validator = FileExtensionValidator::default();
assert_valid(&default_validator, &Path::new("file.jpg"));
}
#[test]
fn prohibit_null_characters_validator_rejects_nul_bytes() {
let validator = ProhibitNullCharactersValidator::default();
assert_valid(&validator, &"something");
let error = validator
.validate(&"\0something")
.expect_err("leading NUL should be rejected");
assert_eq!(error.code, "null_characters_not_allowed");
assert_eq!(
error.params.get("value").map(String::as_str),
Some("\0something")
);
assert_invalid(&validator, &"some\0thing");
}
}