Skip to main content

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}