nntp_proxy/types/
validated.rs

1//! Validated string types that enforce invariants at construction time
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8/// Validation errors for string types
9#[derive(Debug, Clone, Error, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum ValidationError {
12    #[error("hostname cannot be empty or whitespace")]
13    EmptyHostName,
14    #[error("server name cannot be empty or whitespace")]
15    EmptyServerName,
16    #[error("invalid hostname: {0}")]
17    InvalidHostName(String),
18    #[error("port cannot be 0")]
19    InvalidPort,
20    #[error("invalid message ID: {0}")]
21    InvalidMessageId(String),
22    #[error("config path cannot be empty")]
23    EmptyConfigPath,
24    #[error("username cannot be empty or whitespace")]
25    EmptyUsername,
26    #[error("password cannot be empty or whitespace")]
27    EmptyPassword,
28}
29
30/// Generate validated non-empty string newtypes
31macro_rules! validated_string {
32    ($(#[$meta:meta])* $vis:vis struct $name:ident($err:path);) => {
33        $(#[$meta])*
34        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
35        #[serde(transparent)]
36        $vis struct $name(String);
37
38        impl $name {
39            pub fn new(s: String) -> Result<Self, ValidationError> {
40                if s.trim().is_empty() {
41                    Err($err)
42                } else {
43                    Ok(Self(s))
44                }
45            }
46
47            #[must_use]
48            #[inline]
49            pub fn as_str(&self) -> &str {
50                &self.0
51            }
52        }
53
54        impl AsRef<str> for $name {
55            #[inline]
56            fn as_ref(&self) -> &str {
57                &self.0
58            }
59        }
60
61        impl std::ops::Deref for $name {
62            type Target = str;
63            #[inline]
64            fn deref(&self) -> &Self::Target {
65                &self.0
66            }
67        }
68
69        impl fmt::Display for $name {
70            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71                f.write_str(&self.0)
72            }
73        }
74
75        impl TryFrom<String> for $name {
76            type Error = ValidationError;
77            fn try_from(s: String) -> Result<Self, Self::Error> {
78                Self::new(s)
79            }
80        }
81
82        impl<'de> Deserialize<'de> for $name {
83            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84            where
85                D: serde::Deserializer<'de>,
86            {
87                let s = String::deserialize(deserializer)?;
88                Self::new(s).map_err(serde::de::Error::custom)
89            }
90        }
91    };
92}
93
94validated_string! {
95    /// Validated hostname (non-empty, non-whitespace)
96    pub struct HostName(ValidationError::EmptyHostName);
97}
98
99validated_string! {
100    /// Validated server name (non-empty, non-whitespace)
101    pub struct ServerName(ValidationError::EmptyServerName);
102}
103
104validated_string! {
105    /// Validated username (non-empty, non-whitespace)
106    pub struct Username(ValidationError::EmptyUsername);
107}
108
109validated_string! {
110    /// Validated password (non-empty, non-whitespace)
111    pub struct Password(ValidationError::EmptyPassword);
112}
113
114/// Validated configuration file path (non-empty, non-whitespace)
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116pub struct ConfigPath(PathBuf);
117
118impl ConfigPath {
119    pub fn new(path: impl AsRef<Path>) -> Result<Self, ValidationError> {
120        let path_ref = path.as_ref();
121        let path_str = path_ref.to_str().ok_or(ValidationError::EmptyConfigPath)?;
122        if path_str.trim().is_empty() {
123            return Err(ValidationError::EmptyConfigPath);
124        }
125        Ok(Self(path_ref.to_path_buf()))
126    }
127
128    #[must_use]
129    #[inline]
130    pub fn as_path(&self) -> &Path {
131        &self.0
132    }
133
134    #[must_use]
135    #[inline]
136    pub fn as_str(&self) -> &str {
137        self.0.to_str().unwrap_or("")
138    }
139}
140
141impl AsRef<Path> for ConfigPath {
142    #[inline]
143    fn as_ref(&self) -> &Path {
144        &self.0
145    }
146}
147
148impl fmt::Display for ConfigPath {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        write!(f, "{}", self.0.display())
151    }
152}
153
154impl std::str::FromStr for ConfigPath {
155    type Err = ValidationError;
156    fn from_str(s: &str) -> Result<Self, Self::Err> {
157        Self::new(s)
158    }
159}
160
161impl TryFrom<String> for ConfigPath {
162    type Error = ValidationError;
163    fn try_from(s: String) -> Result<Self, Self::Error> {
164        Self::new(s)
165    }
166}
167
168impl TryFrom<&str> for ConfigPath {
169    type Error = ValidationError;
170    fn try_from(s: &str) -> Result<Self, Self::Error> {
171        Self::new(s)
172    }
173}
174
175impl Serialize for ConfigPath {
176    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
177    where
178        S: serde::Serializer,
179    {
180        serializer.serialize_str(self.as_str())
181    }
182}
183
184impl<'de> Deserialize<'de> for ConfigPath {
185    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
186    where
187        D: serde::Deserializer<'de>,
188    {
189        let s = String::deserialize(deserializer)?;
190        Self::new(s).map_err(serde::de::Error::custom)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use proptest::prelude::*;
198    use std::collections::HashSet;
199
200    // Property test strategies for non-empty strings
201    fn non_empty_string() -> impl Strategy<Value = String> {
202        "[a-zA-Z0-9._@-]{1,100}"
203    }
204
205    fn path_string() -> impl Strategy<Value = String> {
206        "[a-zA-Z0-9._/-]{1,100}"
207    }
208
209    // Property tests for HostName
210    proptest! {
211        #[test]
212        fn hostname_non_empty_accepts(s in non_empty_string()) {
213            let hostname = HostName::new(s.clone()).unwrap();
214            prop_assert_eq!(hostname.as_str(), &s);
215        }
216
217        #[test]
218        fn hostname_roundtrip_string(s in non_empty_string()) {
219            let hostname = HostName::new(s.clone()).unwrap();
220            let as_str: &str = hostname.as_ref();
221            prop_assert_eq!(as_str, &s);
222        }
223
224        #[test]
225        fn hostname_deref_works(s in non_empty_string()) {
226            let hostname = HostName::new(s.clone()).unwrap();
227            prop_assert_eq!(&*hostname, &s);
228            prop_assert_eq!(hostname.len(), s.len());
229        }
230
231        #[test]
232        fn hostname_display(s in non_empty_string()) {
233            let hostname = HostName::new(s.clone()).unwrap();
234            prop_assert_eq!(format!("{}", hostname), s);
235        }
236
237        #[test]
238        fn hostname_try_from(s in non_empty_string()) {
239            let result: Result<HostName, _> = s.clone().try_into();
240            prop_assert!(result.is_ok());
241            let hostname = result.unwrap();
242            prop_assert_eq!(hostname.as_str(), &s);
243        }
244
245        #[test]
246        fn hostname_serde_roundtrip(s in non_empty_string()) {
247            let hostname = HostName::new(s).unwrap();
248            let json = serde_json::to_string(&hostname).unwrap();
249            let deserialized: HostName = serde_json::from_str(&json).unwrap();
250            prop_assert_eq!(hostname, deserialized);
251        }
252
253        #[test]
254        fn hostname_clone_equality(s in non_empty_string()) {
255            let hostname = HostName::new(s).unwrap();
256            let cloned = hostname.clone();
257            prop_assert_eq!(hostname, cloned);
258        }
259    }
260
261    // Property tests for ServerName
262    proptest! {
263        #[test]
264        fn server_name_non_empty_accepts(s in non_empty_string()) {
265            let server = ServerName::new(s.clone()).unwrap();
266            prop_assert_eq!(server.as_str(), &s);
267        }
268
269        #[test]
270        fn server_name_deref_works(s in non_empty_string()) {
271            let server = ServerName::new(s.clone()).unwrap();
272            prop_assert_eq!(&*server, &s);
273        }
274
275        #[test]
276        fn server_name_serde_roundtrip(s in non_empty_string()) {
277            let server = ServerName::new(s).unwrap();
278            let json = serde_json::to_string(&server).unwrap();
279            let deserialized: ServerName = serde_json::from_str(&json).unwrap();
280            prop_assert_eq!(server, deserialized);
281        }
282    }
283
284    // Property tests for Username
285    proptest! {
286        #[test]
287        fn username_non_empty_accepts(s in non_empty_string()) {
288            let username = Username::new(s.clone()).unwrap();
289            prop_assert_eq!(username.as_str(), &s);
290        }
291
292        #[test]
293        fn username_serde_roundtrip(s in non_empty_string()) {
294            let username = Username::new(s).unwrap();
295            let json = serde_json::to_string(&username).unwrap();
296            let deserialized: Username = serde_json::from_str(&json).unwrap();
297            prop_assert_eq!(username, deserialized);
298        }
299    }
300
301    // Property tests for Password
302    proptest! {
303        #[test]
304        fn password_non_empty_accepts(s in non_empty_string()) {
305            let password = Password::new(s.clone()).unwrap();
306            prop_assert_eq!(password.as_str(), &s);
307        }
308
309        #[test]
310        fn password_serde_roundtrip(s in non_empty_string()) {
311            let password = Password::new(s).unwrap();
312            let json = serde_json::to_string(&password).unwrap();
313            let deserialized: Password = serde_json::from_str(&json).unwrap();
314            prop_assert_eq!(password, deserialized);
315        }
316    }
317
318    // Property tests for ConfigPath
319    proptest! {
320        #[test]
321        fn config_path_non_empty_accepts(s in path_string()) {
322            let config = ConfigPath::try_from(s.clone()).unwrap();
323            prop_assert_eq!(config.as_str(), &s);
324        }
325
326        #[test]
327        fn config_path_as_path_roundtrip(s in path_string()) {
328            let config = ConfigPath::try_from(s.clone()).unwrap();
329            let path: &Path = config.as_ref();
330            prop_assert_eq!(path, Path::new(&s));
331        }
332
333        #[test]
334        fn config_path_serde_roundtrip(s in path_string()) {
335            let config = ConfigPath::try_from(s).unwrap();
336            let json = serde_json::to_string(&config).unwrap();
337            let deserialized: ConfigPath = serde_json::from_str(&json).unwrap();
338            prop_assert_eq!(config, deserialized);
339        }
340    }
341
342    // Edge case tests - empty validation
343    #[test]
344    fn hostname_empty_rejected() {
345        assert!(matches!(
346            HostName::new("".to_string()),
347            Err(ValidationError::EmptyHostName)
348        ));
349    }
350
351    #[test]
352    fn hostname_whitespace_rejected() {
353        assert!(matches!(
354            HostName::new("   ".to_string()),
355            Err(ValidationError::EmptyHostName)
356        ));
357    }
358
359    #[test]
360    fn server_name_empty_rejected() {
361        assert!(matches!(
362            ServerName::new("".to_string()),
363            Err(ValidationError::EmptyServerName)
364        ));
365    }
366
367    #[test]
368    fn server_name_whitespace_rejected() {
369        assert!(matches!(
370            ServerName::new("\t".to_string()),
371            Err(ValidationError::EmptyServerName)
372        ));
373    }
374
375    #[test]
376    fn username_empty_rejected() {
377        assert!(matches!(
378            Username::new("".to_string()),
379            Err(ValidationError::EmptyUsername)
380        ));
381    }
382
383    #[test]
384    fn username_whitespace_only_rejected() {
385        assert!(Username::new("   ".to_string()).is_err());
386        assert!(Username::new("\t\n".to_string()).is_err());
387    }
388
389    #[test]
390    fn username_with_spaces_accepted() {
391        // Username with spaces in content is valid (trim checks if empty)
392        assert!(Username::new("  user  ".to_string()).is_ok());
393        assert!(Username::new("user name".to_string()).is_ok());
394    }
395
396    #[test]
397    fn username_special_characters_accepted() {
398        assert!(Username::new("user@domain.com".to_string()).is_ok());
399        assert!(Username::new("user-123".to_string()).is_ok());
400        assert!(Username::new("user_name".to_string()).is_ok());
401    }
402
403    #[test]
404    fn password_empty_rejected() {
405        assert!(matches!(
406            Password::new("".to_string()),
407            Err(ValidationError::EmptyPassword)
408        ));
409    }
410
411    #[test]
412    fn password_whitespace_only_rejected() {
413        assert!(Password::new("   ".to_string()).is_err());
414    }
415
416    #[test]
417    fn password_with_spaces_accepted() {
418        assert!(Password::new("   pass   ".to_string()).is_ok());
419        assert!(Password::new("P@ssw0rd!".to_string()).is_ok());
420        assert!(Password::new("密码123".to_string()).is_ok());
421    }
422
423    #[test]
424    fn config_path_empty_rejected() {
425        assert!(matches!(
426            ConfigPath::try_from(""),
427            Err(ValidationError::EmptyConfigPath)
428        ));
429    }
430
431    #[test]
432    fn config_path_whitespace_rejected() {
433        assert!(ConfigPath::try_from("   ").is_err());
434    }
435
436    #[test]
437    fn config_path_with_spaces_accepted() {
438        assert!(ConfigPath::try_from("my config.toml").is_ok());
439        assert!(ConfigPath::try_from("/absolute/path/config.toml").is_ok());
440        assert!(ConfigPath::try_from("./relative/config.toml").is_ok());
441        assert!(ConfigPath::try_from("../parent/config.toml").is_ok());
442    }
443
444    // Deserialization failure tests
445    #[test]
446    fn hostname_deserialize_empty_fails() {
447        let json = "\"\"";
448        let result: Result<HostName, _> = serde_json::from_str(json);
449        assert!(result.is_err());
450    }
451
452    #[test]
453    fn config_path_deserialize_empty_fails() {
454        let json = "\"\"";
455        let result: Result<ConfigPath, _> = serde_json::from_str(json);
456        assert!(result.is_err());
457    }
458
459    // Hash implementation tests
460    #[test]
461    fn hostname_hash_works() {
462        let mut set = HashSet::new();
463        set.insert(HostName::new("example.com".to_string()).unwrap());
464        assert!(set.contains(&HostName::new("example.com".to_string()).unwrap()));
465    }
466
467    #[test]
468    fn server_name_hash_works() {
469        let mut set = HashSet::new();
470        set.insert(ServerName::new("server1".to_string()).unwrap());
471        assert!(set.contains(&ServerName::new("server1".to_string()).unwrap()));
472    }
473
474    // ValidationError tests
475    #[test]
476    fn validation_error_messages() {
477        assert_eq!(
478            ValidationError::EmptyHostName.to_string(),
479            "hostname cannot be empty or whitespace"
480        );
481        assert_eq!(
482            ValidationError::EmptyServerName.to_string(),
483            "server name cannot be empty or whitespace"
484        );
485        assert_eq!(
486            ValidationError::EmptyUsername.to_string(),
487            "username cannot be empty or whitespace"
488        );
489        assert_eq!(
490            ValidationError::EmptyPassword.to_string(),
491            "password cannot be empty or whitespace"
492        );
493        assert_eq!(
494            ValidationError::EmptyConfigPath.to_string(),
495            "config path cannot be empty"
496        );
497    }
498
499    #[test]
500    fn validation_error_clone() {
501        let err = ValidationError::EmptyHostName;
502        let cloned = err.clone();
503        assert_eq!(err, cloned);
504    }
505}