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