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        ValidationError::EmptyHostName
115    }
116}
117
118impl From<ServerNameError> for ValidationError {
119    fn from(_: ServerNameError) -> Self {
120        ValidationError::EmptyServerName
121    }
122}
123
124impl From<UsernameError> for ValidationError {
125    fn from(_: UsernameError) -> Self {
126        ValidationError::EmptyUsername
127    }
128}
129
130impl From<PasswordError> for ValidationError {
131    fn from(_: PasswordError) -> Self {
132        ValidationError::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    pub fn new(path: impl AsRef<Path>) -> Result<Self, ValidationError> {
142        let path_ref = path.as_ref();
143        let path_str = path_ref.to_str().ok_or(ValidationError::EmptyConfigPath)?;
144        if path_str.trim().is_empty() {
145            return Err(ValidationError::EmptyConfigPath);
146        }
147        Ok(Self(path_ref.to_path_buf()))
148    }
149
150    #[must_use]
151    #[inline]
152    pub fn as_path(&self) -> &Path {
153        &self.0
154    }
155
156    #[must_use]
157    #[inline]
158    pub fn as_str(&self) -> &str {
159        self.0.to_str().unwrap_or("")
160    }
161}
162
163impl AsRef<Path> for ConfigPath {
164    #[inline]
165    fn as_ref(&self) -> &Path {
166        &self.0
167    }
168}
169
170impl fmt::Display for ConfigPath {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "{}", self.0.display())
173    }
174}
175
176impl std::str::FromStr for ConfigPath {
177    type Err = ValidationError;
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        Self::new(s)
180    }
181}
182
183impl TryFrom<String> for ConfigPath {
184    type Error = ValidationError;
185    fn try_from(s: String) -> Result<Self, Self::Error> {
186        Self::new(s)
187    }
188}
189
190impl TryFrom<&str> for ConfigPath {
191    type Error = ValidationError;
192    fn try_from(s: &str) -> Result<Self, Self::Error> {
193        Self::new(s)
194    }
195}
196
197impl Serialize for ConfigPath {
198    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
199    where
200        S: serde::Serializer,
201    {
202        serializer.serialize_str(self.as_str())
203    }
204}
205
206impl<'de> Deserialize<'de> for ConfigPath {
207    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
208    where
209        D: serde::Deserializer<'de>,
210    {
211        let s = String::deserialize(deserializer)?;
212        Self::new(s).map_err(serde::de::Error::custom)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use proptest::prelude::*;
220
221    // Property test strategies for non-empty strings
222    fn non_empty_string() -> impl Strategy<Value = String> {
223        "[a-zA-Z0-9._@-]{1,100}"
224    }
225
226    fn path_string() -> impl Strategy<Value = String> {
227        "[a-zA-Z0-9._/-]{1,100}"
228    }
229
230    // Property tests for HostName
231    proptest! {
232        #[test]
233        fn hostname_serde_roundtrip(s in non_empty_string()) {
234            let hostname = HostName::try_new(s).unwrap();
235            let json = serde_json::to_string(&hostname).unwrap();
236            let deserialized: HostName = serde_json::from_str(&json).unwrap();
237            prop_assert_eq!(hostname, deserialized);
238        }
239    }
240
241    // Property tests for ServerName
242    proptest! {
243        #[test]
244        fn server_name_serde_roundtrip(s in non_empty_string()) {
245            let server = ServerName::try_new(s).unwrap();
246            let json = serde_json::to_string(&server).unwrap();
247            let deserialized: ServerName = serde_json::from_str(&json).unwrap();
248            prop_assert_eq!(server, deserialized);
249        }
250    }
251
252    // Property tests for Username
253    proptest! {
254        #[test]
255        fn username_serde_roundtrip(s in non_empty_string()) {
256            let username = Username::try_new(s).unwrap();
257            let json = serde_json::to_string(&username).unwrap();
258            let deserialized: Username = serde_json::from_str(&json).unwrap();
259            prop_assert_eq!(username, deserialized);
260        }
261    }
262
263    // Property tests for Password
264    proptest! {
265        #[test]
266        fn password_serde_roundtrip(s in non_empty_string()) {
267            let password = Password::try_new(s).unwrap();
268            let json = serde_json::to_string(&password).unwrap();
269            let deserialized: Password = serde_json::from_str(&json).unwrap();
270            prop_assert_eq!(password, deserialized);
271        }
272    }
273
274    // Property tests for ConfigPath
275    proptest! {
276        #[test]
277        fn config_path_non_empty_accepts(s in path_string()) {
278            let config = ConfigPath::try_from(s.clone()).unwrap();
279            prop_assert_eq!(config.as_str(), &s);
280        }
281
282        #[test]
283        fn config_path_as_path_roundtrip(s in path_string()) {
284            let config = ConfigPath::try_from(s.clone()).unwrap();
285            let path: &Path = config.as_ref();
286            prop_assert_eq!(path, Path::new(&s));
287        }
288
289        #[test]
290        fn config_path_serde_roundtrip(s in path_string()) {
291            let config = ConfigPath::try_from(s).unwrap();
292            let json = serde_json::to_string(&config).unwrap();
293            let deserialized: ConfigPath = serde_json::from_str(&json).unwrap();
294            prop_assert_eq!(config, deserialized);
295        }
296    }
297
298    // Edge case tests - verify sanitization behavior
299    #[test]
300    fn username_with_spaces_accepted() {
301        // Username with spaces in content is valid (trim only removes leading/trailing)
302        assert!(Username::try_new("  user  ".to_string()).is_ok());
303        assert!(Username::try_new("user name".to_string()).is_ok());
304    }
305
306    #[test]
307    fn password_with_spaces_accepted() {
308        assert!(Password::try_new("   pass   ".to_string()).is_ok());
309        assert!(Password::try_new("P@ssw0rd!".to_string()).is_ok());
310        assert!(Password::try_new("密码123".to_string()).is_ok());
311    }
312
313    #[test]
314    fn config_path_with_spaces_accepted() {
315        assert!(ConfigPath::try_from("my config.toml").is_ok());
316        assert!(ConfigPath::try_from("/absolute/path/config.toml").is_ok());
317        assert!(ConfigPath::try_from("./relative/config.toml").is_ok());
318        assert!(ConfigPath::try_from("../parent/config.toml").is_ok());
319    }
320
321    // Deserialization failure tests
322    #[test]
323    fn hostname_deserialize_empty_fails() {
324        let json = "\"\"";
325        let result: Result<HostName, _> = serde_json::from_str(json);
326        assert!(result.is_err());
327    }
328
329    #[test]
330    fn config_path_deserialize_empty_fails() {
331        let json = "\"\"";
332        let result: Result<ConfigPath, _> = serde_json::from_str(json);
333        assert!(result.is_err());
334    }
335
336    // ValidationError tests
337    #[test]
338    fn validation_error_messages() {
339        assert_eq!(
340            ValidationError::EmptyHostName.to_string(),
341            "hostname cannot be empty or whitespace"
342        );
343        assert_eq!(
344            ValidationError::EmptyServerName.to_string(),
345            "server name cannot be empty or whitespace"
346        );
347        assert_eq!(
348            ValidationError::EmptyUsername.to_string(),
349            "username cannot be empty or whitespace"
350        );
351        assert_eq!(
352            ValidationError::EmptyPassword.to_string(),
353            "password cannot be empty or whitespace"
354        );
355        assert_eq!(
356            ValidationError::EmptyConfigPath.to_string(),
357            "config path cannot be empty"
358        );
359    }
360
361    #[test]
362    fn validation_error_clone() {
363        let err = ValidationError::EmptyHostName;
364        let cloned = err.clone();
365        assert_eq!(err, cloned);
366    }
367}