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;