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 depends on the list it is
10//! written in: it is the store's own reading, except where a cell says the two differ:
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 | **rejected**: GLib reads neither and compares the token to a host so named |
22//! | an entry ending in a dot (`example.com.`) | the dot is the DNS root and comes off; KDE: **rejected**; KIO compares the text | **rejected** | the dot comes off, as CFNetwork reads it | **rejected** |
23//! | a trailing dot on the destination (`example.com.`) | shed, so `example.com` matches it; KDE: kept, so it does not | kept | shed | kept |
24//! | no entry at all | `localhost`, `127.0.0.0/8`, `::1` and `*.localhost` go direct, link-local does not; KDE: nothing does | `localhost`, `loopback`, `127.0.0.1`, `::1` and link-local go direct | `localhost`, `127.0.0.1` and `::1` go direct while the dictionary has a bypass key; with none, nothing does | nothing goes direct |
25//! | a space at either end | trimmed | trimmed | **kept**, and no host carries it, so the entry is rejected | trailing trimmed, leading kept and rejected |
26//!
27//! A rejected entry lands in
28//! [`BypassRules::rejected`](crate::BypassRules::rejected) with the reason, so a rule that
29//! does nothing is not mistaken for a live one.
30//!
31//! Windows readers differ on entries starting with `.`: WinINet refuses the whole list,
32//! while WinHTTP and the registry keep it but proxy the subdomains. Write `*.name`.
33//! A `/` makes the registry reading send every destination direct. Both spellings are
34//! rejected here while neighboring entries stay live.
35//!
36//! A bare name changes meaning between the first column
37//! and the next two, so `contoso.com` in `no_proxy` covers `api.contoso.com` and the same
38//! text in `ProxyOverride` does not. And `<local>` / `<-loopback>` are read out of every
39//! list but GNOME's so the sources share one vocabulary, while WinINet is the only resolver
40//! that acts on both and KDE's KIO acts on `<local>` alone: a macOS store spelling one gets
41//! a bypass from this crate and none from the machine.
42//! `the_local_token_bypasses_nothing_here` in `tests/mac_exceptions_list.rs` measures that.
43//! A GNOME entry spelling one is rejected, because GLib reads it as a host name.
44//!
45//! The last row is [`BypassRules::implicit`](crate::BypassRules::implicit): the
46//! destinations each resolver sends direct when no entry names them. The Windows and macOS
47//! cells are measured against WinINet and CFNetwork; the GNOME and KDE cells are read off
48//! GLib's and KIO's matchers, which have no such set.
49
50use std::collections::HashMap;
51
52use crate::bypass::{BypassDialect, BypassRules, ImplicitBypass};
53use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
54use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme};
55use crate::error::Error;
56use crate::mode::ProxyMode;
57
58/// The port assumed when a Windows `ProxyServer` entry omits one.
59pub const WINDOWS_DEFAULT_PORT: u16 = 80;
60
61// Go's `httpproxy` and libproxy's `config-kde.c` split on `,` alone. KF5 KIO's `revmatch`
62// (`kprotocolmanager.cpp`) and Chromium's `proxy_config_service_linux.cc` also split at a
63// space, which KDE's dialog cannot write; a space-only list is one entry here, refused.
64// `;` is not a separator: every supplier's reader keeps a `;` list whole as one dead rule,
65// and splitting it would send direct hosts they all proxy.
66const LIST_SEPARATORS: [char; 1] = [','];
67
68// The same, plus whitespace, for the Windows bypass list only.
69//
70// WinHTTP documents `lpszProxyBypass` as "one or more server names separated by
71// semicolons or whitespace" (`WINHTTP_PROXY_INFO`), and Chromium reads the string
72// `WinHttpGetIEProxyConfigForCurrentUser` returns with
73// `base::StringTokenizer(proxy_bypass, ";, \t\n\r")`
74// (`ProxyConfigServiceWin::SetFromIEConfig`). Splitting on `;` and `,` alone would turn
75// `ProxyOverride = "*.corp.example intranet"` into one `Domain` pattern that no host can
76// match, with nothing in [`BypassRules::rejected`] to say so.
77//
78// [`no_proxy`] keeps the narrower set: Go's `httpproxy`, its reference, splits on `,`
79// alone. An entry that still contains whitespace there is caught by
80// `HostPattern::parse`, so it is recorded rather than dead.
81const WINDOWS_BYPASS_SEPARATORS: [char; 6] = [';', ',', ' ', '\t', '\r', '\n'];
82
83// Characters that separate entries in the Windows `ProxyServer` string.
84//
85// The same `WINHTTP_PROXY_INFO` page: "The proxy server list contains one or more of the
86// following strings separated by semicolons or whitespace." No `,`: that character is in
87// [`LIST_SEPARATORS`] because environment variables and GNOME put it there, and neither
88// writes this key. Named rather than spelled inline at the split so that the reason a
89// server list and a bypass list disagree about one character sits next to both.
90const WINDOWS_SERVER_SEPARATORS: [char; 5] = [';', ' ', '\t', '\r', '\n'];
91
92/// 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)).
93///
94/// Bare `host:port` → [`Scheme::All`]; or `http=`/`https=`/`ftp=`/`socks=`/`all=`.
95/// Missing port → the port of the token's own `scheme://` when it has one, else
96/// [`WINDOWS_DEFAULT_PORT`] (1080 for bare `socks=`). Empty scheme →
97/// [`ProxyEntry::Disabled`]. Bad tokens skipped; recorded on [`windows_manual`]. A scheme
98/// named twice keeps the last token that was not skipped, so a bad last spelling leaves the
99/// earlier one standing. A `socks=` entry also fills every scheme the string left
100/// unset; the last two examples below are that rule and its limit. An `all=` entry or a
101/// bare `host:port` sets every scheme, so `socks=` fills none beside it (an empty `all=`
102/// included); a rejected `all=` sets nothing and leaves the gaps to `socks=`.
103///
104/// ```
105/// # use proxy_watch::{parse, ProxyScheme, Scheme};
106/// let map = parse::proxy_server("http=h:8080;https=h");
107/// assert_eq!(map[&Scheme::Http].endpoint().unwrap().port, 8080);
108/// assert_eq!(map[&Scheme::Https].endpoint().unwrap().port, 80);
109///
110/// let map = parse::proxy_server("socks=127.0.0.1:1080");
111/// assert_eq!(map[&Scheme::Http].endpoint().unwrap().authority(), "127.0.0.1:1080");
112/// assert_eq!(
113/// map[&Scheme::Http].endpoint().unwrap().scheme_hint,
114/// Some(ProxyScheme::Socks4)
115/// );
116/// let map = parse::proxy_server("http=h1:8080;socks=127.0.0.1:1080");
117/// assert_eq!(map[&Scheme::Http].endpoint().unwrap().authority(), "h1:8080");
118/// ```
119pub fn proxy_server(spec: &str) -> HashMap<Scheme, ProxyEntry> {
120 proxy_server_with_rejected(spec).0
121}
122
123// [`proxy_server`] plus the redacted original text of every token it silently drops.
124//
125// This is what [`windows_manual`] calls instead of [`proxy_server`], so that the dropped
126// tokens end up recorded on the [`ProxyMode::Manual`](crate::ProxyMode::Manual) it
127// returns.
128fn proxy_server_with_rejected(spec: &str) -> (HashMap<Scheme, ProxyEntry>, Vec<RejectedValue>) {
129 let mut map = HashMap::new();
130 let mut rejected = Vec::new();
131 for token in spec.split(WINDOWS_SERVER_SEPARATORS) {
132 let token = token.trim();
133 if token.is_empty() {
134 continue;
135 }
136 match split_scheme_key(token) {
137 Some((key, value)) => {
138 let Some(scheme) = Scheme::from_name(key) else {
139 // Record unknown scheme keys so a dropped proxy cannot silently
140 // become `ProxyMode::Direct`.
141 crate::trace::warning!(
142 "skipping a ProxyServer token with an unrecognised scheme key"
143 );
144 rejected.push(RejectedValue::new(
145 RejectionKind::UnknownProxyScheme,
146 RejectionSource::ProxyServer,
147 token,
148 ));
149 continue;
150 };
151 if value.trim().is_empty() {
152 map.insert(scheme, ProxyEntry::Disabled);
153 } else {
154 let default_port = if scheme == Scheme::Socks {
155 ProxyScheme::Socks4.default_port()
156 } else {
157 WINDOWS_DEFAULT_PORT
158 };
159 match ProxyEndpoint::parse(value, default_port) {
160 Ok(mut endpoint) => {
161 // `socks=h:1080`, with no `scheme://` of its own, is read as
162 // SOCKS4 and not SOCKS4a; [`ProxyScheme`]'s own doc carries the
163 // Microsoft table that says so. Set here, not in
164 // `ProxyScheme::from_str`, which reads a *URI* scheme, where
165 // the same word means SOCKS5 instead.
166 if scheme == Scheme::Socks && endpoint.scheme_hint.is_none() {
167 endpoint.scheme_hint = Some(ProxyScheme::Socks4);
168 }
169 map.insert(scheme, ProxyEntry::Use(endpoint));
170 }
171 Err(err) => {
172 warn_dropped_proxy_server_token(&err);
173 // `socks=` losing its own scheme also loses the fallback
174 // `apply_socks_catch_all` would have run below: a valid SOCKS
175 // entry fills every scheme with no token of its own, so a
176 // malformed one drops exactly what that fallback would have
177 // covered, not just `socks://` itself.
178 let affected = if scheme == Scheme::Socks {
179 Scheme::All
180 } else {
181 scheme
182 };
183 rejected.push(
184 RejectedValue::new(
185 RejectionKind::InvalidProxyEndpoint,
186 RejectionSource::ProxyServer,
187 token,
188 )
189 .for_scheme(Some(affected)),
190 );
191 }
192 }
193 }
194 }
195 None => match ProxyEndpoint::parse(token, WINDOWS_DEFAULT_PORT) {
196 Ok(endpoint) => {
197 map.insert(Scheme::All, ProxyEntry::Use(endpoint));
198 }
199 Err(err) => {
200 warn_dropped_proxy_server_token(&err);
201 rejected.push(
202 RejectedValue::new(
203 RejectionKind::InvalidProxyEndpoint,
204 RejectionSource::ProxyServer,
205 token,
206 )
207 .for_scheme(Some(Scheme::All)),
208 );
209 }
210 },
211 }
212 }
213 apply_socks_catch_all(&mut map);
214 (map, rejected)
215}
216
217// See `proxy_server`: an explicit `all=` answer blocks the SOCKS fallback, including
218// a disabled entry. A rejected `all=` leaves a gap that SOCKS fills.
219fn apply_socks_catch_all(map: &mut HashMap<Scheme, ProxyEntry>) {
220 if map.contains_key(&Scheme::All) {
221 return;
222 }
223 let Some(ProxyEntry::Use(endpoint)) = map.get(&Scheme::Socks) else {
224 return;
225 };
226 let endpoint = endpoint.clone();
227 for scheme in [Scheme::Http, Scheme::Https, Scheme::Ftp, Scheme::All] {
228 map.entry(scheme)
229 .or_insert_with(|| ProxyEntry::Use(endpoint.clone()));
230 }
231}
232
233// Log-only sink for a `ProxyServer` token [`proxy_server`] could not parse.
234//
235// `err` is only read inside the `WARN` line below, so without the `tracing` feature it
236// would otherwise be flagged unused.
237#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
238fn warn_dropped_proxy_server_token(err: &Error) {
239 crate::trace::warning!(
240 error = %crate::trace::SafeError(err),
241 "skipping an unparseable ProxyServer token"
242 );
243}
244
245// Split `http=host:port` into `("http", "host:port")`. Only an `=` before the first `:`,
246// `/`, `?` or `#` separates a key, so a password's or query's `=` stays in the endpoint
247// text for `ProxyEndpoint::parse`, which cuts the authority at the same characters. A user
248// name's `=` comes before its `:` and does separate one: `u=x:pw@h` is the unknown scheme
249// `u`, and is rejected.
250fn split_scheme_key(token: &str) -> Option<(&str, &str)> {
251 let eq = token.find('=')?;
252 let boundary = token.find([':', '/', '?', '#']).unwrap_or(token.len());
253 if eq < boundary {
254 Some((&token[..eq], &token[eq + 1..]))
255 } else {
256 None
257 }
258}
259
260/// Parse a Windows `ProxyOverride` registry value into [`BypassRules`].
261///
262/// Like [`no_proxy`], but entries are separated by `;`, `,` or whitespace, as WinHTTP
263/// documents, and **a bare name matches that name alone** rather than the domain under it:
264/// `contoso.com` here does not bypass `api.contoso.com`. Write `*.contoso.com` for the
265/// subdomains, which is also what Windows asks for; `.contoso.com` is rejected rather than
266/// read, because no measured reader grants it. So is `contoso.com.`: the store compares a
267/// trailing `.` as written, so it would match only a destination spelled with one. A CIDR
268/// entry is rejected too, because
269/// Windows answers a `/` with the whole list: write the range as the wildcard Windows does
270/// read, `10.*`. `<local>` → [`HostPattern::Local`](crate::HostPattern::Local) and
271/// `<-loopback>` (IE9+) →
272/// [`HostPattern::SubtractImplicit`](crate::HostPattern::SubtractImplicit) are read by
273/// [`no_proxy`] as well as by this one, as Chromium reads them. Malformed entries skipped
274/// into [`BypassRules::rejected`].
275///
276/// An IPv4 address entry is read only as four decimal octets. WinINet and WinHTTP compare
277/// an address entry with the destination as text, so `012.1.2.3` there is not `10.1.2.3` in
278/// either position; such an entry is rejected, where [`no_proxy`] reads it as the address a
279/// URL parser would. Limitation: every entry point reads the destination folded (a `Url` or
280/// a `Host` already holds `012.1.2.3` as `10.1.2.3`, and
281/// [`matches_authority`](crate::BypassRules::matches_authority) parses its text the same
282/// way), so an entry `10.1.2.3` or `10.*` reports a bypass for a destination written
283/// `012.1.2.3`, which Windows hands to the proxy.
284///
285/// The bare-name rule is measured against WinINet and WinHTTP. The reimplementations this
286/// crate reads alongside, Chromium and libproxy, answer the question differently, so
287/// neither could settle it. The readings are the rows of
288/// `a_bare_name_in_a_windows_list_is_the_one_host` in `tests/bypass.rs`.
289///
290/// The implicit set is measured the same way, and is [`ImplicitBypass::WinInet`]:
291/// `localhost`, `loopback`, `127.0.0.1`, `::1` and the link-local ranges. It excludes the
292/// rest of `127.0.0.0/8`, `*.localhost` and IPv4-mapped spellings. WinHTTP agrees on the
293/// link-local ranges and bypasses all of `127.0.0.0/8` even under `<-loopback>`. The store
294/// is WinINet's, so this follows WinINet, and a WinHTTP client is therefore told "proxy"
295/// for loopback it sends direct. The rows are
296/// `wininet_implicit_bypass_is_the_measured_six` in `tests/bypass.rs`.
297///
298/// ```
299/// # use proxy_watch::parse;
300/// let rules = parse::proxy_override("<local>;*.contoso.com;<-loopback>");
301/// assert!(rules.excludes_simple_hostnames());
302/// assert!(!rules.bypass_loopback());
303/// assert!(rules.matches_authority("www.contoso.com"));
304///
305/// // Whitespace separates too, so this is two rules and not one dead one.
306/// let rules = parse::proxy_override("*.contoso.com intranet");
307/// assert!(rules.matches_authority("www.contoso.com"));
308/// assert!(rules.matches_authority("intranet"));
309///
310/// // A bare name is the host itself. The same text in `no_proxy` takes the subdomains.
311/// let rules = parse::proxy_override("contoso.com");
312/// assert!(rules.matches_authority("contoso.com"));
313/// assert!(!rules.matches_authority("api.contoso.com"));
314/// assert!(parse::no_proxy("contoso.com").matches_authority("api.contoso.com"));
315///
316/// // A `/` is recorded, not read. `no_proxy` takes the same text as a mask.
317/// let rules = parse::proxy_override("10.0.0.0/8");
318/// assert!(!rules.matches_authority("10.1.2.3"));
319/// assert_eq!(rules.rejected.len(), 1);
320/// assert!(parse::no_proxy("10.0.0.0/8").matches_authority("10.1.2.3"));
321/// ```
322pub fn proxy_override(spec: &str) -> BypassRules {
323 bypass_entries_in(
324 spec.split(WINDOWS_BYPASS_SEPARATORS.as_slice()),
325 BypassDialect::Windows,
326 )
327}
328
329/// Parse `no_proxy` into [`BypassRules`]: `*`, CIDR, domains, `:port`, and a localhost
330/// bypass that stays on unless an entry clears it. Entries are separated by `,` alone, not
331/// whitespace and not `;`, which is what Go's `httpproxy` does and what KDE's readers do
332/// with `NoProxyFor`. Malformed → [`BypassRules::rejected`]. An entry
333/// that repeats one already in [`BypassRules::patterns`] is dropped, first spelling kept,
334/// so the list can be shorter than the string had entries.
335///
336/// Go's `httpproxy` uses the same separator, but this crate's matching follows
337/// Chromium's `ProxyHostMatchingRules` where the two readers differ.
338///
339/// The Windows tokens `<local>` and `<-loopback>` are read here too, which Go does not do;
340/// Chromium feeds the `no_proxy` environment variable through the same
341/// `ProxyHostMatchingRules::ParseFromString` as any other bypass list, and that is where
342/// the tokens are recognised. See [`proxy_override`], which differs in its separators and
343/// in reading a bare name as the one host rather than as the domain under it.
344///
345/// Two further departures from Go follow from reading `*` as Chromium's glob rather than
346/// Go's optional prefix. A star is matched against the destination's text, so `*.*.*.1`
347/// bypasses `10.0.0.1`, where Go answers no domain entry at all against an address
348/// destination (`httpproxy/proxy.go:367-369`). An entry whose body ends in a label that
349/// reads as a number (`.example.123`) goes to [`BypassRules::rejected`]. This is
350/// unreachable for the special schemes these lists are written about, but a
351/// `custom://a.example.123/` it would have matched is proxied instead. That URL matters
352/// because `resolve` sends an unknown scheme to the catch-all entry rather than declining
353/// it.
354///
355/// An IPv4 address written other than as four decimal octets is read the URL way, as
356/// Chromium's `IPAddress::AssignFromIPLiteral` reads it, in an address entry and in a CIDR
357/// block whose address has four parts: `012.1.2.3` is `10.1.2.3` and `012.1.2.0/24` is
358/// `10.1.2.0/24`. A block with a short-form address (`192.168.1/24`) is an error. Limitation: curl and Go read neither
359/// as an address, so under them the entry matches nothing, and this answers `Direct` for a
360/// destination those two proxy.
361///
362/// ```
363/// # use proxy_watch::parse;
364/// let rules = parse::no_proxy("<local>,<-loopback>");
365/// assert!(rules.excludes_simple_hostnames());
366/// assert!(!rules.bypass_loopback());
367/// ```
368pub fn no_proxy(spec: &str) -> BypassRules {
369 bypass_entries_in(
370 spec.split(LIST_SEPARATORS.as_slice()),
371 BypassDialect::Suffix,
372 )
373}
374
375// KDE's `NoProxyFor` (and the variable it names under `ProxyType = 4`): `no_proxy`'s
376// separators and grammar, KIO's comparison.
377#[cfg_attr(not(all(target_os = "linux", feature = "linux-kde")), allow(dead_code))]
378pub(crate) fn kde_no_proxy_for(spec: &str) -> BypassRules {
379 bypass_entries_in(spec.split(LIST_SEPARATORS.as_slice()), BypassDialect::Kde)
380}
381
382// Fold a list's entries into one rule set, each read in its own dialect.
383//
384// Takes an iterator rather than a string because not every source is one: GNOME's
385// `ignore-hosts` arrives as a GSettings `as`, where the array *is* the delimiter and no
386// character in an element is one.
387pub(crate) fn bypass_entries_in<'a>(
388 entries: impl Iterator<Item = &'a str>,
389 dialect: BypassDialect,
390) -> BypassRules {
391 let mut rules = BypassRules::new();
392 // Each store's resolver has its own implicit set; `Suffix` is the environment variable's
393 // and KDE's dialect, and KDE's reader sets its own after this returns.
394 rules.implicit = match dialect {
395 BypassDialect::Suffix => ImplicitBypass::Broad,
396 BypassDialect::Windows => ImplicitBypass::WinInet,
397 BypassDialect::MacOs => ImplicitBypass::CfNetwork,
398 BypassDialect::Gnome | BypassDialect::Kde => ImplicitBypass::Empty,
399 };
400 // Only the environment variable's readers shed a destination's trailing dot and read
401 // an IPv4-mapped destination as the address it maps; every store's resolver compares
402 // what was written.
403 rules.strip_trailing_dot = dialect == BypassDialect::Suffix;
404 rules.ipv4_mapped_as_ipv4 = dialect == BypassDialect::Suffix;
405 if dialect == BypassDialect::Gnome {
406 rules.push_gnome_entries(entries);
407 rules.dedup_patterns();
408 return rules;
409 }
410 for entry in entries {
411 // Trimmed here and not left to `HostPattern::parse_in`, which trims only what it
412 // parses: `push_entry_in` puts the string it was given into `BypassRules::rejected`,
413 // and an entry quoted back with the separator's whitespace still on it is not the
414 // entry anyone wrote.
415 rules.push_entry_in(dialect.trim(entry), dialect);
416 }
417 rules.dedup_patterns();
418 rules
419}
420
421/// Assemble a [`ProxyMode::Manual`] from the two Windows registry strings.
422///
423/// [`proxy_server`] + [`proxy_override`]; the returned manual mode records dropped server
424/// tokens. Empty server, no rejects →
425/// [`ProxyMode::Direct`]; reject-only stays `Manual` so rejects are not lost.
426///
427/// ```
428/// # use proxy_watch::parse;
429/// let mode = parse::windows_manual("http=h:8080;https=h:-1", "");
430/// assert_eq!(mode.rejected().unwrap()[0].redacted_input(), "https=h:-1");
431/// // The well formed `http=` entry survives alongside the dropped `https=` one.
432/// assert!(mode.endpoint_for(proxy_watch::Scheme::Http).is_some());
433/// ```
434pub fn windows_manual(server: &str, override_list: &str) -> ProxyMode {
435 let (per_scheme, rejected) = proxy_server_with_rejected(server);
436 if per_scheme.is_empty() && rejected.is_empty() {
437 return ProxyMode::Direct;
438 }
439 ProxyMode::manual(per_scheme, proxy_override(override_list)).with_rejected(rejected)
440}