Skip to main content

firewall_objects/ip/
fqdn.rs

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