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;