use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; use thiserror::Error; // ============================================================================ // Validation Error // ============================================================================ /// Errors that occur when parsing/validating value objects #[derive(Debug, Error, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum ValidationError { #[error("Invalid email format: {0}")] InvalidEmail(String), #[error("Password must be at least {min} characters, got {actual}")] PasswordTooShort { min: usize, actual: usize }, #[error("Invalid URL: {0}")] InvalidUrl(String), #[error("Value cannot be empty: {0}")] Empty(String), #[error("Secret too short: minimum {min} bytes required, got {actual}")] SecretTooShort { min: usize, actual: usize }, } // ============================================================================ // Email (using email_address crate for RFC-compliant validation) // ============================================================================ /// A validated email address using RFC-compliant validation. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Email(email_address::EmailAddress); impl Email { /// Create a new validated email address pub fn new(value: impl AsRef) -> Result { let value = value.as_ref().trim().to_lowercase(); let addr: email_address::EmailAddress = value .parse() .map_err(|_| ValidationError::InvalidEmail(value.clone()))?; Ok(Self(addr)) } /// Get the inner value pub fn into_inner(self) -> String { self.0.to_string() } } impl AsRef for Email { fn as_ref(&self) -> &str { self.0.as_ref() } } impl fmt::Display for Email { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl TryFrom for Email { type Error = ValidationError; fn try_from(value: String) -> Result { Self::new(value) } } impl TryFrom<&str> for Email { type Error = ValidationError; fn try_from(value: &str) -> Result { Self::new(value) } } impl Serialize for Email { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(self.0.as_ref()) } } impl<'de> Deserialize<'de> for Email { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; Self::new(s).map_err(serde::de::Error::custom) } } // ============================================================================ // Password // ============================================================================ /// A validated password input (NOT the hash). /// /// Enforces minimum length of 6 characters. #[derive(Clone, PartialEq, Eq)] pub struct Password(String); /// Minimum password length (NIST recommendation) pub const MIN_PASSWORD_LENGTH: usize = 8; impl Password { pub fn new(value: impl Into) -> Result { let value = value.into(); if value.len() < MIN_PASSWORD_LENGTH { return Err(ValidationError::PasswordTooShort { min: MIN_PASSWORD_LENGTH, actual: value.len(), }); } Ok(Self(value)) } pub fn into_inner(self) -> String { self.0 } } impl AsRef for Password { fn as_ref(&self) -> &str { &self.0 } } // Intentionally hide password content in Debug impl fmt::Debug for Password { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Password(***)") } } impl TryFrom for Password { type Error = ValidationError; fn try_from(value: String) -> Result { Self::new(value) } } impl TryFrom<&str> for Password { type Error = ValidationError; fn try_from(value: &str) -> Result { Self::new(value) } } impl<'de> Deserialize<'de> for Password { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; Self::new(s).map_err(serde::de::Error::custom) } } // Note: Password should NOT implement Serialize to prevent accidental exposure // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; mod email_tests { use super::*; #[test] fn test_valid_email() { assert!(Email::new("user@example.com").is_ok()); assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase assert!(Email::new(" user@example.com ").is_ok()); // Should trim } #[test] fn test_email_normalizes() { let email = Email::new(" USER@EXAMPLE.COM ").unwrap(); assert_eq!(email.as_ref(), "user@example.com"); } #[test] fn test_invalid_email_no_at() { assert!(Email::new("userexample.com").is_err()); } #[test] fn test_invalid_email_no_domain() { assert!(Email::new("user@").is_err()); } #[test] fn test_invalid_email_no_local() { assert!(Email::new("@example.com").is_err()); } } mod password_tests { use super::*; #[test] fn test_valid_password() { assert!(Password::new("secret123").is_ok()); assert!(Password::new("12345678").is_ok()); // Exactly 8 chars } #[test] fn test_password_too_short() { assert!(Password::new("1234567").is_err()); // 7 chars assert!(Password::new("").is_err()); } #[test] fn test_password_debug_hides_content() { let password = Password::new("supersecret").unwrap(); let debug = format!("{:?}", password); assert!(!debug.contains("supersecret")); assert!(debug.contains("***")); } } }