pub const MAX_NAME_LEN: usize = 200;
pub const NAME_RULES: &str = "must be non-empty, not exceed 200 bytes, \
start with an ASCII alphanumeric character, contain only [a-zA-Z0-9._-], \
must not contain consecutive periods ('..') or end with a period, \
and must not match a Windows reserved device name";
const _: () = assert!(
MAX_NAME_LEN == 200,
"MAX_NAME_LEN changed — update NAME_RULES string to match"
);
#[must_use]
#[inline]
#[doc(alias = "validate_name")]
pub fn is_valid_name(name: &str) -> bool {
if name.is_empty() || name.len() > MAX_NAME_LEN {
return false;
}
if !name.as_bytes()[0].is_ascii_alphanumeric() {
return false;
}
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
return false;
}
if name.contains("..") || name.ends_with('.') {
return false;
}
let stem = name.split('.').next().unwrap_or(name);
if is_windows_reserved_name(stem) {
return false;
}
true
}
const WINDOWS_RESERVED: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
fn is_windows_reserved_name(stem: &str) -> bool {
if stem.len() < 3 || stem.len() > 4 {
return false;
}
WINDOWS_RESERVED
.iter()
.any(|reserved| stem.eq_ignore_ascii_case(reserved))
}
pub const MAX_USERNAME_LEN: usize = 256;
pub const USERNAME_RULES: &str = "must be non-empty, not exceed 256 bytes, \
start with an ASCII alphanumeric character, contain only [a-zA-Z0-9._@-], \
must not contain consecutive special characters, and must not contain null bytes";
const _: () = assert!(
MAX_USERNAME_LEN == 256,
"MAX_USERNAME_LEN changed — update USERNAME_RULES string to match"
);
#[must_use]
#[inline]
#[doc(alias = "validate_username")]
pub fn is_valid_username(s: &str) -> bool {
if s.is_empty() || s.len() > MAX_USERNAME_LEN {
return false;
}
if !s.as_bytes()[0].is_ascii_alphanumeric() {
return false;
}
let mut prev_special = false;
for &b in s.as_bytes() {
if b == 0 {
return false;
}
let is_alnum = b.is_ascii_alphanumeric();
let is_special = b == b'.' || b == b'_' || b == b'-' || b == b'@';
if !is_alnum && !is_special {
return false;
}
if is_special {
if prev_special {
return false;
}
prev_special = true;
} else {
prev_special = false;
}
}
true
}
pub const MAX_REDIRECT_URI_LEN: usize = 2048;
pub const REDIRECT_URI_RULES: &str = "must not be empty, not exceed 2048 bytes, \
start with \"https://\" (or \"http://localhost\" / \"http://127.0.0.1\" for local \
development), must not contain a fragment (#), and must not contain null bytes";
const _: () = assert!(
MAX_REDIRECT_URI_LEN == 2048,
"MAX_REDIRECT_URI_LEN changed — update REDIRECT_URI_RULES string to match"
);
#[must_use]
#[inline]
#[doc(alias = "validate_redirect_uri")]
pub fn is_valid_redirect_uri(s: &str) -> bool {
if s.is_empty() || s.len() > MAX_REDIRECT_URI_LEN {
return false;
}
if s.bytes().any(|b| b == 0) {
return false;
}
if s.contains('#') {
return false;
}
let has_valid_scheme = s.starts_with("https://") || is_http_loopback(s);
if !has_valid_scheme {
return false;
}
true
}
fn is_http_loopback(s: &str) -> bool {
if !s.starts_with("http://") {
return false;
}
let after_scheme = &s["http://".len()..];
let authority = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or(after_scheme);
if authority.contains('@') {
return false;
}
for host in &["localhost", "127.0.0.1", "[::1]"] {
if after_scheme.eq_ignore_ascii_case(host) {
return true;
}
if let Some(rest) = after_scheme
.get(..host.len())
.filter(|prefix| prefix.eq_ignore_ascii_case(host))
.and_then(|_| after_scheme.get(host.len()..))
{
let next = rest.as_bytes().first().copied();
if matches!(next, Some(b':' | b'/' | b'?')) {
return true;
}
}
}
false
}
#[must_use]
#[inline]
pub fn is_https_url(url: &str) -> bool {
url.starts_with("https://")
}
pub const MAX_SCOPE_LEN: usize = 1024;
pub const SCOPE_RULES: &str = "must be non-empty, not exceed 1024 bytes, \
consist of space-delimited tokens where each token contains only NQCHAR \
characters (0x21, 0x23-0x5B, 0x5D-0x7E) per RFC 6749 §3.3";
const _: () = assert!(
MAX_SCOPE_LEN == 1024,
"MAX_SCOPE_LEN changed — update SCOPE_RULES string to match"
);
#[must_use]
#[inline]
#[doc(alias = "validate_scope")]
pub fn is_valid_scope(s: &str) -> bool {
if s.is_empty() || s.len() > MAX_SCOPE_LEN {
return false;
}
for token in s.split(' ') {
if token.is_empty() {
return false;
}
if !token.bytes().all(is_nqchar) {
return false;
}
}
true
}
#[inline]
const fn is_nqchar(b: u8) -> bool {
matches!(b, 0x21 | 0x23..=0x5B | 0x5D..=0x7E)
}
pub const MAX_CLIENT_ID_LEN: usize = 256;
pub const CLIENT_ID_RULES: &str = "must be non-empty, not exceed 256 bytes, \
contain only printable ASCII characters (0x20-0x7E), and must not contain \
null bytes";
const _: () = assert!(
MAX_CLIENT_ID_LEN == 256,
"MAX_CLIENT_ID_LEN changed — update CLIENT_ID_RULES string to match"
);
#[must_use]
#[inline]
#[doc(alias = "validate_client_id")]
pub fn is_valid_client_id(s: &str) -> bool {
if s.is_empty() || s.len() > MAX_CLIENT_ID_LEN {
return false;
}
s.bytes().all(|b| (0x20..=0x7E).contains(&b))
}
pub const MAX_EMAIL_LEN: usize = 254;
pub const EMAIL_RULES: &str = "must be non-empty, not exceed 254 bytes, \
contain exactly one '@', have a non-empty local part (max 64 bytes) and \
a non-empty domain with at least one '.', using only printable ASCII";
const _: () = assert!(
MAX_EMAIL_LEN == 254,
"MAX_EMAIL_LEN changed — update EMAIL_RULES string to match"
);
#[must_use]
#[inline]
#[doc(alias = "validate_email")]
pub fn is_valid_email(email: &str) -> bool {
if email.is_empty() || email.len() > MAX_EMAIL_LEN {
return false;
}
if !email.bytes().all(|b| (0x21..=0x7E).contains(&b)) {
return false;
}
let at_count = email.bytes().filter(|&b| b == b'@').count();
if at_count != 1 {
return false;
}
let Some(at_pos) = email.find('@') else {
return false;
};
let local = &email[..at_pos];
let domain = &email[at_pos + 1..];
if local.is_empty() || local.len() > 64 {
return false;
}
if !local.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'/'
| b'='
| b'?'
| b'^'
| b'_'
| b'`'
| b'{'
| b'|'
| b'}'
| b'~'
| b'.'
)
}) {
return false;
}
if domain.is_empty() || !domain.contains('.') {
return false;
}
for part in [local, domain] {
if part.starts_with('.') || part.ends_with('.') || part.contains("..") {
return false;
}
}
for label in domain.split('.') {
if label.is_empty() {
return false;
}
if label.starts_with('-') || label.ends_with('-') {
return false;
}
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return false;
}
}
true
}
pub const DEFAULT_SPECIAL_CHARS: &str = "!@#$%^&*()_+-=[]{}|;:,.<>?";
pub const PASSWORD_RULES: &str = "must be at least 8 bytes, contain at least one \
uppercase letter, one lowercase letter, one digit, and one special character";
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PasswordRules {
min_length: usize,
require_uppercase: bool,
require_lowercase: bool,
require_digit: bool,
require_special: bool,
special_chars: &'static str,
}
impl Default for PasswordRules {
fn default() -> Self {
Self {
min_length: 8,
require_uppercase: true,
require_lowercase: true,
require_digit: true,
require_special: true,
special_chars: DEFAULT_SPECIAL_CHARS,
}
}
}
impl PasswordRules {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_min_length(mut self, min_length: usize) -> Self {
self.min_length = min_length;
self
}
#[must_use]
pub fn with_require_uppercase(mut self, required: bool) -> Self {
self.require_uppercase = required;
self
}
#[must_use]
pub fn with_require_lowercase(mut self, required: bool) -> Self {
self.require_lowercase = required;
self
}
#[must_use]
pub fn with_require_digit(mut self, required: bool) -> Self {
self.require_digit = required;
self
}
#[must_use]
pub fn with_require_special(mut self, required: bool) -> Self {
self.require_special = required;
self
}
#[must_use]
pub fn with_special_chars(mut self, chars: &'static str) -> Self {
self.special_chars = chars;
self
}
#[must_use]
#[inline]
pub fn min_length(&self) -> usize {
self.min_length
}
}
pub fn validate_password_strength(
password: &str,
rules: &PasswordRules,
) -> Result<(), Vec<&'static str>> {
let mut errors = Vec::new();
if password.len() < rules.min_length {
errors.push("must meet the minimum length requirement");
}
if rules.require_uppercase && !password.chars().any(|c| c.is_ascii_uppercase()) {
errors.push("must contain at least one uppercase letter");
}
if rules.require_lowercase && !password.chars().any(|c| c.is_ascii_lowercase()) {
errors.push("must contain at least one lowercase letter");
}
if rules.require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
errors.push("must contain at least one digit");
}
if rules.require_special && !password.chars().any(|c| rules.special_chars.contains(c)) {
errors.push("must contain at least one special character");
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_empty() {
assert!(!is_valid_name(""));
}
#[test]
fn name_exceeds_max_length() {
let long = "a".repeat(MAX_NAME_LEN + 1);
assert!(!is_valid_name(&long));
}
#[test]
fn name_max_length_accepted() {
let name = "a".repeat(MAX_NAME_LEN);
assert!(is_valid_name(&name));
}
#[test]
fn name_starts_with_hyphen() {
assert!(!is_valid_name("-myapp"));
}
#[test]
fn name_starts_with_underscore() {
assert!(!is_valid_name("_myapp"));
}
#[test]
fn name_starts_with_period() {
assert!(!is_valid_name(".hidden"));
}
#[test]
fn name_contains_slash() {
assert!(!is_valid_name("foo/bar"));
}
#[test]
fn name_contains_backslash() {
assert!(!is_valid_name("foo\\bar"));
}
#[test]
fn name_contains_space() {
assert!(!is_valid_name("foo bar"));
}
#[test]
fn name_contains_null() {
assert!(!is_valid_name("foo\0bar"));
}
#[test]
fn name_contains_newline() {
assert!(!is_valid_name("foo\nbar"));
}
#[test]
fn name_contains_tab() {
assert!(!is_valid_name("foo\tbar"));
}
#[test]
fn name_consecutive_periods() {
assert!(!is_valid_name("foo..bar"));
}
#[test]
fn name_dot_dot() {
assert!(!is_valid_name(".."));
}
#[test]
fn name_single_dot() {
assert!(!is_valid_name("."));
}
#[test]
fn name_trailing_period() {
assert!(!is_valid_name("app."));
}
#[test]
fn name_shell_metacharacters() {
assert!(!is_valid_name("git;echo"));
assert!(!is_valid_name("git|cat"));
assert!(!is_valid_name("$(cmd)"));
}
#[test]
fn name_windows_reserved() {
for name in ["CON", "con", "PRN", "AUX", "NUL", "COM1", "LPT1"] {
assert!(!is_valid_name(name), "{name} should be rejected");
}
}
#[test]
fn name_windows_reserved_with_extension() {
assert!(!is_valid_name("CON.txt"));
assert!(!is_valid_name("nul.log"));
}
#[test]
fn name_simple() {
assert!(is_valid_name("my_app"));
}
#[test]
fn name_with_hyphens() {
assert!(is_valid_name("cargo-deny"));
}
#[test]
fn name_with_periods() {
assert!(is_valid_name("app.v2.1"));
}
#[test]
fn name_with_mixed_characters() {
assert!(is_valid_name("my-app_v2.1"));
}
#[test]
fn name_single_character() {
assert!(is_valid_name("a"));
}
#[test]
fn name_numeric_start() {
assert!(is_valid_name("7zip"));
}
#[test]
fn username_empty() {
assert!(!is_valid_username(""));
}
#[test]
fn username_exceeds_max_length() {
let long = "a".repeat(MAX_USERNAME_LEN + 1);
assert!(!is_valid_username(&long));
}
#[test]
fn username_max_length_accepted() {
let name = "a".repeat(MAX_USERNAME_LEN);
assert!(is_valid_username(&name));
}
#[test]
fn username_simple() {
assert!(is_valid_username("alice"));
}
#[test]
fn username_with_at_sign() {
assert!(is_valid_username("alice@example.com"));
}
#[test]
fn username_with_all_special_chars() {
assert!(is_valid_username("a.b-c_d@e"));
}
#[test]
fn username_numeric_start() {
assert!(is_valid_username("42user"));
}
#[test]
fn username_single_character() {
assert!(is_valid_username("a"));
}
#[test]
fn username_starts_with_hyphen() {
assert!(!is_valid_username("-alice"));
}
#[test]
fn username_starts_with_period() {
assert!(!is_valid_username(".alice"));
}
#[test]
fn username_starts_with_underscore() {
assert!(!is_valid_username("_alice"));
}
#[test]
fn username_starts_with_at() {
assert!(!is_valid_username("@alice"));
}
#[test]
fn username_consecutive_specials() {
assert!(!is_valid_username("alice..bob"));
assert!(!is_valid_username("alice.-bob"));
assert!(!is_valid_username("alice._bob"));
assert!(!is_valid_username("alice@-bob"));
assert!(!is_valid_username("alice-.bob"));
}
#[test]
fn username_contains_space() {
assert!(!is_valid_username("alice bob"));
}
#[test]
fn username_contains_null() {
assert!(!is_valid_username("alice\0bob"));
}
#[test]
fn username_contains_slash() {
assert!(!is_valid_username("alice/bob"));
}
#[test]
fn username_contains_hash() {
assert!(!is_valid_username("alice#1"));
}
#[test]
fn username_trailing_special_allowed() {
assert!(is_valid_username("alice@example.com"));
}
#[test]
fn redirect_uri_empty() {
assert!(!is_valid_redirect_uri(""));
}
#[test]
fn redirect_uri_exceeds_max_length() {
let long = format!("https://example.com/{}", "a".repeat(MAX_REDIRECT_URI_LEN));
assert!(!is_valid_redirect_uri(&long));
}
#[test]
fn redirect_uri_max_length_accepted() {
let padding = "a".repeat(MAX_REDIRECT_URI_LEN - "https://x.co/".len());
let uri = format!("https://x.co/{padding}");
assert_eq!(uri.len(), MAX_REDIRECT_URI_LEN);
assert!(is_valid_redirect_uri(&uri));
}
#[test]
fn redirect_uri_https() {
assert!(is_valid_redirect_uri("https://example.com/callback"));
}
#[test]
fn redirect_uri_https_with_port() {
assert!(is_valid_redirect_uri("https://example.com:8443/callback"));
}
#[test]
fn redirect_uri_https_with_query() {
assert!(is_valid_redirect_uri("https://example.com/cb?state=abc"));
}
#[test]
fn redirect_uri_http_localhost() {
assert!(is_valid_redirect_uri("http://localhost/callback"));
}
#[test]
fn redirect_uri_http_localhost_with_port() {
assert!(is_valid_redirect_uri("http://localhost:8080/callback"));
}
#[test]
fn redirect_uri_http_localhost_bare() {
assert!(is_valid_redirect_uri("http://localhost"));
}
#[test]
fn redirect_uri_http_127_0_0_1() {
assert!(is_valid_redirect_uri("http://127.0.0.1/callback"));
}
#[test]
fn redirect_uri_http_127_0_0_1_with_port() {
assert!(is_valid_redirect_uri("http://127.0.0.1:3000"));
}
#[test]
fn redirect_uri_http_non_loopback_rejected() {
assert!(!is_valid_redirect_uri("http://example.com/callback"));
}
#[test]
fn redirect_uri_http_localhost_like_rejected() {
assert!(!is_valid_redirect_uri("http://localhostevil.com/callback"));
}
#[test]
fn redirect_uri_http_127_0_0_1_like_rejected() {
assert!(!is_valid_redirect_uri("http://127.0.0.1evil.com"));
}
#[test]
fn redirect_uri_http_ipv6_loopback() {
assert!(is_valid_redirect_uri("http://[::1]/callback"));
}
#[test]
fn redirect_uri_http_ipv6_loopback_with_port() {
assert!(is_valid_redirect_uri("http://[::1]:8080/callback"));
}
#[test]
fn redirect_uri_fragment_rejected() {
assert!(!is_valid_redirect_uri("https://example.com/cb#frag"));
}
#[test]
fn redirect_uri_null_byte_rejected() {
assert!(!is_valid_redirect_uri("https://example.com/\0"));
}
#[test]
fn redirect_uri_plain_http_rejected() {
assert!(!is_valid_redirect_uri("http://remote-host.com/callback"));
}
#[test]
fn redirect_uri_ftp_rejected() {
assert!(!is_valid_redirect_uri("ftp://example.com/callback"));
}
#[test]
fn redirect_uri_no_scheme() {
assert!(!is_valid_redirect_uri("example.com/callback"));
}
#[test]
fn redirect_uri_localhost_with_query() {
assert!(is_valid_redirect_uri("http://localhost?code=abc"));
}
#[test]
fn scope_empty() {
assert!(!is_valid_scope(""));
}
#[test]
fn scope_exceeds_max_length() {
let long = "a".repeat(MAX_SCOPE_LEN + 1);
assert!(!is_valid_scope(&long));
}
#[test]
fn scope_max_length_accepted() {
let s = "a".repeat(MAX_SCOPE_LEN);
assert!(is_valid_scope(&s));
}
#[test]
fn scope_single_token() {
assert!(is_valid_scope("openid"));
}
#[test]
fn scope_multiple_tokens() {
assert!(is_valid_scope("openid profile email"));
}
#[test]
fn scope_with_special_nqchar() {
assert!(is_valid_scope("read!write"));
assert!(is_valid_scope("scope#1"));
assert!(is_valid_scope("a~b"));
}
#[test]
fn scope_leading_space_rejected() {
assert!(!is_valid_scope(" openid"));
}
#[test]
fn scope_trailing_space_rejected() {
assert!(!is_valid_scope("openid "));
}
#[test]
fn scope_consecutive_spaces_rejected() {
assert!(!is_valid_scope("openid profile"));
}
#[test]
fn scope_double_quote_rejected() {
assert!(!is_valid_scope("open\"id"));
}
#[test]
fn scope_backslash_rejected() {
assert!(!is_valid_scope("open\\id"));
}
#[test]
fn scope_control_char_rejected() {
assert!(!is_valid_scope("open\x01id"));
}
#[test]
fn scope_null_byte_rejected() {
assert!(!is_valid_scope("open\x00id"));
}
#[test]
fn scope_non_ascii_rejected() {
assert!(!is_valid_scope("öpenid"));
}
#[test]
fn scope_space_only_rejected() {
assert!(!is_valid_scope(" "));
}
#[test]
fn scope_nqchar_boundary_0x21() {
assert!(is_valid_scope("!"));
}
#[test]
fn scope_nqchar_boundary_0x20_rejected() {
assert!(!is_valid_scope(" "));
}
#[test]
fn scope_nqchar_boundary_0x7e() {
assert!(is_valid_scope("~"));
}
#[test]
fn scope_nqchar_boundary_0x7f_rejected() {
assert!(!is_valid_scope("\x7F"));
}
#[test]
fn client_id_empty() {
assert!(!is_valid_client_id(""));
}
#[test]
fn client_id_exceeds_max_length() {
let long = "a".repeat(MAX_CLIENT_ID_LEN + 1);
assert!(!is_valid_client_id(&long));
}
#[test]
fn client_id_max_length_accepted() {
let id = "a".repeat(MAX_CLIENT_ID_LEN);
assert!(is_valid_client_id(&id));
}
#[test]
fn client_id_simple() {
assert!(is_valid_client_id("my-client-123"));
}
#[test]
fn client_id_uuid() {
assert!(is_valid_client_id("550e8400-e29b-41d4-a716-446655440000"));
}
#[test]
fn client_id_with_spaces() {
assert!(is_valid_client_id("my client"));
}
#[test]
fn client_id_all_printable_ascii() {
let all_printable: String = (0x20u8..=0x7Eu8).map(|b| b as char).collect();
assert!(is_valid_client_id(&all_printable));
}
#[test]
fn client_id_null_byte_rejected() {
assert!(!is_valid_client_id("client\0id"));
}
#[test]
fn client_id_control_char_rejected() {
assert!(!is_valid_client_id("client\x01id"));
assert!(!is_valid_client_id("client\nid"));
assert!(!is_valid_client_id("client\tid"));
}
#[test]
fn client_id_del_rejected() {
assert!(!is_valid_client_id("client\x7Fid"));
}
#[test]
fn client_id_non_ascii_rejected() {
assert!(!is_valid_client_id("clïent"));
}
#[test]
fn client_id_single_char() {
assert!(is_valid_client_id("x"));
}
#[test]
fn client_id_single_space() {
assert!(is_valid_client_id(" "));
}
#[test]
fn https_url_valid() {
assert!(is_https_url("https://example.com"));
assert!(is_https_url("https://example.com/path?query=1"));
}
#[test]
fn https_url_rejects_http() {
assert!(!is_https_url("http://example.com"));
}
#[test]
fn https_url_rejects_empty() {
assert!(!is_https_url(""));
}
#[test]
fn https_url_rejects_no_scheme() {
assert!(!is_https_url("example.com"));
}
#[test]
fn email_valid_simple() {
assert!(is_valid_email("user@example.com"));
}
#[test]
fn email_valid_with_subdomain() {
assert!(is_valid_email("user@mail.example.com"));
}
#[test]
fn email_valid_with_plus() {
assert!(is_valid_email("user+tag@example.com"));
}
#[test]
fn email_valid_with_dots_in_local() {
assert!(is_valid_email("first.last@example.com"));
}
#[test]
fn email_empty() {
assert!(!is_valid_email(""));
}
#[test]
fn email_no_at() {
assert!(!is_valid_email("userexample.com"));
}
#[test]
fn email_double_at() {
assert!(!is_valid_email("user@@example.com"));
}
#[test]
fn email_no_domain() {
assert!(!is_valid_email("user@"));
}
#[test]
fn email_no_local() {
assert!(!is_valid_email("@example.com"));
}
#[test]
fn email_no_tld_dot() {
assert!(!is_valid_email("user@localhost"));
}
#[test]
fn email_consecutive_dots_local() {
assert!(!is_valid_email("user..name@example.com"));
}
#[test]
fn email_consecutive_dots_domain() {
assert!(!is_valid_email("user@example..com"));
}
#[test]
fn email_leading_dot_local() {
assert!(!is_valid_email(".user@example.com"));
}
#[test]
fn email_trailing_dot_local() {
assert!(!is_valid_email("user.@example.com"));
}
#[test]
fn email_leading_dot_domain() {
assert!(!is_valid_email("user@.example.com"));
}
#[test]
fn email_domain_label_leading_hyphen() {
assert!(!is_valid_email("user@-example.com"));
}
#[test]
fn email_domain_label_trailing_hyphen() {
assert!(!is_valid_email("user@example-.com"));
}
#[test]
fn email_with_space() {
assert!(!is_valid_email("user @example.com"));
}
#[test]
fn email_with_null() {
assert!(!is_valid_email("user\0@example.com"));
}
#[test]
fn email_exceeds_max_length() {
let local = "a".repeat(64);
let domain = format!("{}.com", "b".repeat(MAX_EMAIL_LEN));
let email = format!("{local}@{domain}");
assert!(!is_valid_email(&email));
}
#[test]
fn email_local_exceeds_64_bytes() {
let local = "a".repeat(65);
let email = format!("{local}@example.com");
assert!(!is_valid_email(&email));
}
#[test]
fn email_non_ascii_rejected() {
assert!(!is_valid_email("üser@example.com"));
}
#[test]
fn password_strength_valid() {
let rules = PasswordRules::default();
assert!(validate_password_strength("MyP@ssw0rd", &rules).is_ok());
}
#[test]
fn password_strength_too_short() {
let rules = PasswordRules::default();
let errors = validate_password_strength("Ab1!", &rules).unwrap_err();
assert!(errors.iter().any(|e| e.contains("minimum length")));
}
#[test]
fn password_strength_no_uppercase() {
let rules = PasswordRules::default();
let errors = validate_password_strength("myp@ssw0rd", &rules).unwrap_err();
assert!(errors.iter().any(|e| e.contains("uppercase")));
}
#[test]
fn password_strength_no_lowercase() {
let rules = PasswordRules::default();
let errors = validate_password_strength("MYP@SSW0RD", &rules).unwrap_err();
assert!(errors.iter().any(|e| e.contains("lowercase")));
}
#[test]
fn password_strength_no_digit() {
let rules = PasswordRules::default();
let errors = validate_password_strength("MyP@ssword", &rules).unwrap_err();
assert!(errors.iter().any(|e| e.contains("digit")));
}
#[test]
fn password_strength_no_special() {
let rules = PasswordRules::default();
let errors = validate_password_strength("MyPassw0rd", &rules).unwrap_err();
assert!(errors.iter().any(|e| e.contains("special")));
}
#[test]
fn password_strength_multiple_failures() {
let rules = PasswordRules::default();
let errors = validate_password_strength("short", &rules).unwrap_err();
assert!(errors.len() >= 3);
}
#[test]
fn password_strength_custom_min_length() {
let rules = PasswordRules::new().with_min_length(12);
let errors = validate_password_strength("MyP@ssw0rd", &rules).unwrap_err();
assert!(errors.iter().any(|e| e.contains("minimum length")));
}
#[test]
fn password_strength_relaxed_rules() {
let rules = PasswordRules::new()
.with_require_uppercase(false)
.with_require_special(false);
assert!(validate_password_strength("password1", &rules).is_ok());
}
#[test]
fn password_rules_builder() {
let rules = PasswordRules::new().with_min_length(16);
assert_eq!(rules.min_length(), 16);
}
}