firewall_objects/ip/
fqdn.rs1use 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 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 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 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}