Skip to main content

proxy_watch/
endpoint.rs

1//! Proxy endpoints: which host/port to talk to, for which request scheme.
2
3use std::fmt;
4use std::net::Ipv6Addr;
5use std::str::FromStr;
6
7use url::{Host, Url};
8
9use crate::auth::ProxyAuth;
10use crate::error::Error;
11use crate::util::{percent_decode, quote_if_not_credential_shaped, split_host_port};
12
13/// Request scheme a proxy setting applies to ([`ProxyMode::Manual`](crate::ProxyMode::Manual)
14/// `per_scheme` key). Not [`ProxyScheme`] (how to talk to the proxy).
15///
16/// Concrete schemes always beat [`Scheme::All`]. On Windows/`socks=`, GNOME (only SOCKS
17/// configured → All), and macOS (`SOCKSEnable` fallback), readers may already have copied
18/// SOCKS into other slots before a `ProxyMode` exists.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
20#[non_exhaustive]
21pub enum Scheme {
22    /// `http://` requests.
23    Http,
24    /// `https://` requests.
25    Https,
26    /// `ftp://` requests.
27    Ftp,
28    /// SOCKS / catch-all on some platforms.
29    Socks,
30    /// Bare `ProxyServer` / `all_proxy` catch-all.
31    All,
32}
33
34impl Scheme {
35    /// All schemes this crate models, in a stable order.
36    pub const ALL: [Scheme; 5] = [
37        Scheme::Http,
38        Scheme::Https,
39        Scheme::Ftp,
40        Scheme::Socks,
41        Scheme::All,
42    ];
43
44    /// The canonical lowercase name, as used by Windows `ProxyServer` keys.
45    #[must_use]
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Scheme::Http => "http",
49            Scheme::Https => "https",
50            Scheme::Ftp => "ftp",
51            Scheme::Socks => "socks",
52            Scheme::All => "all",
53        }
54    }
55
56    /// Parse a scheme key such as `http` in `http=proxy:8080`.
57    ///
58    /// `None` for unmodelled keys (`gopher`, …). `all` is accepted (see
59    /// [`parse::proxy_server`](crate::parse::proxy_server)).
60    #[must_use]
61    pub fn from_name(name: &str) -> Option<Scheme> {
62        match name.trim().to_ascii_lowercase().as_str() {
63            "http" => Some(Scheme::Http),
64            "https" => Some(Scheme::Https),
65            "ftp" => Some(Scheme::Ftp),
66            "socks" => Some(Scheme::Socks),
67            "all" => Some(Scheme::All),
68            _ => None,
69        }
70    }
71}
72
73impl fmt::Display for Scheme {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str(self.as_str())
76    }
77}
78
79/// The wire protocol used to reach the proxy itself.
80///
81/// Only ever a *hint*: most operating system sources (the Windows registry in
82/// particular) store a bare `host:port` and leave the protocol implicit.
83///
84/// Where a source names SOCKS without a version — GNOME's `socks` child, KDE's `socksProxy`
85/// and macOS's `SOCKSProxy` — this crate reports [`ProxyScheme::Socks5`]. That is a
86/// compatibility choice and not something read out of the setting: Chromium makes the same
87/// one and calls it a "policy decision" where it makes it, in
88/// `proxy_config_service_linux.cc`. GIO chose otherwise, treating such a setting as standing
89/// for SOCKS5, SOCKS4a and SOCKS4 alike, so a proxy that speaks only SOCKS4 is reachable from
90/// a GIO application and not from a caller that takes this hint literally.
91///
92/// A version written into the value is kept: `socks4://proxy.example.com` in GNOME's `host`
93/// child or KDE's `socksProxy` reads as [`Socks4`](ProxyScheme::Socks4), because both readers
94/// here apply their default only where the value named nothing. Chromium reads it the same way
95/// — "we default to socks 5, but if the user specifically set it to `socks4://`, then use
96/// that", in `FixupProxyHostScheme`. The native stacks do not: glib-networking formats
97/// `socks://%s:%u` out of the host key verbatim, and KF5-era KIO re-glues a bare `socks://`
98/// over whatever scheme it finds. So the spelling is an escape hatch out of *this crate's*
99/// default, not out of the setting — the platform's own resolver still sees SOCKS5, or nonsense.
100///
101/// The same word can mean different versions in different sources: a `socks://` URI is SOCKS5,
102/// while the Windows registry's `socks=host:port` is read as SOCKS4. Microsoft gives that token
103/// no version of its own, but WinINet owns the key, and the feature table in
104/// [WinINet vs. WinHTTP](https://learn.microsoft.com/en-us/windows/win32/wininet/wininet-vs-winhttp)
105/// grants it SOCKS4 alone: "**SOCKS4 (SOCKS version 4) support**. Doesn't include v4a" is `yes`
106/// for WinINet, and "**SOCKS5 (SOCKS version 5) support**" is `no`.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
108#[non_exhaustive]
109pub enum ProxyScheme {
110    /// Plain HTTP proxy (`CONNECT` for TLS). Default port 80.
111    Http,
112    /// TLS-wrapped HTTP proxy. Default port 443.
113    Https,
114    /// SOCKS4, names resolved locally. Default port 1080.
115    Socks4,
116    /// SOCKS4a, names resolved by the proxy. Default port 1080.
117    Socks4a,
118    /// SOCKS5, names resolved locally. Default port 1080.
119    Socks5,
120    /// SOCKS5, names resolved by the proxy. Default port 1080.
121    Socks5h,
122}
123
124impl ProxyScheme {
125    /// The canonical lowercase URL scheme.
126    #[must_use]
127    pub fn as_str(self) -> &'static str {
128        match self {
129            ProxyScheme::Http => "http",
130            ProxyScheme::Https => "https",
131            ProxyScheme::Socks4 => "socks4",
132            ProxyScheme::Socks4a => "socks4a",
133            ProxyScheme::Socks5 => "socks5",
134            ProxyScheme::Socks5h => "socks5h",
135        }
136    }
137
138    /// The port assumed when the source omitted one.
139    #[must_use]
140    pub fn default_port(self) -> u16 {
141        match self {
142            ProxyScheme::Http => 80,
143            ProxyScheme::Https => 443,
144            ProxyScheme::Socks4
145            | ProxyScheme::Socks4a
146            | ProxyScheme::Socks5
147            | ProxyScheme::Socks5h => 1080,
148        }
149    }
150}
151
152impl fmt::Display for ProxyScheme {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        f.write_str(self.as_str())
155    }
156}
157
158impl FromStr for ProxyScheme {
159    type Err = Error;
160
161    fn from_str(s: &str) -> Result<Self, Self::Err> {
162        match s.trim().to_ascii_lowercase().as_str() {
163            "http" => Ok(ProxyScheme::Http),
164            "https" => Ok(ProxyScheme::Https),
165            "socks4" => Ok(ProxyScheme::Socks4),
166            // A `socks://` URI scheme is SOCKS5, and only here. The same word means
167            // SOCKS4 as the Windows `socks=` bucket key and as the PAC `SOCKS` keyword, which
168            // is why those two set their hint themselves instead of asking this function --
169            // the reference draws the same line, and says so out loud.
170            "socks" | "socks5" => Ok(ProxyScheme::Socks5),
171            "socks4a" => Ok(ProxyScheme::Socks4a),
172            "socks5h" => Ok(ProxyScheme::Socks5h),
173            // Redact: `ProxyEndpoint::parse` can hand `alice:pass@http` here — or
174            // `bob:pw`, from `bob:pw://host`, where no `@` marks the userinfo.
175            other => Err(Error::UnsupportedProxyScheme(
176                crate::util::redact_offending_token(other),
177            )),
178        }
179    }
180}
181
182/// Concrete proxy address. IPv6 brackets resolved at parse; use [`authority`](Self::authority).
183/// `#[non_exhaustive]` — construct via [`new`](Self::new) / [`parse`](Self::parse).
184#[derive(Debug, Clone, PartialEq, Eq, Hash)]
185#[non_exhaustive]
186pub struct ProxyEndpoint {
187    /// Wire protocol hint when the source named one.
188    pub scheme_hint: Option<ProxyScheme>,
189    /// Proxy host.
190    pub host: Host,
191    /// Proxy port.
192    pub port: u16,
193    /// Embedded credentials, if any.
194    pub auth: Option<ProxyAuth>,
195}
196
197impl ProxyEndpoint {
198    /// Build an endpoint from an already-parsed host and port.
199    #[must_use]
200    pub fn new(host: Host, port: u16) -> Self {
201        Self {
202            scheme_hint: None,
203            host,
204            port,
205            auth: None,
206        }
207    }
208
209    /// Set the scheme hint (builder style).
210    #[must_use]
211    pub fn with_scheme_hint(mut self, scheme: ProxyScheme) -> Self {
212        self.scheme_hint = Some(scheme);
213        self
214    }
215
216    /// Set the credentials (builder style).
217    #[must_use]
218    pub fn with_auth(mut self, auth: ProxyAuth) -> Self {
219        self.auth = Some(auth);
220        self
221    }
222
223    /// Parse a proxy address in any of the shapes operating systems store.
224    ///
225    /// `host`, `host:port`, `[::1]:8080`, `user:pass@host[:port]`, or any of those behind
226    /// a `scheme://`. Credentials do not need the scheme: the userinfo split happens after
227    /// the optional prefix, so a bare `user:pass@host` carries a secret just as a
228    /// `scheme://` one does. The *last* `@` is the delimiter, as in WHATWG's authority
229    /// state, so `user@corp.example:pw@proxy:8080` keeps an email address as the user name.
230    /// Port: explicit, else scheme default, else `default_port`.
231    /// A path, query or fragment is accepted and dropped, so `http://proxy:8080/` and
232    /// `http://proxy:8080` are the same endpoint; nothing after the authority survives.
233    ///
234    /// ```
235    /// # use proxy_watch::{ProxyEndpoint, ProxyScheme};
236    /// let ep = ProxyEndpoint::parse("socks5://[::1]", 80).unwrap();
237    /// assert_eq!(ep.scheme_hint, Some(ProxyScheme::Socks5));
238    /// assert_eq!(ep.port, 1080);
239    /// assert_eq!(ep.authority(), "[::1]:1080");
240    /// ```
241    ///
242    /// # Errors
243    ///
244    /// [`Error::InvalidProxyServer`] if the input is not a usable address, or
245    /// [`Error::UnsupportedProxyScheme`] for a `scheme://` prefix this crate does not
246    /// model. Never [`Error::InvalidProxyUrl`]: that one belongs to the callers that
247    /// parse an `AutoConfigURL`-style value, not to an address.
248    ///
249    /// U+FFFD anywhere in the authority — credentials included — is one of the
250    /// unusable addresses. It is what a lossy byte-to-text conversion leaves behind, and
251    /// this crate's platform readers convert that way so that a value they could not decode
252    /// is refused here instead of reading as unset. A path or query is dropped before the
253    /// check, so the character is only refused where the answer is built from it.
254    pub fn parse(input: &str, default_port: u16) -> Result<Self, Error> {
255        let trimmed = input.trim();
256        if trimmed.is_empty() {
257            return Err(Error::proxy_server(input, "empty address"));
258        }
259
260        let (scheme_hint, rest) = match trimmed.split_once("://") {
261            Some((scheme, rest)) => (Some(scheme.parse::<ProxyScheme>()?), rest),
262            None => (None, trimmed),
263        };
264        // Cut the path, query and fragment — and do it *before* the userinfo split below,
265        // not after. `@` is an ordinary path character, so `http://bob:pw/x@proxy.corp:8080`
266        // would otherwise `rsplit_once` into userinfo `bob:pw/x` and a host taken from the
267        // path: a destination the writer never named, reached because a `/` came first.
268        // `trace::tests::safe_error_does_not_leak_credentials_from_real_parsers` is what
269        // fails if these two lines change places.
270        let rest = rest.split(['/', '?', '#']).next().unwrap_or(rest);
271
272        // Four readers convert their bytes the *lossy* way on purpose — `env::readable_var`,
273        // `sys::linux::desktop::text_if_set`, `kioslaverc`'s `ProxyType = 4` lookup and
274        // `sys::win::ffi::string_value` — and every one of them says the same thing about why:
275        // `into_string().ok()` cannot tell "unset" from "set to bytes that are not text", so
276        // the value is kept, mangled, and refused *here* instead of vanishing. Only the host
277        // half ever made that true. `parse_host` does refuse U+FFFD, but `parse_userinfo`
278        // cannot fail, so a `0xFF` in a password parsed clean: the crate then offered the proxy
279        // a secret nobody set, an authentication failure with no `rejected` entry anywhere to
280        // name the value that changed, and one `ProxyAuth`'s `Debug` masks out of the snapshot
281        // that might have shown it. `util::percent_decode` refuses to manufacture the same
282        // character for the same reason, and this is the other end of that rule.
283        //
284        // On the authority, not on `input`: a path, query or fragment is dropped whole, so
285        // mangling there costs nothing the answer is built from.
286        if rest.contains(char::REPLACEMENT_CHARACTER) {
287            return Err(Error::proxy_server(input, "value is not valid text"));
288        }
289
290        let (auth, host_port) = match rest.rsplit_once('@') {
291            Some((userinfo, host_port)) => (Some(parse_userinfo(userinfo)), host_port),
292            None => (None, rest),
293        };
294
295        if host_port.is_empty() {
296            return Err(Error::proxy_server(input, "missing host"));
297        }
298        let (host_text, port) =
299            split_host_port(host_port).map_err(|reason| Error::proxy_server(input, reason))?;
300        let host = parse_host(host_text).map_err(|reason| Error::proxy_server(input, reason))?;
301
302        // `host:` is a *written* port that is empty, which is not the same thing as a source
303        // that omitted the port: the writer meant to name one and did not. Chromium draws the
304        // same line: `ProxyUriToProxyServer` in `net/base/proxy_string_util.cc` splits the
305        // authority and answers with an invalid `ProxyServer()` when the port component
306        // `is_valid() && is_empty()`, while `url::ParsePort` -- the canonicaliser the same
307        // build uses for ordinary URLs -- reads that empty component as `PORT_UNSPECIFIED`,
308        // i.e. as no port at all. Only the proxy side refuses it.
309        // Length, not `ends_with(':')`, is what separates the two: `h:` and `[::1]:` leave a
310        // host shorter than what was split, while a bare host and an unbracketed `2001:db8::`
311        // -- which `split_host_port` hands back whole, colons and all -- do not.
312        if port.is_none() && host_port.len() > host_text.len() {
313            return Err(Error::proxy_server(input, "empty port after ':'"));
314        }
315
316        let port = port
317            .or_else(|| scheme_hint.map(ProxyScheme::default_port))
318            .unwrap_or(default_port);
319
320        Ok(Self {
321            scheme_hint,
322            host,
323            port,
324            auth,
325        })
326    }
327
328    /// The `host:port` form, with IPv6 hosts re-bracketed.
329    #[must_use]
330    pub fn authority(&self) -> String {
331        match &self.host {
332            Host::Ipv6(ip) => format!("[{ip}]:{}", self.port),
333            other => format!("{other}:{}", self.port),
334        }
335    }
336}
337
338impl fmt::Display for ProxyEndpoint {
339    // Renders `host:port`, and prefixes `scheme://` only when a hint was parsed out. The
340    // prefix is the exception, not the shape: most sources store a bare authority, which
341    // is why [`ProxyScheme`] is a hint in the first place. Never the credentials — see
342    // [`ProxyAuth`]'s own documentation for why.
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        if let Some(scheme) = self.scheme_hint {
345            write!(f, "{scheme}://")?;
346        }
347        f.write_str(&self.authority())
348    }
349}
350
351/// Whether a given [`Scheme`] uses a proxy at all.
352///
353/// Three answers, not two. `Disabled` exists so that "this scheme explicitly does *not*
354/// use a proxy" can be distinguished from "this scheme was not configured" — the former
355/// suppresses the [`Scheme::All`] fallback, the latter does not. [`Unusable`](Self::Unusable)
356/// is the third: the scheme *was* configured, and what it was configured with could not be
357/// read. Absence therefore means only "the platform said nothing about this scheme".
358#[derive(Debug, Clone, PartialEq, Eq)]
359#[non_exhaustive]
360pub enum ProxyEntry {
361    /// Route this scheme through the given proxy.
362    Use(ProxyEndpoint),
363    /// The platform named this scheme and gave it no proxy — an off switch, or a slot
364    /// left blank.
365    Disabled,
366    /// The platform named this scheme and gave it a proxy that could not be read.
367    ///
368    /// This is the record of the drop, not an answer: routing the request would need an
369    /// endpoint there is none of, and answering [`Disabled`](Self::Disabled) would claim the
370    /// platform wanted a direct connection when it wanted a proxy nobody can now name. Under
371    /// the `resolve` feature it becomes
372    /// [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable).
373    ///
374    /// Every lookup treats it as the last resort — see
375    /// [`ProxyMode::entry_for`](crate::ProxyMode::entry_for). A live [`Scheme::All`] is an
376    /// answer the platform did configure, so it still wins; only when nothing else covers
377    /// the scheme does the record answer.
378    Unusable(crate::diagnostic::RejectedValue),
379}
380
381impl ProxyEntry {
382    /// The endpoint, or `None` for [`Disabled`](Self::Disabled) and
383    /// [`Unusable`](Self::Unusable) alike — neither has one, which is why a caller that
384    /// needs to tell "go direct" from "the setting was lost" asks
385    /// [`rejected`](Self::rejected) rather than this.
386    #[must_use]
387    pub fn endpoint(&self) -> Option<&ProxyEndpoint> {
388        match self {
389            ProxyEntry::Use(endpoint) => Some(endpoint),
390            ProxyEntry::Disabled | ProxyEntry::Unusable(_) => None,
391        }
392    }
393
394    /// Whether the entry is [`ProxyEntry::Disabled`]. `false` for
395    /// [`Unusable`](Self::Unusable): a lost setting is not an off switch.
396    #[must_use]
397    pub fn is_disabled(&self) -> bool {
398        matches!(self, ProxyEntry::Disabled)
399    }
400
401    /// The drop record, for [`ProxyEntry::Unusable`] only.
402    #[must_use]
403    pub fn rejected(&self) -> Option<&crate::diagnostic::RejectedValue> {
404        match self {
405            ProxyEntry::Unusable(rejected) => Some(rejected),
406            ProxyEntry::Use(_) | ProxyEntry::Disabled => None,
407        }
408    }
409}
410
411// Parse a URL host, accepting bracketed and bare IPv6 literals.
412//
413// Both failure messages go into an [`Error`] `reason`, which `crate::trace::SafeError`
414// prints in full, so neither echoes `text` unconditionally — see
415// [`quote_if_not_credential_shaped`] for what arrives here that is not a host at all.
416pub(crate) fn parse_host(text: &str) -> Result<Host, String> {
417    let text = text.trim();
418    if text.is_empty() {
419        return Err("missing host".to_owned());
420    }
421    if let Some(inner) = text.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
422        return inner.parse::<Ipv6Addr>().map(Host::Ipv6).map_err(|_| {
423            format!(
424                "invalid IPv6 literal {}",
425                quote_if_not_credential_shaped(inner)
426            )
427        });
428    }
429    if let Ok(ip) = text.parse::<Ipv6Addr>() {
430        return Ok(Host::Ipv6(ip));
431    }
432    Host::parse(text)
433        .map_err(|e| format!("invalid host {}: {e}", quote_if_not_credential_shaped(text)))
434}
435
436// The host a request URL names, or `None` if it names none. Every "does this URL have a
437// host?" question in the crate comes through here rather than to [`Url::host`], which answers
438// `Some(Host::Domain(""))` for a URL whose host was emptied — `set_host(None)` on a
439// non-special scheme produces exactly that (url 2.5.8; special schemes refuse it with
440// `EmptyHost`). Such a URL has nothing to connect to, so it is hostless here.
441//
442// Lives here rather than beside its first caller in `resolve` because `BypassRules` asks it
443// too, and `resolve` is behind a feature while `BypassRules` is not.
444fn request_host_ref(url: &Url) -> Option<Host<&str>> {
445    match url.host()? {
446        Host::Domain("") => None,
447        other => Some(other),
448    }
449}
450
451// Whether the URL names a host at all. Borrows, so a caller that asks only this does not pay
452// for the `String` the owned form builds and it would drop. Every caller is under `resolve`,
453// which `pac` and the engine flags all pull in.
454#[cfg(feature = "resolve")]
455pub(crate) fn has_request_host(url: &Url) -> bool {
456    request_host_ref(url).is_some()
457}
458
459pub(crate) fn request_host(url: &Url) -> Option<Host> {
460    match request_host_ref(url)? {
461        Host::Domain(domain) => Some(Host::Domain(domain.to_owned())),
462        Host::Ipv4(ip) => Some(Host::Ipv4(ip)),
463        Host::Ipv6(ip) => Some(Host::Ipv6(ip)),
464    }
465}
466
467fn parse_userinfo(userinfo: &str) -> ProxyAuth {
468    // A literal `:` and nothing else. Percent-encoding a reserved character is how RFC 3986
469    // section 2.2 spells "this is data, not the delimiter", so `alice%3Ahunter2` is one user
470    // name that happens to contain a colon — `url` (this crate's own dependency) and Python's
471    // `urllib` both read it that way. Splitting it here handed the caller a user name that was
472    // never one and a password that never existed. The colon then surviving into `username` is
473    // masked by [`ProxyAuth`]'s `Debug`, which is what keeps it out of a snapshot.
474    match userinfo.split_once(':') {
475        Some((user, password)) => {
476            ProxyAuth::new(percent_decode(user), Some(percent_decode(password)))
477        }
478        None => ProxyAuth::from_username(percent_decode(userinfo)),
479    }
480}