nntp_proxy/types/
validated.rs1use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8#[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
30macro_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 pub struct HostName(ValidationError::EmptyHostName);
97}
98
99validated_string! {
100 pub struct ServerName(ValidationError::EmptyServerName);
102}
103
104validated_string! {
105 pub struct Username(ValidationError::EmptyUsername);
107}
108
109validated_string! {
110 pub struct Password(ValidationError::EmptyPassword);
112}
113
114#[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 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 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 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 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 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 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 #[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 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 #[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 #[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 #[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}