1use crate::Error;
12use secrecy::SecretString;
13use std::env;
14
15#[derive(Clone)]
21pub struct ProxyConfig {
22 pub url: String,
25 pub host: String,
27 pub port: u16,
29 pub username: Option<String>,
31 pub password: Option<SecretString>,
33 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 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 pub fn url_without_credentials(&self) -> String {
103 format!("{}://{}:{}", self.scheme, self.host, self.port)
104 }
105}
106
107pub 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 if pattern == "*" {
133 return true;
134 }
135
136 let pattern_host = pattern.split(':').next().unwrap_or(&pattern);
138
139 if pattern_host.starts_with('.') {
141 if target.ends_with(pattern_host) {
142 return true;
143 }
144 }
145 else if target == pattern_host || target.ends_with(&format!(".{pattern_host}")) {
147 return true;
148 }
149 }
150
151 false
152}
153
154pub 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 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}