Skip to main content

asimov_http/
proxy.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Shared HTTP proxy options and endpoint validation (`proxy` feature).
4//!
5//! `system-proxy` adds `OriginProxy` for construction-time environment/native
6//! discovery. `reqwest` adds its `configure_reqwest` adapter. A resolved policy
7//! belongs to one HTTP(S) origin; it must not configure a multi-origin client.
8//! `hyper` adds `proxy_for_hyper`, including the effective bypass decision.
9//! Native bypass grammars remain separate from `NO_PROXY` syntax.
10
11use alloc::{format, string::String};
12use core::fmt;
13use url::Url;
14
15#[cfg(feature = "system-proxy")]
16mod system;
17#[cfg(feature = "system-proxy")]
18pub use system::OriginProxy;
19#[cfg(feature = "hyper")]
20mod hyper;
21#[cfg(feature = "reqwest")]
22mod reqwest;
23#[cfg(all(test, feature = "reqwest"))]
24mod test_support;
25
26/// How an HTTP client selects its proxy endpoint.
27///
28/// With `system-proxy`, resolve once with `OriginProxy::resolve` before building
29/// an origin-scoped client. This type itself needs only alloc.
30#[derive(Clone, Default, Eq, PartialEq)]
31pub enum ProxyMode {
32    /// Read environment and native static settings. Per-scheme environment
33    /// endpoints override native settings, followed by ALL_PROXY. This is the
34    /// default. See `OriginProxy` for discovery and bypass semantics.
35    #[default]
36    Automatic,
37    /// Connect directly, without reading proxy environment or native settings.
38    /// Ignores [`ProxyOptions::no_proxy`].
39    Direct,
40    /// Use this endpoint instead of environment or native proxy endpoints.
41    ///
42    /// Accepts `http://`, `https://`, `socks4://`, `socks4a://`, `socks5://`, and
43    /// `socks5h://` URLs, or a bare `host[:port]` for HTTP. Ports default to 80,
44    /// 443, or 1080 respectively. IPv6 addresses require brackets. URL userinfo
45    /// supports existing HTTP Basic/SOCKS5 authentication; percent-encode
46    /// reserved characters. Debug output omits the URL, including credentials.
47    ///
48    /// Invalid endpoints return [`Error::InvalidProxyUrl`] at construction,
49    /// even when a bypass rule matches. Paths other than `/`, queries, fragments,
50    /// whitespace, and port zero are rejected. SOCKS hostnames must be ASCII or
51    /// punycode. A failed connection never falls back to another proxy or direct
52    /// access. This explicit choice also applies when `REQUEST_METHOD` is set.
53    Explicit(String),
54}
55
56impl fmt::Debug for ProxyMode {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            Self::Automatic => f.write_str("Automatic"),
60            Self::Direct => f.write_str("Direct"),
61            Self::Explicit(_) => f.write_str("Explicit([REDACTED])"),
62        }
63    }
64}
65
66/// Client-local proxy selection and bypass policy.
67#[derive(Clone, Debug, Default, Eq, PartialEq)]
68pub struct ProxyOptions {
69    /// Endpoint selection. Defaults to [`ProxyMode::Automatic`].
70    pub mode: ProxyMode,
71    /// Bypass list in `NO_PROXY` syntax: comma-separated domains/subdomains,
72    /// IP literals/CIDRs, or `*` for every host, including IP literals.
73    /// Matching does not resolve DNS names to test CIDRs. Port-qualified rules
74    /// and native-only syntax such as `<local>` are not supported here.
75    ///
76    /// - `None` inherits `NO_PROXY`/`no_proxy`. In automatic mode, an empty
77    ///   environment list permits native exclusions when a native proxy wins.
78    ///   Explicit mode never reads native exclusions.
79    /// - `Some(list)` replaces both environment and native exclusions.
80    /// - `Some(String::new())` disables all bypass rules.
81    /// - `Some("*".into())` selects direct access for every host.
82    ///
83    /// Direct mode ignores this field. Automatic mode retains CGI protection
84    /// regardless of the override. Configuration never changes the environment.
85    pub no_proxy: Option<String>,
86}
87
88#[cfg(feature = "system-proxy")]
89fn bypasses_all(list: &str) -> bool {
90    list.split(',').any(|rule| rule.trim() == "*")
91}
92
93/// Proxy configuration failures, separate from HTTP integrity failures.
94/// Diagnostics name settings or fixed reasons, never endpoint contents.
95#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
96pub enum Error {
97    /// Native settings could not be read/decoded, or an enabled endpoint is invalid.
98    #[error("invalid native proxy setting: {0}")]
99    InvalidProxy(&'static str),
100    /// Invalid explicit endpoint, including unsupported schemes.
101    #[error("invalid explicit proxy URL: {0}")]
102    InvalidProxyUrl(&'static str),
103    /// The native source requires an unsupported resolver.
104    #[error("unsupported automatic proxy source: {0}; configure an explicit proxy")]
105    UnsupportedProxy(&'static str),
106    /// A target is not HTTP(S), or differs from the resolved origin.
107    #[error("invalid proxy target origin: {0}")]
108    InvalidOrigin(&'static str),
109}
110
111/// Validates an explicit HTTP(S)/SOCKS proxy URL or bare `host[:port]`.
112///
113/// The returned URL retains userinfo; do not log it without redaction. SOCKS
114/// default ports are supplied by transport adapters. No environment or native
115/// settings are read. See [`ProxyMode::Explicit`] for accepted endpoint syntax.
116///
117/// # Errors
118///
119/// Returns [`Error::InvalidProxyUrl`] with a credential-free reason.
120pub fn parse_endpoint(input: &str) -> Result<Url, Error> {
121    if input.is_empty() || input.chars().any(|c| c.is_whitespace() || c.is_control()) {
122        return Err(Error::InvalidProxyUrl(
123            "expected a nonempty URL without whitespace",
124        ));
125    }
126    let url = environment_proxy(input).ok_or(Error::InvalidProxyUrl(
127        "expected an HTTP(S) or SOCKS4/4a/5/5h endpoint with a host",
128    ))?;
129    if url.port() == Some(0) {
130        return Err(Error::InvalidProxyUrl("port must be nonzero"));
131    }
132    if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() {
133        return Err(Error::InvalidProxyUrl(
134            "paths, queries, and fragments are not supported",
135        ));
136    }
137    // Non-special URL schemes escape Unicode hosts instead of using IDNA.
138    // Require a usable ASCII/punycode name rather than an escaped DNS name.
139    if !url
140        .host_str()
141        .is_some_and(|host| host.is_ascii() && !host.contains('%'))
142    {
143        return Err(Error::InvalidProxyUrl("host must be ASCII or punycode"));
144    }
145    Ok(url)
146}
147
148fn environment_proxy(input: &str) -> Option<Url> {
149    if input.is_empty() {
150        return None;
151    }
152    let url = if input.contains("://") {
153        Url::parse(input)
154    } else {
155        Url::parse(&format!("http://{input}"))
156    }
157    .ok()?;
158    (url.host_str().is_some()
159        && matches!(
160            url.scheme(),
161            "http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h"
162        ))
163    .then_some(url)
164}
165
166#[cfg(test)]
167mod tests;