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