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