Skip to main content

gosub_sonar/net/
proxy.rs

1//! Programmatic proxy configuration for the [`Fetcher`].
2//!
3//! Without configuration the fetcher keeps reqwest's default behaviour and reads the
4//! `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables — see
5//! [`ProxyConfig::System`]. An embedder that has its own proxy settings (a browser's network
6//! preferences, a PAC-derived result, a per-profile override) sets
7//! [`FetcherConfig::proxy`] instead, which takes the environment out of the picture entirely.
8//!
9//! ```no_run
10//! use gosub_sonar::{FetcherConfig, ProxyConfig, ProxyRule};
11//!
12//! let cfg = FetcherConfig {
13//!     proxy: ProxyConfig::Rules(vec![
14//!         ProxyRule::all("http://proxy.corp:8080")
15//!             .with_basic_auth("alice", "hunter2")
16//!             .bypassing("localhost, 10.0.0.0/8, .internal.corp"),
17//!     ]),
18//!     ..FetcherConfig::default()
19//! };
20//! ```
21//!
22//! Native-only: on `wasm32` the browser's `fetch()` applies the user's own proxy settings and
23//! offers no way to override them, so this module is not compiled there.
24//!
25//! [`Fetcher`]: crate::net::fetcher::Fetcher
26//! [`FetcherConfig::proxy`]: crate::net::fetcher::FetcherConfig::proxy
27
28use anyhow::Context;
29
30/// Which request URLs a [`ProxyRule`] applies to.
31#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
32pub enum ProxyScope {
33    /// Only `http://` request URLs.
34    Http,
35    /// Only `https://` request URLs. Requests are tunnelled through the proxy with `CONNECT`.
36    Https,
37    /// Every request URL, whatever its scheme. The default.
38    #[default]
39    All,
40}
41
42/// Credentials presented to the proxy itself, sent as `Proxy-Authorization`.
43///
44/// This is separate from any authentication the *origin server* asks for. Credentials embedded
45/// in the proxy URL (`http://user:pass@proxy:8080`) work too, but anything needing escaping is
46/// easier to get right here.
47#[derive(Debug, Clone, Eq, PartialEq)]
48pub enum ProxyAuth {
49    /// `Proxy-Authorization: Basic <base64(username:password)>`.
50    Basic {
51        /// Username presented to the proxy.
52        username: String,
53        /// Password presented to the proxy.
54        password: String,
55    },
56    /// A verbatim `Proxy-Authorization` header value, for schemes other than Basic
57    /// (e.g. `"Bearer <token>"`). Rejected at [`Fetcher::new`](crate::net::fetcher::Fetcher::new)
58    /// if it is not a valid header value.
59    Custom(String),
60}
61
62/// One proxy: where it lives, which requests go through it, and which hosts bypass it.
63#[derive(Debug, Clone, Eq, PartialEq)]
64pub struct ProxyRule {
65    /// Which request URLs this rule applies to.
66    pub scope: ProxyScope,
67    /// The proxy's own URL, e.g. `http://proxy.corp:8080`. `http` and `https` proxies are always
68    /// supported; `socks4`, `socks5`, and `socks5h` need the crate's `socks` feature. An
69    /// unparseable or unsupported URL is reported by
70    /// [`Fetcher::new`](crate::net::fetcher::Fetcher::new).
71    pub url: String,
72    /// Credentials for the proxy, if it demands any.
73    pub auth: Option<ProxyAuth>,
74    /// Hosts that bypass this proxy, in `NO_PROXY` syntax: comma-separated entries, each a
75    /// domain (matching that domain and its subdomains), an IP address, a CIDR block, or `*` for
76    /// everything. `None` sends every in-scope request through the proxy — note that this does
77    /// *not* fall back to the `NO_PROXY` environment variable, since configuring a rule
78    /// programmatically opts out of the environment entirely.
79    pub no_proxy: Option<String>,
80}
81
82impl ProxyRule {
83    /// A rule routing every request through `url`, whatever the scheme.
84    pub fn all(url: impl Into<String>) -> Self {
85        Self::new(ProxyScope::All, url)
86    }
87
88    /// A rule routing `http://` requests through `url`.
89    pub fn http(url: impl Into<String>) -> Self {
90        Self::new(ProxyScope::Http, url)
91    }
92
93    /// A rule routing `https://` requests through `url`.
94    pub fn https(url: impl Into<String>) -> Self {
95        Self::new(ProxyScope::Https, url)
96    }
97
98    /// A rule with an explicit scope, no credentials, and no bypass list.
99    pub fn new(scope: ProxyScope, url: impl Into<String>) -> Self {
100        Self {
101            scope,
102            url: url.into(),
103            auth: None,
104            no_proxy: None,
105        }
106    }
107
108    /// Present `username` / `password` to the proxy via `Proxy-Authorization: Basic`.
109    #[must_use]
110    pub fn with_basic_auth(
111        mut self,
112        username: impl Into<String>,
113        password: impl Into<String>,
114    ) -> Self {
115        self.auth = Some(ProxyAuth::Basic {
116            username: username.into(),
117            password: password.into(),
118        });
119        self
120    }
121
122    /// Present a verbatim `Proxy-Authorization` header value, e.g. `"Bearer <token>"`.
123    #[must_use]
124    pub fn with_custom_auth(mut self, header_value: impl Into<String>) -> Self {
125        self.auth = Some(ProxyAuth::Custom(header_value.into()));
126        self
127    }
128
129    /// Exempt hosts from this proxy, in `NO_PROXY` syntax — see [`ProxyRule::no_proxy`].
130    #[must_use]
131    pub fn bypassing(mut self, no_proxy: impl Into<String>) -> Self {
132        self.no_proxy = Some(no_proxy.into());
133        self
134    }
135
136    /// Translate into a `reqwest::Proxy`, failing on an unusable proxy URL or auth header.
137    fn to_reqwest(&self) -> anyhow::Result<reqwest::Proxy> {
138        // reqwest accepts a socks URL whether or not its own `socks` feature is on, and without
139        // it quietly falls back to speaking HTTP at the socks port — a connect-time failure with
140        // nothing pointing at the cause. Reject it here, while there is still a URL to name.
141        #[cfg(not(feature = "socks"))]
142        {
143            let scheme = self
144                .url
145                .split("://")
146                .next()
147                .unwrap_or_default()
148                .to_ascii_lowercase();
149            anyhow::ensure!(
150                !matches!(scheme.as_str(), "socks4" | "socks4a" | "socks5" | "socks5h"),
151                "proxy URL {:?} needs the `socks` cargo feature",
152                self.url
153            );
154        }
155
156        let mut proxy = match self.scope {
157            ProxyScope::Http => reqwest::Proxy::http(&self.url),
158            ProxyScope::Https => reqwest::Proxy::https(&self.url),
159            ProxyScope::All => reqwest::Proxy::all(&self.url),
160        }
161        .with_context(|| format!("unusable proxy URL {:?}", self.url))?;
162
163        match self.auth {
164            Some(ProxyAuth::Basic {
165                ref username,
166                ref password,
167            }) => proxy = proxy.basic_auth(username, password),
168            Some(ProxyAuth::Custom(ref value)) => {
169                let header = value.parse().with_context(|| {
170                    format!("proxy {:?}: invalid Proxy-Authorization value", self.url)
171                })?;
172                proxy = proxy.custom_http_auth(header);
173            }
174            None => {}
175        }
176
177        if let Some(ref list) = self.no_proxy {
178            proxy = proxy.no_proxy(reqwest::NoProxy::from_string(list));
179        }
180
181        Ok(proxy)
182    }
183}
184
185/// How the fetcher chooses a proxy for outgoing requests.
186#[derive(Debug, Clone, Eq, PartialEq, Default)]
187pub enum ProxyConfig {
188    /// Take the proxy from the environment: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and the
189    /// `NO_PROXY` bypass list (lowercase spellings included). The default, and what the fetcher
190    /// did before proxies were configurable.
191    #[default]
192    System,
193    /// Send every request directly, ignoring the environment variables.
194    Disabled,
195    /// Use exactly these rules and nothing from the environment. Each request URL is matched
196    /// against the rules in order and takes the first whose scope matches and whose bypass list
197    /// does not exempt it; an empty list therefore behaves like [`ProxyConfig::Disabled`].
198    Rules(Vec<ProxyRule>),
199}
200
201impl ProxyConfig {
202    /// A single proxy for all schemes, with no bypass list — the common case.
203    pub fn single(url: impl Into<String>) -> Self {
204        ProxyConfig::Rules(vec![ProxyRule::all(url)])
205    }
206
207    /// Apply this configuration to a client builder.
208    ///
209    /// [`ProxyConfig::System`] leaves the builder untouched, since reading the environment is
210    /// reqwest's own default; the other variants clear it first so no environment proxy leaks in.
211    pub(crate) fn apply(
212        &self,
213        builder: reqwest::ClientBuilder,
214    ) -> anyhow::Result<reqwest::ClientBuilder> {
215        match self {
216            ProxyConfig::System => Ok(builder),
217            ProxyConfig::Disabled => Ok(builder.no_proxy()),
218            ProxyConfig::Rules(rules) => {
219                // `no_proxy()` first: with an empty `rules` nothing else would switch the
220                // environment lookup off, and a caller that spelled out its rules never wants
221                // `HTTP_PROXY` silently appended to them.
222                let mut builder = builder.no_proxy();
223                for rule in rules {
224                    builder = builder.proxy(rule.to_reqwest()?);
225                }
226                Ok(builder)
227            }
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn builders_set_scope_and_extras() {
238        let rule = ProxyRule::https("http://p:8080")
239            .with_basic_auth("u", "p")
240            .bypassing("localhost");
241        assert_eq!(rule.scope, ProxyScope::Https);
242        assert_eq!(rule.url, "http://p:8080");
243        assert_eq!(
244            rule.auth,
245            Some(ProxyAuth::Basic {
246                username: "u".into(),
247                password: "p".into()
248            })
249        );
250        assert_eq!(rule.no_proxy.as_deref(), Some("localhost"));
251
252        assert_eq!(ProxyRule::http("http://p:8080").scope, ProxyScope::Http);
253        assert_eq!(ProxyRule::all("http://p:8080").scope, ProxyScope::All);
254        assert!(ProxyRule::all("http://p:8080").auth.is_none());
255    }
256
257    #[test]
258    fn default_is_system() {
259        assert_eq!(ProxyConfig::default(), ProxyConfig::System);
260    }
261
262    #[test]
263    fn single_is_an_all_scheme_rule() {
264        assert_eq!(
265            ProxyConfig::single("http://p:8080"),
266            ProxyConfig::Rules(vec![ProxyRule::all("http://p:8080")])
267        );
268    }
269
270    #[test]
271    fn valid_rules_build() {
272        for rule in [
273            ProxyRule::all("http://proxy.example:8080"),
274            ProxyRule::http("http://user:pass@proxy.example:8080"),
275            ProxyRule::https("https://proxy.example:8443").with_basic_auth("u", "p"),
276            ProxyRule::all("http://proxy.example:8080").with_custom_auth("Bearer token"),
277            ProxyRule::all("http://proxy.example:8080").bypassing("localhost, 10.0.0.0/8"),
278        ] {
279            assert!(rule.to_reqwest().is_ok(), "should build: {rule:?}");
280        }
281    }
282
283    /// The `socks` feature is the only thing standing between a `socks5://` URL and a working
284    /// proxy, so assert both directions — the docs promise exactly this trade.
285    #[test]
286    fn socks_urls_need_the_socks_feature() {
287        let built = ProxyRule::all("socks5://127.0.0.1:1080")
288            .to_reqwest()
289            .is_ok();
290        assert_eq!(built, cfg!(feature = "socks"));
291    }
292
293    #[test]
294    fn unusable_proxy_url_is_reported() {
295        let err = ProxyRule::all("not a url").to_reqwest().unwrap_err();
296        assert!(
297            err.to_string().contains("not a url"),
298            "error should name the offending URL, got: {err}"
299        );
300    }
301
302    #[test]
303    fn invalid_custom_auth_header_is_reported() {
304        let err = ProxyRule::all("http://proxy.example:8080")
305            .with_custom_auth("bad\nvalue")
306            .to_reqwest()
307            .unwrap_err();
308        assert!(
309            err.to_string().contains("Proxy-Authorization"),
310            "error should mention the header, got: {err}"
311        );
312    }
313
314    #[test]
315    fn apply_accepts_every_variant() {
316        for cfg in [
317            ProxyConfig::System,
318            ProxyConfig::Disabled,
319            ProxyConfig::Rules(vec![]),
320            ProxyConfig::single("http://proxy.example:8080"),
321        ] {
322            assert!(
323                cfg.apply(reqwest::Client::builder()).is_ok(),
324                "should apply: {cfg:?}"
325            );
326        }
327    }
328
329    #[test]
330    fn apply_propagates_rule_errors() {
331        let cfg = ProxyConfig::single("not a url");
332        assert!(cfg.apply(reqwest::Client::builder()).is_err());
333    }
334}