Skip to main content

structured_email_address/
scope.rs

1//! Whether a domain names something the global DNS can resolve, or something
2//! that means anything only inside a network.
3//!
4//! This is not a grammar question, which is why it sits beside the parse rather
5//! than inside it: `admin@printer`, `postmaster@files.local`, `a@[192.168.1.5]`
6//! and `a@example.com` are all well formed, and what separates them is reach.
7//! No verdict here changes what parses.
8
9use core::net::{Ipv4Addr, Ipv6Addr};
10
11use crate::parser;
12
13/// How far a domain reaches.
14///
15/// Obtained from [`EmailAddress::domain_scope`](crate::EmailAddress::domain_scope).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum DomainScope {
18    /// A name the global DNS can resolve.
19    ///
20    /// With the `psl` feature this is the stronger reading, a name under a
21    /// published public suffix; without it, the weaker one, a final label that
22    /// is TLD-like. Either way, a name reserved by the documents listed on
23    /// [`Local`](Self::Local) is not one of these.
24    Global,
25    /// A name reserved for use inside a network, or a single label, which has
26    /// no meaning outside the resolver that is asked.
27    ///
28    /// The reserved names, each with the document reserving it:
29    ///
30    /// | Name | Reserved by |
31    /// |---|---|
32    /// | `.localhost` | RFC 6761 §6.3 |
33    /// | `.test` | RFC 6761 §6.2 |
34    /// | `.invalid` | RFC 6761 §6.4 |
35    /// | `.example` | RFC 6761 §6.5 |
36    /// | `.local` | RFC 6762 §3 |
37    /// | `home.arpa` | RFC 8375 §2 |
38    /// | `.internal` | ICANN Board resolution 2024.07.29.05 |
39    /// | `.onion` | RFC 7686 §1 |
40    /// | `.alt` | RFC 9476 §2 |
41    ///
42    /// The last two are resolved by something other than the DNS rather than
43    /// reserved for a network, so the global DNS cannot resolve them however
44    /// far the name travels.
45    ///
46    /// A name whose final label is not TLD-like, or which is under no published
47    /// public suffix, lands here too: it is a name only the resolver being
48    /// asked can answer for.
49    Local,
50    /// An address literal, which names a host directly rather than through the
51    /// DNS.
52    Literal(LiteralScope),
53}
54
55impl DomainScope {
56    /// Whether the domain names something reachable from the public internet.
57    ///
58    /// True for [`Global`](Self::Global) and for a literal holding an address
59    /// outside the reserved ranges. A `General-address-literal` is never one of
60    /// these: its body is meaningful only to the system receiving it.
61    pub fn is_global(&self) -> bool {
62        match self {
63            Self::Global => true,
64            Self::Local => false,
65            Self::Literal(literal) => literal.is_global(),
66        }
67    }
68}
69
70/// Which address literal, and how far it reaches (RFC 5321 §4.1.3).
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum LiteralScope {
73    /// `IPv4-address-literal`, as in `[192.0.2.1]`.
74    Ipv4(IpScope),
75    /// `IPv6-address-literal`, as in `[IPv6:2001:db8::1]`.
76    Ipv6(IpScope),
77    /// `General-address-literal`, as in `[AS400:QSYS]`: a tagged address whose
78    /// body SMTP does not interpret and which is meaningful only to the system
79    /// receiving it.
80    General,
81}
82
83impl LiteralScope {
84    /// Whether the literal names an address reachable from the public internet.
85    pub fn is_global(&self) -> bool {
86        matches!(
87            self,
88            Self::Ipv4(IpScope::Global) | Self::Ipv6(IpScope::Global)
89        )
90    }
91}
92
93/// How far an IP address reaches.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum IpScope {
96    /// An address outside every range listed on [`Local`](Self::Local).
97    ///
98    /// Reserved ranges that are neither private nor host-scoped are not
99    /// separated out: `192.0.2.1` is documentation space (RFC 5737) and reads
100    /// as `Global` here, because the question this answers is whether the
101    /// address was set aside for use inside a network, not whether some route
102    /// to it exists today.
103    Global,
104    /// An address that is meaningful only inside a network, or only on the host
105    /// that is asked:
106    ///
107    /// | Range | Reserved by |
108    /// |---|---|
109    /// | `10/8`, `172.16/12`, `192.168/16` | RFC 1918 §3 |
110    /// | `100.64/10` | RFC 6598 §7 |
111    /// | `169.254/16` | RFC 3927 §2.1 |
112    /// | `127/8`, `0/8` | RFC 1122 §3.2.1.3 |
113    /// | `255.255.255.255` | RFC 919 §7 |
114    /// | `224.0.0/24` | RFC 5771 §4 |
115    /// | `239/8` | RFC 2365 §4 |
116    /// | `198.18/15` | RFC 2544 §C.2.2 |
117    /// | `fc00::/7` | RFC 4193 §3 |
118    /// | `fe80::/10` | RFC 4291 §2.5.6 |
119    /// | `fec0::/10` | RFC 3879 §2 |
120    /// | `::1`, `::` | RFC 4291 §2.5.3, §2.5.2 |
121    /// | `ff00::/8` below global scope | RFC 4291 §2.7 |
122    ///
123    /// An address that embeds an IPv4 one, in either the mapped form (RFC 4291
124    /// §2.5.5.2) or the deprecated compatible form (§2.5.5.1), reports the
125    /// reach of the address it holds, so one host does not classify two ways
126    /// for being written two ways.
127    ///
128    /// Multicast is not here for being multicast: an IPv6 group carries its
129    /// scope in the address and only `0xE` is global, and of IPv4 multicast
130    /// only the two blocks above are bounded. `224.0.1.1` and `233.1.2.3` are
131    /// routable and read as [`Global`](Self::Global).
132    Local,
133}
134
135/// Names reserved for use inside a network, matched on whole labels.
136///
137/// Small, fixed, and standards-defined, which is the argument for keeping them
138/// here rather than in each consumer: see [`DomainScope::Local`] for the
139/// document behind each one.
140const RESERVED: &[&str] = &[
141    "localhost",
142    "test",
143    "invalid",
144    "example",
145    "local",
146    "home.arpa",
147    "internal",
148    // Resolved by something other than the DNS, so the global DNS cannot
149    // resolve them however far the name travels. `onion` needs naming here even
150    // where the suffix list is compiled in: the list carries it, so asking the
151    // list alone reports it as a name someone can register.
152    "onion",
153    "alt",
154];
155
156/// Classify a canonical domain: the bracketed form for an address literal, and
157/// otherwise the ASCII, lowercased hostname that
158/// [`EmailAddress::domain`](crate::EmailAddress::domain) returns.
159pub(crate) fn classify(domain: &str) -> DomainScope {
160    if let Some(content) = domain.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
161        return DomainScope::Literal(literal_scope(content));
162    }
163    // A single label names whatever the resolver being asked decides it names,
164    // which is the definition of local. It reaches here only when the caller
165    // opted into single-label domains; the default still refuses them outright.
166    if !domain.contains('.') || is_reserved(domain) || !is_globally_named(domain) {
167        return DomainScope::Local;
168    }
169    DomainScope::Global
170}
171
172fn is_reserved(domain: &str) -> bool {
173    RESERVED.iter().any(|name| is_within(domain, name))
174}
175
176/// Whether `domain` is `name` or a name under it.
177///
178/// Matched on whole labels, so `notlocal.com` is not under `local` and
179/// `nothome.arpa` is not under `home.arpa`.
180fn is_within(domain: &str, name: &str) -> bool {
181    match domain.strip_suffix(name) {
182        Some("") => true,
183        Some(prefix) => prefix.ends_with('.'),
184        None => false,
185    }
186}
187
188/// Whether the name is one the global DNS can be expected to resolve.
189///
190/// With the `psl` feature this asks the Public Suffix List, which is the
191/// question worth asking: a name under a published suffix is a name someone can
192/// register and the root zone delegates. Without it the crate has no list to
193/// consult, so it falls back to the same syntactic reading
194/// [`DomainCheck::Tld`](crate::DomainCheck::Tld) uses — weaker, but an answer
195/// rather than a compile error for a no-std consumer.
196#[cfg(feature = "psl")]
197fn is_globally_named(domain: &str) -> bool {
198    structured_public_domains::is_known_suffix(domain)
199}
200
201#[cfg(not(feature = "psl"))]
202fn is_globally_named(domain: &str) -> bool {
203    crate::validate::has_tld_like_suffix(domain)
204}
205
206fn literal_scope(content: &str) -> LiteralScope {
207    // The padded form second: it is the rarer spelling and the plain parse is
208    // the cheaper test. An `IPv6:` tag that satisfies neither cannot come from
209    // a parsed address — the parser refuses to reread it as free `dcontent`
210    // (RFC 5321 §4.1.3) — so it falls to the residual arm rather than being
211    // reported as an IPv6 address nobody could name.
212    if let Some(addr) = parser::ipv6_address_literal(content)
213        .or_else(|| parser::ipv6_address_literal_with_padded_tail(content))
214    {
215        return LiteralScope::Ipv6(ipv6_scope(addr));
216    }
217    if let Some(addr) = parser::ipv4_address_literal(content) {
218        return LiteralScope::Ipv4(ipv4_scope(addr));
219    }
220    LiteralScope::General
221}
222
223fn ipv4_scope(addr: Ipv4Addr) -> IpScope {
224    let [a, b, c, d] = addr.octets();
225    let local = match (a, b, c, d) {
226        // RFC 1918 §3: private-use.
227        (10, ..) | (192, 168, ..) => true,
228        (172, 16..=31, ..) => true,
229        // RFC 6598 §7: shared address space, 100.64.0.0/10.
230        (100, 64..=127, ..) => true,
231        // RFC 3927 §2.1: link-local, 169.254.0.0/16.
232        (169, 254, ..) => true,
233        // RFC 1122 §3.2.1.3: loopback (127/8) and "this network" (0/8). Neither
234        // is private use in the RFC 1918 sense, and neither leaves the host
235        // being asked, so calling either one global would be a false statement
236        // to whoever is reading the address.
237        (127 | 0, ..) => true,
238        // RFC 2544 §C.2.2: benchmarking, 198.18.0.0/15. Set aside so that test
239        // traffic between network devices cannot escape onto the public
240        // internet, which is the same reason as the blocks above rather than
241        // the "reserved for later" reason that leaves 240/4 global.
242        (198, 18..=19, ..) => true,
243        // RFC 919 §7: the limited broadcast address, which no router forwards.
244        // It sits inside 240/4, which is reserved rather than scoped to a
245        // network, so the rest of that block stays global the way documentation
246        // space does.
247        (255, 255, 255, 255) => true,
248        // Multicast is not local for being multicast, only for its assigned
249        // scope. RFC 5771 §4 gives 224.0.0.0/24 to link-local control traffic,
250        // and RFC 2365 §4 gives 239.0.0.0/8 to administratively scoped groups,
251        // which is the multicast counterpart of RFC 1918. The rest of 224/4 is
252        // routable and stays global.
253        (224, 0, 0, _) | (239, ..) => true,
254        _ => false,
255    };
256    if local {
257        IpScope::Local
258    } else {
259        IpScope::Global
260    }
261}
262
263fn ipv6_scope(addr: Ipv6Addr) -> IpScope {
264    // Answered before the embedded-IPv4 reading below, not after: both sit
265    // inside `::/96` and would decode as `0.0.0.1` and `0.0.0.0`, which lands
266    // on the right answer for the wrong reason. These have a rule of their own.
267    if addr.is_loopback() || addr.is_unspecified() {
268        // RFC 4291 §2.5.3, §2.5.2.
269        return IpScope::Local;
270    }
271    // An address that embeds an IPv4 one has that address's reach: the mapped
272    // form of RFC 4291 §2.5.5.2, and the compatible form of §2.5.5.1, which is
273    // deprecated but still parses and so still reaches this far. Without both,
274    // `[IPv6:::ffff:192.168.1.5]`, `[IPv6:::192.168.1.5]` and `[192.168.1.5]` —
275    // one host, three spellings — would not be classified the same way.
276    if let Some(embedded) = addr.to_ipv4() {
277        return ipv4_scope(embedded);
278    }
279    let leading = addr.segments()[0];
280    // RFC 4291 §2.7: a multicast address carries its own scope in the fourth
281    // nibble, and only 0xE is global. Every other value — interface, link,
282    // admin, site, organization, and the two reserved ones — names a boundary
283    // the traffic does not cross, so the address is local by its own
284    // definition rather than by which block it came from.
285    let bounded_multicast = leading & 0xff00 == 0xff00 && leading & 0x000f != 0x000e;
286    let local = leading & 0xfe00 == 0xfc00          // RFC 4193 §3: fc00::/7
287        || leading & 0xffc0 == 0xfe80               // RFC 4291 §2.5.6: fe80::/10
288        // RFC 3879 deprecated site-local, fec0::/10, but deprecating a block
289        // does not make it reachable: it was defined for use within a site, and
290        // that is what the answer here is about.
291        || leading & 0xffc0 == 0xfec0
292        || bounded_multicast;
293    if local {
294        IpScope::Local
295    } else {
296        IpScope::Global
297    }
298}
299
300#[cfg(test)]
301mod tests;