use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, Default)]
pub struct PasswordContext<'a> {
pub username: Option<&'a str>,
pub email: Option<&'a str>,
}
impl<'a> PasswordContext<'a> {
pub fn empty() -> Self {
Self::default()
}
pub fn for_username(username: &'a str) -> Self {
Self {
username: Some(username),
email: None,
}
}
pub fn new(username: Option<&'a str>, email: Option<&'a str>) -> Self {
Self { username, email }
}
}
pub trait PasswordValidator: Send + Sync + std::fmt::Debug {
fn validate(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), String>;
}
#[derive(Debug, Clone, Copy)]
pub struct MinLengthValidator(pub usize);
impl Default for MinLengthValidator {
fn default() -> Self {
Self(8)
}
}
impl PasswordValidator for MinLengthValidator {
fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
let len = password.chars().count();
if len < self.0 {
Err(format!(
"This password is too short. It must contain at least {} characters.",
self.0
))
} else {
Ok(())
}
}
}
const COMMON_PASSWORDS: &str = include_str!("common_passwords.txt");
#[derive(Debug, Clone, Copy, Default)]
pub struct CommonPasswordValidator;
impl PasswordValidator for CommonPasswordValidator {
fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
let lower = password.trim().to_lowercase();
let hit = COMMON_PASSWORDS
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.any(|entry| entry == lower);
if hit {
Err("This password is too common.".to_string())
} else {
Ok(())
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NumericPasswordValidator;
impl PasswordValidator for NumericPasswordValidator {
fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
if !password.is_empty() && password.chars().all(|c| c.is_ascii_digit()) {
Err("This password is entirely numeric.".to_string())
} else {
Ok(())
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct UserAttributeSimilarityValidator {
pub threshold: f64,
}
impl Default for UserAttributeSimilarityValidator {
fn default() -> Self {
Self { threshold: 0.7 }
}
}
impl UserAttributeSimilarityValidator {
fn too_similar(&self, password: &str, attribute: &str) -> bool {
if attribute.chars().count() < 3 {
return false;
}
if password.contains(attribute) || attribute.contains(password) {
return true;
}
let pw_chars: std::collections::HashSet<char> = password.chars().collect();
let attr_chars: std::collections::HashSet<char> = attribute.chars().collect();
if attr_chars.is_empty() {
return false;
}
let shared = attr_chars.iter().filter(|c| pw_chars.contains(c)).count();
let ratio = shared as f64 / attr_chars.len() as f64;
ratio >= self.threshold
}
}
impl PasswordValidator for UserAttributeSimilarityValidator {
fn validate(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), String> {
let pw = password.to_lowercase();
let mut attributes: Vec<String> = Vec::new();
if let Some(username) = ctx.username {
attributes.push(username.to_lowercase());
}
if let Some(email) = ctx.email {
let email = email.to_lowercase();
if let Some((local, _domain)) = email.split_once('@') {
attributes.push(local.to_string());
}
attributes.push(email);
}
for attribute in attributes {
if self.too_similar(&pw, &attribute) {
return Err("This password is too similar to your username or email.".to_string());
}
}
Ok(())
}
}
#[derive(Debug)]
pub struct PasswordPolicy {
validators: Vec<Box<dyn PasswordValidator>>,
}
impl PasswordPolicy {
pub fn empty() -> Self {
Self {
validators: Vec::new(),
}
}
pub fn none() -> Self {
Self::empty()
}
pub fn new(validators: Vec<Box<dyn PasswordValidator>>) -> Self {
Self { validators }
}
pub fn with(mut self, validator: Box<dyn PasswordValidator>) -> Self {
self.validators.push(validator);
self
}
pub fn len(&self) -> usize {
self.validators.len()
}
pub fn is_empty(&self) -> bool {
self.validators.is_empty()
}
pub fn check(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), Vec<String>> {
let mut reasons = Vec::new();
for validator in &self.validators {
if let Err(reason) = validator.validate(password, ctx) {
reasons.push(reason);
}
}
if reasons.is_empty() {
Ok(())
} else {
Err(reasons)
}
}
}
impl PasswordPolicy {
pub fn recommended_defaults() -> Self {
Self::new(vec![
Box::new(MinLengthValidator::default()),
Box::new(CommonPasswordValidator),
Box::new(NumericPasswordValidator),
Box::new(UserAttributeSimilarityValidator::default()),
])
}
}
impl PasswordPolicy {
fn default_secure() -> Self {
Self::recommended_defaults()
}
}
impl std::default::Default for PasswordPolicy {
fn default() -> Self {
Self::recommended_defaults()
}
}
static PASSWORD_POLICY: OnceLock<PasswordPolicy> = OnceLock::new();
pub(crate) fn install_policy(policy: PasswordPolicy) {
let _ = PASSWORD_POLICY.set(policy);
}
pub fn validate_password(password: &str, ctx: &PasswordContext<'_>) -> Result<(), Vec<String>> {
match PASSWORD_POLICY.get() {
Some(policy) => policy.check(password, ctx),
None => {
PasswordPolicy::default_secure().check(password, ctx)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn min_length_rejects_short() {
let v = MinLengthValidator::default();
assert!(v.validate("abc", &PasswordContext::empty()).is_err());
assert!(v.validate("abcdefgh", &PasswordContext::empty()).is_ok());
}
#[test]
fn min_length_honours_custom_threshold() {
let v = MinLengthValidator(12);
assert!(v.validate("abcdefgh", &PasswordContext::empty()).is_err());
assert!(
v.validate("abcdefghijkl", &PasswordContext::empty())
.is_ok()
);
}
#[test]
fn common_rejects_password_case_insensitive() {
let v = CommonPasswordValidator;
assert!(v.validate("password", &PasswordContext::empty()).is_err());
assert!(v.validate("PASSWORD", &PasswordContext::empty()).is_err());
assert!(v.validate("qwerty", &PasswordContext::empty()).is_err());
assert!(v.validate("letmein", &PasswordContext::empty()).is_err());
assert!(
v.validate("Tr0ub4dour&3xpl", &PasswordContext::empty())
.is_ok()
);
}
#[test]
fn numeric_rejects_all_digits() {
let v = NumericPasswordValidator;
assert!(v.validate("12345678", &PasswordContext::empty()).is_err());
assert!(v.validate("0000000000", &PasswordContext::empty()).is_err());
assert!(v.validate("abc12345", &PasswordContext::empty()).is_ok());
assert!(v.validate("", &PasswordContext::empty()).is_ok());
}
#[test]
fn similarity_rejects_username_in_password() {
let v = UserAttributeSimilarityValidator::default();
let ctx = PasswordContext::for_username("alice");
assert!(v.validate("alice123", &ctx).is_err());
assert!(v.validate("Tr0ub4dour&3xpl", &ctx).is_ok());
}
#[test]
fn similarity_uses_email_local_part() {
let v = UserAttributeSimilarityValidator::default();
let ctx = PasswordContext::new(None, Some("bob.smith@example.com"));
assert!(v.validate("bob.smith99", &ctx).is_err());
}
#[test]
fn similarity_skips_short_attributes() {
let v = UserAttributeSimilarityValidator::default();
let ctx = PasswordContext::for_username("ab");
assert!(v.validate("Tr0ub4dour&3xpl", &ctx).is_ok());
}
#[test]
fn policy_aggregates_multiple_failures() {
let policy = PasswordPolicy::default();
let ctx = PasswordContext::for_username("alice");
let err = policy
.check("alice", &ctx)
.expect_err("weak password must fail");
assert!(
err.len() >= 2,
"expected multiple failure reasons, got {err:?}"
);
}
#[test]
fn strong_password_passes_all() {
let policy = PasswordPolicy::default();
let ctx = PasswordContext::new(Some("alice"), Some("alice@example.com"));
assert!(
policy.check("Tr0ub4dour&3xpl", &ctx).is_ok(),
"a strong password must pass the default policy"
);
}
#[test]
fn default_policy_is_not_empty() {
assert!(!PasswordPolicy::default().is_empty());
assert_eq!(PasswordPolicy::default().len(), 4);
}
#[test]
fn empty_policy_passes_everything() {
let policy = PasswordPolicy::empty();
assert!(policy.check("a", &PasswordContext::empty()).is_ok());
}
#[test]
fn validate_password_falls_back_to_secure_default() {
assert!(validate_password("a", &PasswordContext::empty()).is_err());
assert!(validate_password("Tr0ub4dour&3xpl", &PasswordContext::empty()).is_ok());
}
}