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