Skip to main content

abuse_contact/
query.rs

1//! What you can ask about.
2
3use std::fmt;
4use std::net::IpAddr;
5use std::str::FromStr;
6
7use crate::error::ValidationError;
8
9/// A domain name to look up.
10///
11/// The constructor checks the shape only. A name that no registry holds is a lookup
12/// that finds nothing, not a value this type refuses.
13#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct DomainName(String);
15
16impl DomainName {
17    /// The longest a domain name can be, in bytes.
18    pub const MAX_BYTES: usize = 253;
19
20    /// Wraps a domain name.
21    ///
22    /// The name is lowercased, and one trailing dot is removed.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`ValidationError::EmptyDomain`] for an empty name, and
27    /// [`ValidationError::InvalidDomain`] when the name cannot be a domain name.
28    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    /// Returns what is wrong with the name, or `None` when it is usable.
47    ///
48    /// A label holds letters, digits and hyphens only, and does not start or end with
49    /// a hyphen. The name goes into a URL path as it is, so a character outside that
50    /// set is a lookup for the wrong name: `foo?.com` asks for `foo` and sends `.com`
51    /// as a query.
52    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    /// Returns the name, lowercased and without a trailing dot.
88    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/// The thing you want the abuse contact for.
108///
109/// The variant picks the sources. An IP address reaches the regional registry and the
110/// blocklist zones. A domain name reaches the registry, then the registrar it names.
111#[derive(Clone, Debug, PartialEq, Eq, Hash)]
112pub enum Query {
113    /// An IP address, v4 or v6.
114    Ip(IpAddr),
115    /// A domain name.
116    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        // Each of these puts URL syntax into the path and asks for another name.
171        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
248/// Returns whether the public registries describe this address.
249///
250/// A private, reserved or documentation address sits inside a range that a regional
251/// registry still holds a record for. Asking about `192.168.1.1` returns the record
252/// for the reserved block, whose abuse contact is IANA. That address is a real one and
253/// a wrong one: nobody at IANA can act on a host inside your network, and a report
254/// sent there is noise.
255///
256/// An IPv4 address written as IPv6 is judged as the IPv4 address it carries. That is
257/// an IPv4-mapped address such as `::ffff:10.0.0.1`, and an address under the NAT64
258/// well-known prefix such as `64:ff9b::a00:1`. Without that, the IPv6 spelling of a
259/// private address would pass.
260///
261/// ```
262/// use abuse_contact::is_public;
263///
264/// assert!(is_public("8.8.8.8".parse().unwrap()));
265/// assert!(!is_public("192.168.1.1".parse().unwrap()));
266/// assert!(!is_public("::ffff:192.168.1.1".parse().unwrap()));
267/// assert!(!is_public("64:ff9b::a9fe:a9fe".parse().unwrap()));
268/// // A globally reachable anycast address inside a reserved block.
269/// assert!(is_public("192.0.0.9".parse().unwrap()));
270/// ```
271pub 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    // An address in no special-purpose range is an ordinary allocation.
279    most_specific(table, ip).is_none_or(|reach| reach == Reach::Global)
280}
281
282/// Returns the reach of the most specific range in the table that holds the address.
283///
284/// The registry nests ranges: `192.0.0.9/32` is globally reachable inside
285/// `192.0.0.0/24`, which is not. The most specific row is the one that applies.
286fn 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
299/// Returns the IPv4 address an IPv6 address carries in a fixed place, or the address
300/// as it was.
301///
302/// Two forms carry one in a place that does not depend on the network: an IPv4-mapped
303/// address such as `::ffff:8.8.8.8`, and an address under the NAT64 well-known prefix
304/// such as `64:ff9b::808:808`. Each is one host with `8.8.8.8`, and no registry holds
305/// a record for the IPv6 form, so both the check and the lookup use the IPv4 form.
306pub(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/// Whether the special-purpose registry marks a range as globally reachable.
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
327enum Reach {
328    /// The registry says `True`. A registry describes hosts in the range.
329    Global,
330    /// The registry says `False`, `N/A`, or gives no answer for a deprecated range.
331    ///
332    /// `N/A` is on ranges such as 6to4 and Teredo, which carry another address inside
333    /// them. The range has no single owner to report to, so it is not public here.
334    NotGlobal,
335}
336
337use Reach::{Global, NotGlobal};
338
339/// The IANA IPv4 Special-Purpose Address Registry, one row per address block.
340///
341/// Copied from `iana-ipv4-special-registry-1.csv`. The last row is not in that
342/// registry: it is the multicast range, from the multicast address registry.
343const 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), // deprecated, no answer in the registry
362    ("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
373/// The IANA IPv6 Special-Purpose Address Registry, one row per address block.
374///
375/// Copied from `iana-ipv6-special-registry-1.csv`, with two rows left out and one
376/// added. `::ffff:0:0/96` and `64:ff9b::/96` are left out: [`unmap`] turns an address
377/// in either into IPv4 before this table is read, so the IPv4 table judges it. The last
378/// row is not in that registry: it is the multicast range, from the multicast address
379/// registry.
380const 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), // Teredo, N/A in the registry
388    ("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), // deprecated, no answer in the registry
395    ("2001:20::/28", Global),
396    ("2001:30::/28", Global),
397    ("2001:db8::/32", NotGlobal),
398    ("2002::/16", NotGlobal), // 6to4, N/A in the registry
399    ("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        // most_specific skips a row it cannot read. This keeps that from happening.
418        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        // 192.0.0.0/24 is not globally reachable, but these two anycast addresses are.
508        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        // 2001::/23 is not globally reachable, but each of these is.
515        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        // The edges of a globally reachable range inside a reserved one.
543        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        // A DNS64 network answers these for a name whose only address is the IPv4 one
551        // inside, and its NAT64 gateway connects to that IPv4 address.
552        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        // The local-use NAT64 prefix is not read here: its layout depends on the network.
568        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}