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, so an embedded `*` is a glob
6//! here. The `Suffix` dialect is therefore 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 or macOS 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, Ipv4Addr, Ipv6Addr};
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 or
37/// macOS list reads `example.com` as [`HostPattern::Exact`], because that is what both
38/// match. [`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 or macOS list matches that name and nothing under it.
51    ///
52    /// Its [`Display`](std::fmt::Display) is the name alone, also used for a suffix rule
53    /// for the same domain. Parsing that text with `proxy_override` returns this variant,
54    /// while [`parse::no_proxy`](crate::parse::no_proxy) returns a [`Domain`](Self::Domain)
55    /// that also covers subdomains. Retaining the value avoids widening the rule by
56    /// displaying it and re-parsing it with the other dialect.
57    Exact {
58        /// Host to match.
59        host: Host,
60        /// Optional port restriction.
61        port: Option<u16>,
62    },
63    /// Domain suffix (stored with a leading dot).
64    Domain {
65        /// Suffix (e.g. `.example.com`). A hand-built one without the dot names the
66        /// same domain; an empty one names none and so matches nothing. Case is folded
67        /// at match time, so [`parse`](Self::parse)'s lowercasing is a normalisation and
68        /// not a precondition.
69        suffix: String,
70        /// Whether the bare domain matches too.
71        match_self: bool,
72        /// Optional port restriction.
73        port: Option<u16>,
74    },
75    /// Windows-style glob with `*`.
76    ///
77    /// A plain `*.example.com` is not one: it is the same rule as `.example.com` and parses
78    /// to [`Domain`](Self::Domain). A leading `*.` does reach this variant when the rest
79    /// still holds a glob (`*.a*b.com`), and so does the `*` this parse writes in front of a
80    /// leading-dot glob (`.*.example.com` is stored as `*.*.example.com`).
81    ///
82    /// Matched as text against the destination's canonical spelling, so a glob written
83    /// around a non-canonical address (`*192.168.001.001`, which arrives as `192.168.1.1`)
84    /// matches nothing. Unlike the other variants, such a glob is not refused during
85    /// parsing and does not reach [`BypassRules::rejected`]: `*` can match an empty string,
86    /// so `*10.0.0.1` remains valid, and tests that distinguish these cases admit
87    /// counterexamples.
88    Wildcard {
89        /// Glob pattern. [`parse`](Self::parse) lowercases it, and a hand-built one that
90        /// is not lowercased matches the same hosts anyway: case is folded at match time.
91        pattern: String,
92        /// Optional port restriction.
93        port: Option<u16>,
94    },
95    /// Windows `<local>`: any host name without a dot, and no IP literal. The macOS
96    /// switch is [`BypassRules::exclude_simple_hostnames`], which also takes a dot-less
97    /// IPv6 literal.
98    Local,
99    /// Windows `<-loopback>`: the one entry that takes a bypass away instead of adding
100    /// one. It subtracts the implicit set (loopback *and* link-local, see
101    /// [`NO_LOOPBACK_TOKEN`]) from every entry written before it, and from none written
102    /// after it, which is why it is an entry in [`BypassRules::patterns`] and not a
103    /// switch beside them. [`BypassRules::matches`] has the evaluation order.
104    SubtractImplicit,
105}
106
107// Which list an entry came from, for the places the lists disagree about what an entry
108// means. Named after the source rather than after a behaviour, because no two of them
109// differ from `Suffix` on the same set of axes: `Windows` on four, `MacOs` on five, `Gnome`
110// on four and `Kde` on two, no two the same set, and `MacOs` shares two of the `Windows`
111// axes while disagreeing with it everywhere else.
112//
113// `Suffix` reads a bare name as the name and everything under it, and a leading `*.` or `.`
114// as the subdomains alone. That much is Go's `httpproxy` with `no_proxy` and libproxy's with
115// KDE's `NoProxyFor` (`ignore_domain` in `px-manager.c`, reached from `config-kde.c`), and it is why a
116// bare name here is not the name alone the way it is under `Windows` and `MacOs`.
117//
118// A `*` anywhere else is a glob, and that half is *not* those two. `config.init` in
119// `http/httpproxy/proxy.go` special-cases a bare `*` and strips one character off a leading
120// `*.`; what is left goes to a `domainMatcher` that compares with `strings.HasSuffix`, so no
121// star past the first is syntax. libproxy's `ignore_domain` (`px-manager.c`) reads four
122// shapes and no more: the bare `*`, an exact name, and the two suffix spellings `.name`
123// and `*.name`. Under both, `192.168.*` and `*.*.*.1` are literal text no host carries,
124// so they match nothing. Neither refuses a CIDR entry, though, it leaves the name rules
125// before it reaches them. `config.init` tries `net.ParseCIDR` first, and libproxy tries
126// `ignore_ip` after `ignore_domain` (`px_manager_is_ignore`), which masks an entry holding a
127// `/` with `g_inet_address_mask_new_from_string` against an address literal only.
128//
129// The star is read as a glob because the third reader of these same two lists reads it that
130// way. Chromium hands `no_proxy` and KDE's `NoProxyFor` to the same
131// `ProxyHostMatchingRules::ParseFromString` (`proxy_config_service_linux.cc:230`), which builds a
132// `SchemeHostPortMatcherHostnamePatternRule` for everything that is not a CIDR or an address
133// literal and evaluates it with `base::MatchPattern`
134// (`scheme_host_port_matcher_rule.cc:99,139`), a glob, and the source of the leading-dot
135// rewrite the `Wildcard` arm in `parse_in` already quotes.
136//
137// So this dialect is the union of the two readings rather than either one, and the union is
138// the wider answer, the one that reaches `Direct` more often. `*.*.*.1` is met by
139// `10.0.0.1` here and by `base::MatchPattern`; the two suffix readers send that request to
140// the proxy. The union is kept because narrowing it would leave a rule someone typed a `*`
141// into matching nothing while still reading back as live, which is the shape `Gnome` below
142// refuses outright rather than store.
143//
144// `Windows` differs in four places. A bare name is the name alone, measured on this
145// crate's own terms rather than read off a reimplementation, because the two
146// reimplementations disagree and neither is the OS; the readings are the rows of
147// `a_bare_name_in_a_windows_list_is_the_one_host` in `tests/bypass.rs`. And the three
148// spellings a Windows list has no reading this crate can follow (an entry holding a `/`,
149// an entry starting with `.`, and an IPv4 address written other than as four decimal
150// octets) are refused rather than stored as rules the machine does not honour. Each has
151// a guard in `parse_in` carrying its readings.
152//
153// `MacOs` is CFNetwork's `ExceptionsList`, measured the same way and by the same argument:
154// `tests/mac_exceptions_list.rs` hands `CFNetworkCopyProxiesForURL` a settings dictionary
155// and reads the verdict off the matcher itself, so Chromium's macOS reader is not consulted:
156// it is a reimplementation and not the OS. It agrees with `Windows` that a bare name is the
157// name alone, and that an IPv4 address written other than as four decimal octets is
158// compared as text (`012.1.2.3` met only a destination written `012.1.2.3`, the same
159// diagonal WinINet gives), so it is refused by the same guard. It differs from every other
160// dialect here on three more:
161//
162//   * a `*` is syntax only as a leading `*.` or a trailing `.*`, and a character everywhere
163//     else, so `*probe*`, `*invalid`, `pw-*.invalid` and a bare `*` match nothing. Reading
164//     the bare `*` as `HostPattern::All` turns the proxy off for every destination on a Mac
165//     that proxies all of them.
166//   * a `:port` does not constrain an entry, it kills it. `example.com:80` matched no
167//     destination on port 80, and `example.com` matched one that carried a port, so the
168//     port is not part of the comparison at all.
169//   * neither end is trimmed, which is `trim` below rather than an arm here: a space in
170//     front of a name or behind it kills the entry the same way a `*` in the middle does.
171//
172// Two things it does *not* differ on are left alone. `<local>` and
173// `<-loopback>` bypass nothing there, but they are recognised here anyway, for the reason
174// `sys/linux/gsettings_map.rs` gives for GLib: the tokens are read before the dialect so
175// every source shares one vocabulary, and no macOS writer types a WinINet token. And
176// `name.*` covers the host plus anything whose leading labels are the host (the mirror of
177// `*.name`, not a suffix rule), which the glob this arm already builds gets right except
178// when the star stands for no labels at all (`example.com.*` against `example.com`). That
179// one row is fail-*closed*, so it is recorded rather than answered with a new variant.
180//
181// `Gnome` is GLib's `GSimpleProxyResolver`, which is the code that resolves on GNOME:
182// `gsettings_map.rs` reads the same keys GLib's `GProxyResolverGnome` does. It agrees on
183// the bare name, and on CIDR: `reparse_ignore_hosts` tries
184// `g_inet_address_mask_new_from_string` on the whole entry before it strips anything
185// (`gsimpleproxyresolver.c:186`), so a mask never reaches the name rules. It differs on
186// four more axes, all from the same file:
187//
188//   * `reparse_ignore_hosts` strips a leading `*.` or `.` and stores the rest as a plain
189//     name, which `ignore_host` then matches with `offset == 0` allowed, so
190//     `*.example.com`, `.example.com` and `example.com` are one rule there, covering the
191//     domain *and* its subdomains.
192//   * no other `*` is syntax: what is left is `g_ascii_strcasecmp`d whole, and no
193//     host contains a `*`, so `foo*.example.com` matches nothing at all. A bare `*` is that
194//     rule's worst case and reads the same way: `reparse_ignore_hosts` strips neither a
195//     `*.` nor a leading `.` from it, so it is stored whole and `ignore_host` then wants a
196//     destination whose last character is a `*` behind a dot. None arrives. It shares this
197//     with `MacOs`, and shares the reason with nothing else here: on Windows and in
198//     `no_proxy` a bare `*` does switch the proxy off.
199//   * that same parse chomps with `g_strchomp`, trailing whitespace only, so a leading
200//     space survives into the name and kills the rule the same way.
201//   * `g_simple_proxy_resolver_lookup` resolves the destination with `G_URI_FLAGS_NONE`, and
202//     `g_uri_split_internal` fills a scheme's default port only in its scheme-based
203//     normalization, under `G_URI_FLAGS_SCHEME_NORMALIZE`, so a portless
204//     `http://example.com/` is asked about with port 0, and an `example.com:80` rule does
205//     not fire on it. That last one is not a pattern shape and lives in
206//     [`BypassRules::require_explicit_port`].
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub(crate) enum BypassDialect {
209    Suffix,
210    Windows,
211    MacOs,
212    Gnome,
213    // KDE's `NoProxyFor`: `Suffix` in every shape but two. KIO's `revmatch` compares the
214    // text from its end, so an entry's trailing dot is part of the name, where the
215    // environment variable's readers shed it as the DNS root. And an IPv4 address written
216    // other than as four decimal octets is refused, as under `Windows`, because KIO reads
217    // each part as decimal where the URL grammar reads `012` as octal.
218    #[cfg_attr(not(all(target_os = "linux", feature = "linux-kde")), allow(dead_code))]
219    Kde,
220}
221
222impl BypassDialect {
223    // Match each source's trimming: GLib's `g_strchomp` cuts trailing whitespace, while
224    // CFNetwork cuts neither end. Surviving whitespace makes the entry unmatchable, so
225    // `HostPattern::parse_in` records it in `BypassRules::rejected`.
226    //
227    // Trimming a macOS entry would make a dead rule live and bypass traffic CFNetwork
228    // proxies. `parse_in` uses the trimmed pattern; `parse::bypass_entries_in` quotes
229    // rejected entries without separator padding.
230    pub(crate) fn trim(self, entry: &str) -> &str {
231        match self {
232            // `g_strchomp` cuts ASCII space, tab, LF, FF and CR, but leaves vertical tab
233            // and non-ASCII whitespace; a trailing U+00A0 therefore kills the rule.
234            BypassDialect::Gnome => entry.trim_end_matches(|c: char| c.is_ascii_whitespace()),
235            BypassDialect::MacOs => entry,
236            // ASCII only. An edge U+00A0 or U+3000 stays in the name, as it does when WinINet
237            // reads `ProxyOverride` and in WinHTTP and Chromium (`TrimWhitespaceASCII`), so
238            // the rule matches nothing and the entry goes to `rejected`.
239            BypassDialect::Windows => {
240                entry.trim_matches(|c| matches!(c, '\t' | '\n' | '\x0B' | '\x0C' | '\r' | ' '))
241            }
242            BypassDialect::Suffix | BypassDialect::Kde => entry.trim(),
243        }
244    }
245}
246
247impl HostPattern {
248    /// Parse one bypass entry. `Ok(None)` for empty and for anything with no host left to
249    /// match on: `:8080`, `.`, `..`, `*.`.
250    ///
251    /// ```
252    /// # use proxy_watch::HostPattern;
253    /// let p = HostPattern::parse("*.example.com").unwrap().unwrap();
254    /// assert!(matches!(p, HostPattern::Domain { match_self: false, .. }));
255    /// ```
256    ///
257    /// A bare address entry is stored the way the destination side reads it, not the way it
258    /// was written: `010.0.0.1` is octal (`8.0.0.1`) and `192.168.1` is short-form
259    /// (`192.168.0.1`), because that is how the [`Host`] the destination carries would parse
260    /// the same text.
261    ///
262    /// A CIDR entry's IPv4 address follows the same grammar when it has four parts, as
263    /// Chromium's does: `010.0.0.0/8` is `8.0.0.0/8` and `0x0a.0.0.0/8` is `10.0.0.0/8`.
264    /// curl and Go read neither as a block. A short form (`192.168.1/24`) is an error
265    /// rather than an address. A mapped-spelling block
266    /// (`::ffff:10.0.0.0/104`) is stored as written; [`BypassRules::matches`] reads it as
267    /// `10.0.0.0/8` while [`BypassRules::ipv4_mapped_as_ipv4`] is set.
268    ///
269    /// # Errors
270    ///
271    /// [`Error::InvalidBypassPattern`] for `@`, internal whitespace, a `scheme://` prefix,
272    /// a character that cannot appear in a host name (WHATWG forbidden domain code points
273    /// not already rejected above), an empty label (`a..b`), a subdomain of an address
274    /// (`.10.0.0.1`), a name no destination could carry (`example.123`), bad CIDR `/`, bad
275    /// port, bad IPv6 brackets, or non-ASCII text with no punycode spelling.
276    pub fn parse(entry: &str) -> Result<Option<Self>, Error> {
277        Self::parse_in(entry, BypassDialect::Suffix)
278    }
279
280    pub(crate) fn parse_in(entry: &str, dialect: BypassDialect) -> Result<Option<Self>, Error> {
281        let entry = dialect.trim(entry);
282        if entry.is_empty() {
283            return Ok(None);
284        }
285        let lowered = entry.to_ascii_lowercase();
286
287        match lowered.as_str() {
288            // macOS and GNOME compare a bare `*` as a name, so `All` would bypass traffic
289            // they proxy. The star arm records the dead entry in `rejected`.
290            "*" if !matches!(dialect, BypassDialect::MacOs | BypassDialect::Gnome) => {
291                return Ok(Some(HostPattern::All));
292            }
293            LOCAL_TOKEN => return Ok(Some(HostPattern::Local)),
294            NO_LOOPBACK_TOKEN => return Ok(Some(HostPattern::SubtractImplicit)),
295            _ => {}
296        }
297
298        // Invalid host text would reach `Domain` as a dead suffix rule. Under
299        // `BypassRules::reversed_exceptions`, that silently sends traffic direct, so these
300        // guards record it in `BypassRules::rejected`. The `@` and `://` guards differ.
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 KDE reader, which goes
322        // through it with a list `,`-separated already. GNOME's `ignore-hosts` is an array
323        // and has no separator to split on.
324        // [`parse::proxy_override`](crate::parse::proxy_override) does split on whitespace,
325        // but only on the ASCII spellings Windows itself writes, so a Windows list still
326        // reaches this arm when one entry holds something `char::is_whitespace` calls space
327        // and `WINDOWS_BYPASS_SEPARATORS` does not: `U+00A0` or `U+3000` pasted into the
328        // settings dialog. `src/parse.rs` says the same from the splitting side.
329        if entry.chars().any(char::is_whitespace) {
330            return Err(Error::bypass(
331                entry,
332                // `;` separates entries only in the Windows list. Advising it for
333                // `no_proxy`, GNOME or KDE leaves two entries as 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        // A CIDR block whose IPv4 address is four parts but not four decimal octets
397        // (`012.1.2.0/24`, `0xa.1.2.0/24`) has no reading its readers share. Chromium's
398        // `ParseCIDRBlock` folds the address the URL way, so `012.1.2.0/24` is
399        // `10.1.2.0/24` there, and `Suffix` stores that. CFNetwork and KIO's subnet reader
400        // take each part as decimal, `12.1.2.0/24`, while Chromium reading the same macOS
401        // or KDE list folds it, so `MacOs` and `Kde` refuse it. curl and Go reject such an
402        // address outright. A short form (`192.168.1/24`) is left to `ipnet` below, which
403        // refuses it; so is one with a trailing dot (`1.2.3./24`), which splits into four
404        // parts but which the URL way reads as the short form `1.2.3`. A percent escape is
405        // left there too, as the forbidden-character guard refuses it in an address entry.
406        // The rows are `a_non_decimal_cidr_address_is_folded_in_no_proxy` in
407        // `tests/bypass.rs` and `cidr_addresses_are_read_as_decimal` in
408        // `tests/mac_exceptions_list.rs`.
409        if let Some((address, prefix)) = lowered.split_once('/')
410            && address.parse::<IpAddr>().is_err()
411            && address.split('.').count() == 4
412            && address.split('.').all(|part| !part.is_empty())
413            && !address.chars().any(is_forbidden_host_char)
414            && let Ok(Host::Ipv4(v4)) = crate::endpoint::parse_host(address)
415        {
416            if matches!(dialect, BypassDialect::MacOs | BypassDialect::Kde) {
417                return Err(Error::bypass(
418                    entry,
419                    "entry is a CIDR block whose IPv4 address is written other than as four \
420                     decimal octets, which the system reads as decimal and Chromium as a URL \
421                     host (write the four decimal octets, for example 10.1.2.0/24)",
422                ));
423            }
424            if let Ok(net) = format!("{v4}/{prefix}").parse::<IpNet>() {
425                return Ok(Some(HostPattern::Cidr(net)));
426            }
427        }
428
429        if let Ok(net) = lowered.parse::<IpNet>() {
430            return Ok(Some(HostPattern::Cidr(net)));
431        }
432
433        // After scheme and CIDR parsing, a remaining `/` cannot name a host. Go's
434        // `httpproxy` keeps an invalid CIDR such as `10.0.0/8` as a dead domain rule;
435        // this crate rejects it so `reversed_exceptions` cannot bypass traffic silently.
436        if lowered.contains('/') {
437            return Err(Error::bypass(
438                entry,
439                "entry contains a '/', so it can only be a CIDR block, but it is not a \
440                 valid one (an address, a '/', and a prefix length — for example \
441                 10.0.0.0/8 or fe80::/10)",
442            ));
443        }
444
445        let (bracketed_text, port) =
446            split_host_port(&lowered).map_err(|reason| Error::bypass(entry, reason))?;
447        let host_text = strip_brackets(bracketed_text);
448
449        // These stores' resolvers compare an entry's name as written (KIO's `revmatch` and
450        // Android's string match by their source, WinINet by the fail-closed reading until
451        // measured), so `example.com.` there matches only a destination spelled with the
452        // same dot, and stored as `example.com` it would bypass the destination the store
453        // proxies. GNOME refuses the same shape in `BypassRules::push_gnome_entries`.
454        // CFNetwork is not among them: it sheds the dot on both sides, so a macOS entry
455        // keeps the root-marker reading (`cfnetwork_sheds_a_trailing_dot`). What is left
456        // after `*.`, `.` and the dot is a name; `.` and `*.` alone carry none and fall
457        // through to the arms below.
458        if matches!(dialect, BypassDialect::Windows | BypassDialect::Kde)
459            && host_text.ends_with('.')
460            && !host_text.trim_matches(['*', '.']).is_empty()
461        {
462            return Err(Error::bypass(
463                entry,
464                "entry ends in a '.', which this store compares as written, so it matches \
465                 only a destination spelled with the same trailing dot (write the name \
466                 without it)",
467            ));
468        }
469
470        // CFNetwork matches no entry with a port; storing it as a port-restricted rule
471        // would bypass traffic CFNetwork proxies, so record it in `BypassRules::rejected`.
472        if dialect == BypassDialect::MacOs && port.is_some() {
473            return Err(Error::bypass(
474                entry,
475                "entry carries a port, which macOS does not read as a restriction — it \
476                 compares the whole entry to the destination's host name, so no destination \
477                 could ever match it (write the host on its own)",
478            ));
479        }
480
481        // An invalid bracketed address would become a dead `Domain` suffix rule. Use
482        // `parse_host` because the destination side accepts the same address grammar.
483        if host_text.len() != bracketed_text.len()
484            && !matches!(
485                crate::endpoint::parse_host(host_text),
486                Ok(Host::Ipv4(_) | Host::Ipv6(_))
487            )
488        {
489            return Err(Error::bypass(
490                entry,
491                "entry is bracketed, so it can only be an address literal, but it is not a \
492                 valid one (for example [::1] or [2001:db8::1])",
493            ));
494        }
495
496        // The destination side is `Host::parse`, which refuses WHATWG's "forbidden domain
497        // code point": the C0 controls, DEL, `#`, `%`, `<`, `>`, `?`, `[`, `\`, `]`, `^`
498        // and `|`. A rule holding one names a host that can never arrive:
499        // `a?b.example.com` becomes `suffix = ".a?b.example.com"`. The `@`, whitespace and
500        // `/` guards above are this same test spelled one character at a time; they stay
501        // separate because each has a better reason to give than "not a host character",
502        // and because `/` and `[` are read as syntax before they get here.
503        if let Some(bad) = host_text.chars().find(|c| is_forbidden_host_char(*c)) {
504            return Err(Error::bypass(
505                entry,
506                format!(
507                    "entry contains {bad:?}, which cannot appear in a host name, so no \
508                     destination could ever match it"
509                ),
510            ));
511        }
512
513        // Go runs every domain entry through `idnaASCII`, and this crate's destination side
514        // is converted the same way: by `Host::parse` when the host is parsed from text,
515        // and by `host_key` when a caller built the `Host` itself. Without the step here a
516        // Unicode entry is a dead rule: `日本.example` is stored verbatim while the
517        // destination arrives as `xn--wgv71a.example`, so it matches nothing, in *either*
518        // spelling, because the destination is always converted and the pattern never is.
519        // Only non-ASCII text reaches the converter, preserving existing ASCII rules.
520        let encoded;
521        let host_text = if host_text.is_ascii() {
522            host_text
523        } else {
524            encoded = idna_ascii(host_text).map_err(|reason| Error::bypass(entry, reason))?;
525            &encoded
526        };
527
528        // RFC 1035 section 2.3.1 gives the empty label one place, the root at the end:
529        // which is the single trailing dot the arms below strip. A second one is not a
530        // host name: `example.com..` is stored as `.example.com.` and meets only a
531        // destination spelled the same way. Its own `Display` is `example.com.`, which
532        // reads back as the live rule it is not. The `*.` and `.` suffix spellings and the
533        // root dot are this grammar's own syntax, so they come off before the labels are
534        // counted; what is left of `.` or `..` is no host part at all, which the arms below
535        // already answer with `Ok(None)`. Recognising those two spellings of "a subdomain
536        // of" once, here, is also what keeps the label check just below and the address
537        // check after it from disagreeing about where the body starts.
538        let subdomain_body = host_text
539            .strip_prefix("*.")
540            .or_else(|| host_text.strip_prefix('.'));
541        let labelled = subdomain_body.unwrap_or(host_text);
542        let labelled = labelled.strip_suffix('.').unwrap_or(labelled);
543        if !labelled.is_empty() && labelled.split('.').any(str::is_empty) {
544            return Err(Error::bypass(
545                entry,
546                "entry has an empty label (two dots in a row), so no destination could \
547                 ever match it (write example.com, .example.com or *.example.com)",
548            ));
549        }
550
551        // `.example.com` and `*.example.com` say "a subdomain of this", so what follows the
552        // dot has to be something that *has* subdomains: a domain name. An address does
553        // not: `.10.0.0.1` waits for an `a.10.0.0.1` that `Host::parse` refuses, in every
554        // spelling the address side accepts (`.192.168.001.001`, `.0x0a.0.0.1`) and for an
555        // unbracketed IPv6 too. Neither is a name that could never arrive on its own
556        // (`.example.123`).
557        //
558        // "Could never match" is said of a destination a *special* scheme can carry, which
559        // is what these lists are written about and what `matches_authority` admits: it
560        // goes through `parse_host`, so `a.example.123` is refused there too. `matches_url`
561        // is wider: WHATWG sends a non-special scheme to the opaque-host parser *before*
562        // the "ends in a number" check, so `custom://a.example.123/` parses and its host is
563        // `Domain("a.example.123")` while `http://a.example.123/` fails with
564        // `InvalidIpv4Address`. A rule this arm refuses would have met that host. The
565        // refusal stays (it catches the typo the shape almost always is, and dropping the
566        // rule sends the request to the proxy rather than around it), but the reachability
567        // it tests is the special-scheme one, and nothing further down may read it as more
568        // than that. `a_refused_numeric_tail_is_only_unreachable_for_a_special_scheme` in
569        // `tests/bypass.rs` is the measurement.
570        //
571        // A glob in the body gives the entry a narrow exemption, covering the spellings
572        // that reach here, which keep the dot: `.*.10.0.0.1` becomes
573        // `Wildcard("*.*.10.0.0.1")`, which needs `a.b.10.0.0.1`: its numeric last label
574        // reads as an address, so no URL can carry that name. A star standing for the
575        // empty string does not widen that: `*10.0.0.1` would meet `10.0.0.1`, but it opens
576        // with neither `.` nor `*.`, so it has no subdomain body and never reaches this
577        // arm. That holds for a whole address in the tail and does not survive being
578        // generalised to any numeric one: a star is matched against the destination's
579        // *text*, where it stands for the leading octets of an address as readily as for
580        // whole labels, so `Wildcard("*.*.*.1")` is met by `10.0.0.1` itself and `.*.1` is
581        // a live rule.
582        //
583        // The destination such a rule meets can only be an address, because reaching this
584        // arm at all means the address reading was tried on the body and failed, which the
585        // name reading only does for a last label that is all digits. So the entry is dead
586        // unless the pattern could be laid over a dotted quad, and two things settle that.
587        // Every literal byte has to be one a quad carries: a digit, a dot, or the star
588        // itself, so `.*.0x0a.0.0.1`, `.*.example.123` and the colon of a broken IPv6 are
589        // still refused. And the literal dots have to fit, counting the one in the `*.`
590        // this entry gets in front of its body: a quad has four labels and so three dots,
591        // which is why `.*.1` and `.*.0.1` live while `.*.0.0.1` and `.*.10.0.0.1` do not.
592        // The test is permissive in the direction that costs nothing (no octet is 999, so
593        // `.*.999` is kept and matches nothing) and strict in the one that costs a rule.
594        // Entries whose body is a name never reach it: `.*.example` and `.a*b.example`
595        // parse as domains and pass here as they always did.
596        let suffix_body = subdomain_body
597            .map(|rest| rest.strip_suffix('.').unwrap_or(rest))
598            .filter(|body| !body.is_empty());
599        if let Some(body) = suffix_body {
600            let fits_a_dotted_quad = body.contains('*')
601                && body.matches('.').count() < 3
602                && body
603                    .bytes()
604                    .all(|b| b.is_ascii_digit() || b == b'.' || b == b'*');
605            match crate::endpoint::parse_host(body) {
606                Ok(Host::Domain(_)) => {}
607                Ok(_) => {
608                    return Err(Error::bypass(
609                        entry,
610                        "entry names a subdomain of an address literal, which has none, so \
611                         no destination could ever match it (write the address on its own, \
612                         or a CIDR range such as 10.0.0.0/8)",
613                    ));
614                }
615                Err(_) if fits_a_dotted_quad => {}
616                Err(_) => return Err(Error::bypass(entry, UNREACHABLE_NAME)),
617            }
618        }
619
620        // `*.example.com` is the same rule as `.example.com` (Go strips the `*`).
621        // Handle this *before* stripping a trailing root dot, otherwise `*.` collapses
622        // to `*` and becomes a match-everything wildcard.
623        if let Some(rest) = host_text.strip_prefix("*.") {
624            let rest = rest.strip_suffix('.').unwrap_or(rest);
625            if rest.is_empty() {
626                return Ok(None);
627            }
628            if !rest.contains('*') {
629                return Ok(Some(HostPattern::Domain {
630                    suffix: format!(".{rest}"),
631                    // GNOME stores what is left after the `*.` as a plain name and then
632                    // allows the whole-string match, so there the prefix widens the rule
633                    // instead of excluding the domain itself.
634                    match_self: dialect == BypassDialect::Gnome,
635                    port,
636                }));
637            }
638            if dialect == BypassDialect::Gnome {
639                return Err(Error::bypass(entry, GNOME_LITERAL_STAR));
640            }
641            // A second star, past the one that opened the entry, is a character again.
642            if dialect == BypassDialect::MacOs {
643                return Err(Error::bypass(entry, MACOS_LITERAL_STAR));
644            }
645            return Ok(Some(HostPattern::Wildcard {
646                pattern: format!("*.{rest}"),
647                port,
648            }));
649        }
650
651        // A trailing dot on a pattern is the DNS root marker.
652        let host_text = host_text.strip_suffix('.').unwrap_or(host_text);
653        if host_text.is_empty() {
654            // Go ignores entries with no host part instead of failing: `config.init`
655            // in `http/httpproxy/proxy.go` answers that shape with `continue`.
656            return Ok(None);
657        }
658
659        if let Ok(ip) = host_text.parse::<IpAddr>() {
660            let host = match ip {
661                IpAddr::V4(v4) => Host::Ipv4(v4),
662                IpAddr::V6(v6) => Host::Ipv6(v6),
663            };
664            return Ok(Some(HostPattern::Exact { host, port }));
665        }
666
667        if host_text.contains('*') {
668            if dialect == BypassDialect::Gnome {
669                return Err(Error::bypass(entry, GNOME_LITERAL_STAR));
670            }
671            // The `*.` spelling was taken above, so the only star macOS still reads as
672            // syntax is a trailing `.*`, and only when it is the sole one. What is left of
673            // the entry has to be a name a destination could carry: a leading dot is not,
674            // and the glob would be as dead as the entry it came from.
675            if dialect == BypassDialect::MacOs {
676                let head = host_text.strip_suffix(".*").filter(|head| {
677                    !head.is_empty() && !head.contains('*') && !head.starts_with('.')
678                });
679                return match head {
680                    Some(head) => Ok(Some(HostPattern::Wildcard {
681                        pattern: format!("{head}.*"),
682                        port,
683                    })),
684                    None => Err(Error::bypass(entry, MACOS_LITERAL_STAR)),
685                };
686            }
687            // The leading dot is the reference's own rewrite ("we remap `.google.com` -->
688            // `*.google.com`"), and it is applied to every rule that starts with one, glob
689            // or not (`SchemeHostPortMatcherRule::FromUntrimmedRawString`). The `Domain`
690            // arm below is that rewrite spelled as a suffix; this arm needs it written out,
691            // because a pattern is matched literally and no host key begins with a dot:
692            // left alone, `.*.example.com` is the dead rule the guards above refuse.
693            let pattern = if host_text.starts_with('.') {
694                format!("*{host_text}")
695            } else {
696                host_text.to_owned()
697            };
698            return Ok(Some(HostPattern::Wildcard { pattern, port }));
699        }
700
701        if let Some(rest) = host_text.strip_prefix('.') {
702            if rest.is_empty() {
703                return Ok(None);
704            }
705            return Ok(Some(HostPattern::Domain {
706                suffix: host_text.to_owned(),
707                // The `*.` spelling above, for the same reason.
708                match_self: dialect == BypassDialect::Gnome,
709                port,
710            }));
711        }
712
713        // Store the address the way the destination side spells it, for the same reason the
714        // punycode step above exists: `Host::parse` reads a leading zero as octal, `0x` as
715        // hex and a short form as filling from the right, so `192.168.001.001` kept verbatim
716        // would never meet the `192.168.1.1` that arrives. Reached only once `IpAddr` has
717        // said no, so the entry whose meaning this changes is the all-digit name `123`, now
718        // the address `0.0.0.123`, which is what a destination spelled `123` already
719        // becomes. Hence
720        // `idna_ascii` keeps `Host::parse` away from an all-digit *label*, where the address
721        // reading is the wrong one.
722        // The suffix spellings were answered above; this is the bare one.
723        let Ok(host) = crate::endpoint::parse_host(host_text) else {
724            return Err(Error::bypass(entry, UNREACHABLE_NAME));
725        };
726
727        // `IpAddr` said no above, so an `Ipv4` here is a spelling other than four decimal
728        // octets, and no store behind `Windows`, `MacOs` or `Kde` reads it as the address
729        // folded here. WinINet, WinHTTP, CFNetwork and Android's `ProxySelector` compare it
730        // with the destination as text, so `012.1.2.3` meets only a destination written
731        // `012.1.2.3`. KIO's subnet reader takes each part as decimal, so `012.1.2.3` is
732        // `12.1.2.3` and `10.66051` is no address; libproxy compares text against a host
733        // Qt has already folded. Stored folded, the entry would bypass `10.1.2.3`, which
734        // all of them hand to the proxy; stored as text, it would meet nothing, because
735        // every destination reaches the rules already folded. The rows are
736        // `a_non_decimal_ipv4_entry_is_refused_in_a_windows_list` in `tests/bypass.rs` and
737        // `non_decimal_ipv4_spellings_are_compared_as_text` in `tests/mac_exceptions_list.rs`.
738        if matches!(
739            dialect,
740            BypassDialect::Windows | BypassDialect::MacOs | BypassDialect::Kde
741        ) && matches!(host, Host::Ipv4(_))
742        {
743            return Err(Error::bypass(
744                entry,
745                "entry is an IPv4 address written other than as four decimal octets, which \
746                 this list's reader does not read as the address a URL parser does — Windows, \
747                 macOS and Android compare it as text, KIO reads each part as decimal \
748                 (write the four decimal octets, for example 10.1.2.3)",
749            ));
750        }
751
752        // An address is one host under every dialect: there is nothing "under"
753        // `10.0.0.1` for a suffix rule to reach.
754        if matches!(host, Host::Ipv4(_))
755            || matches!(dialect, BypassDialect::Windows | BypassDialect::MacOs)
756        {
757            return Ok(Some(HostPattern::Exact { host, port }));
758        }
759
760        Ok(Some(HostPattern::Domain {
761            suffix: format!(".{host_text}"),
762            match_self: true,
763            port,
764        }))
765    }
766
767    // Test the pattern against a destination.
768    //
769    // `host_text` must already be lowercased, and has had a trailing dot shed when
770    // [`BypassRules::strip_trailing_dot`] says to; `ip` is `Some` when the host is a
771    // literal address, already reduced by `reduce_mapped` when `reduce` is set, which is
772    // [`BypassRules::ipv4_mapped_as_ipv4`]. Prefer [`BypassRules::matches`], which also
773    // applies the implicit set and the simple-hostname switch.
774    fn matches(
775        &self,
776        host_text: &str,
777        ip: Option<IpAddr>,
778        port: Option<u16>,
779        reduce: bool,
780    ) -> bool {
781        match self {
782            HostPattern::All => true,
783            HostPattern::Cidr(net) => {
784                let net = if reduce {
785                    reduce_mapped_net(*net)
786                } else {
787                    *net
788                };
789                ip.is_some_and(|ip| net.contains(&ip))
790            }
791            HostPattern::Exact {
792                host,
793                port: rule_port,
794            } => {
795                // Addresses compare as addresses, so `10.0.0.1` and `[::ffff:10.0.0.1]` are
796                // the one host they denote: `reduce_mapped` reduces both sides. This is
797                // Go's `ipMatch`, which compares with `net.IP.Equal`. The references split
798                // here: Chromium's `SchemeHostPortMatcherIPHostRule::Evaluate` is
799                // `base::MatchPattern(url.GetHost(), ip_host_)`, plain text. Go is the one
800                // to follow because the CIDR arm above reads the mapped spelling under the
801                // same switch, and a rule that agreed with it on one shape and not the
802                // other would make the matching inconsistent. The text fallback carries the
803                // `Exact { host: Host::Domain(_) }` that a Windows list produces for every
804                // bare name, and the hand-built one.
805                let rule_ip = host_ip(host).map(|rule_ip| {
806                    if reduce {
807                        reduce_mapped(rule_ip)
808                    } else {
809                        rule_ip
810                    }
811                });
812                let hit = match (rule_ip, ip) {
813                    (Some(rule_ip), Some(destination_ip)) => rule_ip == destination_ip,
814                    _ => host_text == host_key(host),
815                };
816                hit && port_matches(*rule_port, port)
817            }
818            HostPattern::Domain {
819                suffix,
820                match_self,
821                port: rule_port,
822            } => {
823                // No `ip.is_none()` guard, so `.1` also matches `10.0.0.1` through text
824                // matching, as in the Windows `ProxyOverride` lists this crate reads. Go
825                // excludes addresses here; Chromium does not.
826                //
827                // The leading dot is `parse`'s convention, not something the type can
828                // enforce: the fields are public and `BypassRules::new` exists to be filled
829                // in by hand. So name the domain rather than slicing a byte off it (a
830                // non-ASCII first character is not a char boundary) and refuse the empty
831                // name outright, which is what the field doc promises matches nothing. It
832                // would not: an empty `bare` strips nothing, so the whole host comes back
833                // as `rest`, and any host still ending in a dot once one is shed then
834                // satisfies the `ends_with('.')` test below. The destination side of that
835                // is an ordinary URL and not a hand-built value: `url` reads
836                // `http://example.com../` as `Host::Domain("example.com..")`. (Not "matches
837                // every host": that is what an `ends_with(bare)` shape would do, and
838                // this one strips instead.)
839                //
840                // The suffix's case is also a `parse` convention, so fold it here. The
841                // `Exact` arm folds through `host_key`, which also lowercases the
842                // destination. `Exact { "FooBar.com" }` and `Domain { ".FooBar.com" }` must
843                // use the same case-insensitive comparison.
844                let bare = suffix.strip_prefix('.').unwrap_or(suffix);
845                let hit = !bare.is_empty()
846                    && strip_suffix_ascii_case(host_text, bare).is_some_and(|rest| {
847                        rest.ends_with('.') || (*match_self && rest.is_empty())
848                    });
849                hit && port_matches(*rule_port, port)
850            }
851            HostPattern::Wildcard {
852                pattern,
853                port: rule_port,
854            } => {
855                // Folded on the rule side like `Domain` above, and folded *here* rather
856                // than inside `glob_match`: that helper is shared with `sh_exp_match`, the
857                // PAC `shExpMatch`, where case sensitivity is the reference behaviour:
858                // Mozilla's `ascii_pac_utils.js` builds a `RegExp` with no `i` flag. The
859                // allocation is skipped for the shape `parse` produces, which is all of them.
860                let hit = if pattern.bytes().any(|byte| byte.is_ascii_uppercase()) {
861                    glob_match(&pattern.to_ascii_lowercase(), host_text)
862                } else {
863                    glob_match(pattern, host_text)
864                };
865                hit && port_matches(*rule_port, port)
866            }
867            HostPattern::Local => is_simple_host_name(host_text, ip),
868            // Not a bypass this entry adds, and the only entry that can take one away.
869            // [`BypassRules::matches`] answers for it before it reaches here, because the
870            // answer depends on the destination's membership in the implicit set rather
871            // than on this pattern.
872            HostPattern::SubtractImplicit => false,
873        }
874    }
875}
876
877impl fmt::Display for HostPattern {
878    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
879        match self {
880            HostPattern::All => f.write_str("*"),
881            HostPattern::Cidr(net) => write!(f, "{net}"),
882            HostPattern::Exact { host, port } => write_with_port(f, &host_display(host), *port),
883            HostPattern::Domain {
884                suffix,
885                match_self,
886                port,
887            } => {
888                // Add the dot because a hand-built suffix without one matches the same
889                // domain. Printing a subdomain-only rule as `example.com` makes
890                // [`Self::parse`] read it back with `match_self: true`, widening the bypass
891                // when a logged rule is parsed again.
892                let base = match (*match_self, suffix.strip_prefix('.')) {
893                    (true, Some(bare)) => bare.to_owned(),
894                    (false, None) => format!(".{suffix}"),
895                    _ => suffix.clone(),
896                };
897                write_with_port(f, &base, *port)
898            }
899            HostPattern::Wildcard { pattern, port } => write_with_port(f, pattern, *port),
900            HostPattern::Local => f.write_str(LOCAL_TOKEN),
901            HostPattern::SubtractImplicit => f.write_str(NO_LOOPBACK_TOKEN),
902        }
903    }
904}
905
906/// The Windows `ProxyOverride` token that bypasses dot-less (intranet) host names.
907pub const LOCAL_TOKEN: &str = "<local>";
908
909/// Windows `<-loopback>`: subtracts the whole implicit bypass set
910/// ([`BypassRules::implicit`]) from the entries written before it. Parsed to
911/// [`HostPattern::SubtractImplicit`]; see [`BypassRules::bypass_loopback`].
912pub const NO_LOOPBACK_TOKEN: &str = "<-loopback>";
913
914/// The destinations a resolver sends direct with no list entry naming them: the implicit
915/// bypass set, held in [`BypassRules::implicit`].
916///
917/// Each store's own resolver has its own set, so a rule set read from a store carries that
918/// store's: an implicit bypass the resolver does not have is a destination this crate
919/// reports direct while the machine sends it to the proxy. The `WinInet` and `CfNetwork`
920/// columns are the machine's answer, measured by handing the resolver a proxy and an
921/// otherwise empty list and asking where each destination went; `Empty` is read off
922/// GLib's and KIO's matchers, which consult the list and nothing else.
923///
924/// | Destination | `Broad` | `WinInet` | `CfNetwork` | `Empty` |
925/// |---|---|---|---|---|
926/// | `localhost` (any case) | ✓ | ✓ | ✓ | |
927/// | `loopback` | ✓ | ✓ | | |
928/// | `127.0.0.1`, `[::1]` | ✓ | ✓ | ✓ | |
929/// | the rest of `127.0.0.0/8` | ✓ | | | |
930/// | `*.localhost`, a trailing dot | ✓ | | | |
931/// | `169.254.0.0/16`, `[fe80::]/10` | ✓ | ✓ | | |
932/// | an IPv4-mapped spelling of any of the above | ✓ | | | |
933///
934/// [`HostPattern::SubtractImplicit`] (`<-loopback>`) subtracts whichever set is in force.
935#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
936#[non_exhaustive]
937pub enum ImplicitBypass {
938    /// Chromium's implicit rules (`IsLocalhost`, IPv4-mapped loopback, link-local) and
939    /// WinINet's `loopback` name. The default, and what [`parse::no_proxy`] carries: the
940    /// environment variable has no single resolver to measure, and this is the widest set
941    /// any reader of it applies.
942    ///
943    /// [`parse::no_proxy`]: crate::parse::no_proxy
944    #[default]
945    Broad,
946    /// WinINet's, which [`parse::proxy_override`](crate::parse::proxy_override) and the
947    /// Windows store carry: the names `localhost` and `loopback`, the addresses
948    /// `127.0.0.1` and `::1`, and the link-local ranges. The same six Chromium records as
949    /// Windows' own set.
950    WinInet,
951    /// CFNetwork's: the name `localhost` and the addresses `127.0.0.1` and `::1`.
952    /// CFNetwork applies it only while the settings carry a bypass key, so the macOS and
953    /// iOS stores carry this set when the dictionary has an `ExceptionsList` array or
954    /// `ExcludeSimpleHostnames` switched on, and [`Empty`](Self::Empty) otherwise,
955    /// `ExcludeSimpleHostnames` set to 0 included. A Mac configured through System Settings
956    /// always has the array.
957    CfNetwork,
958    /// No implicit bypass: every destination is decided by the entries. GNOME's
959    /// `GSimpleProxyResolver` and KDE's KIO have none.
960    Empty,
961}
962
963impl ImplicitBypass {
964    // Whether the destination is in this set. `ip` is the address as written, before
965    // `reduce_mapped`: only `Broad` reads an IPv4-mapped spelling as the address it maps.
966    fn covers(self, host_text: &str, ip: Option<IpAddr>) -> bool {
967        match self {
968            Self::Broad => {
969                let ip = ip.map(reduce_mapped);
970                is_loopback(host_text, ip) || is_link_local(ip)
971            }
972            Self::WinInet => match ip {
973                Some(ip) => is_loopback_address(ip) || is_link_local(Some(ip)),
974                None => host_text == "localhost" || host_text == "loopback",
975            },
976            Self::CfNetwork => match ip {
977                Some(ip) => is_loopback_address(ip),
978                None => host_text == "localhost",
979            },
980            Self::Empty => false,
981        }
982    }
983}
984
985// Covers bare and suffix entries, including malformed IPv6 such as `2001:db8:1`.
986// `Error::bypass` withholds colon-containing input, so the reason must identify its shape.
987const UNREACHABLE_NAME: &str = "entry is not a name any destination could carry, so no \
988                                destination could ever match it (a last label that reads as \
989                                a number, as in example.123, is taken for an address and \
990                                refused as one; so is an unbracketed IPv6 that is not a \
991                                valid address, as in 2001:db8:1)";
992
993// GNOME strips only leading `*.` (`gsimpleproxyresolver.c:227`); every other `*`, including
994// a bare one, is literal and matches no host. Reject the dead rule so it cannot become a
995// bypass GNOME does not grant; see the `All` guard in `parse_in`.
996const GNOME_LITERAL_STAR: &str = "entry contains a '*' that GNOME does not read as a \
997                                  wildcard — only a leading '*.' is one there — so no \
998                                  destination could ever match it (write *.example.com for \
999                                  a domain, or the host on its own)";
1000
1001// macOS reads only leading `*.` and trailing `.*` as wildcards. A bare `*` matches nothing;
1002// treating it as `All` would switch off the proxy.
1003const MACOS_LITERAL_STAR: &str = "entry contains a '*' that macOS does not read as a \
1004                                  wildcard — only a leading '*.' or a trailing '.*' is one \
1005                                  there — so no destination could ever match it (write \
1006                                  *.example.com for a domain, or the host on its own)";
1007
1008/// Destinations that must not go through a proxy
1009/// ([`parse::no_proxy`](crate::parse::no_proxy) / [`parse::proxy_override`](crate::parse::proxy_override)).
1010#[derive(Debug, Clone, PartialEq, Eq)]
1011#[non_exhaustive]
1012pub struct BypassRules {
1013    /// Parsed list entries.
1014    pub patterns: Vec<HostPattern>,
1015    /// Dot-less host bypass, as a switch (macOS `ExcludeSimpleHostnames`). It takes any
1016    /// destination without a dot, a dot-less IPv6 literal included, where the list
1017    /// spelling [`HostPattern::Local`] takes no IP literal; and under
1018    /// [`reversed_exceptions`](Self::reversed_exceptions) the two mean opposite things (see
1019    /// [`excludes_simple_hostnames`](Self::excludes_simple_hostnames)).
1020    pub exclude_simple_hostnames: bool,
1021    /// KDE/`config-kde` inclusion list; the implicit set, where there is one, still
1022    /// applies; see [`matches`](Self::matches).
1023    pub reversed_exceptions: bool,
1024    /// Redacted unparseable originals; affects verdict under
1025    /// [`reversed_exceptions`](Self::reversed_exceptions). Not a complete ledger of the
1026    /// entries that match nothing: see [`HostPattern::Wildcard`].
1027    pub rejected: Vec<RejectedValue>,
1028    /// Whether a destination's port counts only when the destination wrote one, so that a
1029    /// ported entry such as `example.com:80` does not meet a portless `http://example.com/`.
1030    ///
1031    /// Set for GNOME `ignore-hosts` and nowhere else. GLib resolves the destination with
1032    /// `G_URI_FLAGS_NONE` (`gsimpleproxyresolver.c:341`) and fills a scheme's default port
1033    /// only under `G_URI_FLAGS_SCHEME_NORMALIZE` (`guri.c:1006`), so the port it compares
1034    /// against is 0 unless the URL carried one. Windows fills it: a `ProxyOverride` entry
1035    /// of `host:80` was measured bypassing a portless `http://host/`.
1036    ///
1037    /// [`matches`](Self::matches) does not read this: it is told a port and answers about
1038    /// that port. [`matches_url`](Self::matches_url) is where it is read, and is the reason
1039    /// to prefer that method whenever the destination is a `Url`: it passes
1040    /// [`Url::port`](https://docs.rs/url/latest/url/struct.Url.html#method.port) rather than
1041    /// `port_or_known_default` when this is set. Reading the field by hand is supported, not
1042    /// recommended.
1043    ///
1044    /// `Url` drops a port equal to the scheme's default while parsing, so `http://host:80/`
1045    /// and `http://host/` become the same value and `port` returns `None` for both. A rule
1046    /// on that port (`host:80` for `http`, `host:443` for `https`) therefore never matches
1047    /// here, while GLib still matches the spelling with an explicit port. The error is in
1048    /// the safe direction: the destination uses the proxy instead of connecting directly.
1049    /// URL parsing removes the distinction before this crate receives the URL.
1050    pub require_explicit_port: bool,
1051    /// The destinations bypassed with no entry naming them. [`ImplicitBypass::Broad`]
1052    /// unless the source says otherwise; each store sets its own resolver's set.
1053    pub implicit: ImplicitBypass,
1054    /// Whether an IPv4-mapped IPv6 destination (`[::ffff:10.0.0.1]`) meets the entries as
1055    /// the IPv4 address it maps. Set by default and for
1056    /// [`parse::no_proxy`](crate::parse::no_proxy), which is Chromium's and Go's reading.
1057    ///
1058    /// Cleared for every store's list. Android's selector compares the host string, and
1059    /// WinINet and CFNetwork are unmeasured for an explicit entry and take the reading that
1060    /// cannot send a proxied destination direct. GNOME `ignore-hosts` and KDE `NoProxyFor`
1061    /// are read off their resolvers, which compare the address in the family it was
1062    /// written in: GLib's `g_inet_address_mask_matches`
1063    /// and Qt's `QHostAddress::isInSubnet`, which KIO calls, both answer no across
1064    /// families, so `10.0.0.0/8` does not bypass `[::ffff:10.0.0.1]` there.
1065    /// [`implicit`](Self::implicit) does not read this; which spellings an implicit set
1066    /// covers is part of the set.
1067    pub ipv4_mapped_as_ipv4: bool,
1068    /// Whether a destination's trailing dot is shed before it meets the entries, so that
1069    /// `example.com` and `*.example.com` also match `example.com.` and `www.example.com.`.
1070    /// Set by default and for [`parse::no_proxy`](crate::parse::no_proxy), which read the
1071    /// dot as the DNS root it denotes.
1072    ///
1073    /// Set for macOS and iOS too: CFNetwork was measured shedding the dot on the
1074    /// destination and on the entry (`cfnetwork_sheds_a_trailing_dot` in
1075    /// `tests/mac_exceptions_list.rs`).
1076    ///
1077    /// Cleared for every other store, because each of those resolvers compares the name as
1078    /// written (and a store entry that itself ends in a dot is refused into
1079    /// [`rejected`](Self::rejected) there for the same reason): WinINet sent `localhost.`
1080    /// to the proxy under a `ProxyOverride` naming `localhost`, GLib's `ignore_host` and
1081    /// KIO's `revmatch` compare text, and Android's `ProxySelector` compares strings. The
1082    /// implicit set and the simple-hostname switch do not read this; each set says which
1083    /// spellings it covers, and a trailing dot is a dot.
1084    pub strip_trailing_dot: bool,
1085}
1086
1087impl Default for BypassRules {
1088    fn default() -> Self {
1089        Self::new()
1090    }
1091}
1092
1093impl BypassRules {
1094    /// An empty rule set with the [`ImplicitBypass::Broad`] implicit set, so loopback and
1095    /// link-local destinations are bypassed.
1096    #[must_use]
1097    pub const fn new() -> Self {
1098        Self {
1099            patterns: Vec::new(),
1100            exclude_simple_hostnames: false,
1101            reversed_exceptions: false,
1102            rejected: Vec::new(),
1103            require_explicit_port: false,
1104            implicit: ImplicitBypass::Broad,
1105            ipv4_mapped_as_ipv4: true,
1106            strip_trailing_dot: true,
1107        }
1108    }
1109
1110    /// Whether the list leaves the implicit bypass set ([`implicit`](Self::implicit)) in
1111    /// force, which it does unless it carries [`HostPattern::SubtractImplicit`]
1112    /// (`<-loopback>`). Under [`ImplicitBypass::Empty`] there is no set to leave in force,
1113    /// and the answer is about the list alone.
1114    ///
1115    /// A rough answer, and the reason it is not a field: `<-loopback>` subtracts the
1116    /// implicit set only from the entries written before it, so a list that carries the
1117    /// token can still bypass a loopback destination named after it. Ask
1118    /// [`matches`](Self::matches) about a destination; ask this about the list.
1119    ///
1120    /// ```
1121    /// # use proxy_watch::parse;
1122    /// assert!(parse::no_proxy("localhost").bypass_loopback());
1123    /// assert!(!parse::proxy_override("<-loopback>").bypass_loopback());
1124    /// // The token is in the list, so this is `false`, but `localhost` is written
1125    /// // after it and wins on the destination it names.
1126    /// let readded = parse::proxy_override("<-loopback>;localhost");
1127    /// assert!(!readded.bypass_loopback());
1128    /// assert!(readded.matches_authority("localhost"));
1129    /// assert!(!readded.matches_authority("127.0.0.1"));
1130    /// ```
1131    #[must_use]
1132    pub fn bypass_loopback(&self) -> bool {
1133        !self
1134            .patterns
1135            .iter()
1136            .any(|p| matches!(p, HostPattern::SubtractImplicit))
1137    }
1138
1139    // Repeats are collapsed by [`dedup_patterns`](Self::dedup_patterns) after parsing.
1140    pub(crate) fn push_pattern(&mut self, pattern: HostPattern) {
1141        self.patterns.push(pattern);
1142    }
1143
1144    // Collapse repeats once the list is complete; a `Vec::contains` per push is quadratic
1145    // on a long list. A repeat is dropped only within a run between `<-loopback>` tokens,
1146    // because [`HostPattern::SubtractImplicit`] changes what a repeat means: in
1147    // `localhost, <-loopback>, localhost` the second `localhost` is the one that decides.
1148    pub(crate) fn dedup_patterns(&mut self) {
1149        let mut seen = HashSet::with_capacity(self.patterns.len());
1150        self.patterns.retain(|pattern| {
1151            if matches!(pattern, HostPattern::SubtractImplicit) {
1152                seen.clear();
1153                return true;
1154            }
1155            seen.insert(pattern.clone())
1156        });
1157    }
1158
1159    // GNOME's `ignore-hosts`, sorted the way GLib's `reparse_ignore_hosts`
1160    // (`gsimpleproxyresolver.c`) sorts it before anything is matched. An entry GLib reads
1161    // as an address or a mask is a `Cidr` here, unreduced, because `GInetAddressMask`
1162    // compares addresses of one family only. Everything else is a name, and a name GLib
1163    // either refuses or can never match is refused here rather than handed to `parse_in`,
1164    // which would normalise it into a live rule: a bracketed address with no port, a mask
1165    // GLib rejects (bits past the prefix, a leading zero), `<local>` and `<-loopback>`,
1166    // non-ASCII text, a trailing dot, and an IPv4 spelling `inet_pton` does not read.
1167    // GLib's matcher also stops at the
1168    // first name of length zero (`for (...; priv->ignore_domains[i].length; ...)`), so
1169    // `["", "example.com"]` bypasses nothing there; every name after such an entry is
1170    // refused for that reason.
1171    #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
1172    pub(crate) fn push_gnome_entries<'a>(&mut self, entries: impl Iterator<Item = &'a str>) {
1173        let mut names_ended = false;
1174        for entry in entries {
1175            let entry = BypassDialect::Gnome.trim(entry);
1176            if let Some(net) = glib_mask(entry) {
1177                self.push_pattern(HostPattern::Cidr(net));
1178                continue;
1179            }
1180            let refusal = match glib_name(entry) {
1181                Err(reason) => Some(reason),
1182                Ok("") => {
1183                    names_ended = true;
1184                    None
1185                }
1186                Ok(_) if names_ended => Some(
1187                    "entry follows one that leaves no name (empty, '.', '*.' or a bare \
1188                     port), and GLib stops reading names there, so it never matches",
1189                ),
1190                Ok(name)
1191                    if name.eq_ignore_ascii_case(LOCAL_TOKEN)
1192                        || name.eq_ignore_ascii_case(NO_LOOPBACK_TOKEN) =>
1193                {
1194                    Some(
1195                        "entry is a WinINet token, which GLib does not read: it compares the \
1196                         entry to a host so named, and no host is",
1197                    )
1198                }
1199                Ok(name) if !name.is_ascii() => Some(
1200                    "entry is not ASCII, and GLib compares it unconverted with the ASCII \
1201                     form of the destination, so it never matches (write the xn-- form)",
1202                ),
1203                Ok(name) if name.ends_with('.') => Some(
1204                    "entry ends in a '.', which GLib compares as written, so it matches \
1205                     only a destination spelled with the same trailing dot",
1206                ),
1207                Ok(name) if name.contains('/') => Some(
1208                    "entry is not a mask GLib accepts (an address with bits set past the \
1209                     prefix, or with a leading zero), so GLib reads it as a name that \
1210                     matches nothing",
1211                ),
1212                Ok(name)
1213                    if name.parse::<IpAddr>().is_err()
1214                        && matches!(crate::endpoint::parse_host(name), Ok(Host::Ipv4(_))) =>
1215                {
1216                    Some(
1217                        "entry is an IPv4 spelling inet_pton does not read (short, octal or \
1218                         hex), so GLib compares it as text and it never matches",
1219                    )
1220                }
1221                Ok(_) => None,
1222            };
1223            match refusal {
1224                Some(reason) => {
1225                    crate::trace::warning!(
1226                        error = %crate::trace::SafeError(&Error::bypass(entry, reason)),
1227                        "skipping an ignore-hosts entry GLib would not match"
1228                    );
1229                    self.rejected.push(RejectedValue::new(
1230                        RejectionKind::InvalidBypassPattern,
1231                        RejectionSource::BypassList,
1232                        entry,
1233                    ));
1234                }
1235                None if names_ended && entry.is_empty() => {}
1236                None => self.push_entry_in(entry, BypassDialect::Gnome),
1237            }
1238        }
1239    }
1240
1241    pub(crate) fn push_entry_in(&mut self, entry: &str, dialect: BypassDialect) {
1242        self.push_entry_from(entry, dialect, RejectionSource::BypassList);
1243    }
1244
1245    // [`Self::push_entry_in`] for a source that records its own refusals under a name of
1246    // its own, so a refused entry carries one source whichever check refused it.
1247    #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
1248    pub(crate) fn push_entry_from(
1249        &mut self,
1250        entry: &str,
1251        dialect: BypassDialect,
1252        source: RejectionSource,
1253    ) {
1254        match HostPattern::parse_in(entry, dialect) {
1255            Ok(Some(pattern)) => self.push_pattern(pattern),
1256            Ok(None) => {}
1257            Err(err) => {
1258                crate::trace::warning!(
1259                    error = %crate::trace::SafeError(&err),
1260                    "skipping an unparseable bypass list entry"
1261                );
1262                self.rejected.push(RejectedValue::new(
1263                    RejectionKind::InvalidBypassPattern,
1264                    source,
1265                    entry,
1266                ));
1267            }
1268        }
1269    }
1270
1271    /// Whether either representation of the dot-less host rule is present: the
1272    /// [`exclude_simple_hostnames`](Self::exclude_simple_hostnames) switch or a
1273    /// [`HostPattern::Local`] entry.
1274    ///
1275    /// The switch also bypasses dot-less IPv6 literals, which a `Local` entry does not.
1276    /// Under [`reversed_exceptions`](Self::reversed_exceptions), the switch bypasses
1277    /// dot-less hosts before the list is consulted, as the implicit set does. A `Local`
1278    /// entry is part of the inclusion list, so it keeps dot-less hosts using the proxy. A
1279    /// `true` result therefore does not guarantee that [`matches`](Self::matches) bypasses
1280    /// a dot-less host; use `matches` to check a destination. Converting between these
1281    /// representations silently reverses that verdict.
1282    #[must_use]
1283    pub fn excludes_simple_hostnames(&self) -> bool {
1284        self.exclude_simple_hostnames
1285            || self
1286                .patterns
1287                .iter()
1288                .any(|p| matches!(p, HostPattern::Local))
1289    }
1290
1291    /// No entries and neither switch: [`patterns`](Self::patterns) empty,
1292    /// [`exclude_simple_hostnames`](Self::exclude_simple_hostnames) and
1293    /// [`reversed_exceptions`](Self::reversed_exceptions) off. `<-loopback>` is an entry
1294    /// in [`patterns`](Self::patterns), so a list holding only that is not empty; it
1295    /// carries an instruction, and reporting it empty would invite a caller to drop it and
1296    /// put the implicit bypass back. Omits [`rejected`](Self::rejected) (only matters under
1297    /// [`reversed_exceptions`](Self::reversed_exceptions), which already answers `false`).
1298    ///
1299    /// Omits [`implicit`](Self::implicit) too: an empty list is "no entries", not "bypasses
1300    /// nothing". Under [`ImplicitBypass::WinInet`] it still sends WinINet's six destinations
1301    /// direct, and only under [`ImplicitBypass::Empty`] does it send nothing direct.
1302    ///
1303    /// ```
1304    /// # use proxy_watch::{parse, BypassRules};
1305    /// assert!(BypassRules::new().is_empty());
1306    /// assert!(parse::no_proxy("").is_empty());
1307    /// // `<-loopback>` is an instruction, not an absence of one.
1308    /// assert!(!parse::proxy_override("<-loopback>").is_empty());
1309    /// ```
1310    #[must_use]
1311    pub fn is_empty(&self) -> bool {
1312        self.patterns.is_empty()
1313            && !self.exclude_simple_hostnames
1314            // Reversed empty list bypasses everything, not trivial.
1315            && !self.reversed_exceptions
1316    }
1317
1318    /// Whether `host` bypasses the proxy.
1319    ///
1320    /// An empty host returns `false` in either mode before any other checks. The crate's
1321    /// routing functions handle hostless URLs as Direct before consulting the bypass list,
1322    /// so this applies to caller-constructed `Host` values. A `Host::Domain` is treated as
1323    /// text without re-parsing it as an address: `Domain("0177.0.0.1")` misses the loopback
1324    /// switch that [`matches_authority`](Self::matches_authority) matches for the same
1325    /// text. `Url::host` and `url::Host::parse` normalize numeric spellings to `Host::Ipv4`
1326    /// and cannot produce this domain value. Re-parsing here would add work to every call
1327    /// for a value that requires explicit construction with `Host::Domain(_)`.
1328    ///
1329    /// No name resolution is performed. A host that `/etc/hosts` or DNS maps to `127.0.0.1`
1330    /// is matched as text, so it misses the implicit set and uses the proxy unless an entry
1331    /// matches it. Chromium matches the same way:`ProxyHostMatchingRules::Matches` takes
1332    /// a `GURL` and its rules check `url.host()`. Resolving names in this per-request
1333    /// predicate would add lookup latency and failures to each request.
1334    ///
1335    /// Ported `:port` rules never match `port = None` (Go). Under
1336    /// [`reversed_exceptions`](Self::reversed_exceptions), patterns are inclusion-only
1337    /// while [`rejected`](Self::rejected) is empty.
1338    ///
1339    /// Entries are evaluated **back to front**, and the first matching rule determines the
1340    /// result: "Later rules override earlier rules … when mixing positive and negative
1341    /// rules, evaluation order makes a difference" (Chromium,
1342    /// `net/base/scheme_host_port_matcher.cc`; the expectation "comes from WinInet (which
1343    /// is where `<-loopback>` comes from)", `proxy_host_matching_rules_unittest.cc`). Order
1344    /// matters only with [`HostPattern::SubtractImplicit`], which removes a bypass:
1345    /// `127.0.0.1;<-loopback>` proxies `127.0.0.1`, while `<-loopback>;127.0.0.1` sends it
1346    /// direct. If no entry matches, the implicit set ([`implicit`](Self::implicit))
1347    /// determines the result. It is not inverted by `reversed_exceptions`, because an
1348    /// inclusion list that does not name loopback does not request proxying for loopback.
1349    ///
1350    /// [`exclude_simple_hostnames`](Self::exclude_simple_hostnames) is a switch and not an
1351    /// entry, so it is not inverted either, unlike a [`HostPattern::Local`] entry, which
1352    /// is; see [`excludes_simple_hostnames`](Self::excludes_simple_hostnames) for why that
1353    /// changes the answer here. An IPv4-mapped IPv6 destination (`::ffff:a.b.c.d`) is
1354    /// compared with the entries as the IPv4 address it maps while
1355    /// [`ipv4_mapped_as_ipv4`](Self::ipv4_mapped_as_ipv4) is set, against CIDR and exact
1356    /// patterns alike.
1357    ///
1358    /// ```
1359    /// # use proxy_watch::parse;
1360    /// let rules = parse::no_proxy("localhost, .example.com, 10.0.0.0/8");
1361    /// assert!(rules.matches_authority("www.example.com"));
1362    /// assert!(rules.matches_authority("10.1.2.3:443"));
1363    /// assert!(!rules.matches_authority("example.org"));
1364    /// // Link-local destinations bypass even though the list above never mentions them.
1365    /// assert!(rules.matches_authority("169.254.1.1"));
1366    /// assert!(rules.matches_authority("[fe80::1]"));
1367    /// // A Windows list carries WinINet's implicit set, which stops at `127.0.0.1`.
1368    /// assert!(parse::proxy_override("").matches_authority("127.0.0.1"));
1369    /// assert!(!parse::proxy_override("").matches_authority("127.0.0.2"));
1370    /// ```
1371    #[must_use]
1372    pub fn matches(&self, host: &Host, port: Option<u16>) -> bool {
1373        let text = host_key(host);
1374        if text.is_empty() {
1375            return false;
1376        }
1377        let written = host_ip(host);
1378        let reduce = self.ipv4_mapped_as_ipv4;
1379        let ip = if reduce {
1380            written.map(reduce_mapped)
1381        } else {
1382            written
1383        };
1384        let implicit = self.implicit.covers(&text, written);
1385        let entry_text = if self.strip_trailing_dot {
1386            text.strip_suffix('.').unwrap_or(&text)
1387        } else {
1388            &text
1389        };
1390        // What a destination no entry names answers under `reversed_exceptions`: the list
1391        // is then the set that *uses* the proxy, so being absent from it is the bypass. A
1392        // rejected entry makes that set incomplete, and an incomplete inclusion list may
1393        // not send anything direct.
1394        let unnamed = self.reversed_exceptions && self.rejected.is_empty();
1395
1396        // Before the entries, and so before `reversed_exceptions` can invert it: this is a
1397        // switch, not a list entry. macOS is where it comes from and macOS has no reversed
1398        // mode, so the combination has no reference to copy, but the shape does, and the
1399        // list spelling `<local>` is a `HostPattern::Local` among the entries, is inverted,
1400        // and therefore answers the opposite. Both readings are right for what they are;
1401        // what would be wrong is treating them as one flag.
1402        //
1403        // The one entry that still overrides it is `<-loopback>`, because Chromium models
1404        // this switch as a rule *prepended* to the list
1405        // (`PrependRuleToBypassSimpleHostnames`, `proxy_config_service_mac.cc`) and so puts
1406        // every entry after it. Only a dot-less host that is also in the implicit set
1407        // (`localhost`, `[::1]`) is reachable both ways; the rest of what the switch
1408        // covers no negative entry can name.
1409        //
1410        // The test is the dot alone, so an IPv6 literal is simple here and an IPv4 one is
1411        // not: CFNetwork was measured to bypass `[fe80::1]` and `[fec0::1]` under this
1412        // switch and to proxy them under `<local>`, which keeps Chromium's rule of no IP
1413        // literals at all.
1414        if self.exclude_simple_hostnames
1415            && !text.contains('.')
1416            && (self.bypass_loopback() || !implicit)
1417        {
1418            return true;
1419        }
1420
1421        for pattern in self.patterns.iter().rev() {
1422            if matches!(pattern, HostPattern::SubtractImplicit) {
1423                if implicit {
1424                    return unnamed;
1425                }
1426            } else if pattern.matches(
1427                // `<local>` asks whether the name has a dot, and a trailing one counts.
1428                if matches!(pattern, HostPattern::Local) {
1429                    &text
1430                } else {
1431                    entry_text
1432                },
1433                ip,
1434                port,
1435                reduce,
1436            ) {
1437                return !self.reversed_exceptions;
1438            }
1439        }
1440        implicit || unnamed
1441    }
1442
1443    /// Whether a destination URL bypasses the proxy.
1444    ///
1445    /// Prefer this over [`matches`](Self::matches) whenever the destination is a `Url`: it is
1446    /// the only entry point that reads [`require_explicit_port`](Self::require_explicit_port),
1447    /// and getting that wrong by hand is silent: a GNOME `ignore-hosts` of `example.com:80`
1448    /// asked about with `port_or_known_default` reports a bypass GNOME does not have. This
1449    /// crate's own `resolve` goes through here.
1450    ///
1451    /// A URL with no host to compare (`data:`, `mailto:`, or one whose host was emptied)
1452    /// is reported as "does not bypass", the same reading as an unparseable authority in
1453    /// [`matches_authority`](Self::matches_authority). It is not a statement that such a URL
1454    /// needs a proxy; a caller that routes hostless URLs direct should say so before asking.
1455    ///
1456    /// ```
1457    /// # use proxy_watch::parse;
1458    /// # use url::Url;
1459    /// let rules = parse::no_proxy(".example.com");
1460    /// assert!(rules.matches_url(&Url::parse("https://www.example.com/x").unwrap()));
1461    /// assert!(!rules.matches_url(&Url::parse("https://example.org/").unwrap()));
1462    /// assert!(!rules.matches_url(&Url::parse("data:,hello").unwrap()));
1463    /// ```
1464    #[must_use]
1465    pub fn matches_url(&self, url: &Url) -> bool {
1466        let Some(host) = crate::endpoint::request_host(url) else {
1467            return false;
1468        };
1469        // The flag is read only here. `port_or_known_default` is the majority reading: a
1470        // rule written `example.com:80` is meant for the HTTP port whether or not the URL
1471        // spelled it out, and Windows was measured agreeing: a `ProxyOverride` of `host:80`
1472        // bypasses a portless `http://host/`.
1473        let port = if self.require_explicit_port {
1474            url.port()
1475        } else {
1476            url.port_or_known_default()
1477        };
1478        self.matches(&host, port)
1479    }
1480
1481    /// Convenience wrapper around [`BypassRules::matches`] taking a `host[:port]`
1482    /// string such as `example.com:8080` or `[::1]:443`.
1483    ///
1484    /// Unparseable input is reported as "does not bypass": an authority this cannot
1485    /// split is one it cannot prove is exempt, so it goes through the proxy, which can
1486    /// still refuse it. (Other stacks disagree on this edge; this crate does not claim
1487    /// their spelling.)
1488    ///
1489    /// Supplies no default port: an authority written without one is asked with none, so
1490    /// a rule spelled `example.com:80` does not match `example.com`.
1491    /// [`matches_url`](Self::matches_url) is the entry point that fills one in.
1492    #[must_use]
1493    pub fn matches_authority(&self, authority: &str) -> bool {
1494        let Ok((host_text, port)) = split_host_port(authority.trim()) else {
1495            return false;
1496        };
1497        let Ok(host) = crate::endpoint::parse_host(host_text) else {
1498            return false;
1499        };
1500        self.matches(&host, port)
1501    }
1502}
1503
1504// WHATWG's forbidden domain code points, less `/`, `@`, and ASCII space (earlier
1505// guards) and `:` when it splits a single-colon `host:port`. Several colons without
1506// brackets are treated as an IPv6 literal attempt, not rejected here. `*` is not
1507// forbidden, which is what leaves room for the glob spelling.
1508//
1509// `char::is_control` also catches U+0080..=U+009F. These inputs fail punycode anyway, but
1510// naming the control character in the error helps identify it. Whitespace is checked first.
1511fn is_forbidden_host_char(c: char) -> bool {
1512    c.is_control()
1513        || matches!(
1514            c,
1515            '#' | '%' | '<' | '>' | '?' | '[' | '\\' | ']' | '^' | '|'
1516        )
1517}
1518
1519// The punycode spelling of a bypass entry's host part.
1520//
1521// Label by label, because the whole string is not a host: it may carry a leading dot, a
1522// `*.` prefix, or an embedded glob, none of which `Host::parse` accepts as written. A
1523// label that is already ASCII is left untouched, which is most of them.
1524fn idna_ascii(host_text: &str) -> Result<String, String> {
1525    // `Host::parse` reads a name it can also read as an address as the address, and the
1526    // labels here arrive one at a time, so every one of them looks final to it. Lend the
1527    // label a last label that cannot be part of an address, and take the loan back off.
1528    const LOAN: &str = ".a";
1529
1530    let mut out = String::with_capacity(host_text.len());
1531    for (index, label) in host_text.split('.').enumerate() {
1532        if index > 0 {
1533            out.push('.');
1534        }
1535        if label.is_ascii() {
1536            out.push_str(label);
1537            continue;
1538        }
1539        // Reject `*` here because punycode would encode it and change the glob's meaning.
1540        if label.contains('*') {
1541            return Err(
1542                "entry mixes a '*' glob with non-ASCII text in one label, which has no \
1543                 punycode spelling; write the label as punycode (xn--…) instead"
1544                    .to_owned(),
1545            );
1546        }
1547        // The loan keeps `123` a label rather than the IPv4 address `123`; `parse_host`
1548        // decides afterwards, over the whole host, whether it is a name or an address.
1549        match Host::parse(&format!("{label}{LOAN}")) {
1550            // The loan is ASCII and lowercase, so it survives the round trip unchanged.
1551            Ok(Host::Domain(ascii)) if ascii.ends_with(LOAN) => {
1552                out.push_str(&ascii[..ascii.len() - LOAN.len()]);
1553            }
1554            _ => {
1555                return Err(
1556                    "entry contains non-ASCII text that is not a valid internationalised \
1557                     domain name"
1558                        .to_owned(),
1559                );
1560            }
1561        }
1562    }
1563    Ok(out)
1564}
1565
1566fn port_matches(rule_port: Option<u16>, port: Option<u16>) -> bool {
1567    match rule_port {
1568        None => true,
1569        Some(expected) => port == Some(expected),
1570    }
1571}
1572
1573// `g_inet_address_mask_new_from_string`: an address `inet_pton` reads, optionally `/` and
1574// a decimal prefix no longer than the family's, with no bit set past the prefix.
1575fn glib_mask(entry: &str) -> Option<IpNet> {
1576    let (address, prefix) = match entry.split_once('/') {
1577        Some((address, prefix)) => (address, Some(prefix)),
1578        None => (entry, None),
1579    };
1580    let address: IpAddr = address.parse().ok()?;
1581    let full = if address.is_ipv4() { 32 } else { 128 };
1582    let prefix = match prefix {
1583        Some(text) if !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) => {
1584            text.parse::<u8>().ok()?
1585        }
1586        Some(_) => return None,
1587        None => full,
1588    };
1589    let net = IpNet::new(address, prefix).ok()?;
1590    (net.trunc().addr() == address).then_some(net)
1591}
1592
1593// The name `reparse_ignore_hosts` stores for an entry that is not a mask: a bracketed
1594// address needs a port after it, a single `:` splits off a decimal port, and a leading `*.`
1595// or `.` comes off unless the rest is an address.
1596fn glib_name(entry: &str) -> Result<&str, &'static str> {
1597    let (name, port) = if let Some(inner) = entry.strip_prefix('[') {
1598        let (address, rest) = inner
1599            .split_once(']')
1600            .ok_or("entry opens a bracket it does not close")?;
1601        let port = rest.strip_prefix(':').ok_or(
1602            "entry is a bracketed address with no port, which GLib refuses (write it \
1603             without brackets, or as [address]:port)",
1604        )?;
1605        (address, Some(port))
1606    } else {
1607        match entry.split_once(':') {
1608            Some((name, port)) if !port.contains(':') => (name, Some(port)),
1609            _ => (entry, None),
1610        }
1611    };
1612    if port.is_some_and(|port| !port.bytes().all(|b| b.is_ascii_digit())) {
1613        return Err("entry's port is not a number");
1614    }
1615    if name.parse::<IpAddr>().is_ok() {
1616        return Ok(name);
1617    }
1618    Ok(name
1619        .strip_prefix("*.")
1620        .or_else(|| name.strip_prefix('.'))
1621        .unwrap_or(name))
1622}
1623
1624// The loopback half of `ImplicitBypass::Broad`; the other sets name their members outright.
1625fn is_loopback(host_text: &str, ip: Option<IpAddr>) -> bool {
1626    if let Some(ip) = ip {
1627        // `127.0.0.0/8` and `::1`. `::ffff:127.0.0.1` arrives as `127.0.0.1`, reduced by
1628        // `ImplicitBypass::covers`.
1629        return ip.is_loopback();
1630    }
1631    // WinINet bypasses `localhost` and `loopback` by name; Go bypasses `localhost`.
1632    // Chromium additionally treats any `*.localhost` subdomain and a single trailing
1633    // dot as local (`net/base/url_util.cc`, `IsLocalHostname`); a trailing dot
1634    // is stripped before comparing so `localhost.` and `app.localhost.` match too, the
1635    // same way DNS treats a trailing dot as denoting the root and not a distinct name.
1636    // Neither WinINet nor CFNetwork does either, which is why their sets are their own.
1637    let name = host_text.strip_suffix('.').unwrap_or(host_text);
1638    name == "localhost" || name == "loopback" || name.ends_with(".localhost")
1639}
1640
1641// Whether `ip` is link-local: IPv4 `169.254.0.0/16` (APIPA) or IPv6 `fe80::/10`.
1642// `::ffff:169.254.0.0/112` is the first of those only once `reduce_mapped` has run, which
1643// `ImplicitBypass::Broad` does and `ImplicitBypass::WinInet` does not.
1644//
1645// Read as one implicit set together with the loopback members, so
1646// [`NO_LOOPBACK_TOKEN`] clears both at once. That is what the token means upstream:
1647// Chromium's `MatchesImplicitRules` is `IsLocalhost || IsIPv4MappedLoopback ||
1648// IsLinkLocalIP` in one expression, and `<-loopback>` is the rule that subtracts the whole
1649// of it: "The name <-loopback> is not a very precise name (as the implicit rules cover
1650// more than strictly loopback addresses), however this is the name that is used on Windows
1651// so re-used here" (`net/proxy_resolution/proxy_host_matching_rules.cc`, the file
1652// `proxy_bypass_rules.cc` became). The same file records Windows' own implicit set as
1653// "localhost, loopback, 127.0.0.1, [::1], 169.254/16, [FE80::]/10", which is
1654// `ImplicitBypass::WinInet` member for member, measured against WinINet itself. The flag
1655// here is named for the token that clears it, not for half of what the token covers.
1656//
1657// A proxy on the same host can reach link-local `169.254.169.254`; ignoring `<-loopback>`
1658// for link-local addresses can send instance-metadata traffic direct.
1659fn is_link_local(ip: Option<IpAddr>) -> bool {
1660    match ip {
1661        Some(IpAddr::V4(v4)) => v4.is_link_local(),
1662        Some(IpAddr::V6(v6)) => v6.is_unicast_link_local(),
1663        None => false,
1664    }
1665}
1666
1667// Chromium's `BypassSimpleHostnamesRule` (`proxy_host_matching_rules.cc:80`): a name with no
1668// period, and never an IP literal. A trailing dot counts as "has a period", so it is not stripped
1669// first, unlike the `Domain` arm, which strips one from the same `host_text`.
1670//
1671// This is `HostPattern::Local`'s test. The `exclude_simple_hostnames` switch tests the dot
1672// alone, because CFNetwork counts a dot-less IPv6 literal as simple; see `matches`.
1673fn is_simple_host_name(host_text: &str, ip: Option<IpAddr>) -> bool {
1674    ip.is_none() && !host_text.contains('.')
1675}
1676
1677// The lowercase textual key used for matching. IPv6 hosts are *not* bracketed here,
1678// matching Go's `config.init` (`http/httpproxy/proxy.go`), which strips the brackets
1679// before `net.ParseIP` so `ipMatch` compares an unbracketed address.
1680fn host_key(host: &Host) -> String {
1681    match host {
1682        // Public `matches` accepts caller-built `Host::Domain` values. Convert them like
1683        // `matches_authority` does so Unicode names meet punycoded rules; an unconvertible
1684        // name retains its text and matches nothing.
1685        Host::Domain(domain) if !domain.is_ascii() => idna_ascii(domain)
1686            .unwrap_or_else(|_| domain.clone())
1687            .to_ascii_lowercase(),
1688        Host::Domain(domain) => domain.to_ascii_lowercase(),
1689        Host::Ipv4(ip) => ip.to_string(),
1690        Host::Ipv6(ip) => ip.to_string(),
1691    }
1692}
1693
1694// `str::strip_suffix`, comparing ASCII case-insensitively.
1695//
1696// Allocation-free because it runs once per rule per destination. A split inside a
1697// multi-byte character would panic, so the boundary check returns `false` before slicing.
1698fn strip_suffix_ascii_case<'a>(text: &'a str, suffix: &str) -> Option<&'a str> {
1699    let split = text.len().checked_sub(suffix.len())?;
1700    if !text.is_char_boundary(split) {
1701        return None;
1702    }
1703    text[split..]
1704        .eq_ignore_ascii_case(suffix)
1705        .then(|| &text[..split])
1706}
1707
1708fn host_display(host: &Host) -> String {
1709    match host {
1710        Host::Ipv6(ip) => format!("[{ip}]"),
1711        other => other.to_string(),
1712    }
1713}
1714
1715// The address a host denotes, in the family it was written in.
1716fn host_ip(host: &Host) -> Option<IpAddr> {
1717    match host {
1718        Host::Domain(_) => None,
1719        Host::Ipv4(ip) => Some(IpAddr::V4(*ip)),
1720        Host::Ipv6(ip) => Some(IpAddr::V6(*ip)),
1721    }
1722}
1723
1724// `::ffff:a.b.c.d` reduced to the IPv4 address it maps; every other address unchanged.
1725//
1726// Applied once in `BypassRules::matches` rather than at each comparison, so `is_loopback`
1727// and `is_link_local` need no mapped-spelling arm of their own. Chromium and Go both
1728// reduce, in their own idiom; GLib and Qt do not, which is what
1729// `BypassRules::ipv4_mapped_as_ipv4` switches.
1730fn reduce_mapped(ip: IpAddr) -> IpAddr {
1731    match ip {
1732        IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
1733        IpAddr::V4(_) => ip,
1734    }
1735}
1736
1737// `127.0.0.1` or `::1` as written: the two loopback addresses WinINet and CFNetwork bypass,
1738// where `Ipv4Addr::is_loopback` would take the whole `127.0.0.0/8`.
1739fn is_loopback_address(ip: IpAddr) -> bool {
1740    ip == IpAddr::V4(Ipv4Addr::LOCALHOST) || ip == IpAddr::V6(Ipv6Addr::LOCALHOST)
1741}
1742
1743// The rule-side half of [`reduce_mapped`]: `::ffff:10.0.0.0/104` is the IPv4 block
1744// `10.0.0.0/8`. Applied at match time under the same switch, so a source whose resolver
1745// compares one family only keeps the block it wrote.
1746//
1747// With the switch set, destinations reach `contains` as IPv4; a mapped rule must also be
1748// reduced or it matches neither spelling. Under `reversed_exceptions`, this silent miss
1749// sends traffic direct around the proxy.
1750//
1751// Only a net that lies wholly inside `::ffff:0:0/96` converts. A shorter prefix covers
1752// unmapped IPv6 as well, so it is an IPv6 rule about IPv6 destinations, and mapped
1753// destinations are not those, by the same reduction.
1754fn reduce_mapped_net(net: IpNet) -> IpNet {
1755    let IpNet::V6(v6) = net else {
1756        return net;
1757    };
1758    let (Some(addr), true) = (v6.addr().to_ipv4_mapped(), v6.prefix_len() >= 96) else {
1759        return net;
1760    };
1761    Ipv4Net::new(addr, v6.prefix_len() - 96).map_or(net, IpNet::V4)
1762}
1763
1764fn write_with_port(f: &mut fmt::Formatter<'_>, base: &str, port: Option<u16>) -> fmt::Result {
1765    match port {
1766        Some(port) => write!(f, "{base}:{port}"),
1767        None => f.write_str(base),
1768    }
1769}