]>
Commit | Line | Data |
---|---|---|
5362c05c AL |
1 | use std::{ |
2 | fmt, | |
3 | net::{AddrParseError, IpAddr}, | |
4 | num::ParseIntError, | |
5 | str::FromStr, | |
6 | }; | |
7 | ||
8 | use serde::Deserialize; | |
9 | ||
10 | /// Possible errors that might occur when parsing CIDR addresses. | |
11 | #[derive(Debug)] | |
12 | pub enum CidrAddressParseError { | |
13 | /// No delimiter for separating address and mask was found. | |
14 | NoDelimiter, | |
15 | /// The IP address part could not be parsed. | |
16 | InvalidAddr(AddrParseError), | |
17 | /// The mask could not be parsed. | |
18 | InvalidMask(Option<ParseIntError>), | |
19 | } | |
20 | ||
21 | /// An IP address (IPv4 or IPv6), including network mask. | |
22 | /// | |
23 | /// See the [`IpAddr`] type for more information how IP addresses are handled. | |
24 | /// The mask is appropriately enforced to be `0 <= mask <= 32` for IPv4 or | |
25 | /// `0 <= mask <= 128` for IPv6 addresses. | |
26 | /// | |
27 | /// # Examples | |
28 | /// ``` | |
29 | /// use std::net::{Ipv4Addr, Ipv6Addr}; | |
eeb06272 | 30 | /// use proxmox_installer_common::utils::CidrAddress; |
5362c05c AL |
31 | /// let ipv4 = CidrAddress::new(Ipv4Addr::new(192, 168, 0, 1), 24).unwrap(); |
32 | /// let ipv6 = CidrAddress::new(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0xc0a8, 1), 32).unwrap(); | |
33 | /// | |
34 | /// assert_eq!(ipv4.to_string(), "192.168.0.1/24"); | |
35 | /// assert_eq!(ipv6.to_string(), "2001:db8::c0a8:1/32"); | |
36 | /// ``` | |
37 | #[derive(Clone, Debug, PartialEq)] | |
38 | pub struct CidrAddress { | |
39 | addr: IpAddr, | |
40 | mask: usize, | |
41 | } | |
42 | ||
43 | impl CidrAddress { | |
44 | /// Constructs a new CIDR address. | |
45 | /// | |
46 | /// It fails if the mask is invalid for the given IP address. | |
47 | pub fn new<T: Into<IpAddr>>(addr: T, mask: usize) -> Result<Self, CidrAddressParseError> { | |
48 | let addr = addr.into(); | |
49 | ||
50 | if mask > mask_limit(&addr) { | |
51 | Err(CidrAddressParseError::InvalidMask(None)) | |
52 | } else { | |
53 | Ok(Self { addr, mask }) | |
54 | } | |
55 | } | |
56 | ||
57 | /// Returns only the IP address part of the address. | |
58 | pub fn addr(&self) -> IpAddr { | |
59 | self.addr | |
60 | } | |
61 | ||
62 | /// Returns `true` if this address is an IPv4 address, `false` otherwise. | |
63 | pub fn is_ipv4(&self) -> bool { | |
64 | self.addr.is_ipv4() | |
65 | } | |
66 | ||
67 | /// Returns `true` if this address is an IPv6 address, `false` otherwise. | |
68 | pub fn is_ipv6(&self) -> bool { | |
69 | self.addr.is_ipv6() | |
70 | } | |
71 | ||
72 | /// Returns only the mask part of the address. | |
73 | pub fn mask(&self) -> usize { | |
74 | self.mask | |
75 | } | |
76 | } | |
77 | ||
78 | impl FromStr for CidrAddress { | |
79 | type Err = CidrAddressParseError; | |
80 | ||
81 | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
82 | let (addr, mask) = s | |
83 | .split_once('/') | |
84 | .ok_or(CidrAddressParseError::NoDelimiter)?; | |
85 | ||
86 | let addr = addr.parse().map_err(CidrAddressParseError::InvalidAddr)?; | |
87 | ||
88 | let mask = mask | |
89 | .parse() | |
90 | .map_err(|err| CidrAddressParseError::InvalidMask(Some(err)))?; | |
91 | ||
92 | if mask > mask_limit(&addr) { | |
93 | Err(CidrAddressParseError::InvalidMask(None)) | |
94 | } else { | |
95 | Ok(Self { addr, mask }) | |
96 | } | |
97 | } | |
98 | } | |
99 | ||
100 | impl fmt::Display for CidrAddress { | |
101 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
102 | write!(f, "{}/{}", self.addr, self.mask) | |
103 | } | |
104 | } | |
105 | ||
106 | fn mask_limit(addr: &IpAddr) -> usize { | |
107 | if addr.is_ipv4() { | |
108 | 32 | |
109 | } else { | |
110 | 128 | |
111 | } | |
112 | } | |
113 | ||
114 | /// Possible errors that might occur when parsing FQDNs. | |
115 | #[derive(Debug, Eq, PartialEq)] | |
116 | pub enum FqdnParseError { | |
117 | MissingHostname, | |
118 | NumericHostname, | |
119 | InvalidPart(String), | |
d932ec49 | 120 | TooLong(usize), |
5362c05c AL |
121 | } |
122 | ||
123 | impl fmt::Display for FqdnParseError { | |
124 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
125 | use FqdnParseError::*; | |
126 | match self { | |
127 | MissingHostname => write!(f, "missing hostname part"), | |
128 | NumericHostname => write!(f, "hostname cannot be purely numeric"), | |
129 | InvalidPart(part) => write!( | |
130 | f, | |
131 | "FQDN must only consist of alphanumeric characters and dashes. Invalid part: '{part}'", | |
132 | ), | |
d932ec49 | 133 | TooLong(len) => write!(f, "FQDN too long: {len} > {}", Fqdn::MAX_LENGTH), |
5362c05c AL |
134 | } |
135 | } | |
136 | } | |
137 | ||
d932ec49 CH |
138 | /// A type for safely representing fully-qualified domain names (FQDNs). |
139 | /// | |
140 | /// It considers following RFCs: | |
141 | /// https://www.ietf.org/rfc/rfc952.txt (sec. "ASSUMPTIONS", 1.) | |
142 | /// https://www.ietf.org/rfc/rfc1035.txt (sec. 2.3. "Conventions") | |
143 | /// https://www.ietf.org/rfc/rfc1123.txt (sec. 2.1. "Host Names and Numbers") | |
144 | /// https://www.ietf.org/rfc/rfc3492.txt | |
145 | /// https://www.ietf.org/rfc/rfc4343.txt | |
146 | /// | |
147 | /// .. and applies some restriction given by Debian, e.g. 253 instead of 255 | |
148 | /// maximum total length and maximum 63 characters per label. | |
149 | /// https://manpages.debian.org/stable/manpages/hostname.7.en.html | |
150 | /// | |
151 | /// Additionally: | |
152 | /// - It enforces the restriction as per Bugzilla #1054, in that | |
153 | /// purely numeric hostnames are not allowed - against RFC1123 sec. 2.1. | |
154 | /// | |
155 | /// Some terminology: | |
156 | /// - "label" - a single part of a FQDN, e.g. <label>.<label>.<tld> | |
5ee2afa7 | 157 | #[derive(Clone, Debug, Eq)] |
5362c05c AL |
158 | pub struct Fqdn { |
159 | parts: Vec<String>, | |
160 | } | |
161 | ||
162 | impl Fqdn { | |
d932ec49 CH |
163 | /// Maximum length of a single label of the FQDN |
164 | const MAX_LABEL_LENGTH: usize = 63; | |
165 | /// Maximum total length of the FQDN | |
166 | const MAX_LENGTH: usize = 253; | |
167 | ||
5362c05c | 168 | pub fn from(fqdn: &str) -> Result<Self, FqdnParseError> { |
d932ec49 CH |
169 | if fqdn.len() > Self::MAX_LENGTH { |
170 | return Err(FqdnParseError::TooLong(fqdn.len())); | |
171 | } | |
172 | ||
5362c05c AL |
173 | let parts = fqdn |
174 | .split('.') | |
175 | .map(ToOwned::to_owned) | |
176 | .collect::<Vec<String>>(); | |
177 | ||
178 | for part in &parts { | |
179 | if !Self::validate_single(part) { | |
180 | return Err(FqdnParseError::InvalidPart(part.clone())); | |
181 | } | |
182 | } | |
183 | ||
184 | if parts.len() < 2 { | |
185 | Err(FqdnParseError::MissingHostname) | |
186 | } else if parts[0].chars().all(|c| c.is_ascii_digit()) { | |
d932ec49 CH |
187 | // Do not allow a purely numeric hostname, see: |
188 | // https://bugzilla.proxmox.com/show_bug.cgi?id=1054 | |
5362c05c AL |
189 | Err(FqdnParseError::NumericHostname) |
190 | } else { | |
191 | Ok(Self { parts }) | |
192 | } | |
193 | } | |
194 | ||
195 | pub fn host(&self) -> Option<&str> { | |
196 | self.has_host().then_some(&self.parts[0]) | |
197 | } | |
198 | ||
199 | pub fn domain(&self) -> String { | |
200 | let parts = if self.has_host() { | |
201 | &self.parts[1..] | |
202 | } else { | |
203 | &self.parts | |
204 | }; | |
205 | ||
206 | parts.join(".") | |
207 | } | |
208 | ||
209 | /// Checks whether the FQDN has a hostname associated with it, i.e. is has more than 1 part. | |
210 | fn has_host(&self) -> bool { | |
211 | self.parts.len() > 1 | |
212 | } | |
213 | ||
214 | fn validate_single(s: &String) -> bool { | |
215 | !s.is_empty() | |
d932ec49 | 216 | && s.len() <= Self::MAX_LABEL_LENGTH |
5362c05c AL |
217 | // First character must be alphanumeric |
218 | && s.chars() | |
219 | .next() | |
220 | .map(|c| c.is_ascii_alphanumeric()) | |
221 | .unwrap_or_default() | |
222 | // .. last character as well, | |
223 | && s.chars() | |
224 | .last() | |
225 | .map(|c| c.is_ascii_alphanumeric()) | |
226 | .unwrap_or_default() | |
227 | // and anything between must be alphanumeric or - | |
228 | && s.chars() | |
229 | .skip(1) | |
230 | .take(s.len().saturating_sub(2)) | |
231 | .all(|c| c.is_ascii_alphanumeric() || c == '-') | |
232 | } | |
233 | } | |
234 | ||
235 | impl FromStr for Fqdn { | |
236 | type Err = FqdnParseError; | |
237 | ||
238 | fn from_str(value: &str) -> Result<Self, Self::Err> { | |
239 | Self::from(value) | |
240 | } | |
241 | } | |
242 | ||
243 | impl fmt::Display for Fqdn { | |
244 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
245 | write!(f, "{}", self.parts.join(".")) | |
246 | } | |
247 | } | |
248 | ||
249 | impl<'de> Deserialize<'de> for Fqdn { | |
250 | fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | |
251 | where | |
252 | D: serde::Deserializer<'de>, | |
253 | { | |
254 | let s: String = Deserialize::deserialize(deserializer)?; | |
255 | s.parse() | |
256 | .map_err(|_| serde::de::Error::custom("invalid FQDN")) | |
257 | } | |
258 | } | |
259 | ||
5ee2afa7 CH |
260 | impl PartialEq for Fqdn { |
261 | fn eq(&self, other: &Self) -> bool { | |
262 | // Case-insensitive comparison, as per RFC 952 "ASSUMPTIONS", | |
263 | // RFC 1035 sec. 2.3.3. "Character Case" and RFC 4343 as a whole | |
264 | let a = self | |
265 | .parts | |
266 | .iter() | |
267 | .map(|s| s.to_lowercase()) | |
268 | .collect::<Vec<String>>(); | |
269 | ||
270 | let b = other | |
271 | .parts | |
272 | .iter() | |
273 | .map(|s| s.to_lowercase()) | |
274 | .collect::<Vec<String>>(); | |
275 | ||
276 | a == b | |
277 | } | |
278 | } | |
279 | ||
5362c05c AL |
280 | #[cfg(test)] |
281 | mod tests { | |
282 | use super::*; | |
283 | ||
284 | #[test] | |
285 | fn fqdn_construct() { | |
286 | use FqdnParseError::*; | |
287 | assert!(Fqdn::from("foo.example.com").is_ok()); | |
288 | assert!(Fqdn::from("foo-bar.com").is_ok()); | |
289 | assert!(Fqdn::from("a-b.com").is_ok()); | |
290 | ||
291 | assert_eq!(Fqdn::from("foo"), Err(MissingHostname)); | |
292 | ||
293 | assert_eq!(Fqdn::from("-foo.com"), Err(InvalidPart("-foo".to_owned()))); | |
294 | assert_eq!(Fqdn::from("foo-.com"), Err(InvalidPart("foo-".to_owned()))); | |
295 | assert_eq!(Fqdn::from("foo.com-"), Err(InvalidPart("com-".to_owned()))); | |
296 | assert_eq!(Fqdn::from("-o-.com"), Err(InvalidPart("-o-".to_owned()))); | |
297 | ||
d932ec49 | 298 | // https://bugzilla.proxmox.com/show_bug.cgi?id=1054 |
5362c05c AL |
299 | assert_eq!(Fqdn::from("123.com"), Err(NumericHostname)); |
300 | assert!(Fqdn::from("foo123.com").is_ok()); | |
301 | assert!(Fqdn::from("123foo.com").is_ok()); | |
d932ec49 CH |
302 | |
303 | assert!(Fqdn::from(&format!("{}.com", "a".repeat(63))).is_ok()); | |
304 | assert_eq!( | |
305 | Fqdn::from(&format!("{}.com", "a".repeat(250))), | |
306 | Err(TooLong(254)), | |
307 | ); | |
308 | assert_eq!( | |
309 | Fqdn::from(&format!("{}.com", "a".repeat(64))), | |
310 | Err(InvalidPart("a".repeat(64))), | |
311 | ); | |
f6bea065 CH |
312 | |
313 | // https://bugzilla.proxmox.com/show_bug.cgi?id=5230 | |
314 | assert_eq!( | |
315 | Fqdn::from("123@foo.com"), | |
316 | Err(InvalidPart("123@foo".to_owned())) | |
317 | ); | |
5362c05c AL |
318 | } |
319 | ||
320 | #[test] | |
321 | fn fqdn_parts() { | |
322 | let fqdn = Fqdn::from("pve.example.com").unwrap(); | |
323 | assert_eq!(fqdn.host().unwrap(), "pve"); | |
324 | assert_eq!(fqdn.domain(), "example.com"); | |
325 | assert_eq!( | |
326 | fqdn.parts, | |
327 | &["pve".to_owned(), "example".to_owned(), "com".to_owned()] | |
328 | ); | |
329 | } | |
330 | ||
331 | #[test] | |
332 | fn fqdn_display() { | |
333 | assert_eq!( | |
334 | Fqdn::from("foo.example.com").unwrap().to_string(), | |
335 | "foo.example.com" | |
336 | ); | |
337 | } | |
5ee2afa7 CH |
338 | |
339 | #[test] | |
340 | fn fqdn_compare() { | |
341 | assert_eq!(Fqdn::from("example.com"), Fqdn::from("ExAmPle.Com")); | |
342 | assert_eq!(Fqdn::from("ExAmPle.Com"), Fqdn::from("example.com")); | |
343 | } | |
5362c05c | 344 | } |