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