firewall_objects/ip/
fqdn.rs

1//! Fully qualified domain name validation and normalization.
2
3use std::fmt;
4use std::str::FromStr;
5
6#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub struct Fqdn(String);
8
9impl Fqdn {
10    /// Create a validated FQDN.
11    ///
12    /// Normalizes the value by trimming whitespace, removing a trailing '.',
13    /// and lowercasing.
14    ///
15    /// # Examples
16    /// ```rust
17    /// use firewall_objects::ip::fqdn::Fqdn;
18    ///
19    /// let d = Fqdn::new("WWW.Example.COM.").unwrap();
20    /// assert_eq!(d.to_string(), "www.example.com");
21    /// ```
22    ///
23    /// Invalid inputs fail:
24    /// ```rust
25    /// use firewall_objects::ip::fqdn::Fqdn;
26    ///
27    /// assert!(Fqdn::new("").is_err());
28    /// assert!(Fqdn::new("bad label!.com").is_err());
29    /// assert!(Fqdn::new("-bad.com").is_err());
30    /// assert!(Fqdn::new("bad-.com").is_err());
31    /// ```
32    pub fn new(s: &str) -> Result<Self, String> {
33        let trimmed = s.trim();
34
35        if trimmed.is_empty() {
36            return Err("FQDN cannot be empty".into());
37        }
38
39        let trailing_dot_count = trimmed.chars().rev().take_while(|c| *c == '.').count();
40        if trailing_dot_count > 1 {
41            return Err("FQDN cannot end with empty labels".into());
42        }
43
44        let core = if trailing_dot_count == 1 {
45            &trimmed[..trimmed.len() - 1]
46        } else {
47            trimmed
48        };
49
50        if core.is_empty() {
51            return Err("FQDN cannot be empty".into());
52        }
53
54        let v = core.to_ascii_lowercase();
55
56        if v.chars().any(|c| c.is_whitespace()) {
57            return Err("FQDN cannot contain whitespace".into());
58        }
59
60        if v.len() > 253 {
61            return Err("FQDN too long (max 253)".into());
62        }
63
64        // conservative DNS label validation
65        for (idx, label) in v.split('.').enumerate() {
66            if label.is_empty() || label.len() > 63 {
67                return Err("invalid DNS label length".into());
68            }
69
70            if label == "*" {
71                if idx == 0 {
72                    continue;
73                }
74                return Err("wildcard '*' label only allowed in the leftmost position".into());
75            }
76
77            if !label
78                .chars()
79                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
80            {
81                return Err("invalid character in DNS label".into());
82            }
83            if label.starts_with('-') || label.ends_with('-') {
84                return Err("DNS label cannot start or end with '-'".into());
85            }
86        }
87
88        Ok(Self(v))
89    }
90}
91
92impl FromStr for Fqdn {
93    type Err = String;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        Fqdn::new(s)
97    }
98}
99
100impl fmt::Display for Fqdn {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(f, "{}", self.0)
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::Fqdn;
109
110    #[test]
111    fn rejects_multiple_trailing_dots() {
112        assert!(Fqdn::new("db.example.com..").is_err());
113        assert!(Fqdn::new("...").is_err());
114    }
115
116    #[test]
117    fn accepts_wildcard_leftmost_and_service_labels() {
118        let wildcard = Fqdn::new("*.example.com.").unwrap();
119        assert_eq!(wildcard.to_string(), "*.example.com");
120
121        let srv = Fqdn::new("_imap._tcp.mail.example.com").unwrap();
122        assert_eq!(srv.to_string(), "_imap._tcp.mail.example.com");
123    }
124
125    #[test]
126    fn rejects_wildcard_in_non_leftmost_label() {
127        assert!(Fqdn::new("vpn.*.example.com").is_err());
128        assert!(Fqdn::new("db.example.*").is_err());
129    }
130}