firewall_objects/ip/
fqdn.rs1use std::fmt;
4use std::str::FromStr;
5
6#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub struct Fqdn(String);
8
9impl Fqdn {
10 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 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}