Skip to main content

nntp_proxy/types/
validated.rs

1//! Validated string types that enforce invariants at construction time
2
3use nutype::nutype;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9/// Validation errors for string types
10#[derive(Debug, Clone, Error, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum ValidationError {
13    #[error("hostname cannot be empty or whitespace")]
14    EmptyHostName,
15    #[error("server name cannot be empty or whitespace")]
16    EmptyServerName,
17    #[error("invalid hostname: {0}")]
18    InvalidHostName(String),
19    #[error("port cannot be 0")]
20    InvalidPort,
21    #[error("invalid message ID: {0}")]
22    InvalidMessageId(String),
23    #[error("config path cannot be empty")]
24    EmptyConfigPath,
25    #[error("username cannot be empty or whitespace")]
26    EmptyUsername,
27    #[error("password cannot be empty or whitespace")]
28    EmptyPassword,
29}
30
31/// Validated hostname (non-empty, non-whitespace)
32#[nutype(
33    sanitize(trim),
34    validate(not_empty),
35    derive(
36        Debug,
37        Clone,
38        PartialEq,
39        Eq,
40        Hash,
41        Display,
42        AsRef,
43        Deref,
44        TryFrom,
45        Serialize,
46        Deserialize
47    )
48)]
49pub struct HostName(String);
50
51/// Validated server name (non-empty, non-whitespace)
52#[nutype(
53    sanitize(trim),
54    validate(not_empty),
55    derive(
56        Debug,
57        Clone,
58        PartialEq,
59        Eq,
60        Hash,
61        Display,
62        AsRef,
63        Deref,
64        TryFrom,
65        Serialize,
66        Deserialize
67    )
68)]
69pub struct ServerName(String);
70
71/// Validated username (non-empty, non-whitespace)
72#[nutype(
73    sanitize(trim),
74    validate(not_empty),
75    derive(
76        Debug,
77        Clone,
78        PartialEq,
79        Eq,
80        Hash,
81        Display,
82        AsRef,
83        Deref,
84        TryFrom,
85        Serialize,
86        Deserialize
87    )
88)]
89pub struct Username(String);
90
91/// Validated password (non-empty, non-whitespace)
92#[nutype(
93    sanitize(trim),
94    validate(not_empty),
95    derive(
96        Debug,
97        Clone,
98        PartialEq,
99        Eq,
100        Hash,
101        Display,
102        AsRef,
103        Deref,
104        TryFrom,
105        Serialize,
106        Deserialize
107    )
108)]
109pub struct Password(String);
110
111// Convert nutype errors to our ValidationError
112impl From<HostNameError> for ValidationError {
113    fn from(_: HostNameError) -> Self {
114        Self::EmptyHostName
115    }
116}
117
118impl From<ServerNameError> for ValidationError {
119    fn from(_: ServerNameError) -> Self {
120        Self::EmptyServerName
121    }
122}
123
124impl From<UsernameError> for ValidationError {
125    fn from(_: UsernameError) -> Self {
126        Self::EmptyUsername
127    }
128}
129
130impl From<PasswordError> for ValidationError {
131    fn from(_: PasswordError) -> Self {
132        Self::EmptyPassword
133    }
134}
135
136/// Validated configuration file path (non-empty, non-whitespace)
137#[derive(Debug, Clone, PartialEq, Eq, Hash)]
138pub struct ConfigPath(PathBuf);
139
140impl ConfigPath {
141    /// Create a validated configuration path.
142    ///
143    /// # Errors
144    /// Returns `ValidationError::EmptyConfigPath` when the path is empty,
145    /// whitespace-only, or not valid UTF-8.
146    pub fn new(path: impl AsRef<Path>) -> Result<Self, ValidationError> {
147        let path_ref = path.as_ref();
148        let path_str = path_ref.to_str().ok_or(ValidationError::EmptyConfigPath)?;
149        if path_str.trim().is_empty() {
150            return Err(ValidationError::EmptyConfigPath);
151        }
152        Ok(Self(path_ref.to_path_buf()))
153    }
154
155    #[must_use]
156    #[inline]
157    pub fn as_path(&self) -> &Path {
158        &self.0
159    }
160
161    #[must_use]
162    #[inline]
163    pub fn as_str(&self) -> &str {
164        self.0.to_str().unwrap_or("")
165    }
166}
167
168impl AsRef<Path> for ConfigPath {
169    #[inline]
170    fn as_ref(&self) -> &Path {
171        &self.0
172    }
173}
174
175impl fmt::Display for ConfigPath {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        write!(f, "{}", self.0.display())
178    }
179}
180
181impl std::str::FromStr for ConfigPath {
182    type Err = ValidationError;
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        Self::new(s)
185    }
186}
187
188impl TryFrom<String> for ConfigPath {
189    type Error = ValidationError;
190    fn try_from(s: String) -> Result<Self, Self::Error> {
191        Self::new(s)
192    }
193}
194
195impl TryFrom<&str> for ConfigPath {
196    type Error = ValidationError;
197    fn try_from(s: &str) -> Result<Self, Self::Error> {
198        Self::new(s)
199    }
200}
201
202impl Serialize for ConfigPath {
203    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
204    where
205        S: serde::Serializer,
206    {
207        serializer.serialize_str(self.as_str())
208    }
209}
210
211impl<'de> Deserialize<'de> for ConfigPath {
212    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
213    where
214        D: serde::Deserializer<'de>,
215    {
216        let s = String::deserialize(deserializer)?;
217        Self::new(s).map_err(serde::de::Error::custom)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use proptest::prelude::*;
225
226    // Property test strategies for non-empty strings
227    fn non_empty_string() -> impl Strategy<Value = String> {
228        "[a-zA-Z0-9._@-]{1,100}"
229    }
230
231    fn path_string() -> impl Strategy<Value = String> {
232        "[a-zA-Z0-9._/-]{1,100}"
233    }
234
235    // Property tests for HostName
236    proptest! {
237        #[test]
238        fn hostname_serde_roundtrip(s in non_empty_string()) {
239            let hostname = HostName::try_new(s).unwrap();
240            let json = serde_json::to_string(&hostname).unwrap();
241            let deserialized: HostName = serde_json::from_str(&json).unwrap();
242            prop_assert_eq!(hostname, deserialized);
243        }
244    }
245
246    // Property tests for ServerName
247    proptest! {
248        #[test]
249        fn server_name_serde_roundtrip(s in non_empty_string()) {
250            let server = ServerName::try_new(s).unwrap();
251            let json = serde_json::to_string(&server).unwrap();
252            let deserialized: ServerName = serde_json::from_str(&json).unwrap();
253            prop_assert_eq!(server, deserialized);
254        }
255    }
256
257    // Property tests for Username
258    proptest! {
259        #[test]
260        fn username_serde_roundtrip(s in non_empty_string()) {
261            let username = Username::try_new(s).unwrap();
262            let json = serde_json::to_string(&username).unwrap();
263            let deserialized: Username = serde_json::from_str(&json).unwrap();
264            prop_assert_eq!(username, deserialized);
265        }
266    }
267
268    // Property tests for Password
269    proptest! {
270        #[test]
271        fn password_serde_roundtrip(s in non_empty_string()) {
272            let password = Password::try_new(s).unwrap();
273            let json = serde_json::to_string(&password).unwrap();
274            let deserialized: Password = serde_json::from_str(&json).unwrap();
275            prop_assert_eq!(password, deserialized);
276        }
277    }
278
279    // Property tests for ConfigPath
280    proptest! {
281        #[test]
282        fn config_path_non_empty_accepts(s in path_string()) {
283            let config = ConfigPath::try_from(s.clone()).unwrap();
284            prop_assert_eq!(config.as_str(), &s);
285        }
286
287        #[test]
288        fn config_path_as_path_roundtrip(s in path_string()) {
289            let config = ConfigPath::try_from(s.clone()).unwrap();
290            let path: &Path = config.as_ref();
291            prop_assert_eq!(path, Path::new(&s));
292        }
293
294        #[test]
295        fn config_path_serde_roundtrip(s in path_string()) {
296            let config = ConfigPath::try_from(s).unwrap();
297            let json = serde_json::to_string(&config).unwrap();
298            let deserialized: ConfigPath = serde_json::from_str(&json).unwrap();
299            prop_assert_eq!(config, deserialized);
300        }
301    }
302
303    // Edge case tests - verify sanitization behavior
304    #[test]
305    fn username_with_spaces_accepted() {
306        // Username with spaces in content is valid (trim only removes leading/trailing)
307        assert!(Username::try_new("  user  ".to_string()).is_ok());
308        assert!(Username::try_new("user name".to_string()).is_ok());
309    }
310
311    #[test]
312    fn password_with_spaces_accepted() {
313        assert!(Password::try_new("   pass   ".to_string()).is_ok());
314        assert!(Password::try_new("P@ssw0rd!".to_string()).is_ok());
315        assert!(Password::try_new("密码123".to_string()).is_ok());
316    }
317
318    #[test]
319    fn config_path_with_spaces_accepted() {
320        assert!(ConfigPath::try_from("my config.toml").is_ok());
321        assert!(ConfigPath::try_from("/absolute/path/config.toml").is_ok());
322        assert!(ConfigPath::try_from("./relative/config.toml").is_ok());
323        assert!(ConfigPath::try_from("../parent/config.toml").is_ok());
324    }
325
326    // Deserialization failure tests
327    #[test]
328    fn hostname_deserialize_empty_fails() {
329        let json = "\"\"";
330        let result: Result<HostName, _> = serde_json::from_str(json);
331        assert!(result.is_err());
332    }
333
334    #[test]
335    fn config_path_deserialize_empty_fails() {
336        let json = "\"\"";
337        let result: Result<ConfigPath, _> = serde_json::from_str(json);
338        assert!(result.is_err());
339    }
340
341    // ValidationError tests
342    #[test]
343    fn validation_error_messages() {
344        assert_eq!(
345            ValidationError::EmptyHostName.to_string(),
346            "hostname cannot be empty or whitespace"
347        );
348        assert_eq!(
349            ValidationError::EmptyServerName.to_string(),
350            "server name cannot be empty or whitespace"
351        );
352        assert_eq!(
353            ValidationError::EmptyUsername.to_string(),
354            "username cannot be empty or whitespace"
355        );
356        assert_eq!(
357            ValidationError::EmptyPassword.to_string(),
358            "password cannot be empty or whitespace"
359        );
360        assert_eq!(
361            ValidationError::EmptyConfigPath.to_string(),
362            "config path cannot be empty"
363        );
364    }
365
366    #[test]
367    fn validation_error_clone() {
368        let err = ValidationError::EmptyHostName;
369        let cloned = err.clone();
370        assert_eq!(err, cloned);
371    }
372}