1use std::fmt;
4use std::net::IpAddr;
5use std::str::FromStr;
6
7use crate::error::ValidationError;
8
9#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct DomainName(String);
15
16impl DomainName {
17 pub const MAX_BYTES: usize = 253;
19
20 pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
29 let value = value.into();
30 let trimmed = value.trim().trim_end_matches('.').to_lowercase();
31
32 if trimmed.is_empty() {
33 return Err(ValidationError::EmptyDomain);
34 }
35
36 if let Some(problem) = Self::problem(&trimmed) {
37 return Err(ValidationError::InvalidDomain {
38 value: trimmed,
39 problem,
40 });
41 }
42
43 Ok(Self(trimmed))
44 }
45
46 fn problem(value: &str) -> Option<&'static str> {
53 if value.len() > Self::MAX_BYTES {
54 return Some("it is longer than 253 bytes");
55 }
56 if !value.is_ascii() {
57 return Some(
58 "it has a character outside ASCII. Write an international name in its \
59 xn-- form",
60 );
61 }
62 if !value.contains('.') {
63 return Some("it has no dot, so it is not a full domain name");
64 }
65
66 for label in value.split('.') {
67 if label.is_empty() {
68 return Some("it has an empty label");
69 }
70 if label.len() > 63 {
71 return Some("it has a label longer than 63 bytes");
72 }
73 if !label
74 .bytes()
75 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
76 {
77 return Some("it has a character other than a letter, a digit, a hyphen or a dot");
78 }
79 if label.starts_with('-') || label.ends_with('-') {
80 return Some("a label starts or ends with a hyphen");
81 }
82 }
83
84 None
85 }
86
87 pub fn as_str(&self) -> &str {
89 &self.0
90 }
91}
92
93impl FromStr for DomainName {
94 type Err = ValidationError;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 Self::new(s)
98 }
99}
100
101impl fmt::Display for DomainName {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.write_str(&self.0)
104 }
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Hash)]
112pub enum Query {
113 Ip(IpAddr),
115 Domain(DomainName),
117}
118
119impl From<IpAddr> for Query {
120 fn from(ip: IpAddr) -> Self {
121 Query::Ip(ip)
122 }
123}
124
125impl From<DomainName> for Query {
126 fn from(domain: DomainName) -> Self {
127 Query::Domain(domain)
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn lowercases_and_drops_the_trailing_dot() {
137 assert_eq!(
138 DomainName::new("Example.COM.").unwrap().as_str(),
139 "example.com"
140 );
141 }
142
143 #[test]
144 fn rejects_an_empty_name() {
145 assert_eq!(DomainName::new(" "), Err(ValidationError::EmptyDomain));
146 assert_eq!(DomainName::new("."), Err(ValidationError::EmptyDomain));
147 }
148
149 #[test]
150 fn rejects_names_that_cannot_be_domains() {
151 for value in [
152 "localhost",
153 "a..b.com",
154 "a b.com",
155 "user@example.com",
156 "example.com/path",
157 ] {
158 assert!(
159 matches!(
160 DomainName::new(value),
161 Err(ValidationError::InvalidDomain { .. })
162 ),
163 "expected {value:?} to be rejected"
164 );
165 }
166 }
167
168 #[test]
169 fn rejects_a_character_that_would_change_the_url() {
170 for value in [
172 "foo?.com",
173 "foo#x.com",
174 "foo%2f.com",
175 "foo&x.com",
176 "foo;x.com",
177 "foo+x.com",
178 ] {
179 assert_eq!(
180 DomainName::new(value),
181 Err(ValidationError::InvalidDomain {
182 value: value.to_owned(),
183 problem: "it has a character other than a letter, a digit, a hyphen or a dot",
184 }),
185 "expected {value:?} to be rejected"
186 );
187 }
188 }
189
190 #[test]
191 fn rejects_an_international_name_and_says_to_use_the_ascii_form() {
192 assert_eq!(
193 DomainName::new("bücher.de"),
194 Err(ValidationError::InvalidDomain {
195 value: "bücher.de".to_owned(),
196 problem: "it has a character outside ASCII. Write an international name in \
197 its xn-- form",
198 })
199 );
200 }
201
202 #[test]
203 fn accepts_the_ascii_form_of_an_international_name() {
204 assert_eq!(
205 DomainName::new("xn--bcher-kva.de").unwrap().as_str(),
206 "xn--bcher-kva.de"
207 );
208 }
209
210 #[test]
211 fn rejects_a_label_that_starts_or_ends_with_a_hyphen() {
212 for value in ["-example.com", "example-.com", "example.-com"] {
213 assert!(
214 matches!(
215 DomainName::new(value),
216 Err(ValidationError::InvalidDomain {
217 problem: "a label starts or ends with a hyphen",
218 ..
219 })
220 ),
221 "expected {value:?} to be rejected"
222 );
223 }
224 }
225
226 #[test]
227 fn accepts_a_hyphen_inside_a_label() {
228 assert_eq!(
229 DomainName::new("my-shop.example.co.uk").unwrap().as_str(),
230 "my-shop.example.co.uk"
231 );
232 }
233
234 #[test]
235 fn rejects_a_name_over_the_length_limit() {
236 let long = format!("{}.com", "a".repeat(250));
237
238 assert!(matches!(
239 DomainName::new(long),
240 Err(ValidationError::InvalidDomain {
241 problem: "it is longer than 253 bytes",
242 ..
243 })
244 ));
245 }
246}
247
248pub fn is_public(ip: IpAddr) -> bool {
272 let ip = unmap(ip);
273 let table = match ip {
274 IpAddr::V4(_) => SPECIAL_V4,
275 IpAddr::V6(_) => SPECIAL_V6,
276 };
277
278 most_specific(table, ip).is_none_or(|reach| reach == Reach::Global)
280}
281
282fn most_specific(table: &[(&str, Reach)], ip: IpAddr) -> Option<Reach> {
287 table
288 .iter()
289 .filter_map(|&(range, reach)| {
290 let network: ipnet::IpNet = range.parse().ok()?;
291 network
292 .contains(&ip)
293 .then_some((network.prefix_len(), reach))
294 })
295 .max_by_key(|&(length, _)| length)
296 .map(|(_, reach)| reach)
297}
298
299pub(crate) fn unmap(ip: IpAddr) -> IpAddr {
307 let IpAddr::V6(v6) = ip else {
308 return ip;
309 };
310
311 if let Some(v4) = v6.to_ipv4_mapped() {
312 return IpAddr::V4(v4);
313 }
314
315 let well_known = crate::nat64::WELL_KNOWN_PREFIX;
316 if well_known.contains(&v6)
317 && let Some(v4) = crate::nat64::embedded_ipv4(v6, well_known.prefix_len())
318 {
319 return IpAddr::V4(v4);
320 }
321
322 ip
323}
324
325#[derive(Clone, Copy, Debug, PartialEq, Eq)]
327enum Reach {
328 Global,
330 NotGlobal,
335}
336
337use Reach::{Global, NotGlobal};
338
339const SPECIAL_V4: &[(&str, Reach)] = &[
344 ("0.0.0.0/8", NotGlobal),
345 ("0.0.0.0/32", NotGlobal),
346 ("10.0.0.0/8", NotGlobal),
347 ("100.64.0.0/10", NotGlobal),
348 ("127.0.0.0/8", NotGlobal),
349 ("169.254.0.0/16", NotGlobal),
350 ("172.16.0.0/12", NotGlobal),
351 ("192.0.0.0/24", NotGlobal),
352 ("192.0.0.0/29", NotGlobal),
353 ("192.0.0.8/32", NotGlobal),
354 ("192.0.0.9/32", Global),
355 ("192.0.0.10/32", Global),
356 ("192.0.0.170/32", NotGlobal),
357 ("192.0.0.171/32", NotGlobal),
358 ("192.0.2.0/24", NotGlobal),
359 ("192.31.196.0/24", Global),
360 ("192.52.193.0/24", Global),
361 ("192.88.99.0/24", NotGlobal), ("192.88.99.2/32", NotGlobal),
363 ("192.168.0.0/16", NotGlobal),
364 ("192.175.48.0/24", Global),
365 ("198.18.0.0/15", NotGlobal),
366 ("198.51.100.0/24", NotGlobal),
367 ("203.0.113.0/24", NotGlobal),
368 ("240.0.0.0/4", NotGlobal),
369 ("255.255.255.255/32", NotGlobal),
370 ("224.0.0.0/4", NotGlobal),
371];
372
373const SPECIAL_V6: &[(&str, Reach)] = &[
381 ("::1/128", NotGlobal),
382 ("::/128", NotGlobal),
383 ("64:ff9b:1::/48", NotGlobal),
384 ("100::/64", NotGlobal),
385 ("100:0:0:1::/64", NotGlobal),
386 ("2001::/23", NotGlobal),
387 ("2001::/32", NotGlobal), ("2001:1::1/128", Global),
389 ("2001:1::2/128", Global),
390 ("2001:1::3/128", Global),
391 ("2001:2::/48", NotGlobal),
392 ("2001:3::/32", Global),
393 ("2001:4:112::/48", Global),
394 ("2001:10::/28", NotGlobal), ("2001:20::/28", Global),
396 ("2001:30::/28", Global),
397 ("2001:db8::/32", NotGlobal),
398 ("2002::/16", NotGlobal), ("2620:4f:8000::/48", Global),
400 ("3fff::/20", NotGlobal),
401 ("5f00::/16", NotGlobal),
402 ("fc00::/7", NotGlobal),
403 ("fe80::/10", NotGlobal),
404 ("ff00::/8", NotGlobal),
405];
406
407#[cfg(test)]
408mod public_tests {
409 use super::*;
410
411 fn public(value: &str) -> bool {
412 is_public(value.parse().unwrap())
413 }
414
415 #[test]
416 fn every_row_of_the_tables_is_a_range() {
417 for &(range, _) in SPECIAL_V4.iter().chain(SPECIAL_V6) {
419 assert!(
420 range.parse::<ipnet::IpNet>().is_ok(),
421 "{range:?} is not a range"
422 );
423 }
424 }
425
426 #[test]
427 fn every_row_is_of_the_family_of_its_table() {
428 for &(range, _) in SPECIAL_V4 {
429 let network: ipnet::IpNet = range.parse().unwrap();
430 assert!(network.addr().is_ipv4(), "{range} is in the IPv4 table");
431 }
432 for &(range, _) in SPECIAL_V6 {
433 let network: ipnet::IpNet = range.parse().unwrap();
434 assert!(network.addr().is_ipv6(), "{range} is in the IPv6 table");
435 }
436 }
437
438 #[test]
439 fn a_routable_address_is_public() {
440 for value in [
441 "8.8.8.8",
442 "193.0.6.139",
443 "1.1.1.1",
444 "2001:4860:4860::8888",
445 "2c00::1",
446 "64:ff9b::808:808",
447 "2001:200::1",
448 ] {
449 assert!(public(value), "{value} must be public");
450 }
451 }
452
453 #[test]
454 fn every_ipv4_range_that_is_not_globally_reachable_is_refused() {
455 for value in [
456 "0.1.2.3",
457 "10.1.2.3",
458 "100.64.0.1",
459 "127.0.0.1",
460 "169.254.1.1",
461 "172.16.0.1",
462 "192.0.0.1",
463 "192.0.0.8",
464 "192.0.0.11",
465 "192.0.0.170",
466 "192.0.2.1",
467 "192.88.99.1",
468 "192.168.1.1",
469 "198.18.0.1",
470 "198.51.100.1",
471 "203.0.113.1",
472 "224.0.0.1",
473 "240.0.0.1",
474 "255.255.255.255",
475 ] {
476 assert!(!public(value), "{value} must not be public");
477 }
478 }
479
480 #[test]
481 fn every_ipv6_range_that_is_not_globally_reachable_is_refused() {
482 for value in [
483 "::",
484 "::1",
485 "64:ff9b:1::1",
486 "100::1",
487 "100:0:0:1::1",
488 "2001::1",
489 "2001:1::4",
490 "2001:2::1",
491 "2001:10::1",
492 "2001:db8::1",
493 "2002::1",
494 "3fff::1",
495 "5f00::1",
496 "fc00::1",
497 "fd12:3456::1",
498 "fe80::1",
499 "ff02::1",
500 ] {
501 assert!(!public(value), "{value} must not be public");
502 }
503 }
504
505 #[test]
506 fn a_globally_reachable_ipv4_address_inside_a_reserved_block_is_public() {
507 assert!(public("192.0.0.9"), "Port Control Protocol anycast");
509 assert!(public("192.0.0.10"), "TURN anycast");
510 }
511
512 #[test]
513 fn a_globally_reachable_ipv6_range_inside_the_ietf_block_is_public() {
514 for (value, name) in [
516 ("2001:1::1", "Port Control Protocol anycast"),
517 ("2001:1::2", "TURN anycast"),
518 ("2001:1::3", "DNS-SD Service Registration Protocol anycast"),
519 ("2001:3::1", "AMT"),
520 ("2001:4:112::1", "AS112-v6"),
521 ("2001:20::1", "ORCHIDv2"),
522 ("2001:30::1", "Drone Remote ID"),
523 ] {
524 assert!(public(value), "{value} ({name}) must be public");
525 }
526 }
527
528 #[test]
529 fn the_edges_of_a_reserved_range_are_exact() {
530 assert!(!public("172.16.0.0"));
531 assert!(!public("172.31.255.255"));
532 assert!(public("172.15.255.255"));
533 assert!(public("172.32.0.0"));
534
535 assert!(!public("100.127.255.255"));
536 assert!(public("100.128.0.0"));
537 assert!(public("100.63.255.255"));
538
539 assert!(!public("2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff"));
540 assert!(public("2001:200::"));
541
542 assert!(public("2001:3::"));
544 assert!(public("2001:3:ffff:ffff:ffff:ffff:ffff:ffff"));
545 assert!(!public("2001:4::"));
546 }
547
548 #[test]
549 fn an_address_under_the_nat64_well_known_prefix_is_judged_as_ipv4() {
550 assert!(
553 !public("64:ff9b::a9fe:a9fe"),
554 "169.254.169.254, cloud metadata"
555 );
556 assert!(!public("64:ff9b::7f00:1"), "127.0.0.1");
557 assert!(!public("64:ff9b::a00:1"), "10.0.0.1");
558 assert!(public("64:ff9b::808:808"), "8.8.8.8");
559 }
560
561 #[test]
562 fn unmap_reads_the_nat64_well_known_prefix() {
563 assert_eq!(
564 unmap("64:ff9b::808:808".parse().unwrap()),
565 "8.8.8.8".parse::<IpAddr>().unwrap()
566 );
567 assert_eq!(
569 unmap("64:ff9b:1::808:808".parse().unwrap()),
570 "64:ff9b:1::808:808".parse::<IpAddr>().unwrap()
571 );
572 }
573
574 #[test]
575 fn an_ipv4_address_written_as_ipv6_is_judged_as_ipv4() {
576 assert!(!public("::ffff:10.0.0.1"));
577 assert!(!public("::ffff:192.168.1.1"));
578 assert!(!public("::ffff:127.0.0.1"));
579 assert!(public("::ffff:8.8.8.8"));
580 assert!(public("::ffff:192.0.0.9"));
581 }
582
583 #[test]
584 fn unmap_gives_the_ipv4_address_a_mapped_address_carries() {
585 assert_eq!(
586 unmap("::ffff:8.8.8.8".parse().unwrap()),
587 "8.8.8.8".parse::<IpAddr>().unwrap()
588 );
589 assert_eq!(
590 unmap("2001:4860::8888".parse().unwrap()),
591 "2001:4860::8888".parse::<IpAddr>().unwrap()
592 );
593 assert_eq!(
594 unmap("8.8.8.8".parse().unwrap()),
595 "8.8.8.8".parse::<IpAddr>().unwrap()
596 );
597 }
598}