proxy_watch/bypass.rs
1//! Bypass ("no proxy") rules.
2//!
3//! What an entry may look like comes from Go
4//! [`httpproxy`](https://github.com/golang/net/blob/master/http/httpproxy/proxy.go); what one
5//! *matches* follows Chromium wherever the readers disagree, which is what makes an embedded
6//! `*` a glob here. So the `Suffix` dialect is their union rather than a port of either —
7//! [`BypassDialect`] carries the measurement. Windows `<local>` / `<-loopback>` on top.
8//!
9//! One rule is not shared: a bare name covers the subdomains everywhere except in a
10//! Windows list, where it matches that name alone. See
11//! [`parse::proxy_override`](crate::parse::proxy_override).
12
13use std::collections::HashSet;
14use std::fmt;
15use std::net::IpAddr;
16
17use ipnet::{IpNet, Ipv4Net};
18use url::{Host, Url};
19
20use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
21use crate::error::Error;
22use crate::util::{glob_match, redact_offending_token, split_host_port, strip_brackets};
23
24/// A single entry of a bypass list.
25///
26/// | Source | Variant |
27/// |---|---|
28/// | `*` | [`HostPattern::All`] (not from a macOS or GNOME list, where it names nothing) |
29/// | CIDR | [`HostPattern::Cidr`] |
30/// | IP / `host:port` | [`HostPattern::Exact`] |
31/// | `example.com` / `.example.com` / `*.example.com` | [`HostPattern::Domain`] |
32/// | `192.168.*` | [`HostPattern::Wildcard`] |
33/// | `<local>` | [`HostPattern::Local`] |
34/// | `<-loopback>` | [`HostPattern::SubtractImplicit`] |
35///
36/// The bare-name row is the one that depends on where the list came from: a Windows
37/// list reads `example.com` as [`HostPattern::Exact`], because that is what Windows
38/// matches. [`parse::proxy_override`](crate::parse::proxy_override) has the readings.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40#[non_exhaustive]
41pub enum HostPattern {
42 /// Matches every host: a bare `*` in the list disables proxying entirely.
43 All,
44 /// Matches any address inside an IP network.
45 Cidr(IpNet),
46 /// Literal host, optionally port-restricted.
47 ///
48 /// [`parse`](Self::parse) reaches it only for an address literal, but
49 /// [`parse::proxy_override`](crate::parse::proxy_override) reaches it for every bare
50 /// name: a bare entry in a Windows list matches that name and nothing under it.
51 ///
52 /// Its [`Display`](std::fmt::Display) is the name alone, which is also how a suffix
53 /// rule for the same domain is written. Reading that text back therefore gives this
54 /// variant through `proxy_override` and a [`Domain`](Self::Domain) covering the
55 /// subdomains through [`parse::no_proxy`](crate::parse::no_proxy) — the same text,
56 /// two rules — so a pattern displayed and re-read through the *other* dialect widens.
57 /// Hold the value instead of its text if that matters.
58 Exact {
59 /// Host to match.
60 host: Host,
61 /// Optional port restriction.
62 port: Option<u16>,
63 },
64 /// Domain suffix (stored with a leading dot).
65 Domain {
66 /// Suffix (e.g. `.example.com`). A hand-built one without the dot names the
67 /// same domain; an empty one names none and so matches nothing. Case is folded
68 /// at match time, so [`parse`](Self::parse)'s lowercasing is a normalisation and
69 /// not a precondition.
70 suffix: String,
71 /// Whether the bare domain matches too.
72 match_self: bool,
73 /// Optional port restriction.
74 port: Option<u16>,
75 },
76 /// Windows-style glob with `*`.
77 ///
78 /// A plain `*.example.com` is not one: it is the same rule as `.example.com` and parses
79 /// to [`Domain`](Self::Domain). A leading `*.` does reach this variant when the rest
80 /// still holds a glob (`*.a*b.com`), and so does the `*` this parse writes in front of a
81 /// leading-dot glob (`.*.example.com` is stored as `*.*.example.com`).
82 ///
83 /// Matched as text against the destination's canonical spelling, so a glob written
84 /// around a non-canonical address (`*192.168.001.001`, which arrives as `192.168.1.1`)
85 /// matches nothing. Unlike the other variants, such a glob is not refused during
86 /// parsing and so does not reach [`BypassRules::rejected`]: `*` stands for the empty
87 /// string as well as for text, which leaves `*10.0.0.1` live and every test that
88 /// separates the two cases open to a counterexample.
89 Wildcard {
90 /// Glob pattern. [`parse`](Self::parse) lowercases it, and a hand-built one that
91 /// is not lowercased matches the same hosts anyway: case is folded at match time.
92 pattern: String,
93 /// Optional port restriction.
94 port: Option<u16>,
95 },
96 /// Windows `<local>` / macOS `ExcludeSimpleHostnames`: any host name without a dot.
97 Local,
98 /// Windows `<-loopback>`: the one entry that takes a bypass away instead of adding
99 /// one. It subtracts the implicit set — loopback *and* link-local, see
100 /// [`NO_LOOPBACK_TOKEN`] — from every entry written before it, and from none written
101 /// after it, which is why it is an entry in [`BypassRules::patterns`] and not a
102 /// switch beside them. [`BypassRules::matches`] has the evaluation order.
103 SubtractImplicit,
104}
105
106// Which list an entry came from, for the places the lists disagree about what an entry
107// means. Named after the source rather than after a behaviour, because no two of them
108// differ from `Suffix` on the same set of axes: `Windows` on three, `MacOs` and `Gnome` on
109// four each and not the same four — and `MacOs` shares one of the three `Windows` axes
110// while disagreeing with it everywhere else.
111//
112// `Suffix` reads a bare name as the name and everything under it, and a leading `*.` or `.`
113// as the subdomains alone. That much is Go's `httpproxy` with `no_proxy` and libproxy's with
114// KDE's `NoProxyFor` (`px-manager.c:736,752`, reached from `config-kde.c`), and it is why a
115// bare name here is not the name alone the way it is under `Windows` and `MacOs`.
116//
117// A `*` anywhere else is a glob, and that half is *not* those two. `config.init` in
118// `http/httpproxy/proxy.go` special-cases a bare `*` and strips one character off a leading
119// `*.`; what is left goes to a `domainMatcher` that compares with `strings.HasSuffix`, so no
120// star past the first is syntax. libproxy's `ignore_domain` (`px-manager.c`) reads four
121// shapes and no more — the bare `*`, an exact name, and the two suffix spellings `.name`
122// and `*.name`. Under both, `192.168.*` and `*.*.*.1` are literal text no host carries,
123// so they match nothing. Neither refuses a CIDR entry, though — it leaves the name rules
124// before it reaches them. `config.init` tries `net.ParseCIDR` first, and libproxy tries
125// `ignore_ip` after `ignore_domain` (`px-manager.c:848`), which masks an entry holding a
126// `/` with `g_inet_address_mask_new_from_string` against an address literal only.
127//
128// The star is read as a glob because the third reader of these same two lists reads it that
129// way. Chromium hands `no_proxy` and KDE's `NoProxyFor` to the same
130// `ProxyHostMatchingRules::ParseFromString` (`proxy_config_service_linux.cc:230`), which builds a
131// `SchemeHostPortMatcherHostnamePatternRule` for everything that is not a CIDR or an address
132// literal and evaluates it with `base::MatchPattern`
133// (`scheme_host_port_matcher_rule.cc:99,139`) — a glob, and the source of the leading-dot
134// rewrite the `Wildcard` arm in `parse_in` already quotes.
135//
136// So this dialect is the union of the two readings rather than either one, and the union is
137// the wider answer — the one that reaches `Direct` more often. `*.*.*.1` is met by
138// `10.0.0.1` here and by `base::MatchPattern`; the two suffix readers send that request to
139// the proxy. The union is kept because narrowing it would leave a rule someone typed a `*`
140// into matching nothing while still reading back as live, which is the shape `Gnome` below
141// refuses outright rather than store.
142//
143// `Windows` differs in three places. A bare name is the name alone, measured on this
144// crate's own terms rather than read off a reimplementation, because the two
145// reimplementations disagree and neither is the OS; the readings are the rows of
146// `a_bare_name_in_a_windows_list_is_the_one_host` in `tests/bypass.rs`. And the two
147// spellings a Windows list has no reading for — an entry holding a `/`, and an entry
148// starting with `.` — are refused rather than stored as rules the machine does not honour.
149// Each has a guard in `parse_in` carrying its readings and the vendor sentence.
150//
151// `MacOs` is CFNetwork's `ExceptionsList`, measured the same way and by the same argument —
152// `tests/mac_exceptions_list.rs` hands `CFNetworkCopyProxiesForURL` a settings dictionary
153// and reads the verdict off the matcher itself, so Chromium's macOS reader is not consulted:
154// it is a reimplementation and not the OS. It agrees with `Windows` that a bare name is the
155// name alone, and differs from every other dialect here on three more:
156//
157// * a `*` is syntax only as a leading `*.` or a trailing `.*`, and a character everywhere
158// else — so `*probe*`, `*invalid`, `pw-*.invalid` and a bare `*` match nothing. The
159// bare `*` is the one that matters: read as `HostPattern::All` it turns the proxy off
160// for every destination on a Mac that proxies all of them.
161// * a `:port` does not constrain an entry, it kills it. `example.com:80` matched no
162// destination on port 80, and `example.com` matched one that carried a port, so the
163// port is not part of the comparison at all.
164// * neither end is trimmed, which is `trim` below rather than an arm here: a space in
165// front of a name or behind it kills the entry the same way a `*` in the middle does.
166//
167// Two things it does *not* differ on are left alone deliberately. `<local>` and
168// `<-loopback>` bypass nothing there, but they are recognised here anyway, for the reason
169// `sys/linux/gsettings_map.rs` gives for GLib: the tokens are read before the dialect so
170// every source shares one vocabulary, and no macOS writer types a WinINet token. And
171// `name.*` covers the host plus anything whose leading labels are the host — the mirror of
172// `*.name`, not a suffix rule — which the glob this arm already builds gets right except
173// when the star stands for no labels at all (`example.com.*` against `example.com`). That
174// one row is fail-*closed*, so it is recorded rather than answered with a new variant.
175//
176// `Gnome` is GLib's `GSimpleProxyResolver`, which is the code that resolves on GNOME —
177// `gsettings_map.rs` reads the same keys GLib's `GProxyResolverGnome` does. It agrees on
178// the bare name, and on CIDR: `reparse_ignore_hosts` tries
179// `g_inet_address_mask_new_from_string` on the whole entry before it strips anything
180// (`gsimpleproxyresolver.c:186`), so a mask never reaches the name rules. It differs on
181// four more axes, all from the same file:
182//
183// * `reparse_ignore_hosts` strips a leading `*.` or `.` and stores the rest as a plain
184// name, which `ignore_host` then matches with `offset == 0` allowed — so `*.example.com`,
185// `.example.com` and `example.com` are one rule there, covering the domain *and* its
186// subdomains.
187// * no other `*` is syntax: what is left is `g_ascii_strcasecmp`d whole, and no
188// host contains a `*`, so `foo*.example.com` matches nothing at all. A bare `*` is that
189// rule's worst case and reads the same way — `reparse_ignore_hosts` strips neither a
190// `*.` nor a leading `.` from it, so it is stored whole and `ignore_host` then wants a
191// destination whose last character is a `*` behind a dot. None arrives. It shares this
192// with `MacOs`, and shares the reason with nothing else here: on Windows and in
193// `no_proxy` a bare `*` really does switch the proxy off.
194// * that same parse chomps with `g_strchomp`, trailing whitespace only, so a leading
195// space survives into the name and kills the rule the same way.
196// * `g_simple_proxy_resolver_lookup` resolves the destination with `G_URI_FLAGS_NONE`, and
197// `g_uri_split_internal` fills a scheme's default port only in its scheme-based
198// normalization, under `G_URI_FLAGS_SCHEME_NORMALIZE` — so a portless
199// `http://example.com/` is asked about with port 0, and an `example.com:80` rule does
200// not fire on it. That last one is not a pattern shape and lives in
201// [`BypassRules::require_explicit_port`].
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub(crate) enum BypassDialect {
204 Suffix,
205 Windows,
206 MacOs,
207 Gnome,
208}
209
210impl BypassDialect {
211 // Cut the whitespace the source itself would cut, and no more. `g_strchomp` is the
212 // trailing end only, so a GNOME entry written with a leading space keeps it; CFNetwork
213 // cuts neither end, so a macOS entry keeps both. In each case the space that survives
214 // is part of a name no destination can carry: it meets the whitespace guard in
215 // `HostPattern::parse_in` and the entry is refused, which is the verdict the source
216 // itself reaches (the rule matches nothing) with the entry visible in
217 // `BypassRules::rejected` rather than sitting in the list looking live.
218 //
219 // The macOS row is measured, not inherited: ` pw-probe.invalid` and `pw-probe.invalid `
220 // both proxied a destination the unpadded entry bypasses
221 // (`tests/mac_exceptions_list.rs` holds that on a macOS runner). Trimming either would
222 // make it a live rule here, and the crate would answer `ProxyStep::Direct` for traffic
223 // the Mac hands the proxy.
224 //
225 // Called from both ends because both need it and for different reasons — `parse_in` so
226 // the pattern it builds is the trimmed one, `parse::bypass_entries_in` so an entry it
227 // hands to `rejected` is quoted back without the separator's padding.
228 pub(crate) fn trim(self, entry: &str) -> &str {
229 match self {
230 BypassDialect::Gnome => entry.trim_end(),
231 BypassDialect::MacOs => entry,
232 BypassDialect::Suffix | BypassDialect::Windows => entry.trim(),
233 }
234 }
235}
236
237impl HostPattern {
238 /// Parse one bypass entry. `Ok(None)` for empty and for anything with no host left to
239 /// match on — `:8080`, `.`, `..`, `*.`.
240 ///
241 /// ```
242 /// # use proxy_watch::HostPattern;
243 /// let p = HostPattern::parse("*.example.com").unwrap().unwrap();
244 /// assert!(matches!(p, HostPattern::Domain { match_self: false, .. }));
245 /// ```
246 ///
247 /// A bare address entry is stored the way the destination side reads it, not the way it
248 /// was written: `010.0.0.1` is octal (`8.0.0.1`) and `192.168.1` is short-form
249 /// (`192.168.0.1`), because that is how the [`Host`] the destination carries would parse
250 /// the same text.
251 ///
252 /// A CIDR entry does not follow that grammar, because it is not a host: `ipnet` reads
253 /// four decimal octets, so `010.0.0.0/8` is `10.0.0.0/8` — the block the writer meant,
254 /// but not the block the same text would denote as a destination — and `192.168.1/24`
255 /// and `0x0a.0.0.0/8` are errors rather than addresses. What is shared is the mapped
256 /// reduction: `::ffff:10.0.0.0/104` is stored as `10.0.0.0/8`, matching what
257 /// destinations `10.0.0.1` and `[::ffff:10.0.0.1]` both become.
258 ///
259 /// # Errors
260 ///
261 /// [`Error::InvalidBypassPattern`] for `@`, internal whitespace, a `scheme://` prefix,
262 /// a character that cannot appear in a host name (WHATWG forbidden domain code points
263 /// not already rejected above), an empty label (`a..b`), a subdomain of an address
264 /// (`.10.0.0.1`), a name no destination could carry (`example.123`), bad CIDR `/`, bad
265 /// port, bad IPv6 brackets, or non-ASCII text with no punycode spelling.
266 pub fn parse(entry: &str) -> Result<Option<Self>, Error> {
267 Self::parse_in(entry, BypassDialect::Suffix)
268 }
269
270 pub(crate) fn parse_in(entry: &str, dialect: BypassDialect) -> Result<Option<Self>, Error> {
271 let entry = dialect.trim(entry);
272 if entry.is_empty() {
273 return Ok(None);
274 }
275 let lowered = entry.to_ascii_lowercase();
276
277 match lowered.as_str() {
278 // Not on macOS, and not on GNOME: a lone `*` is compared to the destination as
279 // a name on both, and meets none, so reading it as `All` would report every
280 // destination direct on a machine that proxies every one of them. It falls
281 // through to the star arm below, which refuses it by the same rule that refuses
282 // `*probe*` — dead entries belong in `rejected`, where the writer can see the
283 // rule did nothing, and not in the list looking live.
284 "*" if !matches!(dialect, BypassDialect::MacOs | BypassDialect::Gnome) => {
285 return Ok(Some(HostPattern::All));
286 }
287 LOCAL_TOKEN => return Ok(Some(HostPattern::Local)),
288 NO_LOOPBACK_TOKEN => return Ok(Some(HostPattern::SubtractImplicit)),
289 _ => {}
290 }
291
292 // Most of the guards below share one reason, written here rather than at each of
293 // them: an entry this crate cannot read as a host still reaches the `Domain` arm at
294 // the end, where it is stored as a suffix of whatever was written — a rule that
295 // then matches no destination and says so nowhere. Under
296 // `BypassRules::reversed_exceptions` that dead rule sends the traffic it names out
297 // direct, which is fail-open, and refusing instead is affordable only because the
298 // entry survives in `BypassRules::rejected`. Each guard adds the shape it catches
299 // and the dead rule that shape would have become; the two that refuse for a
300 // *different* reason — the `@` and `://` guards — say so themselves.
301
302 // `@` marks a pasted proxy URL, not a bypass host — reject before port parse
303 // would embed the password in the error reason ([`SafeError`] prints it in full).
304 if entry.contains('@') {
305 return Err(Error::bypass(
306 entry,
307 format!(
308 "entry looks like a proxy URL with credentials ({}), not a bare \
309 host[:port] or CIDR; bypass/no-proxy lists cannot carry a \
310 username or password",
311 redact_offending_token(entry)
312 ),
313 ));
314 }
315
316 // Whitespace cannot occur inside a host name any more than a `/` can, so an entry
317 // that still holds some is stored as `suffix = ".a.example b.example"`.
318 // It reaches here from a list whose separators do not include whitespace at all:
319 // [`parse::no_proxy`](crate::parse::no_proxy), which splits on `,` alone — Go's
320 // `httpproxy`, its reference, does the same, and `;` was removed from
321 // `LIST_SEPARATORS` for the reason written there — and the GNOME and KDE readers
322 // that go through it with lists `,`-separated already.
323 // [`parse::proxy_override`](crate::parse::proxy_override) does split on whitespace,
324 // but only on the ASCII spellings Windows itself writes, so a Windows list still
325 // reaches this arm when one entry holds something `char::is_whitespace` calls space
326 // and `WINDOWS_BYPASS_SEPARATORS` does not — `U+00A0` or `U+3000` pasted into the
327 // settings dialog. `src/parse.rs` says the same from the splitting side.
328 if entry.chars().any(char::is_whitespace) {
329 return Err(Error::bypass(
330 entry,
331 // Not "',' or ';'": `;` separates nothing outside the Windows list, so
332 // advising it told a `no_proxy`, GNOME or KDE writer to reach for the one
333 // character that leaves their two entries a single dead rule.
334 "entry contains whitespace, so it is not a single host[:port] or CIDR \
335 (separate entries with ',', or on Windows with ';' or a space)",
336 ));
337 }
338
339 // A rule may name a scheme: Chromium's grammar is
340 // `[<scheme>"://"]<host-pattern>[":"<port>]`, and
341 // `SchemeHostPortMatcherRule::FromUntrimmedRawString` splits on `://` *before* it
342 // reads a `/` as a CIDR mask, so `https://example.com` and `https://10.0.0.0/8` are
343 // both rules there. A [`HostPattern`] has nowhere to put the scheme, and dropping
344 // the one written would turn an https-only rule into one that bypasses plain HTTP
345 // too — a wider bypass than the entry asks for. Refused instead, and the reason
346 // says which grammar was not met rather than the CIDR guard's claim below.
347 if lowered.contains("://") {
348 return Err(Error::bypass(
349 entry,
350 "entry names a scheme, and a bypass pattern here applies to every scheme, \
351 so the restriction cannot be honoured (write the host on its own, for \
352 example example.com)",
353 ));
354 }
355
356 // Not on Windows: an entry holding a `/` invalidates the whole list there, so a
357 // `Cidr` rule built from one reports a bypass no reader of that list grants.
358 // Microsoft bounds the damage at the list — "don't enter subwebs or trailing
359 // slashes ... as they are invalidating the whole list otherwise" (KB 4551930) — and
360 // both readers land past that bound: `InternetOpenW` fails with
361 // `ERROR_INVALID_PARAMETER`, and a `ProxyOverride` holding one sends every
362 // destination direct, which is measured rather than documented. The range Windows
363 // does read is a wildcard, `10.*`. The rows are
364 // `a_slash_ends_a_windows_bypass_list` in `tests/bypass.rs`.
365 if dialect == BypassDialect::Windows && lowered.contains('/') {
366 return Err(Error::bypass(
367 entry,
368 "entry contains a '/', which Windows does not read as a CIDR block — it \
369 invalidates the whole bypass list (write the range with wildcards, for \
370 example 10.* rather than 10.0.0.0/8)",
371 ));
372 }
373
374 // Not on Windows: a leading `.` is a spelling no reader of that list grants, so a
375 // `Domain` rule built from one sends traffic direct that the machine proxies.
376 // Microsoft documents the wildcard in its place — "Enter a wildcard at the beginning
377 // of an Internet address, IP address, or domain name that has a common ending"
378 // (KB 4551930) — and the three readings agree, asked for `sub.name`: `InternetOpenW`
379 // fails with `ERROR_INVALID_NAME` and takes the whole list with it, while WinHTTP and
380 // the registry reading keep the list and reach the proxy anyway. `*.name` bypasses on
381 // all three. `.` and `..` are left to the arms below, which build no rule from either
382 // and so have nothing to refuse. The rows are
383 // `a_leading_dot_is_not_a_windows_suffix` in `tests/bypass.rs`.
384 if dialect == BypassDialect::Windows
385 && lowered.starts_with('.')
386 && !lowered.trim_start_matches('.').is_empty()
387 {
388 return Err(Error::bypass(
389 entry,
390 "entry starts with a '.', which Windows does not read as a subdomain rule — \
391 WinINet refuses the whole bypass list over one (write the subdomains as \
392 *.example.com rather than .example.com)",
393 ));
394 }
395
396 if let Ok(net) = lowered.parse::<IpNet>() {
397 return Ok(Some(HostPattern::Cidr(reduce_mapped_net(net))));
398 }
399
400 // With the scheme form taken above, a `/` that survives the CIDR parse cannot be
401 // anything else: host names do not contain one, and neither does the `host[:port]`
402 // form. Without this check `10.0.0/8` falls through to the `Domain` arm and becomes
403 // `suffix = "./10.0.0/8"`. Go is where that dead rule was observed, not where the
404 // guard comes from: `httpproxy`'s `config.init` tries `net.ParseCIDR` on every
405 // entry and, on failure, falls straight through to the domain arm, so `10.0.0/8`
406 // becomes a `domainMatch` on `.10.0.0/8` there too. Erroring instead is this
407 // crate's own choice.
408 if lowered.contains('/') {
409 return Err(Error::bypass(
410 entry,
411 "entry contains a '/', so it can only be a CIDR block, but it is not a \
412 valid one (an address, a '/', and a prefix length — for example \
413 10.0.0.0/8 or fe80::/10)",
414 ));
415 }
416
417 let (bracketed_text, port) =
418 split_host_port(&lowered).map_err(|reason| Error::bypass(entry, reason))?;
419 let host_text = strip_brackets(bracketed_text);
420
421 // CFNetwork does not read a port in an entry as a restriction on the entry; it reads
422 // the entry as not matching. Stored with a port here it would be the narrower rule
423 // the writer appears to have asked for, which is the one shape of divergence this
424 // file never lets stand: on macOS the entry is dead, so the port is what killed it
425 // and `BypassRules::rejected` is where that has to be visible.
426 if dialect == BypassDialect::MacOs && port.is_some() {
427 return Err(Error::bypass(
428 entry,
429 "entry carries a port, which macOS does not read as a restriction — it \
430 compares the whole entry to the destination's host name, so no destination \
431 could ever match it (write the host on its own)",
432 ));
433 }
434
435 // Brackets are the address-literal spelling and nothing else, so a bracketed entry
436 // that is not one reaches the `Domain` arm as `suffix = ".2001:db8::zz"` for
437 // `[2001:db8::zz]`. Not a spelling ruling: `parse_host` and not
438 // `IpAddr`, because that is the grammar the destination side reads, and the address
439 // arm at the end of this function accepts the same padded and hex spellings.
440 if host_text.len() != bracketed_text.len()
441 && !matches!(
442 crate::endpoint::parse_host(host_text),
443 Ok(Host::Ipv4(_) | Host::Ipv6(_))
444 )
445 {
446 return Err(Error::bypass(
447 entry,
448 "entry is bracketed, so it can only be an address literal, but it is not a \
449 valid one (for example [::1] or [2001:db8::1])",
450 ));
451 }
452
453 // The destination side is `Host::parse`, which refuses WHATWG's "forbidden domain
454 // code point" — the C0 controls, DEL, `#`, `%`, `<`, `>`, `?`, `[`, `\`, `]`, `^`
455 // and `|`. A rule holding one names a host that can never arrive:
456 // `a?b.example.com` becomes `suffix = ".a?b.example.com"`. The `@`, whitespace and
457 // `/` guards above are this same test spelled one character at a time; they stay
458 // separate because each has a better reason to give than "not a host character",
459 // and because `/` and `[` are read as syntax before they get here.
460 if let Some(bad) = host_text.chars().find(|c| is_forbidden_host_char(*c)) {
461 return Err(Error::bypass(
462 entry,
463 format!(
464 "entry contains {bad:?}, which cannot appear in a host name, so no \
465 destination could ever match it"
466 ),
467 ));
468 }
469
470 // Go runs every domain entry through `idnaASCII`, and this crate's destination side
471 // is converted the same way — by `Host::parse` when the host is parsed from text,
472 // and by `host_key` when a caller built the `Host` itself. Without the step here a Unicode
473 // entry is a dead rule: `日本.example` is stored verbatim while the destination arrives
474 // as `xn--wgv71a.example`, so it matches nothing — in *either* spelling, because the
475 // destination is always converted and the pattern never is. Only non-ASCII text
476 // reaches the converter, so no entry that works today changes shape.
477 let encoded;
478 let host_text = if host_text.is_ascii() {
479 host_text
480 } else {
481 encoded = idna_ascii(host_text).map_err(|reason| Error::bypass(entry, reason))?;
482 &encoded
483 };
484
485 // RFC 1035 section 2.3.1 gives the empty label one place, the root at the end —
486 // which is the single trailing dot the arms below strip. A second one is not a
487 // host name: `example.com..` is stored as `.example.com.` and meets only a
488 // destination spelled the same way. Its own `Display` is `example.com.`, which
489 // reads back as the live rule it is not. The `*.` and `.` suffix spellings and the
490 // root dot are this grammar's own syntax, so they come off before the labels are
491 // counted; what is left of `.` or `..` is no host part at all, which the arms below
492 // already answer with `Ok(None)`. Recognising those two spellings of "a subdomain
493 // of" once, here, is also what keeps the label check just below and the address
494 // check after it from disagreeing about where the body starts.
495 let subdomain_body = host_text
496 .strip_prefix("*.")
497 .or_else(|| host_text.strip_prefix('.'));
498 let labelled = subdomain_body.unwrap_or(host_text);
499 let labelled = labelled.strip_suffix('.').unwrap_or(labelled);
500 if !labelled.is_empty() && labelled.split('.').any(str::is_empty) {
501 return Err(Error::bypass(
502 entry,
503 "entry has an empty label (two dots in a row), so no destination could \
504 ever match it (write example.com, .example.com or *.example.com)",
505 ));
506 }
507
508 // `.example.com` and `*.example.com` say "a subdomain of this", so what follows the
509 // dot has to be something that *has* subdomains — a domain name. An address does
510 // not: `.10.0.0.1` waits for an `a.10.0.0.1` that `Host::parse` refuses, in every
511 // spelling the address side accepts (`.192.168.001.001`, `.0x0a.0.0.1`) and for an
512 // unbracketed IPv6 too. Neither is a name that could never arrive on its own
513 // (`.example.123`).
514 //
515 // "Could never match" is said of a destination a *special* scheme can carry, which is
516 // what these lists are written about and what `matches_authority` admits — it goes
517 // through `parse_host`, so `a.example.123` is refused there too. `matches_url` is
518 // wider: WHATWG sends a non-special scheme to the opaque-host parser *before* the
519 // "ends in a number" check, so `custom://a.example.123/` parses and its host is
520 // `Domain("a.example.123")` while `http://a.example.123/` fails with
521 // `InvalidIpv4Address`. A rule this arm refuses would have met that host. The refusal
522 // stays — it catches the typo the shape almost always is, and dropping the rule sends
523 // the request to the proxy rather than around it — but the reachability it tests is
524 // the special-scheme one, and nothing further down may read it as more than that.
525 // `a_refused_numeric_tail_is_only_unreachable_for_a_special_scheme` in `tests/bypass.rs`
526 // is the measurement.
527 //
528 // A glob in the body buys the entry a narrow exemption, covering the spellings that
529 // reach here, which keep the dot: `.*.10.0.0.1` becomes `Wildcard("*.*.10.0.0.1")`,
530 // which needs a destination of the form `a.b.10.0.0.1` — a name whose last label
531 // reads as a number, which is the address reading and not a host any URL can carry.
532 // A star standing for the empty string does not widen that: `*10.0.0.1` would meet
533 // `10.0.0.1`, but it opens with neither `.` nor `*.`, so it has no subdomain body and
534 // never reaches this arm.
535 // That holds for a whole address in the tail and does not survive being generalised
536 // to any numeric one: a star is matched against the destination's *text*, where it
537 // stands for the leading octets of an address as readily as for whole labels, so
538 // `Wildcard("*.*.*.1")` is met by `10.0.0.1` itself and `.*.1` is a live rule.
539 //
540 // The destination such a rule meets can only be an address, because reaching this
541 // arm at all means the address reading was tried on the body and failed, which the
542 // name reading only does for a last label that is all digits. So the entry is dead
543 // unless the pattern could be laid over a dotted quad, and two things settle that.
544 // Every literal byte has to be one a quad carries: a digit, a dot, or the star
545 // itself — which is what still refuses `.*.0x0a.0.0.1`, `.*.example.123` and the
546 // colon of a broken IPv6. And the literal dots have to fit, counting the one in the
547 // `*.` this entry gets in front of its body: a quad has four labels and so three
548 // dots, which is why `.*.1` and `.*.0.1` live while `.*.0.0.1` and `.*.10.0.0.1` do
549 // not. The test is permissive in the direction that costs nothing — no octet is 999,
550 // so `.*.999` is kept and matches nothing — and strict in the one that costs a rule.
551 // Entries whose body is a name never reach it: `.*.example` and `.a*b.example` parse
552 // as domains and pass here as they always did.
553 let suffix_body = subdomain_body
554 .map(|rest| rest.strip_suffix('.').unwrap_or(rest))
555 .filter(|body| !body.is_empty());
556 if let Some(body) = suffix_body {
557 let fits_a_dotted_quad = body.contains('*')
558 && body.matches('.').count() < 3
559 && body
560 .bytes()
561 .all(|b| b.is_ascii_digit() || b == b'.' || b == b'*');
562 match crate::endpoint::parse_host(body) {
563 Ok(Host::Domain(_)) => {}
564 Ok(_) => {
565 return Err(Error::bypass(
566 entry,
567 "entry names a subdomain of an address literal, which has none, so \
568 no destination could ever match it (write the address on its own, \
569 or a CIDR range such as 10.0.0.0/8)",
570 ));
571 }
572 Err(_) if fits_a_dotted_quad => {}
573 Err(_) => return Err(Error::bypass(entry, UNREACHABLE_NAME)),
574 }
575 }
576
577 // `*.example.com` is the same rule as `.example.com` (Go strips the `*`).
578 // Handle this *before* stripping a trailing root dot, otherwise `*.` collapses
579 // to `*` and becomes a match-everything wildcard.
580 if let Some(rest) = host_text.strip_prefix("*.") {
581 let rest = rest.strip_suffix('.').unwrap_or(rest);
582 if rest.is_empty() {
583 return Ok(None);
584 }
585 if !rest.contains('*') {
586 return Ok(Some(HostPattern::Domain {
587 suffix: format!(".{rest}"),
588 // GNOME stores what is left after the `*.` as a plain name and then
589 // allows the whole-string match, so there the prefix widens the rule
590 // instead of excluding the domain itself.
591 match_self: dialect == BypassDialect::Gnome,
592 port,
593 }));
594 }
595 if dialect == BypassDialect::Gnome {
596 return Err(Error::bypass(entry, GNOME_LITERAL_STAR));
597 }
598 // A second star, past the one that opened the entry, is a character again.
599 if dialect == BypassDialect::MacOs {
600 return Err(Error::bypass(entry, MACOS_LITERAL_STAR));
601 }
602 return Ok(Some(HostPattern::Wildcard {
603 pattern: format!("*.{rest}"),
604 port,
605 }));
606 }
607
608 // A trailing dot on a pattern is the DNS root marker.
609 let host_text = host_text.strip_suffix('.').unwrap_or(host_text);
610 if host_text.is_empty() {
611 // Go ignores entries with no host part instead of failing — `config.init`
612 // in `http/httpproxy/proxy.go` answers that shape with `continue`.
613 return Ok(None);
614 }
615
616 if let Ok(ip) = host_text.parse::<IpAddr>() {
617 let host = match ip {
618 IpAddr::V4(v4) => Host::Ipv4(v4),
619 IpAddr::V6(v6) => Host::Ipv6(v6),
620 };
621 return Ok(Some(HostPattern::Exact { host, port }));
622 }
623
624 if host_text.contains('*') {
625 if dialect == BypassDialect::Gnome {
626 return Err(Error::bypass(entry, GNOME_LITERAL_STAR));
627 }
628 // The `*.` spelling was taken above, so the only star macOS still reads as
629 // syntax is a trailing `.*`, and only when it is the sole one. What is left of
630 // the entry has to be a name a destination could carry — a leading dot is not,
631 // and the glob would be as dead as the entry it came from.
632 if dialect == BypassDialect::MacOs {
633 let head = host_text.strip_suffix(".*").filter(|head| {
634 !head.is_empty() && !head.contains('*') && !head.starts_with('.')
635 });
636 return match head {
637 Some(head) => Ok(Some(HostPattern::Wildcard {
638 pattern: format!("{head}.*"),
639 port,
640 })),
641 None => Err(Error::bypass(entry, MACOS_LITERAL_STAR)),
642 };
643 }
644 // The leading dot is the reference's own rewrite — "we remap `.google.com` -->
645 // `*.google.com`" — and it is applied to every rule that starts with one, glob
646 // or not (`SchemeHostPortMatcherRule::FromUntrimmedRawString`). The `Domain` arm
647 // below is that rewrite spelled as a suffix; this arm needs it written out,
648 // because a pattern is matched literally and no host key begins with a dot —
649 // left alone, `.*.example.com` is the dead rule the guards above refuse.
650 let pattern = if host_text.starts_with('.') {
651 format!("*{host_text}")
652 } else {
653 host_text.to_owned()
654 };
655 return Ok(Some(HostPattern::Wildcard { pattern, port }));
656 }
657
658 if let Some(rest) = host_text.strip_prefix('.') {
659 if rest.is_empty() {
660 return Ok(None);
661 }
662 return Ok(Some(HostPattern::Domain {
663 suffix: host_text.to_owned(),
664 // The `*.` spelling above, for the same reason.
665 match_self: dialect == BypassDialect::Gnome,
666 port,
667 }));
668 }
669
670 // Store the address the way the destination side spells it, for the same reason the
671 // punycode step above exists: `Host::parse` reads a leading zero as octal, `0x` as
672 // hex and a short form as filling from the right, so `192.168.001.001` kept verbatim
673 // would never meet the `192.168.1.1` that arrives. Reached only once `IpAddr` has
674 // said no, so the entry whose meaning this changes is the all-digit name `123`, now
675 // the address `0.0.0.123` — which is what a destination spelled `123` already
676 // becomes. Hence
677 // `idna_ascii` keeps `Host::parse` away from an all-digit *label*, where the address
678 // reading is the wrong one.
679 // The suffix spellings were answered above; this is the bare one.
680 let Ok(host) = crate::endpoint::parse_host(host_text) else {
681 return Err(Error::bypass(entry, UNREACHABLE_NAME));
682 };
683
684 // An address is one host under every dialect — there is nothing "under"
685 // `10.0.0.1` for a suffix rule to reach.
686 if matches!(host, Host::Ipv4(_))
687 || matches!(dialect, BypassDialect::Windows | BypassDialect::MacOs)
688 {
689 return Ok(Some(HostPattern::Exact { host, port }));
690 }
691
692 Ok(Some(HostPattern::Domain {
693 suffix: format!(".{host_text}"),
694 match_self: true,
695 port,
696 }))
697 }
698
699 // Test the pattern against a destination.
700 //
701 // `host_text` must already be lowercased; `ip` is `Some` when the host is a
702 // literal address. Prefer [`BypassRules::matches`], which also applies the
703 // loopback and simple-hostname rules.
704 fn matches(&self, host_text: &str, ip: Option<IpAddr>, port: Option<u16>) -> bool {
705 match self {
706 HostPattern::All => true,
707 HostPattern::Cidr(net) => ip.is_some_and(|ip| net.contains(&ip)),
708 HostPattern::Exact {
709 host,
710 port: rule_port,
711 } => {
712 // Addresses compare as addresses, so `10.0.0.1` and `[::ffff:10.0.0.1]`
713 // are the one host they denote — `host_ip` has reduced both sides. This
714 // is Go's `ipMatch`, which compares with `net.IP.Equal`. The references
715 // split here: Chromium's `SchemeHostPortMatcherIPHostRule::Evaluate` is
716 // `base::MatchPattern(url.GetHost(), ip_host_)`, plain text. Go is the
717 // one to follow because the rest of this file already reads the mapped
718 // spelling as the address it maps — the CIDR arm below, `is_loopback`,
719 // `is_link_local` — and a rule that agreed with none of them would be
720 // the asymmetry those exist to avoid. The text fallback carries the
721 // `Exact { host: Host::Domain(_) }` that a Windows list produces for every
722 // bare name, and the hand-built one.
723 let hit = match (host_ip(host), ip) {
724 (Some(rule_ip), Some(destination_ip)) => rule_ip == destination_ip,
725 _ => host_text.strip_suffix('.').unwrap_or(host_text) == host_key(host),
726 };
727 hit && port_matches(*rule_port, port)
728 }
729 HostPattern::Domain {
730 suffix,
731 match_self,
732 port: rule_port,
733 } => {
734 // No `ip.is_none()` guard, so `.1` also matches `10.0.0.1` — text matching,
735 // which is what the Windows `ProxyOverride` lists this crate also reads have
736 // always done. (Go excludes addresses here, Chromium does not.)
737 //
738 // The leading dot is `parse`'s convention, not something the type can
739 // enforce: the fields are public and `BypassRules::new` exists to be filled
740 // in by hand. So name the domain rather than slicing a byte off it — a
741 // non-ASCII first character is not a char boundary — and refuse the empty
742 // name outright, which is what the field doc promises matches nothing. It
743 // would not: an empty `bare` strips nothing, so the whole host comes back as
744 // `rest`, and any host still ending in a dot once one is shed then satisfies
745 // the `ends_with('.')` test below. The destination side of that is an
746 // ordinary URL and not a hand-built value — `url` reads `http://example.com../`
747 // as `Host::Domain("example.com..")`. (Not "matches every host": that is what
748 // an `ends_with(bare)` shape would do, and this one strips instead.)
749 //
750 // The case of the suffix is `parse`'s convention for the same reason, and
751 // gets the same treatment: fold it here instead of trusting it. The `Exact`
752 // arm above has always folded — not by design, but because `host_key` is
753 // also what lowercases the destination — and a rule set that answered one
754 // way for `Exact { "FooBar.com" }` and another for `Domain { ".FooBar.com" }`
755 // would be the asymmetry this file keeps saying it avoids.
756 let host_text = host_text.strip_suffix('.').unwrap_or(host_text);
757 let bare = suffix.strip_prefix('.').unwrap_or(suffix);
758 let hit = !bare.is_empty()
759 && strip_suffix_ascii_case(host_text, bare).is_some_and(|rest| {
760 rest.ends_with('.') || (*match_self && rest.is_empty())
761 });
762 hit && port_matches(*rule_port, port)
763 }
764 HostPattern::Wildcard {
765 pattern,
766 port: rule_port,
767 } => {
768 let host_text = host_text.strip_suffix('.').unwrap_or(host_text);
769 // Folded on the rule side like `Domain` above, and folded *here* rather
770 // than inside `glob_match`: that helper is shared with `sh_exp_match`, the
771 // PAC `shExpMatch`, where case sensitivity is the reference behaviour —
772 // Mozilla's `ascii_pac_utils.js` builds a `RegExp` with no `i` flag. The
773 // allocation is skipped for the shape `parse` produces, which is all of them.
774 let hit = if pattern.bytes().any(|byte| byte.is_ascii_uppercase()) {
775 glob_match(&pattern.to_ascii_lowercase(), host_text)
776 } else {
777 glob_match(pattern, host_text)
778 };
779 hit && port_matches(*rule_port, port)
780 }
781 HostPattern::Local => is_simple_host_name(host_text, ip),
782 // Not a bypass this entry adds, and the only entry that can take one away.
783 // [`BypassRules::matches`] answers for it before it reaches here, because the
784 // answer depends on the destination's membership in the implicit set rather
785 // than on this pattern.
786 HostPattern::SubtractImplicit => false,
787 }
788 }
789}
790
791impl fmt::Display for HostPattern {
792 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
793 match self {
794 HostPattern::All => f.write_str("*"),
795 HostPattern::Cidr(net) => write!(f, "{net}"),
796 HostPattern::Exact { host, port } => write_with_port(f, &host_display(host), *port),
797 HostPattern::Domain {
798 suffix,
799 match_self,
800 port,
801 } => {
802 // The dot is added rather than passed through, because the field doc says a
803 // hand-built suffix may be written without one and mean the same domain.
804 // That is true of matching and was not true of this: `Domain { suffix:
805 // "example.com", match_self: false }` printed `example.com`, which
806 // [`Self::parse`] reads back as `match_self: true` — a rule for the
807 // subdomains alone, displayed and re-read as one that takes the bare domain
808 // with them. Anyone logging a rule set and feeding it back gets a wider
809 // bypass than the one they built.
810 let base = match (*match_self, suffix.strip_prefix('.')) {
811 (true, Some(bare)) => bare.to_owned(),
812 (false, None) => format!(".{suffix}"),
813 _ => suffix.clone(),
814 };
815 write_with_port(f, &base, *port)
816 }
817 HostPattern::Wildcard { pattern, port } => write_with_port(f, pattern, *port),
818 HostPattern::Local => f.write_str(LOCAL_TOKEN),
819 HostPattern::SubtractImplicit => f.write_str(NO_LOOPBACK_TOKEN),
820 }
821 }
822}
823
824/// The Windows `ProxyOverride` token that bypasses dot-less (intranet) host names.
825pub const LOCAL_TOKEN: &str = "<local>";
826
827/// Windows `<-loopback>`: subtracts the whole implicit bypass set — loopback *and*
828/// link-local — from the entries written before it. Parsed to
829/// [`HostPattern::SubtractImplicit`]; see [`BypassRules::bypass_loopback`].
830pub const NO_LOOPBACK_TOKEN: &str = "<-loopback>";
831
832// Given twice by `HostPattern::parse`: once for a bare entry, once for a suffix one.
833//
834// The parenthetical names both shapes that arrive here, because the reason has to hold for
835// every entry `parse_host` refused and not just the one that motivated it. `2001:db8:1`
836// reaches this too — the guards above let a colon through as an attempt at an unbracketed
837// IPv6 — and a colon also makes `Error::bypass` withhold the input, so a message that only
838// described `example.123` would leave the reader with neither the entry nor its reason.
839const UNREACHABLE_NAME: &str = "entry is not a name any destination could carry, so no \
840 destination could ever match it (a last label that reads as \
841 a number, as in example.123, is taken for an address and \
842 refused as one; so is an unbracketed IPv6 that is not a \
843 valid address, as in 2001:db8:1)";
844
845// GNOME's only wildcard is the leading `*.`, and it is stripped rather than matched
846// (`gsimpleproxyresolver.c:227`); anything else holding a `*` is compared whole against a
847// host name, which cannot contain one. Refused rather than stored, because storing it is
848// the dead rule the guards above exist to keep out of the list — and refusing it here is
849// what keeps this crate from reporting a bypass GNOME does not have.
850//
851// The bare `*` is the one that carries the whole arm, the same way it does for macOS below:
852// it is not the leading `*.` the strip looks for, so GLib stores it whole and matches it
853// against nothing, while `HostPattern::All` is every destination on the machine reported
854// direct at once. It reaches this constant through the guard further down rather than the
855// `All` arm at the top of `parse_in`, which is why that arm names this dialect.
856const GNOME_LITERAL_STAR: &str = "entry contains a '*' that GNOME does not read as a \
857 wildcard — only a leading '*.' is one there — so no \
858 destination could ever match it (write *.example.com for \
859 a domain, or the host on its own)";
860
861// macOS reads a star at either end of an entry and nowhere in between:
862// `tests/mac_exceptions_list.rs` has `*.pw-probe.invalid` and `pw-probe.invalid.*` matching
863// and `*probe*`, `*invalid`, `pw-*.invalid` and a bare `*` matching nothing. Refused for
864// the reason above it, and the bare `*` is why the refusal is worth the arm: kept as a
865// wildcard it is the whole proxy switched off.
866const MACOS_LITERAL_STAR: &str = "entry contains a '*' that macOS does not read as a \
867 wildcard — only a leading '*.' or a trailing '.*' is one \
868 there — so no destination could ever match it (write \
869 *.example.com for a domain, or the host on its own)";
870
871/// Destinations that must not go through a proxy
872/// ([`parse::no_proxy`](crate::parse::no_proxy) / [`parse::proxy_override`](crate::parse::proxy_override)).
873#[derive(Debug, Clone, PartialEq, Eq)]
874#[non_exhaustive]
875pub struct BypassRules {
876 /// Parsed list entries.
877 pub patterns: Vec<HostPattern>,
878 /// Dot-less host bypass, as a switch (macOS `ExcludeSimpleHostnames`). The list
879 /// spelling [`HostPattern::Local`] says the same thing until
880 /// [`reversed_exceptions`](Self::reversed_exceptions) is set, where a switch and a
881 /// list entry mean opposite things — see
882 /// [`excludes_simple_hostnames`](Self::excludes_simple_hostnames).
883 pub exclude_simple_hostnames: bool,
884 /// KDE/`config-kde` inclusion list — the implicit set still applies; see [`matches`](Self::matches).
885 pub reversed_exceptions: bool,
886 /// Redacted unparseable originals; affects verdict under
887 /// [`reversed_exceptions`](Self::reversed_exceptions). Not a complete ledger of the
888 /// entries that match nothing — see [`HostPattern::Wildcard`].
889 pub rejected: Vec<RejectedValue>,
890 /// Whether a destination's port counts only when the destination wrote one, so that a
891 /// ported entry such as `example.com:80` does not meet a portless `http://example.com/`.
892 ///
893 /// Set for GNOME `ignore-hosts` and nowhere else. GLib resolves the destination with
894 /// `G_URI_FLAGS_NONE` (`gsimpleproxyresolver.c:341`) and fills a scheme's default port
895 /// only under `G_URI_FLAGS_SCHEME_NORMALIZE` (`guri.c:1006`), so the port it compares
896 /// against is 0 unless the URL carried one. Windows fills it — a `ProxyOverride` entry
897 /// of `host:80` was measured bypassing a portless `http://host/`.
898 ///
899 /// [`matches`](Self::matches) does not read this: it is told a port and answers about
900 /// that port. [`matches_url`](Self::matches_url) is where it is read, and is the reason
901 /// to prefer that method whenever the destination is a `Url` — it passes
902 /// [`Url::port`](https://docs.rs/url/latest/url/struct.Url.html#method.port) rather than
903 /// `port_or_known_default` when this is set. Reading the field by hand is supported, not
904 /// recommended.
905 ///
906 /// One case is approximate, and in the safe direction. `Url` drops a port equal to the
907 /// scheme's default while parsing, so `http://host:80/` and `http://host/` are the same
908 /// value and `port` answers `None` for both. A rule on that port — `host:80` for
909 /// `http`, `host:443` for `https` — therefore stops firing entirely here, where GLib
910 /// still fires it on the spelling that wrote the port out. The destination goes to the
911 /// proxy instead of direct; the distinction was gone before this crate saw the URL.
912 pub require_explicit_port: bool,
913}
914
915impl Default for BypassRules {
916 fn default() -> Self {
917 Self::new()
918 }
919}
920
921impl BypassRules {
922 /// An empty rule set with the default loopback behaviour (loopback is bypassed).
923 #[must_use]
924 pub const fn new() -> Self {
925 Self {
926 patterns: Vec::new(),
927 exclude_simple_hostnames: false,
928 reversed_exceptions: false,
929 rejected: Vec::new(),
930 require_explicit_port: false,
931 }
932 }
933
934 /// Whether the implicit bypass set — loopback *and* link-local — is still in force,
935 /// which it is unless the list carries [`HostPattern::SubtractImplicit`]
936 /// (`<-loopback>`).
937 ///
938 /// A rough answer, and the reason it is not a field: `<-loopback>` subtracts the
939 /// implicit set only from the entries written before it, so a list that carries the
940 /// token can still bypass a loopback destination named after it. Ask
941 /// [`matches`](Self::matches) about a destination; ask this about the list.
942 ///
943 /// ```
944 /// # use proxy_watch::parse;
945 /// assert!(parse::no_proxy("localhost").bypass_loopback());
946 /// assert!(!parse::proxy_override("<-loopback>").bypass_loopback());
947 /// // The token is in the list, so this is `false` — but `localhost` is written
948 /// // after it and wins on the destination it names.
949 /// let readded = parse::proxy_override("<-loopback>;localhost");
950 /// assert!(!readded.bypass_loopback());
951 /// assert!(readded.matches_authority("localhost"));
952 /// assert!(!readded.matches_authority("127.0.0.1"));
953 /// ```
954 #[must_use]
955 pub fn bypass_loopback(&self) -> bool {
956 !self
957 .patterns
958 .iter()
959 .any(|p| matches!(p, HostPattern::SubtractImplicit))
960 }
961
962 // Add `pattern` to [`BypassRules::patterns`]. Repeats are collapsed by
963 // [`dedup_patterns`](Self::dedup_patterns) once the list is complete, not here.
964 pub(crate) fn push_pattern(&mut self, pattern: HostPattern) {
965 self.patterns.push(pattern);
966 }
967
968 // Collapse repeats in [`BypassRules::patterns`], keeping the first of each.
969 //
970 // Do not move this into `push_pattern` as one `Vec::contains` per entry: the vector it
971 // would scan is the one being built, so a list of distinct entries costs time in the
972 // square of its length — 16,000 entries take 41.4 s in a debug build
973 // (`a_long_list_of_distinct_entries_does_not_stall_the_parse`). No source of a bypass
974 // list caps it, and all five are open-ended — `no_proxy` is an environment variable,
975 // `ProxyOverride` a registry value group policy writes, `ExceptionsList` a `configd`
976 // array, `ignore-hosts` a GSettings `as`, `NoProxyFor` one line of a text file.
977 //
978 // Moving it here rather than handing every caller a set keeps the rule in one place at
979 // the cost of a transiently duplicated vector, which nothing reads: every loop that
980 // fills a list from one of those sources ends with this call, and there are no others
981 // (`parse::bypass_entries_in`, `sys::proxy_dict::bypass_from_dict`).
982 //
983 // The rule is per run, not per list, because [`HostPattern::SubtractImplicit`] changes
984 // what a repeat means: in `localhost, <-loopback>, localhost` the second spelling is
985 // the one that decides, so dropping it as a duplicate of the first would hand the
986 // verdict to the token between them.
987 pub(crate) fn dedup_patterns(&mut self) {
988 let mut seen = HashSet::with_capacity(self.patterns.len());
989 self.patterns.retain(|pattern| {
990 if matches!(pattern, HostPattern::SubtractImplicit) {
991 seen.clear();
992 return true;
993 }
994 seen.insert(pattern.clone())
995 });
996 }
997
998 // Parse one bypass list entry and fold the result into `self` — the fail-soft
999 // boundary this crate settled on.
1000 #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
1001 pub(crate) fn push_entry_in(&mut self, entry: &str, dialect: BypassDialect) {
1002 match HostPattern::parse_in(entry, dialect) {
1003 Ok(Some(pattern)) => self.push_pattern(pattern),
1004 Ok(None) => {}
1005 Err(err) => {
1006 crate::trace::warning!(
1007 error = %crate::trace::SafeError(&err),
1008 "skipping an unparseable bypass list entry"
1009 );
1010 self.rejected.push(RejectedValue::new(
1011 RejectionKind::InvalidBypassPattern,
1012 RejectionSource::BypassList,
1013 entry,
1014 ));
1015 }
1016 }
1017 }
1018
1019 /// Whether either representation of the dot-less host rule is present — the
1020 /// [`exclude_simple_hostnames`](Self::exclude_simple_hostnames) switch or a
1021 /// [`HostPattern::Local`] entry.
1022 ///
1023 /// Present, not equivalent. Under
1024 /// [`reversed_exceptions`](Self::reversed_exceptions) the two part company, because
1025 /// one is a switch and the other is a list entry: the switch bypasses dot-less hosts
1026 /// before the list is consulted at all, the way the implicit set does, while a
1027 /// `Local` *entry* is inside the inclusion list and so means the opposite — dot-less
1028 /// hosts are exactly the ones that keep using the proxy. So this answering `true`
1029 /// does not by itself tell you [`matches`](Self::matches) will bypass a dot-less
1030 /// host; ask `matches`. Folding one representation into the other silently flips
1031 /// that verdict.
1032 #[must_use]
1033 pub fn excludes_simple_hostnames(&self) -> bool {
1034 self.exclude_simple_hostnames
1035 || self
1036 .patterns
1037 .iter()
1038 .any(|p| matches!(p, HostPattern::Local))
1039 }
1040
1041 /// Empty patterns and default flags ([`BypassRules::new`]). `<-loopback>` is an entry
1042 /// in [`patterns`](Self::patterns), so a list holding only that is not empty — it
1043 /// carries an instruction, and reporting it empty would invite a caller to drop it and
1044 /// put the implicit bypass back. Omits [`rejected`](Self::rejected) (only matters under
1045 /// [`reversed_exceptions`](Self::reversed_exceptions), which already answers `false`).
1046 ///
1047 /// ```
1048 /// # use proxy_watch::{parse, BypassRules};
1049 /// assert!(BypassRules::new().is_empty());
1050 /// assert!(parse::no_proxy("").is_empty());
1051 /// // `<-loopback>` is an instruction, not an absence of one.
1052 /// assert!(!parse::proxy_override("<-loopback>").is_empty());
1053 /// ```
1054 #[must_use]
1055 pub fn is_empty(&self) -> bool {
1056 self.patterns.is_empty()
1057 && !self.exclude_simple_hostnames
1058 // Reversed empty list bypasses everything — not trivial.
1059 && !self.reversed_exceptions
1060 }
1061
1062 /// Whether `host` bypasses the proxy.
1063 ///
1064 /// A host with no text answers `false` before any of the below, in both modes. Nothing
1065 /// in this crate asks — a URL with no host is Direct long before the bypass list is
1066 /// reached — so that answer is for a caller who assembled the `Host` itself. Such a
1067 /// caller owns the other end of this too: a `Host::Domain` is taken as text and never
1068 /// re-read as an address, so `Domain("0177.0.0.1")` misses the loopback switch that
1069 /// [`matches_authority`](Self::matches_authority) hits for the same characters. Neither
1070 /// `Url::host` nor `url::Host::parse` can hand over that value — both fold every numeric
1071 /// spelling to `Host::Ipv4` — and re-parsing here would cost every call for a shape only
1072 /// a literal `Host::Domain(_)` can make.
1073 ///
1074 /// No name is resolved here, ever. A host that `/etc/hosts` or DNS points at
1075 /// `127.0.0.1` is still read as the characters it was written with, so it misses the
1076 /// implicit set and goes through the proxy unless an entry names it. Chromium reads the
1077 /// same way — `ProxyHostMatchingRules::Matches` takes a `GURL` and every rule under it
1078 /// asks `url.host()` — and a lookup inside a predicate that runs per request would put
1079 /// its latency and its failures there too.
1080 ///
1081 /// Ported `:port` rules never match `port = None` (Go). Under
1082 /// [`reversed_exceptions`](Self::reversed_exceptions), patterns are inclusion-only
1083 /// while [`rejected`](Self::rejected) is empty.
1084 ///
1085 /// Entries are read **back to front**, and the first one that has something to say
1086 /// answers: "Later rules override earlier rules … when mixing positive and negative
1087 /// rules, evaluation order makes a difference"
1088 /// (Chromium, `net/base/scheme_host_port_matcher.cc`; the expectation "comes from
1089 /// WinInet (which is where `<-loopback>` comes from)",
1090 /// `proxy_host_matching_rules_unittest.cc`). Order only ever matters because of
1091 /// [`HostPattern::SubtractImplicit`], the one entry that takes a bypass away — so
1092 /// `127.0.0.1;<-loopback>` proxies `127.0.0.1` and `<-loopback>;127.0.0.1` sends it
1093 /// direct. With no entry deciding, the implicit set does: loopback and link-local
1094 /// alike, and not inverted by `reversed_exceptions`, because an inclusion list that
1095 /// never named loopback has not asked for loopback to be proxied.
1096 ///
1097 /// [`exclude_simple_hostnames`](Self::exclude_simple_hostnames) is a switch and not an
1098 /// entry, so it is not inverted either — unlike a [`HostPattern::Local`] entry, which
1099 /// is; see [`excludes_simple_hostnames`](Self::excludes_simple_hostnames) for why that
1100 /// changes the answer here. An IPv4-mapped IPv6 destination
1101 /// (`::ffff:a.b.c.d`) is compared as the IPv4 address it maps, against CIDR and exact
1102 /// patterns alike, and against the loopback/link-local switches.
1103 ///
1104 /// ```
1105 /// # use proxy_watch::parse;
1106 /// let rules = parse::no_proxy("localhost, .example.com, 10.0.0.0/8");
1107 /// assert!(rules.matches_authority("www.example.com"));
1108 /// assert!(rules.matches_authority("10.1.2.3:443"));
1109 /// assert!(!rules.matches_authority("example.org"));
1110 /// // Link-local destinations bypass even though the list above never mentions them.
1111 /// assert!(rules.matches_authority("169.254.1.1"));
1112 /// assert!(rules.matches_authority("[fe80::1]"));
1113 /// ```
1114 #[must_use]
1115 pub fn matches(&self, host: &Host, port: Option<u16>) -> bool {
1116 let text = host_key(host);
1117 if text.is_empty() {
1118 return false;
1119 }
1120 let ip = host_ip(host);
1121 let implicit = is_loopback(&text, ip) || is_link_local(ip);
1122 // What a destination no entry names answers under `reversed_exceptions`: the list
1123 // is then the set that *uses* the proxy, so being absent from it is the bypass. A
1124 // rejected entry makes that set incomplete, and an incomplete inclusion list may
1125 // not send anything direct.
1126 let unnamed = self.reversed_exceptions && self.rejected.is_empty();
1127
1128 // Before the entries, and so before `reversed_exceptions` can invert it: this is a
1129 // switch, not a list entry. macOS is where it comes from and macOS has no reversed
1130 // mode, so the combination has no reference to copy — but the shape does, and the
1131 // list spelling `<local>` is a `HostPattern::Local` among the entries, is inverted,
1132 // and therefore answers the opposite. Both readings are right for what they are;
1133 // what would be wrong is treating them as one flag.
1134 //
1135 // The one entry that still overrides it is `<-loopback>`, because Chromium models
1136 // this switch as a rule *prepended* to the list
1137 // (`PrependRuleToBypassSimpleHostnames`, `proxy_config_service_mac.cc`) and so puts
1138 // every entry after it. Only a dot-less name that is also in the implicit set —
1139 // `localhost`, `loopback` — is reachable both ways; the rest of what the switch
1140 // covers no negative entry can name.
1141 if self.exclude_simple_hostnames
1142 && is_simple_host_name(&text, ip)
1143 && (self.bypass_loopback() || !implicit)
1144 {
1145 return true;
1146 }
1147
1148 for pattern in self.patterns.iter().rev() {
1149 if matches!(pattern, HostPattern::SubtractImplicit) {
1150 if implicit {
1151 return unnamed;
1152 }
1153 } else if pattern.matches(&text, ip, port) {
1154 return !self.reversed_exceptions;
1155 }
1156 }
1157 implicit || unnamed
1158 }
1159
1160 /// Whether a destination URL bypasses the proxy.
1161 ///
1162 /// Prefer this over [`matches`](Self::matches) whenever the destination is a `Url`: it is
1163 /// the only entry point that reads [`require_explicit_port`](Self::require_explicit_port),
1164 /// and getting that wrong by hand is silent — a GNOME `ignore-hosts` of `example.com:80`
1165 /// asked about with `port_or_known_default` reports a bypass GNOME does not have. This
1166 /// crate's own `resolve` goes through here.
1167 ///
1168 /// A URL with no host to compare — `data:`, `mailto:`, or one whose host was emptied —
1169 /// is reported as "does not bypass", the same reading as an unparseable authority in
1170 /// [`matches_authority`](Self::matches_authority). It is not a statement that such a URL
1171 /// needs a proxy; a caller that routes hostless URLs direct should say so before asking.
1172 ///
1173 /// ```
1174 /// # use proxy_watch::parse;
1175 /// # use url::Url;
1176 /// let rules = parse::no_proxy(".example.com");
1177 /// assert!(rules.matches_url(&Url::parse("https://www.example.com/x").unwrap()));
1178 /// assert!(!rules.matches_url(&Url::parse("https://example.org/").unwrap()));
1179 /// assert!(!rules.matches_url(&Url::parse("data:,hello").unwrap()));
1180 /// ```
1181 #[must_use]
1182 pub fn matches_url(&self, url: &Url) -> bool {
1183 let Some(host) = crate::endpoint::request_host(url) else {
1184 return false;
1185 };
1186 // The one place the flag is read. `port_or_known_default` is the majority reading —
1187 // a rule written `example.com:80` is meant for the HTTP port whether or not the URL
1188 // spelled it out, and Windows was measured agreeing: a `ProxyOverride` of `host:80`
1189 // bypasses a portless `http://host/`.
1190 let port = if self.require_explicit_port {
1191 url.port()
1192 } else {
1193 url.port_or_known_default()
1194 };
1195 self.matches(&host, port)
1196 }
1197
1198 /// Convenience wrapper around [`BypassRules::matches`] taking a `host[:port]`
1199 /// string such as `example.com:8080` or `[::1]:443`.
1200 ///
1201 /// Unparseable input is reported as "does not bypass": an authority this cannot
1202 /// split is one it cannot prove is exempt, so it goes through the proxy, which can
1203 /// still refuse it. (Other stacks disagree on this edge; this crate does not claim
1204 /// their spelling.)
1205 ///
1206 /// Supplies no default port: an authority written without one is asked with none, so
1207 /// a rule spelled `example.com:80` does not match `example.com`.
1208 /// [`matches_url`](Self::matches_url) is the entry point that fills one in.
1209 #[must_use]
1210 pub fn matches_authority(&self, authority: &str) -> bool {
1211 let Ok((host_text, port)) = split_host_port(authority.trim()) else {
1212 return false;
1213 };
1214 let Ok(host) = crate::endpoint::parse_host(host_text) else {
1215 return false;
1216 };
1217 self.matches(&host, port)
1218 }
1219}
1220
1221// WHATWG's forbidden domain code points, less `/`, `@`, and ASCII space (earlier
1222// guards) and `:` when it splits a single-colon `host:port`. Several colons without
1223// brackets are treated as an IPv6 literal attempt, not rejected here. `*` is not
1224// forbidden, which is what leaves room for the glob spelling.
1225//
1226// One widening, deliberate rather than overlooked: `char::is_control` is Unicode's `Cc`,
1227// so it also refuses U+0080..=U+009F, which that list does not name. What it buys is the
1228// reason and not the verdict: `is_ascii_control` here leaves `a\u{86}b.example`
1229// to the punycode step below, which refuses it too, as text "that is not a valid
1230// internationalised domain name". Naming the character instead is the difference between a
1231// writer who can find the byte they pasted and one who cannot. (U+0085 and U+00A0 reach
1232// neither test: `char::is_whitespace` counts them, and the whitespace guard is first.)
1233fn is_forbidden_host_char(c: char) -> bool {
1234 c.is_control()
1235 || matches!(
1236 c,
1237 '#' | '%' | '<' | '>' | '?' | '[' | '\\' | ']' | '^' | '|'
1238 )
1239}
1240
1241// The punycode spelling of a bypass entry's host part.
1242//
1243// Label by label, because the whole string is not a host: it may carry a leading dot, a
1244// `*.` prefix, or an embedded glob, none of which `Host::parse` accepts as written. A
1245// label that is already ASCII is left untouched, which is most of them.
1246fn idna_ascii(host_text: &str) -> Result<String, String> {
1247 // `Host::parse` reads a name it can also read as an address as the address, and the
1248 // labels here arrive one at a time, so every one of them looks final to it. Lend the
1249 // label a last label that cannot be part of an address, and take the loan back off.
1250 const LOAN: &str = ".a";
1251
1252 let mut out = String::with_capacity(host_text.len());
1253 for (index, label) in host_text.split('.').enumerate() {
1254 if index > 0 {
1255 out.push('.');
1256 }
1257 if label.is_ascii() {
1258 out.push_str(label);
1259 continue;
1260 }
1261 // Punycode would swallow the `*` into the encoded label and the glob would stop
1262 // being one. A rule that quietly stops meaning what it says is the thing this
1263 // conversion exists to prevent, so say so instead.
1264 if label.contains('*') {
1265 return Err(
1266 "entry mixes a '*' glob with non-ASCII text in one label, which has no \
1267 punycode spelling; write the label as punycode (xn--…) instead"
1268 .to_owned(),
1269 );
1270 }
1271 // Without the loan `123` normalises to `123` and comes back `Ipv4`, and the entry
1272 // dies — while the destination side reads `123.example` as the name it is, because
1273 // there the digits are not the last label. Converting is not judging: whether the
1274 // ASCII text denotes a name or an address is settled afterwards by `parse_host`,
1275 // over the whole host part, where the address reading is the right one.
1276 match Host::parse(&format!("{label}{LOAN}")) {
1277 // The loan is ASCII and lowercase, so it survives the round trip unchanged.
1278 Ok(Host::Domain(ascii)) if ascii.ends_with(LOAN) => {
1279 out.push_str(&ascii[..ascii.len() - LOAN.len()]);
1280 }
1281 _ => {
1282 return Err(
1283 "entry contains non-ASCII text that is not a valid internationalised \
1284 domain name"
1285 .to_owned(),
1286 );
1287 }
1288 }
1289 }
1290 Ok(out)
1291}
1292
1293fn port_matches(rule_port: Option<u16>, port: Option<u16>) -> bool {
1294 match rule_port {
1295 None => true,
1296 Some(expected) => port == Some(expected),
1297 }
1298}
1299
1300fn is_loopback(host_text: &str, ip: Option<IpAddr>) -> bool {
1301 if let Some(ip) = ip {
1302 // `127.0.0.0/8` and `::1`. `::ffff:127.0.0.1` arrives as `127.0.0.1` because
1303 // `host_ip` reduced it — see there for why every numeric rule gets it that way.
1304 return ip.is_loopback();
1305 }
1306 // WinINet bypasses `localhost` and `loopback` by name; Go bypasses `localhost`.
1307 // Chromium additionally treats any `*.localhost` subdomain and a single trailing
1308 // dot as local (`net/base/url_util.cc`, `IsLocalHostname`); a trailing dot
1309 // is stripped before comparing so `localhost.` and `app.localhost.` match too, the
1310 // same way DNS treats a trailing dot as denoting the root and not a distinct name.
1311 let name = host_text.strip_suffix('.').unwrap_or(host_text);
1312 name == "localhost" || name == "loopback" || name.ends_with(".localhost")
1313}
1314
1315// Whether `ip` is link-local: IPv4 `169.254.0.0/16` (APIPA) or IPv6 `fe80::/10`.
1316// `::ffff:169.254.0.0/112` is the first of those, already reduced by `host_ip`.
1317//
1318// Read as one implicit set together with [`is_loopback`], so
1319// [`NO_LOOPBACK_TOKEN`] clears both at once. That is what the token means upstream:
1320// Chromium's `MatchesImplicitRules` is `IsLocalhost || IsIPv4MappedLoopback ||
1321// IsLinkLocalIP` in one expression, and `<-loopback>` is the rule that subtracts the whole
1322// of it — "The name <-loopback> is not a very precise name (as the implicit rules cover
1323// more than strictly loopback addresses), however this is the name that is used on Windows
1324// so re-used here" (`net/proxy_resolution/proxy_host_matching_rules.cc`, the file
1325// `proxy_bypass_rules.cc` became). The same file records Windows' own implicit set as
1326// "localhost, loopback, 127.0.0.1, [::1], 169.254/16, [FE80::]/10". The flag here is named
1327// for the token that clears it, not for half of what the token covers.
1328//
1329// Holding the two apart, and calling that a deliberate divergence, is the tempting reading:
1330// a proxy cannot reach an address that exists only on the client's own link. `169.254.169.254`
1331// refutes it: the cloud instance-metadata endpoint is link-local, a proxy on the same host
1332// reaches it, and routing it through an inspecting proxy is one of the reasons `<-loopback>`
1333// gets set at all. Answering Direct for it is fail-open on the one destination the setting
1334// most often exists to catch.
1335fn is_link_local(ip: Option<IpAddr>) -> bool {
1336 match ip {
1337 Some(IpAddr::V4(v4)) => v4.is_link_local(),
1338 Some(IpAddr::V6(v6)) => v6.is_unicast_link_local(),
1339 None => false,
1340 }
1341}
1342
1343// Chromium's `BypassSimpleHostnamesRule` (`proxy_host_matching_rules.cc:80`): a name with no
1344// period, and never an IP literal. A trailing dot counts as "has a period", so it is not stripped
1345// first — unlike the `Domain` arm, which strips one from the same `host_text`.
1346//
1347// One function for the two spellings of the rule, the `exclude_simple_hostnames` switch
1348// and a `HostPattern::Local` list entry, because they answer the same question about a
1349// host even though `matches` consults them at different points and under different
1350// inversion. Written twice they could only drift.
1351fn is_simple_host_name(host_text: &str, ip: Option<IpAddr>) -> bool {
1352 ip.is_none() && !host_text.contains('.')
1353}
1354
1355// The lowercase textual key used for matching. IPv6 hosts are *not* bracketed here,
1356// matching Go's `config.init` (`http/httpproxy/proxy.go`), which strips the brackets
1357// before `net.ParseIP` so `ipMatch` compares an unbracketed address.
1358fn host_key(host: &Host) -> String {
1359 match host {
1360 // `Host::parse` punycodes a Unicode name, and every `Host` this crate builds itself
1361 // came from it — but `matches` is public and takes the `Host`, so a caller can hand
1362 // over a `Host::Domain` that never went through it. Converting here rather than
1363 // trusting the caller is what keeps `matches` and `matches_authority` answering
1364 // alike; the rule side is converted at parse time by `idna_ascii`, so an unconverted
1365 // destination would silently match nothing. A name with no punycode spelling keeps
1366 // its own text and matches nothing, which is `matches_authority`'s own direction for
1367 // input it cannot make sense of.
1368 Host::Domain(domain) if !domain.is_ascii() => idna_ascii(domain)
1369 .unwrap_or_else(|_| domain.clone())
1370 .to_ascii_lowercase(),
1371 Host::Domain(domain) => domain.to_ascii_lowercase(),
1372 Host::Ipv4(ip) => ip.to_string(),
1373 Host::Ipv6(ip) => ip.to_string(),
1374 }
1375}
1376
1377// `str::strip_suffix`, comparing ASCII case-insensitively.
1378//
1379// Allocation-free, which is the reason it exists rather than a `to_ascii_lowercase` on
1380// either side: this runs once per rule per destination. The boundary check is what keeps
1381// the slicing sound — a split point inside a multi-byte character would panic, and `false`
1382// is the right answer there anyway, because a `str`'s first byte is never a continuation
1383// byte and so no suffix can start there.
1384fn strip_suffix_ascii_case<'a>(text: &'a str, suffix: &str) -> Option<&'a str> {
1385 let split = text.len().checked_sub(suffix.len())?;
1386 if !text.is_char_boundary(split) {
1387 return None;
1388 }
1389 text[split..]
1390 .eq_ignore_ascii_case(suffix)
1391 .then(|| &text[..split])
1392}
1393
1394fn host_display(host: &Host) -> String {
1395 match host {
1396 Host::Ipv6(ip) => format!("[{ip}]"),
1397 other => other.to_string(),
1398 }
1399}
1400
1401// The address a host denotes, with `::ffff:a.b.c.d` reduced to the IPv4 address it maps.
1402//
1403// Every numeric comparison in this file goes through here, so the reduction happens once
1404// instead of at each of them — including `is_loopback` and `is_link_local`, which is why
1405// neither needs a mapped-spelling arm of its own. Both references reduce too, in their own
1406// idiom.
1407fn host_ip(host: &Host) -> Option<IpAddr> {
1408 match host {
1409 Host::Domain(_) => None,
1410 Host::Ipv4(ip) => Some(IpAddr::V4(*ip)),
1411 Host::Ipv6(ip) => Some(ip.to_ipv4_mapped().map_or(IpAddr::V6(*ip), IpAddr::V4)),
1412 }
1413}
1414
1415// The rule-side half of [`host_ip`]: `::ffff:10.0.0.0/104` is the IPv4 block `10.0.0.0/8`.
1416//
1417// Without this the rule is dead and says so nowhere. Every destination reaches
1418// [`HostPattern::matches`] already reduced by [`host_ip`], so it arrives as an
1419// `IpAddr::V4`, and `IpNet`'s `contains` is false across families — `10.0.0.1` and
1420// `[::ffff:10.0.0.1]` both miss a mapped-spelling rule that names exactly them. Nothing
1421// records the miss, because the entry parsed; under
1422// [`BypassRules::reversed_exceptions`] that is the fail-open direction this file rejects
1423// for `10.0.0/8` and `[2001:db8::zz]` a few lines apart.
1424//
1425// Only a net that lies wholly inside `::ffff:0:0/96` converts. A shorter prefix covers
1426// unmapped IPv6 as well, so it is an IPv6 rule about IPv6 destinations — and mapped
1427// destinations are not those, by the same reduction.
1428fn reduce_mapped_net(net: IpNet) -> IpNet {
1429 let IpNet::V6(v6) = net else {
1430 return net;
1431 };
1432 let (Some(addr), true) = (v6.addr().to_ipv4_mapped(), v6.prefix_len() >= 96) else {
1433 return net;
1434 };
1435 Ipv4Net::new(addr, v6.prefix_len() - 96).map_or(net, IpNet::V4)
1436}
1437
1438fn write_with_port(f: &mut fmt::Formatter<'_>, base: &str, port: Option<u16>) -> fmt::Result {
1439 match port {
1440 Some(port) => write!(f, "{base}:{port}"),
1441 None => f.write_str(base),
1442 }
1443}