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