Skip to main content

nythos_core/domain/
value_objects.rs

1use std::fmt;
2use std::str::FromStr;
3
4use crate::{AuthError, NythosResult};
5
6/// Validated email value object used as the core input boundary.
7///
8/// Construction normalizes the email into a stable lookup from:
9/// - trims surrounding whitespace
10/// - requires exactly one '@'
11/// - lowercases the full address
12/// - rejects empty local/domain parts
13/// - rejects whitespace inside the address
14#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub struct Email(String);
16
17impl Email {
18    /// Parses and validates an email input into its normalized form.
19    pub fn parse(input: impl AsRef<str>) -> NythosResult<Self> {
20        let raw = input.as_ref().trim();
21
22        if raw.is_empty() {
23            return Err(AuthError::ValidationError(
24                "email cannot be empty".to_owned(),
25            ));
26        }
27
28        if raw.chars().any(char::is_whitespace) {
29            return Err(AuthError::ValidationError(
30                "email cannot contain whitespace".to_owned(),
31            ));
32        }
33
34        let (local, domain) = raw.split_once("@").ok_or_else(|| {
35            AuthError::ValidationError("email must contain a single @".to_owned())
36        })?;
37
38        if local.is_empty() || domain.is_empty() || domain.contains('@') {
39            return Err(AuthError::ValidationError(
40                "email must contain a single @ with non-empty local and domain parts".to_owned(),
41            ));
42        }
43
44        if domain.starts_with('.') || domain.ends_with('.') || !domain.contains('.') {
45            return Err(AuthError::ValidationError(
46                "email domain must be valid".to_owned(),
47            ));
48        }
49
50        let normalized = raw.to_ascii_lowercase();
51
52        Ok(Self(normalized))
53    }
54
55    /// Returns the normalized email string.
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59
60    /// Consumes the value object and returns the normalized email string.
61    pub fn into_inner(self) -> String {
62        self.0
63    }
64}
65
66impl AsRef<str> for Email {
67    fn as_ref(&self) -> &str {
68        self.as_str()
69    }
70}
71
72impl fmt::Display for Email {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        self.0.fmt(f)
75    }
76}
77
78impl FromStr for Email {
79    type Err = AuthError;
80
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        Self::parse(s)
83    }
84}
85
86/// Raw validated password input.
87///
88/// This is intentionally distinct from a stored password hash. It represents
89/// inbound credential material that has passed the core validation boundary.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Password(String);
92
93impl Password {
94    const MIN_LEN: usize = 8;
95    const MAX_LEN: usize = 1024;
96
97    /// Validates and constructs a raw password input.
98    pub fn new(input: impl AsRef<str>) -> NythosResult<Self> {
99        let raw = input.as_ref();
100
101        if raw.is_empty() {
102            return Err(AuthError::ValidationError(
103                "password cannot be empty".to_owned(),
104            ));
105        }
106
107        if raw.len() < Self::MIN_LEN {
108            return Err(AuthError::ValidationError(format!(
109                "password must be at least {} characters",
110                Self::MIN_LEN
111            )));
112        }
113
114        if raw.len() > Self::MAX_LEN {
115            return Err(AuthError::ValidationError(format!(
116                "password must be at most {} characters",
117                Self::MAX_LEN
118            )));
119        }
120
121        if raw.chars().any(|c| c == '\n' || c == '\r') {
122            return Err(AuthError::ValidationError(
123                "password cannot contain newlines".to_owned(),
124            ));
125        }
126
127        Ok(Self(raw.to_owned()))
128    }
129
130    /// Returns the validated raw password as a string slice.
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134
135    /// Consumes the password input and returns the owned string.
136    pub fn into_inner(self) -> String {
137        self.0
138    }
139}
140
141impl AsRef<str> for Password {
142    fn as_ref(&self) -> &str {
143        self.as_str()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::{Email, Password};
150    use crate::AuthError;
151
152    #[test]
153    fn email_normalizes_for_stable_lookup() {
154        let email = Email::parse("  Alice.Example@Example.COM").unwrap();
155
156        assert_eq!(email.as_str(), "alice.example@example.com");
157    }
158
159    #[test]
160    fn email_rejects_empty_input() {
161        let error = Email::parse("   ").unwrap_err();
162
163        assert_eq!(
164            error,
165            AuthError::ValidationError("email cannot be empty".to_owned())
166        )
167    }
168
169    #[test]
170    fn email_rejects_invalid_shapes() {
171        assert!(matches!(
172            Email::parse("missing-at.example.com"),
173            Err(AuthError::ValidationError(_))
174        ));
175        assert!(matches!(
176            Email::parse("a@b"),
177            Err(AuthError::ValidationError(_))
178        ));
179        assert!(matches!(
180            Email::parse("a@@example.com"),
181            Err(AuthError::ValidationError(_))
182        ));
183        assert!(matches!(
184            Email::parse("a @example.com"),
185            Err(AuthError::ValidationError(_))
186        ));
187    }
188
189    #[test]
190    fn password_accepts_valid_raw_input() {
191        let password = Password::new("correct-horse-battery-staple").unwrap();
192
193        assert_eq!(password.as_str(), "correct-horse-battery-staple");
194    }
195
196    #[test]
197    fn password_rejects_empty_short_and_newline_inputs() {
198        assert!(matches!(
199            Password::new(""),
200            Err(AuthError::ValidationError(_))
201        ));
202        assert!(matches!(
203            Password::new("short"),
204            Err(AuthError::ValidationError(_))
205        ));
206        assert!(matches!(
207            Password::new("line\nbreak"),
208            Err(AuthError::ValidationError(_))
209        ));
210    }
211}