Skip to main content

hasp_core/
proxy.rs

1//! HTTP CONNECT proxy configuration.
2//!
3//! Corporate networks often force outbound traffic through an HTTP CONNECT
4//! proxy (Squid, Blue Coat, Zscaler, etc.). This module provides the
5//! configuration type and resolution helpers used by HTTP-based backends.
6//!
7//! SOCKS5 is supported for HTTP-based backends that use `reqwest`.
8//! Enable the `socks5-proxy` feature on the backend crate to include
9//! `reqwest`'s `socks` feature.
10
11use crate::Error;
12use secrecy::SecretString;
13use std::env;
14
15/// Parsed HTTP CONNECT proxy configuration.
16///
17/// Credentials are stored in a `SecretString` so they never appear in
18/// `Debug` output or error diagnostics. The `url` field is kept for
19/// diagnostics but is the raw string supplied by the caller.
20#[derive(Clone)]
21pub struct ProxyConfig {
22    /// The original URL string (for diagnostics only — may contain
23    /// credentials that should not be logged).
24    pub url: String,
25    /// Proxy hostname or IP.
26    pub host: String,
27    /// Proxy port.
28    pub port: u16,
29    /// Username for Basic auth (optional).
30    pub username: Option<String>,
31    /// Password for Basic auth (optional).
32    pub password: Option<SecretString>,
33    /// Original scheme (http or https) — preserved so
34    /// `url_without_credentials` reconstructs the correct URL.
35    scheme: String,
36}
37
38impl std::fmt::Debug for ProxyConfig {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("ProxyConfig")
41            .field("url", &self.url_without_credentials())
42            .field("host", &self.host)
43            .field("port", &self.port)
44            .field("username", &self.username)
45            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
46            .finish()
47    }
48}
49
50impl ProxyConfig {
51    /// Parse from a URL string like `http://proxy:8080` or
52    /// `http://user:pass@proxy:3128`.
53    ///
54    /// # Errors
55    ///
56    /// Returns `Error::InvalidUrl` when the string is not a valid URL,
57    /// has no host, or uses a non-HTTP scheme.
58    pub fn parse(url: &str) -> Result<Self, Error> {
59        let parsed =
60            ::url::Url::parse(url).map_err(|e| Error::InvalidUrl(format!("proxy URL: {e}")))?;
61
62        if parsed.scheme() != "http" && parsed.scheme() != "https" && parsed.scheme() != "socks5" {
63            return Err(Error::InvalidUrl(format!(
64                "proxy URL must be http://, https://, or socks5://, got {}",
65                parsed.scheme()
66            )));
67        }
68
69        let host = parsed
70            .host_str()
71            .ok_or_else(|| Error::InvalidUrl("proxy URL has no host".to_string()))?
72            .to_string();
73        let port = parsed.port().unwrap_or(8080);
74
75        let (username, password) = if let Some(pass) = parsed.password() {
76            (
77                Some(parsed.username().to_string()),
78                Some(SecretString::new(pass.to_string().into())),
79            )
80        } else if !parsed.username().is_empty() {
81            (Some(parsed.username().to_string()), None)
82        } else {
83            (None, None)
84        };
85
86        Ok(ProxyConfig {
87            url: url.to_string(),
88            host,
89            port,
90            username,
91            password,
92            scheme: parsed.scheme().to_string(),
93        })
94    }
95
96    /// Return the proxy URL with credentials removed, suitable for
97    /// passing to HTTP client libraries (e.g. `reqwest::Proxy::all`).
98    ///
99    /// If the original URL contained a password, this returns
100    /// `http://proxy:8080` so the library handles auth itself via the
101    /// username/password fields.
102    pub fn url_without_credentials(&self) -> String {
103        format!("{}://{}:{}", self.scheme, self.host, self.port)
104    }
105}
106
107/// Check whether the target host is covered by the `NO_PROXY`
108/// environment variable.
109///
110/// Supports the same syntax as curl:
111///
112/// - `*` — disables proxy for every host.
113/// - `localhost,127.0.0.1` — exact matches, comma-separated.
114/// - `.example.com` — suffix match (`db.example.com` matches,
115///   `example.com` does not).
116/// - Port numbers in patterns are ignored — hasp matches hostnames only.
117pub fn is_no_proxy(target_host: &str) -> bool {
118    let no_proxy = match env::var("NO_PROXY").or_else(|_| env::var("no_proxy")) {
119        Ok(v) if !v.is_empty() => v,
120        _ => return false,
121    };
122
123    let target = target_host.to_ascii_lowercase();
124
125    for pattern in no_proxy.split(',') {
126        let pattern = pattern.trim().to_ascii_lowercase();
127        if pattern.is_empty() {
128            continue;
129        }
130
131        // Match everything.
132        if pattern == "*" {
133            return true;
134        }
135
136        // Strip port if present (e.g. "host:8080" → "host").
137        let pattern_host = pattern.split(':').next().unwrap_or(&pattern);
138
139        // Suffix match: .domain.com matches any.sub.domain.com
140        if pattern_host.starts_with('.') {
141            if target.ends_with(pattern_host) {
142                return true;
143            }
144        }
145        // Exact match, or suffix match without leading dot.
146        else if target == pattern_host || target.ends_with(&format!(".{pattern_host}")) {
147            return true;
148        }
149    }
150
151    false
152}
153
154/// Resolve proxy configuration from the standard environment
155/// variable stack.
156///
157/// Checks `ALL_PROXY`, then `HTTPS_PROXY` / `HTTP_PROXY`.  Skips
158/// `NO_PROXY` hosts at this layer — callers should call
159/// `is_no_proxy` separately when they know the target hostname.
160///
161/// The target hostname is used only for the `NO_PROXY` decision,
162/// not for selecting between HTTP vs HTTPS proxy.
163pub fn resolve_proxy_from_env(target_host: &str) -> Option<ProxyConfig> {
164    if is_no_proxy(target_host) {
165        return None;
166    }
167
168    let try_env = |name: &str| -> Option<ProxyConfig> {
169        env::var(name)
170            .ok()
171            .filter(|s| !s.is_empty())
172            .and_then(|url| ProxyConfig::parse(&url).ok())
173    };
174
175    try_env("ALL_PROXY")
176        .or_else(|| try_env("all_proxy"))
177        .or_else(|| try_env("HTTPS_PROXY"))
178        .or_else(|| try_env("https_proxy"))
179        .or_else(|| try_env("HTTP_PROXY"))
180        .or_else(|| try_env("http_proxy"))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use secrecy::ExposeSecret;
187    use std::sync::Mutex;
188
189    static ENV_LOCK: Mutex<()> = Mutex::new(());
190
191    #[test]
192    fn parse_simple() {
193        let cfg = ProxyConfig::parse("http://proxy:8080").unwrap();
194        assert_eq!(cfg.host, "proxy");
195        assert_eq!(cfg.port, 8080);
196        assert_eq!(cfg.username, None);
197        assert!(cfg.password.is_none());
198    }
199
200    #[test]
201    fn parse_with_auth() {
202        let cfg = ProxyConfig::parse("http://user:pass@proxy:3128").unwrap();
203        assert_eq!(cfg.host, "proxy");
204        assert_eq!(cfg.port, 3128);
205        assert_eq!(cfg.username, Some("user".to_string()));
206        assert_eq!(cfg.password.as_ref().unwrap().expose_secret(), "pass");
207    }
208
209    #[test]
210    fn parse_only_username() {
211        let cfg = ProxyConfig::parse("http://user@proxy:3128").unwrap();
212        assert_eq!(cfg.username, Some("user".to_string()));
213        assert!(cfg.password.is_none());
214    }
215
216    #[test]
217    fn parse_no_port_uses_8080() {
218        let cfg = ProxyConfig::parse("http://proxy").unwrap();
219        assert_eq!(cfg.port, 8080);
220    }
221
222    #[test]
223    fn parse_https_scheme_accepted() {
224        let cfg = ProxyConfig::parse("https://proxy:8443").unwrap();
225        assert_eq!(cfg.host, "proxy");
226        assert_eq!(cfg.port, 8443);
227    }
228
229    #[test]
230    fn parse_socks5_scheme_accepted() {
231        let cfg = ProxyConfig::parse("socks5://proxy:1080").unwrap();
232        assert_eq!(cfg.scheme, "socks5");
233        assert_eq!(cfg.host, "proxy");
234        assert_eq!(cfg.port, 1080);
235    }
236
237    #[test]
238    fn parse_ftp_scheme_rejected() {
239        let err = ProxyConfig::parse("ftp://proxy:1080").unwrap_err();
240        assert!(
241            matches!(err, Error::InvalidUrl(ref s) if s.contains("http://, https://, or socks5://")),
242            "expected InvalidUrl for non-http scheme, got: {err}"
243        );
244    }
245
246    #[test]
247    fn parse_no_host_rejected() {
248        let err = ProxyConfig::parse("://missing-scheme").unwrap_err();
249        assert!(
250            matches!(err, Error::InvalidUrl(ref s) if s.contains("proxy URL")),
251            "expected InvalidUrl for missing host, got: {err}"
252        );
253    }
254
255    #[test]
256    fn url_without_credentials_strips_auth() {
257        let cfg = ProxyConfig::parse("http://user:pass@proxy:3128").unwrap();
258        assert_eq!(cfg.url_without_credentials(), "http://proxy:3128");
259    }
260
261    #[test]
262    fn url_without_credentials_preserves_https() {
263        let cfg = ProxyConfig::parse("https://user:pass@proxy:3128").unwrap();
264        assert_eq!(cfg.url_without_credentials(), "https://proxy:3128");
265    }
266
267    #[test]
268    fn debug_redacts_password() {
269        let cfg = ProxyConfig::parse("http://user:s3cr3t@proxy:3128").unwrap();
270        let dbg = format!("{cfg:?}");
271        assert!(!dbg.contains("s3cr3t"));
272        assert!(dbg.contains("[REDACTED]"));
273    }
274
275    #[test]
276    fn is_no_proxy_star() {
277        let _guard = ENV_LOCK.lock().unwrap();
278        env::set_var("NO_PROXY", "*");
279        assert!(is_no_proxy("anything"));
280        env::remove_var("NO_PROXY");
281    }
282
283    #[test]
284    fn is_no_proxy_exact() {
285        let _guard = ENV_LOCK.lock().unwrap();
286        env::set_var("NO_PROXY", "localhost");
287        assert!(is_no_proxy("localhost"));
288        assert!(!is_no_proxy("otherhost"));
289        env::remove_var("NO_PROXY");
290    }
291
292    #[test]
293    fn is_no_proxy_suffix() {
294        let _guard = ENV_LOCK.lock().unwrap();
295        env::set_var("NO_PROXY", ".example.com");
296        assert!(is_no_proxy("db.example.com"));
297        assert!(!is_no_proxy("example.com"));
298        env::remove_var("NO_PROXY");
299    }
300
301    #[test]
302    fn is_no_proxy_case_insensitive() {
303        let _guard = ENV_LOCK.lock().unwrap();
304        env::set_var("NO_PROXY", "LOCALHOST");
305        assert!(is_no_proxy("localhost"));
306        env::remove_var("NO_PROXY");
307    }
308
309    #[test]
310    fn is_no_proxy_lower_case_var() {
311        let _guard = ENV_LOCK.lock().unwrap();
312        env::set_var("no_proxy", "localhost");
313        assert!(is_no_proxy("localhost"));
314        env::remove_var("no_proxy");
315    }
316
317    #[test]
318    fn resolve_proxy_from_env_empty() {
319        let _guard = ENV_LOCK.lock().unwrap();
320        // Should return None when no env vars are set.
321        assert!(resolve_proxy_from_env("example.com").is_none());
322    }
323
324    #[test]
325    fn resolve_proxy_from_env_all_proxy() {
326        let _guard = ENV_LOCK.lock().unwrap();
327        env::set_var("ALL_PROXY", "http://proxy:8080");
328        let cfg = resolve_proxy_from_env("example.com").unwrap();
329        assert_eq!(cfg.host, "proxy");
330        assert_eq!(cfg.port, 8080);
331        env::remove_var("ALL_PROXY");
332    }
333
334    #[test]
335    fn resolve_proxy_from_env_no_proxy_blocks() {
336        let _guard = ENV_LOCK.lock().unwrap();
337        env::set_var("ALL_PROXY", "http://proxy:8080");
338        env::set_var("NO_PROXY", "example.com");
339        assert!(resolve_proxy_from_env("example.com").is_none());
340        env::remove_var("ALL_PROXY");
341        env::remove_var("NO_PROXY");
342    }
343
344    #[test]
345    fn resolve_proxy_from_env_ignores_no_proxy_for_other_host() {
346        let _guard = ENV_LOCK.lock().unwrap();
347        env::set_var("ALL_PROXY", "http://proxy:8080");
348        env::set_var("NO_PROXY", "example.com");
349        let cfg = resolve_proxy_from_env("other.com").unwrap();
350        assert_eq!(cfg.host, "proxy");
351        env::remove_var("ALL_PROXY");
352        env::remove_var("NO_PROXY");
353    }
354}