proxy_watch/parse.rs
1//! Pure parsers for OS proxy string formats.
2//!
3//! List parsers drop malformed *elements* and keep the rest. Dropping a scheme endpoint
4//! without recording it would be fail-open; bypass drops are fail-closed → `rejected`.
5//!
6//! # Writing a bypass list
7//!
8//! Every store has one, no two of them read it the same way, and the differences decide
9//! which hosts go direct. What an entry means to this crate, by the list it is written in
10//! — the store's own reading, except where a cell says the two part company:
11//!
12//! | Entry | `no_proxy`, KDE `NoProxyFor` | Windows `ProxyOverride` | macOS `ExceptionsList` | GNOME `ignore-hosts` |
13//! |---|---|---|---|---|
14//! | separator between entries | `,` alone — a `;` leaves the two names one dead rule, and an entry still holding a space is rejected | `;`, `,` or whitespace | the array is the separator | the array is the separator |
15//! | `example.com` | that host **and everything under it** | that host alone | that host alone | that host **and everything under it** |
16//! | `.example.com`, `*.example.com` | the subdomains, not `example.com` itself | `*.example.com` is the subdomains; `.example.com` is **rejected** — Windows has no reading for a leading `.` (below) | the subdomains | the same rule as the bare name: the domain *and* its subdomains |
17//! | a `*` anywhere else (`192.168.*`) | a glob here, which is Chromium's reading; Go and libproxy take it as literal text no host carries | a glob | syntax only as a trailing `.*`, which misses the bare host a Mac's own `name.*` still reaches; anything else is rejected | never syntax; rejected |
18//! | a bare `*` | every destination direct | every destination direct | rejected — it matches no host on a Mac | rejected, same reason |
19//! | `10.0.0.0/8` | the network | **rejected** — Windows has no `/` in this grammar, and one entry holding it turns the proxy off for every destination (below); write the range as `10.*` | the network | the network |
20//! | `example.com:8080` | holds the entry to that port | holds it | **rejected** — macOS compares the whole entry to the host name, so a port kills it | holds it, but a portless `http://` URL is asked about with port 0 and will not meet it |
21//! | `<local>`, `<-loopback>` | read | read | read | read |
22//! | a space at either end | trimmed | trimmed | **kept**, and no host carries it, so the entry is rejected | trailing trimmed, leading kept and rejected |
23//!
24//! A rejected entry is not dropped in silence: it lands in
25//! [`BypassRules::rejected`](crate::BypassRules::rejected) with the reason, so a rule that
26//! does nothing reads as doing nothing rather than as live.
27//!
28//! Two spellings cost a Windows list more than themselves, and both are refused here rather
29//! than read. An entry starting with `.` is granted nothing by any reader measured: WinINet
30//! refuses the whole list over one, while WinHTTP and the registry reading keep the list and
31//! send the subdomains to the proxy regardless — `*.name` is the spelling to write. An entry
32//! holding a `/`, a CIDR block included, goes further still: the registry reading stops
33//! using the proxy at all and every destination goes direct. Both land in `rejected` with
34//! the reason while the entries beside them stay live — which is narrower than what the
35//! machine does with the same list, and as wide a claim as the readings support.
36//!
37//! Two rows are worth stating flat. A bare name changes meaning between the first column
38//! and the next two, so `contoso.com` in `no_proxy` covers `api.contoso.com` and the same
39//! text in `ProxyOverride` does not. And `<local>` / `<-loopback>` are read out of every
40//! list here so the sources share one vocabulary, while only Windows' own resolver acts on
41//! them: a macOS or GNOME store spelling one gets a bypass from this crate and none from
42//! the machine. `the_local_token_bypasses_nothing_here` in `tests/mac_exceptions_list.rs`
43//! measures the macOS half; GNOME's is read off GLib, in `src/sys/linux/gsettings_map.rs`.
44
45use std::collections::HashMap;
46
47use crate::bypass::{BypassDialect, BypassRules};
48use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
49use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme};
50use crate::error::Error;
51use crate::mode::ProxyMode;
52
53/// The port assumed when a Windows `ProxyServer` entry omits one.
54pub const WINDOWS_DEFAULT_PORT: u16 = 80;
55
56// Characters that separate entries in a bypass list.
57//
58// [`no_proxy`] is the only splitter that takes these, so its sources are the ones that
59// matter: the environment variable, where Go's `httpproxy` splits on `,` alone, and KDE's
60// `NoProxyFor`, where every implementation that reads the file does the same —
61// `g_strsplit (value->str, ",", -1)` in libproxy's `config-kde.c`, and a tokenizer over
62// `", "` in Chromium's `proxy_config_service_linux.cc`. Windows takes the wider
63// [`WINDOWS_BYPASS_SEPARATORS`], and GNOME's array never reaches here at all
64// (`sys::linux::gsettings_map::bypass_from_settings`).
65//
66// `;` is not here, though neither character can occur inside a host name and taking both
67// would be the safe-looking superset. That reading is beside the point: a `;`-separated
68// list is not two rules anywhere else, it is one rule that matches nothing. Splitting it
69// sends a pair of hosts direct that every supplier's own reader puts through the proxy,
70// which is the one direction this crate must not invent. Kept whole it is the same dead rule
71// everyone else is holding — no record, because `;` is a character
72// [`crate::endpoint::parse_host`] takes and the entry is only unmatchable, not malformed.
73const LIST_SEPARATORS: [char; 1] = [','];
74
75// The same, plus whitespace, for the Windows bypass list only.
76//
77// WinHTTP documents `lpszProxyBypass` as "one or more server names separated by
78// semicolons or whitespace" (`WINHTTP_PROXY_INFO`), and Chromium reads the string
79// `WinHttpGetIEProxyConfigForCurrentUser` returns with
80// `base::StringTokenizer(proxy_bypass, ";, \t\n\r")`
81// (`ProxyConfigServiceWin::SetFromIEConfig`). Splitting on `;` and `,` alone turned
82// `ProxyOverride = "*.corp.example intranet"` into one `Domain` pattern that no host can
83// match, with nothing in [`BypassRules::rejected`] to say so.
84//
85// [`no_proxy`] keeps the narrower set: Go's `httpproxy`, its reference, splits on `,`
86// alone. An entry that still contains whitespace there is caught by
87// `HostPattern::parse`, so it is recorded rather than dead.
88const WINDOWS_BYPASS_SEPARATORS: [char; 6] = [';', ',', ' ', '\t', '\r', '\n'];
89
90// Characters that separate entries in the Windows `ProxyServer` string.
91//
92// The same `WINHTTP_PROXY_INFO` page: "The proxy server list contains one or more of the
93// following strings separated by semicolons or whitespace." No `,` — that character is in
94// [`LIST_SEPARATORS`] because environment variables and GNOME put it there, and neither
95// writes this key. Named rather than spelled inline at the split so that the reason a
96// server list and a bypass list disagree about one character sits next to both.
97const WINDOWS_SERVER_SEPARATORS: [char; 5] = [';', ' ', '\t', '\r', '\n'];
98
99/// Parse Windows `ProxyServer` ([`WINHTTP_CURRENT_USER_IE_PROXY_CONFIG`](https://learn.microsoft.com/en-us/windows/win32/api/winhttp/ns-winhttp-winhttp_current_user_ie_proxy_config)).
100///
101/// Bare `host:port` → [`Scheme::All`]; or `http=`/`https=`/`ftp=`/`socks=`/`all=`.
102/// Missing port → [`WINDOWS_DEFAULT_PORT`] (1080 for bare `socks=`). Empty scheme →
103/// [`ProxyEntry::Disabled`]. Bad tokens skipped; recorded on [`windows_manual`]. A scheme
104/// named twice keeps the last token that was not skipped, so a bad last spelling leaves the
105/// earlier one standing. A `socks=` entry also fills every scheme the string left
106/// unset — the last two examples below are that rule and its limit.
107///
108/// ```
109/// # use proxy_watch::{parse, ProxyScheme, Scheme};
110/// let map = parse::proxy_server("http=h:8080;https=h");
111/// assert_eq!(map[&Scheme::Http].endpoint().unwrap().port, 8080);
112/// assert_eq!(map[&Scheme::Https].endpoint().unwrap().port, 80);
113///
114/// let map = parse::proxy_server("socks=127.0.0.1:1080");
115/// assert_eq!(map[&Scheme::Http].endpoint().unwrap().authority(), "127.0.0.1:1080");
116/// assert_eq!(
117/// map[&Scheme::Http].endpoint().unwrap().scheme_hint,
118/// Some(ProxyScheme::Socks4)
119/// );
120/// let map = parse::proxy_server("http=h1:8080;socks=127.0.0.1:1080");
121/// assert_eq!(map[&Scheme::Http].endpoint().unwrap().authority(), "h1:8080");
122/// ```
123pub fn proxy_server(spec: &str) -> HashMap<Scheme, ProxyEntry> {
124 proxy_server_with_rejected(spec).0
125}
126
127// [`proxy_server`] plus the redacted original text of every token it silently drops.
128//
129// This is what [`windows_manual`] calls instead of [`proxy_server`], so that the dropped
130// tokens end up recorded on the [`ProxyMode::Manual`](crate::ProxyMode::Manual) it
131// returns.
132fn proxy_server_with_rejected(spec: &str) -> (HashMap<Scheme, ProxyEntry>, Vec<RejectedValue>) {
133 let mut map = HashMap::new();
134 let mut rejected = Vec::new();
135 for token in spec.split(WINDOWS_SERVER_SEPARATORS) {
136 let token = token.trim();
137 if token.is_empty() {
138 continue;
139 }
140 match split_scheme_key(token) {
141 Some((key, value)) => {
142 let Some(scheme) = Scheme::from_name(key) else {
143 // The module doc's rule, applied to a drop that is easy to miss: a
144 // scheme endpoint that goes missing without a record is fail-open.
145 // `gopher=proxy:80` on an old machine, or a plain typo like `htttp=`,
146 // otherwise leaves `windows_manual` returning `ProxyMode::Direct` with
147 // nothing anywhere to say a proxy had been configured at all.
148 crate::trace::warning!(
149 "skipping a ProxyServer token with an unrecognised scheme key"
150 );
151 rejected.push(RejectedValue::new(
152 RejectionKind::UnknownProxyScheme,
153 RejectionSource::ProxyServer,
154 token,
155 ));
156 continue;
157 };
158 if value.trim().is_empty() {
159 map.insert(scheme, ProxyEntry::Disabled);
160 } else {
161 let default_port = if scheme == Scheme::Socks {
162 ProxyScheme::Socks4.default_port()
163 } else {
164 WINDOWS_DEFAULT_PORT
165 };
166 match ProxyEndpoint::parse(value, default_port) {
167 Ok(mut endpoint) => {
168 // `socks=h:1080`, with no `scheme://` of its own, is read as
169 // SOCKS4 and not SOCKS4a; [`ProxyScheme`]'s own doc carries the
170 // Microsoft table that says so. Set here, not in
171 // `ProxyScheme::from_str`, which reads a *URI* scheme — where
172 // the same word means SOCKS5 instead.
173 if scheme == Scheme::Socks && endpoint.scheme_hint.is_none() {
174 endpoint.scheme_hint = Some(ProxyScheme::Socks4);
175 }
176 map.insert(scheme, ProxyEntry::Use(endpoint));
177 }
178 Err(err) => {
179 warn_dropped_proxy_server_token(&err);
180 // `socks=` losing its own scheme also loses the fallback
181 // `apply_socks_catch_all` would have run below: a valid SOCKS
182 // entry fills every scheme with no token of its own, so a
183 // malformed one drops exactly what that fallback would have
184 // covered, not just `socks://` itself.
185 let affected = if scheme == Scheme::Socks {
186 Scheme::All
187 } else {
188 scheme
189 };
190 rejected.push(
191 RejectedValue::new(
192 RejectionKind::InvalidProxyEndpoint,
193 RejectionSource::ProxyServer,
194 token,
195 )
196 .for_scheme(Some(affected)),
197 );
198 }
199 }
200 }
201 }
202 None => match ProxyEndpoint::parse(token, WINDOWS_DEFAULT_PORT) {
203 Ok(endpoint) => {
204 map.insert(Scheme::All, ProxyEntry::Use(endpoint));
205 }
206 Err(err) => {
207 warn_dropped_proxy_server_token(&err);
208 rejected.push(
209 RejectedValue::new(
210 RejectionKind::InvalidProxyEndpoint,
211 RejectionSource::ProxyServer,
212 token,
213 )
214 .for_scheme(Some(Scheme::All)),
215 );
216 }
217 },
218 }
219 }
220 apply_socks_catch_all(&mut map);
221 (map, rejected)
222}
223
224// The `socks=` → "everything else" fallback described on [`proxy_server`]'s doc comment:
225// fills [`Scheme::Http`]/[`Scheme::Https`]/[`Scheme::Ftp`] gaps, and [`Scheme::All`],
226// from [`Scheme::Socks`] once every token has been read — unless the string already
227// answered for [`Scheme::All`] itself, in either of the writings that reach the map: an
228// address (a bare `host:port`, or an `all=host:port`) or an `all=` that disabled it. The
229// second covers nothing, so the guard is not "already covered" but "already answered".
230// It reads the map rather than the string, so naming `all` is not by itself the answer:
231// an `all=` whose address failed to parse left a `rejected` record and no entry, and the
232// gap it leaves is filled from `socks=` like any other — the fail-open drop the module
233// doc sets out for every scheme key, not a special case for this one.
234fn apply_socks_catch_all(map: &mut HashMap<Scheme, ProxyEntry>) {
235 if map.contains_key(&Scheme::All) {
236 return;
237 }
238 let Some(ProxyEntry::Use(endpoint)) = map.get(&Scheme::Socks) else {
239 return;
240 };
241 let endpoint = endpoint.clone();
242 // `Scheme::All` is known absent by the early return above, so its `or_insert_with`
243 // always fires; the other three fill only where nothing explicit was written.
244 for scheme in [Scheme::Http, Scheme::Https, Scheme::Ftp, Scheme::All] {
245 map.entry(scheme)
246 .or_insert_with(|| ProxyEntry::Use(endpoint.clone()));
247 }
248}
249
250// Log-only sink for a `ProxyServer` token [`proxy_server`] could not parse.
251//
252// `err` is only read inside the `WARN` line below, so without the `tracing` feature it
253// would otherwise be flagged unused.
254#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
255fn warn_dropped_proxy_server_token(err: &Error) {
256 crate::trace::warning!(
257 error = %crate::trace::SafeError(err),
258 "skipping an unparseable ProxyServer token"
259 );
260}
261
262// Split `http=host:port` into `("http", "host:port")`.
263//
264// Returns `None` when the token has no scheme key: only an `=` that appears before the first
265// `:`, `/`, `?` or `#` counts. The `:` is what keeps a percent-free password's `=` and a URL
266// query's from being read as a key separator — a URL names a scheme or a port, so it carries
267// a colon ahead of its path either way. The other three cover the shape that has no colon at
268// all: `proxy.corp/path?a=b` and `proxy.corp?a=b` are neither a URL nor a `host:port`, and
269// without them the token is recorded as an unknown scheme key named `proxy.corp/path?a` or
270// `proxy.corp?a`, a drop naming a scheme the token never had. With them the token reaches
271// `ProxyEndpoint::parse`, which cuts the authority at those same three characters — the
272// boundary set is that one, not a shorter guess at it.
273fn split_scheme_key(token: &str) -> Option<(&str, &str)> {
274 let eq = token.find('=')?;
275 let boundary = token.find([':', '/', '?', '#']).unwrap_or(token.len());
276 if eq < boundary {
277 Some((&token[..eq], &token[eq + 1..]))
278 } else {
279 None
280 }
281}
282
283/// Parse a Windows `ProxyOverride` registry value into [`BypassRules`].
284///
285/// Like [`no_proxy`], but entries are separated by `;`, `,` or whitespace, as WinHTTP
286/// documents, and **a bare name matches that name alone** rather than the domain under
287/// it: `contoso.com` here does not bypass `api.contoso.com`. Write `*.contoso.com` for
288/// the subdomains, which is also what Windows asks for — `.contoso.com` is rejected rather
289/// than read, because no measured reader grants it. A CIDR entry is rejected too, because
290/// Windows answers a `/` with the whole list: write the range as the wildcard Windows does
291/// read, `10.*`. `<local>` →
292/// [`HostPattern::Local`](crate::HostPattern::Local) and `<-loopback>` (IE9+) →
293/// [`HostPattern::SubtractImplicit`](crate::HostPattern::SubtractImplicit) are read by
294/// [`no_proxy`] as well as by this one, the
295/// way Chromium reads them ("we allow it on all platforms and interpret it the same way",
296/// `proxy_host_matching_rules.cc:113`). Malformed entries skipped into
297/// [`BypassRules::rejected`].
298///
299/// The bare-name rule is measured, not inherited: WinINet and WinHTTP were each handed a
300/// bypass list and a destination and asked where they connected. Both reimplementations
301/// this crate reads alongside — Chromium and libproxy — answer the question, and they
302/// answer it differently, so neither could settle it. The readings are the rows of
303/// `a_bare_name_in_a_windows_list_is_the_one_host` in `tests/bypass.rs`.
304///
305/// ```
306/// # use proxy_watch::parse;
307/// let rules = parse::proxy_override("<local>;*.contoso.com;<-loopback>");
308/// assert!(rules.excludes_simple_hostnames());
309/// assert!(!rules.bypass_loopback());
310/// assert!(rules.matches_authority("www.contoso.com"));
311///
312/// // Whitespace separates too, so this is two rules and not one dead one.
313/// let rules = parse::proxy_override("*.contoso.com intranet");
314/// assert!(rules.matches_authority("www.contoso.com"));
315/// assert!(rules.matches_authority("intranet"));
316///
317/// // A bare name is the host itself. The same text in `no_proxy` takes the subdomains.
318/// let rules = parse::proxy_override("contoso.com");
319/// assert!(rules.matches_authority("contoso.com"));
320/// assert!(!rules.matches_authority("api.contoso.com"));
321/// assert!(parse::no_proxy("contoso.com").matches_authority("api.contoso.com"));
322///
323/// // A `/` is recorded, not read. `no_proxy` takes the same text as a mask.
324/// let rules = parse::proxy_override("10.0.0.0/8");
325/// assert!(!rules.matches_authority("10.1.2.3"));
326/// assert_eq!(rules.rejected.len(), 1);
327/// assert!(parse::no_proxy("10.0.0.0/8").matches_authority("10.1.2.3"));
328/// ```
329pub fn proxy_override(spec: &str) -> BypassRules {
330 bypass_entries_in(
331 spec.split(WINDOWS_BYPASS_SEPARATORS.as_slice()),
332 BypassDialect::Windows,
333 )
334}
335
336/// Parse `no_proxy` into [`BypassRules`]: `*`, CIDR, domains, `:port`, and a localhost
337/// bypass that stays on unless an entry clears it. Entries are separated by `,` alone — not
338/// whitespace and not `;`, which is what Go's `httpproxy` does and what KDE's readers do
339/// with `NoProxyFor`. Malformed → [`BypassRules::rejected`]. An entry
340/// that repeats one already in [`BypassRules::patterns`] is dropped, first spelling kept,
341/// so the list can be shorter than the string had entries.
342///
343/// Go's `httpproxy` is the nearest relative and the separator above is its rule, but this
344/// does not implement it: what an entry *matches* follows Chromium's
345/// `ProxyHostMatchingRules` wherever the readers answer differently, and the rest of this
346/// doc is where they do. None of it is a corner case.
347///
348/// The Windows tokens `<local>` and `<-loopback>` are read here too, which Go does not
349/// do — Chromium feeds the `no_proxy` environment variable through the same
350/// `ProxyHostMatchingRules::ParseFromString` as any other bypass list, and that is where the
351/// tokens are recognised. See [`proxy_override`], which differs in its separators and in
352/// reading a bare name as the one host rather than as the domain under it.
353///
354/// Two further departures from Go, both downstream of reading `*` as Chromium's glob
355/// rather than as Go's optional prefix. A star is matched against the destination's text,
356/// so `*.*.*.1` bypasses `10.0.0.1`, where Go answers no domain entry at all against an
357/// address destination (`httpproxy/proxy.go:367-369`). And an entry whose body ends in a
358/// label that reads as a number — `.example.123` — goes to [`BypassRules::rejected`]
359/// rather than being kept: unreachable for the *special* schemes these lists are written
360/// about, but a `custom://a.example.123/` it would have matched is proxied instead — and
361/// that URL is one this crate answers about, since `resolve` (the `resolve` feature's entry
362/// point) sends an unknown scheme to the catch-all entry rather than declining it.
363///
364/// ```
365/// # use proxy_watch::parse;
366/// let rules = parse::no_proxy("<local>,<-loopback>");
367/// assert!(rules.excludes_simple_hostnames());
368/// assert!(!rules.bypass_loopback());
369/// ```
370pub fn no_proxy(spec: &str) -> BypassRules {
371 bypass_entries_in(
372 spec.split(LIST_SEPARATORS.as_slice()),
373 BypassDialect::Suffix,
374 )
375}
376
377// Fold a list's entries into one rule set, each read in its own dialect.
378//
379// Takes an iterator rather than a string because not every source is one: GNOME's
380// `ignore-hosts` arrives as a GSettings `as`, where the array *is* the delimiter and no
381// character in an element is one.
382pub(crate) fn bypass_entries_in<'a>(
383 entries: impl Iterator<Item = &'a str>,
384 dialect: BypassDialect,
385) -> BypassRules {
386 let mut rules = BypassRules::new();
387 for entry in entries {
388 // Trimmed here and not left to `HostPattern::parse_in`, which trims only what it
389 // parses: `push_entry_in` puts the string it was given into `BypassRules::rejected`,
390 // and an entry quoted back with the separator's whitespace still on it is not the
391 // entry anyone wrote.
392 rules.push_entry_in(dialect.trim(entry), dialect);
393 }
394 rules.dedup_patterns();
395 rules
396}
397
398/// Assemble a [`ProxyMode::Manual`] from the two Windows registry strings.
399///
400/// [`proxy_server`] + [`proxy_override`]; the returned manual mode records dropped server
401/// tokens. Empty server, no rejects →
402/// [`ProxyMode::Direct`]; reject-only stays `Manual` so rejects are not lost.
403///
404/// ```
405/// # use proxy_watch::parse;
406/// let mode = parse::windows_manual("http=h:8080;https=h:-1", "");
407/// assert_eq!(mode.rejected().unwrap()[0].redacted_input(), "https=h:-1");
408/// // The well formed `http=` entry survives alongside the dropped `https=` one.
409/// assert!(mode.endpoint_for(proxy_watch::Scheme::Http).is_some());
410/// ```
411pub fn windows_manual(server: &str, override_list: &str) -> ProxyMode {
412 let (per_scheme, rejected) = proxy_server_with_rejected(server);
413 if per_scheme.is_empty() && rejected.is_empty() {
414 return ProxyMode::Direct;
415 }
416 ProxyMode::manual(per_scheme, proxy_override(override_list)).with_rejected(rejected)
417}