proxy_watch/resolve.rs
1//! Turning a snapshot into a routing decision: [`resolve`] and [`ProxyStep`].
2//!
3//! Everything in this module lives behind the `resolve` feature (enabled by default).
4
5use url::Url;
6
7use crate::config::ProxyConfig;
8use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme, has_request_host};
9use crate::error::Error;
10use crate::mode::ProxyMode;
11
12/// One way to reach a destination ([`resolve`]).
13///
14/// Names the protocol **to the proxy**; bare `host:port` → [`ProxyStep::Http`].
15/// `Socks4a`/`Socks5h` collapse to [`Socks4`](ProxyStep::Socks4)/[`Socks5`](ProxyStep::Socks5);
16/// [`to_url`](Self::to_url) keeps `socks4a`/`socks5h`, [`scheme`](Self::scheme) does not.
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum ProxyStep {
20 /// Connect directly.
21 Direct,
22 /// Plain HTTP proxy (absolute-form requests, `CONNECT` for TLS).
23 Http(ProxyEndpoint),
24 /// HTTP proxy over TLS.
25 Https(ProxyEndpoint),
26 /// SOCKS4 proxy.
27 Socks4(ProxyEndpoint),
28 /// SOCKS5 proxy.
29 Socks5(ProxyEndpoint),
30}
31
32impl ProxyStep {
33 /// Whether the step means "no proxy".
34 #[must_use]
35 pub fn is_direct(&self) -> bool {
36 matches!(self, ProxyStep::Direct)
37 }
38
39 /// The proxy endpoint, or `None` for [`ProxyStep::Direct`].
40 #[must_use]
41 pub fn endpoint(&self) -> Option<&ProxyEndpoint> {
42 match self {
43 ProxyStep::Direct => None,
44 ProxyStep::Http(endpoint)
45 | ProxyStep::Https(endpoint)
46 | ProxyStep::Socks4(endpoint)
47 | ProxyStep::Socks5(endpoint) => Some(endpoint),
48 }
49 }
50
51 /// `"http"` / `"https"` / `"socks4"` / `"socks5"`, or `None` for [`Direct`](Self::Direct).
52 /// Never `socks4a`/`socks5h`: use [`to_url`](Self::to_url) / `endpoint().scheme_hint`.
53 #[must_use]
54 pub fn scheme(&self) -> Option<&'static str> {
55 match self {
56 ProxyStep::Direct => None,
57 ProxyStep::Http(_) => Some("http"),
58 ProxyStep::Https(_) => Some("https"),
59 ProxyStep::Socks4(_) => Some("socks4"),
60 ProxyStep::Socks5(_) => Some("socks5"),
61 }
62 }
63
64 /// Proxy URL for HTTP clients, or `None` for [`Direct`](Self::Direct).
65 ///
66 /// `None` also when the endpoint's host cannot be written into a URL. `Host::Domain`
67 /// wraps a `String` without validating it and `Host` is re-exported from the crate
68 /// root, so a hand-built endpoint can hold characters no URL host may carry; one from
69 /// [`ProxyEndpoint::parse`] cannot. Do not read `None` as "connect directly":
70 /// [`endpoint`](Self::endpoint) is what answers that question.
71 ///
72 /// Percent-encodes credentials (do not log). Keeps `socks4a`/`socks5h` from
73 /// [`ProxyEndpoint::scheme_hint`], unlike [`scheme`](Self::scheme). HTTP 407: client
74 /// job.
75 ///
76 /// ```
77 /// # use proxy_watch::{ProxyEndpoint, ProxyStep};
78 /// let e = ProxyEndpoint::parse("socks5h://proxy.example:1080", 80).unwrap();
79 /// assert_eq!(ProxyStep::Socks5(e).to_url().unwrap().scheme(), "socks5h");
80 /// assert!(ProxyStep::Direct.to_url().is_none());
81 /// ```
82 #[must_use]
83 pub fn to_url(&self) -> Option<Url> {
84 let endpoint = self.endpoint()?;
85 let scheme = self.url_scheme(endpoint);
86 // The host has to be readable back as one host. `Url::parse` alone refuses only
87 // text no URL can hold; a hand-built `Host::Domain` carrying `@`, `/`, `?` or `#`
88 // parses successfully into a *different* URL: `user@evil` splits at the `@` and
89 // leaves `evil` as the host. Naming another machine is worse than naming none.
90 crate::endpoint::parse_host(&endpoint.host.to_string()).ok()?;
91 let mut url = Url::parse(&format!("{scheme}://{}", endpoint.authority())).ok()?;
92 if let Some(auth) = &endpoint.auth {
93 url.set_username(auth.username()).ok()?;
94 if let Some(password) = auth.password() {
95 url.set_password(Some(password)).ok()?;
96 }
97 }
98 Some(url)
99 }
100
101 // The refinement only, over [`scheme`](Self::scheme), which is the same table without
102 // `socks4a`/`socks5h`, as that method's doc says. The hint refines the step rather than
103 // replacing it: a caller can build `ProxyStep::Socks5(e)` around an endpoint carrying
104 // any hint at all, and the variant is what the step means. `None` is `Direct`, which
105 // `to_url` returned on before calling this.
106 fn url_scheme(&self, endpoint: &ProxyEndpoint) -> &'static str {
107 match (self, endpoint.scheme_hint) {
108 (ProxyStep::Socks4(_), Some(ProxyScheme::Socks4a)) => "socks4a",
109 (ProxyStep::Socks5(_), Some(ProxyScheme::Socks5h)) => "socks5h",
110 _ => self.scheme().unwrap_or(""),
111 }
112 }
113
114 // The crate's one table from a scheme hint to a step. `pac::result` and
115 // `pac::winhttp` build their endpoint's hint from the keyword they just read and then
116 // come here, so the mapping is written once and stays exhaustive.
117 pub(crate) fn from_endpoint(endpoint: ProxyEndpoint) -> Self {
118 match endpoint.scheme_hint {
119 None | Some(ProxyScheme::Http) => ProxyStep::Http(endpoint),
120 Some(ProxyScheme::Https) => ProxyStep::Https(endpoint),
121 Some(ProxyScheme::Socks4 | ProxyScheme::Socks4a) => ProxyStep::Socks4(endpoint),
122 Some(ProxyScheme::Socks5 | ProxyScheme::Socks5h) => ProxyStep::Socks5(endpoint),
123 }
124 }
125}
126
127/// Decide how to reach `url` under `config`.
128///
129/// The result is never empty. Pac/WPAD returns [`Error::PacNotSupported`], including for
130/// hostless URLs, because this entry point cannot resolve any URL under an auto-config
131/// mode. `resolve_with_pac` and the native resolvers' `resolve_config` handle hostless URLs
132/// first because they can resolve the remaining URLs. Under a manual mode, hostless or
133/// bypassed URLs return Direct; otherwise resolution uses
134/// [`entry_for`](ProxyMode::entry_for), or [`websocket_entry`](ProxyMode::websocket_entry)
135/// for `ws`/`wss`.
136///
137/// The list transcribes what the configuration says; it never invents alternatives. A single
138/// manual entry yields a single step, and a versionless `socks` host is one step at this
139/// crate's assumed version rather than one step per version a caller might try.
140///
141/// A [`ProxyMode::Direct`] carrying
142/// [`ProxyConfigSource::Portal`](crate::ProxyConfigSource::Portal) reports that the portal
143/// has no proxy for a fixed probe host, not for `url`; see that variant's documentation for
144/// the reason. The snapshot does not retain the probe URL, so resolution cannot account for
145/// this difference.
146///
147/// # Errors
148///
149/// [`Error::PacNotSupported`] for auto-config modes, and
150/// [`Error::ProxyEntryUnusable`] where the only entry that would have covered `url` was
151/// dropped, reporting that is not the same as answering Direct.
152///
153/// ```
154/// # use proxy_watch::{parse, resolve, ProxyConfig, ProxyConfigSource, ProxyStep, Url};
155/// let mode = parse::windows_manual("http=proxy.corp:8080", "<local>;*.corp.example");
156/// let config = ProxyConfig::from_source(ProxyConfigSource::Registry, mode);
157///
158/// let steps = resolve(&config, &Url::parse("http://example.net/x").unwrap())?;
159/// assert_eq!(steps[0].endpoint().unwrap().authority(), "proxy.corp:8080");
160///
161/// let steps = resolve(&config, &Url::parse("http://api.corp.example/x").unwrap())?;
162/// assert_eq!(steps, vec![ProxyStep::Direct]);
163/// # Ok::<(), proxy_watch::Error>(())
164/// ```
165pub fn resolve(config: &ProxyConfig, url: &Url) -> Result<Vec<ProxyStep>, Error> {
166 resolve_mode(&config.effective, url)
167}
168
169// Mode-only body of [`resolve`] (tests / future single-source callers).
170fn resolve_mode(mode: &ProxyMode, url: &Url) -> Result<Vec<ProxyStep>, Error> {
171 match mode {
172 ProxyMode::Direct => return Ok(vec![ProxyStep::Direct]),
173 ProxyMode::Pac { .. } => return Err(Error::PacNotSupported { mode: "pac" }),
174 ProxyMode::PacInline { .. } => {
175 return Err(Error::PacNotSupported { mode: "pac-inline" });
176 }
177 ProxyMode::WpadAutoDetect => return Err(Error::PacNotSupported { mode: "wpad" }),
178 ProxyMode::Manual { .. } => {}
179 }
180
181 // No host → Direct (`data:`, `mailto:`, `file:///…`). Asked before the bypass list and
182 // not left to it: `matches_url` answers "does not bypass" for a hostless URL, which is
183 // the right answer to *that* question and the wrong routing decision here: there is no
184 // destination for a proxy to reach.
185 if !has_request_host(url) {
186 return Ok(vec![ProxyStep::Direct]);
187 }
188
189 // Through `matches_url` rather than `matches`, because which port a portless URL is
190 // compared with is the source's business and `BypassRules` is what knows the source.
191 if let Some(bypass) = mode.bypass()
192 && bypass.matches_url(url)
193 {
194 return Ok(vec![ProxyStep::Direct]);
195 }
196
197 let entry = if matches!(url.scheme(), "ws" | "wss") {
198 mode.websocket_entry()
199 } else {
200 mode.entry_for(request_scheme(url))
201 };
202 match entry {
203 Some(ProxyEntry::Use(endpoint)) => Ok(vec![ProxyStep::from_endpoint(endpoint.clone())]),
204 // `Disabled` is an answer: the platform named this scheme and left it unproxied.
205 Some(ProxyEntry::Disabled) => Ok(vec![ProxyStep::Direct]),
206 // A drop *is* why nothing covers the scheme. The lookups above are what found it,
207 // so the record reported is the one whose loss took this request's answer away:
208 // for `ws` that is not the request's own scheme, and there is no second walk to
209 // keep in step with them. Naming it by the slot it came from is what
210 // `Error::ProxyEntryUnusable::scheme` promises, and the slot is the key
211 // `ProxyMode::with_rejected` filed the record under: its own `affected_scheme`,
212 // which for a mode this crate built is never `None`, since a record naming no
213 // scheme is never filed at all. A caller assembling a `Manual` by hand can put one
214 // there (every piece of that is public) and the `unwrap_or` is what answers then;
215 // it says the catch-all rather than an unspecified concrete scheme.
216 Some(ProxyEntry::Unusable(rejected)) => Err(Error::ProxyEntryUnusable {
217 scheme: rejected.affected_scheme().unwrap_or(Scheme::All),
218 rejected: rejected.clone(),
219 }),
220 None => Ok(vec![ProxyStep::Direct]),
221 }
222}
223
224/// Like [`resolve`], but evaluates PAC when the mode requires it.
225///
226/// For [`ProxyMode::Direct`] and [`ProxyMode::Manual`], this agrees with [`resolve`] and
227/// `script` is ignored. `PacInline`: body from `script` or mode. `Pac`: `Some(script)` or
228/// [`Error::PacFetchRequired`]. `WpadAutoDetect`: `Some(script)` or
229/// [`Error::PacNotSupported`]. Never downloads; hostless → Direct; no bypass in PAC modes.
230/// [`PacResolver`](crate::pac::PacResolver) adds WinHTTP, CFNetwork or Android's PAC
231/// service for the last two when one is attached; `WpadAutoDetect` needs it built
232/// `with_wpad(true)`.
233///
234/// # Errors
235///
236/// [`Error::PacFetchRequired`], [`Error::PacNotSupported`],
237/// [`pac::evaluate`](crate::pac::evaluate), and (because `Direct` and `Manual` are handed
238/// straight to [`resolve`])
239/// [`Error::ProxyEntryUnusable`].
240///
241/// ```
242/// # #[cfg(pac_quickjs)] {
243/// use proxy_watch::pac::PacPolicy;
244/// use proxy_watch::{Error, ProxyConfig, ProxyConfigSource, ProxyMode, Url, resolve_with_pac};
245///
246/// let mode = ProxyMode::pac(Url::parse("http://wpad.corp/proxy.pac").unwrap());
247/// let config = ProxyConfig::from_source(ProxyConfigSource::Registry, mode);
248/// let url = Url::parse("http://example.net/").unwrap();
249///
250/// let Error::PacFetchRequired { url: wanted } =
251/// resolve_with_pac(&config, &url, None, &PacPolicy::new()).unwrap_err()
252/// else { panic!() };
253/// assert_eq!(wanted.as_str(), "http://wpad.corp/proxy.pac");
254/// # }
255/// # Ok::<(), proxy_watch::Error>(())
256/// ```
257#[cfg(feature = "pac")]
258pub fn resolve_with_pac(
259 config: &ProxyConfig,
260 url: &Url,
261 script: Option<&crate::pac::PacScript>,
262 policy: &crate::pac::PacPolicy,
263) -> Result<Vec<ProxyStep>, Error> {
264 resolve_with_pac_using(config, url, script, |script| {
265 crate::pac::evaluate(script, url, policy)
266 })
267}
268
269// `resolve_with_pac` with the evaluation left to `run`, so `PacResolver` can hand the script
270// to an attached evaluator and still answer every other arm the same way.
271#[cfg(feature = "pac")]
272pub(crate) fn resolve_with_pac_using(
273 config: &ProxyConfig,
274 url: &Url,
275 script: Option<&crate::pac::PacScript>,
276 run: impl FnOnce(&crate::pac::PacScript) -> Result<Vec<ProxyStep>, Error>,
277) -> Result<Vec<ProxyStep>, Error> {
278 use crate::pac::PacScript;
279
280 let mode = &config.effective;
281 if matches!(mode, ProxyMode::Direct | ProxyMode::Manual { .. }) {
282 return resolve_mode(mode, url);
283 }
284
285 // Hostless → Direct before mode checks (unlike WinHTTP's PacInline refusal).
286 if !has_request_host(url) {
287 return Ok(vec![ProxyStep::Direct]);
288 }
289
290 let from_mode;
291 let script = match script {
292 Some(script) => script,
293 None => match mode {
294 ProxyMode::PacInline { script, .. } => {
295 from_mode = PacScript::new(script.clone());
296 &from_mode
297 }
298 ProxyMode::Pac { url, .. } => return Err(Error::PacFetchRequired { url: url.clone() }),
299 // WPAD discovery is a non-goal (DHCP/DNS MITM); caller must supply the script.
300 ProxyMode::WpadAutoDetect => return Err(Error::PacNotSupported { mode: "wpad" }),
301 // A mode added later names itself as unknown rather than as WPAD.
302 _ => return Err(Error::PacNotSupported { mode: "unknown" }),
303 },
304 };
305
306 run(script)
307}
308
309// The `per_scheme` key a request URL is looked up under.
310fn request_scheme(url: &Url) -> Scheme {
311 match url.scheme() {
312 "http" => Scheme::Http,
313 "https" => Scheme::Https,
314 "ftp" => Scheme::Ftp,
315 "socks" | "socks4" | "socks4a" | "socks5" | "socks5h" => Scheme::Socks,
316 // Unknown scheme: only a catch-all entry can apply.
317 _ => Scheme::All,
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 use url::Host;
326
327 use crate::auth::ProxyAuth;
328 use crate::config::ProxyConfigSource;
329 use crate::diagnostic::RejectedValue;
330
331 /// A ported bypass entry meets a portless URL everywhere except GNOME. Do not fill the
332 /// scheme's default port at the call site for every source: that makes an `ignore-hosts`
333 /// of `intranet.corp:80` report `http://intranet.corp/` direct while GNOME sends it to
334 /// the proxy.
335 ///
336 /// GLib resolves the destination with `G_URI_FLAGS_NONE` (`gsimpleproxyresolver.c`,
337 /// `g_simple_proxy_resolver_lookup`) and fills a scheme's default port only under
338 /// `G_URI_FLAGS_SCHEME_NORMALIZE` (`guri.c`), so it compares against port 0 and
339 /// `ignore_host`'s `domain->port == port` fails. Windows does the opposite: a
340 /// `ProxyOverride` of `host:80` bypasses a portless `http://host/`, so the default
341 /// stays the default and GNOME asks for the other reading through the flag.
342 #[test]
343 fn a_bypass_list_that_wants_an_explicit_port_does_not_get_a_default_one() {
344 let manual = |bypass| ProxyMode::Manual {
345 per_scheme: std::collections::HashMap::from([(
346 Scheme::All,
347 ProxyEntry::Use(ProxyEndpoint::new(
348 Host::Domain("proxy.corp".to_owned()),
349 8080,
350 )),
351 )]),
352 bypass,
353 rejected: Vec::new(),
354 };
355 let bare = Url::parse("http://intranet.corp/").unwrap();
356 let ported = Url::parse("http://intranet.corp:8081/").unwrap();
357 let proxied = |mode: &ProxyMode, url: &Url| {
358 resolve_mode(mode, url).unwrap()[0]
359 .endpoint()
360 .map(ProxyEndpoint::authority)
361 };
362
363 // The usual reading: a rule written `:80` is the HTTP port whether or not the URL
364 // spelled it out.
365 let usual = manual(crate::parse::no_proxy("intranet.corp:80"));
366 assert_eq!(
367 resolve_mode(&usual, &bare).unwrap(),
368 vec![ProxyStep::Direct]
369 );
370
371 // GNOME's: the port the URL did not write is not one the rule may match on.
372 let mut rules = crate::parse::no_proxy("intranet.corp:80");
373 rules.require_explicit_port = true;
374 assert_eq!(
375 proxied(&manual(rules), &bare).as_deref(),
376 Some("proxy.corp:8080")
377 );
378
379 // Written out, it is: the flag is about inference, not about ports.
380 let mut rules = crate::parse::no_proxy("intranet.corp:8081");
381 rules.require_explicit_port = true;
382 let gnome = manual(rules);
383 assert_eq!(
384 resolve_mode(&gnome, &ported).unwrap(),
385 vec![ProxyStep::Direct]
386 );
387 assert_eq!(proxied(&gnome, &bare).as_deref(), Some("proxy.corp:8080"));
388
389 // The limit, held here rather than left to be discovered: a rule on a scheme's own
390 // default port can no longer fire at all, because `Url` normalises that port out of
391 // the URL at parse time: `http://intranet.corp:80/` and `http://intranet.corp/`
392 // are the same value by the time `resolve` is handed one, so the destination GLib
393 // would bypass cannot be told from the one it would not. Reporting the proxy is the
394 // safe half of a distinction this crate's input type has already lost.
395 let mut default_port = crate::parse::no_proxy("intranet.corp:80");
396 default_port.require_explicit_port = true;
397 assert_eq!(
398 proxied(
399 &manual(default_port),
400 &Url::parse("http://intranet.corp:80/").unwrap()
401 )
402 .as_deref(),
403 Some("proxy.corp:8080")
404 );
405
406 // An entry with no port of its own is unaffected either way, which is what keeps
407 // the flag from reading as "GNOME bypasses less".
408 let mut portless = crate::parse::no_proxy("intranet.corp");
409 portless.require_explicit_port = true;
410 assert_eq!(
411 resolve_mode(&manual(portless), &bare).unwrap(),
412 vec![ProxyStep::Direct]
413 );
414 }
415
416 #[test]
417 fn scheme_hints_pick_the_step_variant() {
418 let base = ProxyEndpoint::new(Host::Domain("p".to_owned()), 1080);
419 let with = |hint| ProxyStep::from_endpoint(base.clone().with_scheme_hint(hint));
420
421 assert!(matches!(
422 ProxyStep::from_endpoint(base.clone()),
423 ProxyStep::Http(_)
424 ));
425 assert!(matches!(with(ProxyScheme::Http), ProxyStep::Http(_)));
426 assert!(matches!(with(ProxyScheme::Https), ProxyStep::Https(_)));
427 assert!(matches!(with(ProxyScheme::Socks4), ProxyStep::Socks4(_)));
428 assert!(matches!(with(ProxyScheme::Socks4a), ProxyStep::Socks4(_)));
429 assert!(matches!(with(ProxyScheme::Socks5), ProxyStep::Socks5(_)));
430 assert!(matches!(with(ProxyScheme::Socks5h), ProxyStep::Socks5(_)));
431 }
432
433 // The host guard in `to_url` reads the host back through `parse_host`, and an address
434 // has to come back bracketed; a guard tightened past that answers `None`, which a
435 // caller reads as Direct.
436 #[test]
437 fn to_url_keeps_an_ipv6_proxy_in_brackets() {
438 let endpoint = ProxyEndpoint::parse("http://[2001:db8::1]:8080", 80).unwrap();
439 let url = ProxyStep::Http(endpoint).to_url().unwrap();
440 assert_eq!(url.as_str(), "http://[2001:db8::1]:8080/");
441
442 let endpoint = ProxyEndpoint::parse("socks5h://[::1]", 80).unwrap();
443 let url = ProxyStep::Socks5(endpoint).to_url().unwrap();
444 assert_eq!(url.as_str(), "socks5h://[::1]:1080");
445 }
446
447 #[test]
448 fn to_url_preserves_the_socks_remote_dns_hint() {
449 // `socks5h://` must come back as `socks5h://`, not rounded down to
450 // `socks5://`: the `h` means the proxy resolves the name; dropping it would
451 // make the client resolve instead (a DNS leak), not just lose a spelling.
452 let endpoint = ProxyEndpoint::parse("socks5h://proxy.example:1080", 80).unwrap();
453 let url = ProxyStep::Socks5(endpoint).to_url().unwrap();
454 assert_eq!(url.scheme(), "socks5h");
455 assert_eq!(url.as_str(), "socks5h://proxy.example:1080");
456
457 // Same rule for the SOCKS4 side.
458 let endpoint = ProxyEndpoint::parse("socks4a://proxy.example:1080", 80).unwrap();
459 let url = ProxyStep::Socks4(endpoint).to_url().unwrap();
460 assert_eq!(url.scheme(), "socks4a");
461 assert_eq!(url.as_str(), "socks4a://proxy.example:1080");
462
463 // The non-`a`/`h` forms still round-trip as themselves.
464 let endpoint = ProxyEndpoint::parse("socks5://proxy.example:1080", 80).unwrap();
465 let url = ProxyStep::Socks5(endpoint).to_url().unwrap();
466 assert_eq!(url.scheme(), "socks5");
467
468 let endpoint = ProxyEndpoint::parse("socks4://proxy.example:1080", 80).unwrap();
469 let url = ProxyStep::Socks4(endpoint).to_url().unwrap();
470 assert_eq!(url.scheme(), "socks4");
471
472 // No hint at all (the usual case from a bare `host:port` source): the variant's
473 // plain scheme.
474 let endpoint = ProxyEndpoint::new(Host::Domain("proxy".to_owned()), 1080);
475 let url = ProxyStep::Socks5(endpoint).to_url().unwrap();
476 assert_eq!(url.scheme(), "socks5");
477
478 // `scheme()` itself only ever names the variant, never
479 // the `a`/`h` hint.
480 let endpoint = ProxyEndpoint::parse("socks5h://proxy.example:1080", 80).unwrap();
481 assert_eq!(ProxyStep::Socks5(endpoint).scheme(), Some("socks5"));
482 }
483
484 #[test]
485 fn to_url_answers_none_for_a_host_no_url_can_hold() {
486 // `None` can mean an unrepresentable proxy endpoint as well as Direct; treating it
487 // as "connect directly" contradicts this step. The public API permits this:
488 // `Host::Domain` does not validate its `String` and `Host` is re-exported from the
489 // crate root.
490 let step = ProxyStep::Http(ProxyEndpoint::new(
491 Host::Domain("bad host".to_owned()),
492 8080,
493 ));
494 assert!(step.to_url().is_none());
495 assert!(step.endpoint().is_some(), "and yet the step is not Direct");
496 assert_eq!(step.scheme(), Some("http"));
497
498 // The other half, and the one a caller cannot see coming: a host the URL parser
499 // accepts by reading part of it as something else. `user@evil` splits at the `@`
500 // into userinfo and the host `evil`; `evil/x` keeps `evil` and takes the port with
501 // it into the path. Both are `None` for the same reason "bad host" is.
502 for host in ["user@evil", "evil/x", "evil?x", "evil#x"] {
503 let step = ProxyStep::Http(ProxyEndpoint::new(Host::Domain(host.to_owned()), 8080));
504 assert!(step.to_url().is_none(), "{host} produced a URL");
505 }
506
507 // And the tightening stops there: a name only IDNA can spell still resolves, and
508 // the case it was written in is not what makes a host unusable.
509 for host in ["例え.jp", "Proxy.Example"] {
510 let step = ProxyStep::Http(ProxyEndpoint::new(Host::Domain(host.to_owned()), 8080));
511 assert!(step.to_url().is_some(), "{host} lost its URL");
512 }
513 }
514
515 #[test]
516 fn to_url_percent_encodes_credentials() {
517 let endpoint = ProxyEndpoint::new(Host::Domain("proxy".to_owned()), 8080)
518 .with_auth(ProxyAuth::new("al ice", Some("p@ss")));
519 let url = ProxyStep::Http(endpoint).to_url().unwrap();
520 assert_eq!(url.as_str(), "http://al%20ice:p%40ss@proxy:8080/");
521 assert_eq!(url.password(), Some("p%40ss"));
522 }
523
524 #[test]
525 fn a_snapshot_resolves_through_its_effective_mode() {
526 let mode = crate::parse::windows_manual("proxy:8080", "");
527 let config = ProxyConfig::from_source(ProxyConfigSource::Registry, mode);
528 let url = Url::parse("http://example.com/").unwrap();
529 assert_eq!(
530 resolve(&config, &url).unwrap()[0].endpoint().unwrap().port,
531 8080
532 );
533 }
534
535 #[test]
536 fn request_schemes_map_onto_entry_keys() {
537 // `ws`/`wss` are absent here: `resolve_mode` never calls `request_scheme` for them
538 // (see `resolve`'s doc comment): they go through `ProxyMode::websocket_entry`
539 // instead, exercised below. Every SOCKS spelling `request_scheme`'s `or` pattern
540 // lists, not one of them, because each spelling is checked only by its row: one
541 // that falls out of that pattern lands on `Scheme::All` without an error and is
542 // routed by a catch-all entry instead of the SOCKS one.
543 let cases = [
544 ("http://h/", Scheme::Http),
545 ("https://h/", Scheme::Https),
546 ("ftp://h/", Scheme::Ftp),
547 ("socks://h/", Scheme::Socks),
548 ("socks4://h/", Scheme::Socks),
549 ("socks4a://h/", Scheme::Socks),
550 ("socks5://h/", Scheme::Socks),
551 ("socks5h://h/", Scheme::Socks),
552 ("gopher://h/", Scheme::All),
553 ];
554 for (input, expected) in cases {
555 let url = Url::parse(input).unwrap();
556 assert_eq!(request_scheme(&url), expected, "{input}");
557 }
558 }
559
560 // Whether a drop names the scheme being asked about is the whole trigger, so the cases
561 // below vary that against modes that would otherwise all answer `Direct`.
562 fn dropped(scheme: Option<Scheme>) -> RejectedValue {
563 RejectedValue::new(
564 crate::RejectionKind::UnsupportedMapping,
565 crate::RejectionSource::Kioslaverc("ftpProxy".to_owned()),
566 "$ftp_proxy",
567 )
568 .for_scheme(scheme)
569 }
570
571 fn mode_with(entries: Vec<(Scheme, ProxyEntry)>, rejected: Vec<RejectedValue>) -> ProxyMode {
572 ProxyMode::manual(entries.into_iter().collect(), crate::BypassRules::new())
573 .with_rejected(rejected)
574 }
575
576 #[test]
577 fn a_drop_that_covered_the_requested_scheme_is_reported_not_called_direct() {
578 let mode = mode_with(Vec::new(), vec![dropped(Some(Scheme::Ftp))]);
579 let url = Url::parse("ftp://files.example/x").unwrap();
580 let err = resolve_mode(&mode, &url).unwrap_err();
581 assert!(
582 matches!(&err, Error::ProxyEntryUnusable { scheme, rejected }
583 if *scheme == Scheme::Ftp && rejected.affected_scheme() == Some(Scheme::Ftp)),
584 "{err:?}"
585 );
586 }
587
588 // Which of several drops the error names. Only a mode whose drops sit in two different
589 // tiers can tell the search order apart, and the record order is the reverse
590 // of the lookup order in both halves below.
591 #[test]
592 fn the_drop_reported_is_the_one_the_lookup_would_have_used_first() {
593 // The ordinary path, where the order is "the request's own scheme, then the catch-all".
594 let mode = mode_with(
595 Vec::new(),
596 vec![dropped(Some(Scheme::All)), dropped(Some(Scheme::Http))],
597 );
598 let url = Url::parse("http://intranet.corp/x").unwrap();
599 let err = resolve_mode(&mode, &url).unwrap_err();
600 assert!(
601 matches!(&err, Error::ProxyEntryUnusable { scheme, .. } if *scheme == Scheme::Http),
602 "{err:?}"
603 );
604
605 // The websocket chain, which has four tiers rather than two: it tries SOCKS before
606 // HTTP, so losing the SOCKS slot is what took this request's answer away, even
607 // though the HTTP drop was recorded first.
608 let mode = mode_with(
609 Vec::new(),
610 vec![dropped(Some(Scheme::Http)), dropped(Some(Scheme::Socks))],
611 );
612 let url = Url::parse("wss://chat.example/x").unwrap();
613 let err = resolve_mode(&mode, &url).unwrap_err();
614 assert!(
615 matches!(&err, Error::ProxyEntryUnusable { scheme, .. } if *scheme == Scheme::Socks),
616 "{err:?}"
617 );
618 }
619
620 // Two rejection records can name the same tier. [`Error::ProxyEntryUnusable`]'s
621 // `rejected` field must report the first. Cases with drops in different tiers cannot
622 // distinguish first-record selection from last-record selection within a tier.
623 //
624 // Reachable from one registry value: `proxy_server_with_rejected` files a record per
625 // malformed token, and a `ProxyServer` string may repeat a scheme key. Built through it
626 // rather than by hand, since which of two same-tier records the caller sees is only
627 // interesting if a source can produce two.
628 #[test]
629 fn the_first_of_two_drops_naming_one_tier_is_the_one_reported() {
630 // Out-of-range ports rather than anything with a space in it: the separator set
631 // includes whitespace, so a two-word token is two tokens and neither is malformed.
632 let mode = crate::parse::windows_manual("http=h:99999;http=h:88888", "");
633 let Some([first, second]) = mode
634 .rejected()
635 .and_then(|all| <&[_; 2]>::try_from(all).ok())
636 else {
637 panic!("both tokens must be refused, or this holds nothing: {mode:?}");
638 };
639 assert_eq!(first.redacted_input(), "http=h:99999");
640 assert_eq!(second.redacted_input(), "http=h:88888");
641
642 let url = Url::parse("http://intranet.corp/x").unwrap();
643 let err = resolve_mode(&mode, &url).unwrap_err();
644 assert!(
645 matches!(&err, Error::ProxyEntryUnusable { rejected, .. }
646 if rejected.redacted_input() == "http=h:99999"),
647 "{err:?}"
648 );
649 }
650
651 // A live catch-all covers the dropped scheme, so the drop removes no usable route and
652 // is not reported. Chromium's `MapUrlSchemeToProxyList` reaches for `fallback_proxies`
653 // in the same case.
654 #[test]
655 fn a_live_catch_all_still_covers_a_dropped_scheme() {
656 let socks = ProxyEndpoint::new(Host::Domain("s".to_owned()), 1080);
657 let mode = mode_with(
658 vec![(Scheme::All, ProxyEntry::Use(socks))],
659 vec![dropped(Some(Scheme::Ftp))],
660 );
661 let url = Url::parse("ftp://files.example/x").unwrap();
662 let steps = resolve_mode(&mode, &url).unwrap();
663 assert!(!steps.contains(&ProxyStep::Direct), "{steps:?}");
664 }
665
666 #[test]
667 fn a_drop_naming_another_scheme_leaves_this_one_direct() {
668 let mode = mode_with(Vec::new(), vec![dropped(Some(Scheme::Ftp))]);
669 let url = Url::parse("https://a.example/x").unwrap();
670 assert_eq!(resolve_mode(&mode, &url).unwrap(), vec![ProxyStep::Direct]);
671 }
672
673 // An unrecognised key names no scheme, so it cannot say which requests it would have
674 // covered, and must not turn every uncovered request into an error.
675 #[test]
676 fn a_drop_naming_no_scheme_never_turns_direct_into_an_error() {
677 let mode = mode_with(Vec::new(), vec![dropped(None)]);
678 for input in [
679 "ftp://f.example/x",
680 "https://a.example/x",
681 "ws://a.example/x",
682 ] {
683 let url = Url::parse(input).unwrap();
684 assert_eq!(
685 resolve_mode(&mode, &url).unwrap(),
686 vec![ProxyStep::Direct],
687 "{input}"
688 );
689 }
690 }
691
692 // The `unwrap_or` behind that error's `scheme`. Inside the crate it is unreachable, and
693 // the comment at the call site says so: `ProxyMode::with_rejected` skips a record
694 // naming no scheme, so nothing it files under `per_scheme` can hold one. But
695 // `ProxyMode::manual`, `ProxyEntry::Unusable` and `RejectedValue::new` are all public,
696 // so a caller assembling a mode by hand puts one there, and then the default is what
697 // the error reports. No other test checks the default. The message is built from that
698 // field, so with a concrete scheme there instead, a caller who never named http would
699 // read "the configured http proxy could not be used" about an ftp request.
700 // `Scheme::All` is what the field is documented to say wherever the slot is not a
701 // concrete one.
702 #[test]
703 fn a_hand_built_drop_naming_no_scheme_is_reported_against_the_catch_all() {
704 let mode = mode_with(
705 vec![(Scheme::Ftp, ProxyEntry::Unusable(dropped(None)))],
706 Vec::new(),
707 );
708 let config = ProxyConfig::from_source(ProxyConfigSource::Registry, mode);
709 let url = Url::parse("ftp://files.example/x").unwrap();
710 let err = resolve(&config, &url).unwrap_err();
711 assert!(
712 matches!(&err, Error::ProxyEntryUnusable { scheme, .. } if *scheme == Scheme::All),
713 "{err:?}"
714 );
715 }
716
717 // `Disabled` is an answer the platform gave, not an answer this crate lost.
718 #[test]
719 fn an_explicitly_disabled_scheme_stays_direct_beside_a_drop() {
720 let mode = mode_with(
721 vec![(Scheme::Ftp, ProxyEntry::Disabled)],
722 vec![dropped(Some(Scheme::Ftp))],
723 );
724 let url = Url::parse("ftp://files.example/x").unwrap();
725 assert_eq!(resolve_mode(&mode, &url).unwrap(), vec![ProxyStep::Direct]);
726 }
727
728 // The same, with the drop moved to `Scheme::All`. A *live* catch-all does not reach a
729 // scheme holding `Disabled`: that is `entry_for`'s first rule, so losing one cannot
730 // have taken that scheme's answer away either. `http_proxy=` beside an unparseable
731 // `all_proxy=` is the reachable spelling: direct for HTTP, the drop for everything
732 // else.
733 #[test]
734 fn an_all_drop_does_not_reach_past_an_explicitly_disabled_scheme() {
735 let mode = mode_with(
736 vec![(Scheme::Http, ProxyEntry::Disabled)],
737 vec![dropped(Some(Scheme::All))],
738 );
739 let url = Url::parse("http://a.example/x").unwrap();
740 assert_eq!(resolve_mode(&mode, &url).unwrap(), vec![ProxyStep::Direct]);
741
742 let url = Url::parse("ftp://files.example/x").unwrap();
743 let err = resolve_mode(&mode, &url).unwrap_err();
744 assert!(
745 matches!(&err, Error::ProxyEntryUnusable { rejected, .. }
746 if rejected.affected_scheme() == Some(Scheme::All)),
747 "{err:?}"
748 );
749 }
750
751 // `a_live_catch_all_still_covers_a_dropped_scheme` with the answer moved out of the
752 // drop's own reach, which is what the record now sitting *in* `per_scheme` can get
753 // wrong: it is passed over on the way to a later slot rather than never looked at at
754 // all. Both rows below therefore need a step the lookup takes after the lost one: a
755 // `Disabled` catch-all is still an answer, and the `ws` chain still walks on, so
756 // neither can be satisfied by a drop that stops the walk.
757 #[test]
758 fn a_drop_never_pre_empts_an_answer_the_chain_reaches_later() {
759 let proxy = ProxyEndpoint::parse("p.example:3128", 80).unwrap();
760 let cases = [
761 (
762 "http://a.example/x",
763 vec![(Scheme::All, ProxyEntry::Disabled)],
764 Scheme::Http,
765 vec![ProxyStep::Direct],
766 ),
767 (
768 "ws://a.example/x",
769 vec![(Scheme::Http, ProxyEntry::Use(proxy.clone()))],
770 Scheme::Socks,
771 vec![ProxyStep::from_endpoint(proxy)],
772 ),
773 ];
774 for (input, entries, lost, expected) in cases {
775 let mode = mode_with(entries, vec![dropped(Some(lost))]);
776 let url = Url::parse(input).unwrap();
777 assert_eq!(
778 resolve_mode(&mode, &url).unwrap(),
779 expected,
780 "{input} without {lost:?}"
781 );
782 }
783 }
784
785 // `ws` never asks `entry_for`, so the drop is matched against the chain
786 // `websocket_entry` walks rather than against `request_scheme`'s single key.
787 #[test]
788 fn a_websocket_request_is_covered_by_the_chain_it_would_have_walked() {
789 let mode = mode_with(Vec::new(), vec![dropped(Some(Scheme::Https))]);
790 let url = Url::parse("ws://a.example/x").unwrap();
791 let err = resolve_mode(&mode, &url).unwrap_err();
792 assert!(
793 matches!(&err, Error::ProxyEntryUnusable { scheme, .. } if *scheme == Scheme::Https),
794 "{err:?}"
795 );
796 }
797}