Skip to main content

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