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}