1use std::borrow::Cow;
8use std::collections::HashMap;
9use std::fmt;
10use std::io;
11use std::sync::Mutex;
12use std::sync::OnceLock;
13use std::time::Duration;
14use std::time::Instant;
15#[cfg(any(target_os = "windows", target_os = "macos"))]
16use tokio::sync::Semaphore;
17
18use crate::custom_ca::BuildCustomCaTransportError;
19use crate::custom_ca::build_reqwest_client_with_custom_ca;
20use sha2::Digest;
21use sha2::Sha256;
22use thiserror::Error;
23
24const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60);
25const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5);
26const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256;
27#[cfg(any(target_os = "windows", target_os = "macos"))]
28static ASYNC_SYSTEM_PROXY_RESOLUTION_PERMIT: Semaphore = Semaphore::const_new(1);
29
30#[cfg(target_os = "macos")]
31mod macos;
32#[cfg(target_os = "windows")]
33mod windows;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ClientRouteClass {
43 Auth,
45 Api,
47 WebSocket,
49 Other,
51}
52
53impl fmt::Display for ClientRouteClass {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.write_str(match self {
56 Self::Auth => "auth",
57 Self::Api => "api",
58 Self::WebSocket => "wss",
59 Self::Other => "other",
60 })
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum RouteFailureClass {
67 ProxyResolutionUnavailable,
68 ConnectTimeout,
69 ProxyAuthenticationRequired,
70 TlsError,
71 InvalidProxyConfig,
72 UnsupportedProxyScheme,
73 ResolverError,
74}
75
76impl fmt::Display for RouteFailureClass {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.write_str(match self {
79 Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable",
80 Self::ConnectTimeout => "connect_timeout",
81 Self::ProxyAuthenticationRequired => "proxy_407",
82 Self::TlsError => "tls_error",
83 Self::InvalidProxyConfig => "invalid_proxy_config",
84 Self::UnsupportedProxyScheme => "unsupported_proxy_scheme",
85 Self::ResolverError => "resolver_error",
86 })
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum OutboundProxyPolicy {
96 ReqwestDefault,
98 RespectSystemProxy,
100}
101
102#[derive(Clone, Hash, PartialEq, Eq)]
110pub enum OutboundProxyRoute {
111 TransportDefault,
113 Direct,
115 Proxy {
117 url: String,
118 no_proxy: Option<String>,
119 },
120}
121
122impl fmt::Debug for OutboundProxyRoute {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 match self {
125 Self::TransportDefault => f.write_str("TransportDefault"),
126 Self::Direct => f.write_str("Direct"),
127 Self::Proxy { .. } => f
128 .debug_struct("Proxy")
129 .field("url", &"<redacted>")
130 .field("no_proxy", &"<redacted>")
131 .finish(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct HttpClientFactory {
143 outbound_proxy_policy: OutboundProxyPolicy,
144}
145
146impl HttpClientFactory {
147 pub const fn new(outbound_proxy_policy: OutboundProxyPolicy) -> Self {
149 Self {
150 outbound_proxy_policy,
151 }
152 }
153
154 pub const fn outbound_proxy_policy(&self) -> OutboundProxyPolicy {
156 self.outbound_proxy_policy
157 }
158
159 pub fn resolve_proxy_route(&self, request_url: &str) -> OutboundProxyRoute {
166 resolve_proxy_route(
167 &ProcessEnv,
168 request_url,
169 self.outbound_proxy_policy,
170 resolve_system_proxy,
171 )
172 }
173
174 pub async fn resolve_proxy_route_async(
176 &self,
177 request_url: String,
178 ) -> io::Result<OutboundProxyRoute> {
179 if matches!(
180 self.outbound_proxy_policy,
181 OutboundProxyPolicy::ReqwestDefault
182 ) {
183 return Ok(OutboundProxyRoute::TransportDefault);
184 }
185
186 if let Some(route) = self.cached_proxy_route(&request_url) {
187 return Ok(route);
188 }
189
190 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
191 return Ok(self.resolve_proxy_route(&request_url));
192
193 #[cfg(any(target_os = "windows", target_os = "macos"))]
194 {
195 let permit = ASYNC_SYSTEM_PROXY_RESOLUTION_PERMIT
196 .acquire()
197 .await
198 .map_err(io::Error::other)?;
199 let factory = self.clone();
200 tokio::task::spawn_blocking(move || {
201 let _permit = permit;
204 factory.resolve_proxy_route(&request_url)
205 })
206 .await
207 .map_err(io::Error::other)
208 }
209 }
210
211 fn cached_proxy_route(&self, request_url: &str) -> Option<OutboundProxyRoute> {
212 let env_proxy_kind = EnvProxyKind::from_request_url(request_url);
213 let request_url = proxy_resolution_url(request_url);
214 if RequestOrigin::parse(&request_url).is_none() {
215 return Some(OutboundProxyRoute::Direct);
216 }
217 cached_system_proxy_decision(&request_url)
218 .map(|decision| route_from_system_decision(&ProcessEnv, env_proxy_kind, decision))
219 }
220
221 pub fn build_reqwest_client(
223 &self,
224 builder: reqwest::ClientBuilder,
225 request_url: &str,
226 route_class: ClientRouteClass,
227 ) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
228 build_reqwest_client_for_route(
229 builder,
230 request_url,
231 route_class,
232 self.outbound_proxy_policy,
233 )
234 }
235
236 pub(crate) fn build_reqwest_client_for_resolved_route(
237 &self,
238 builder: reqwest::ClientBuilder,
239 route_class: ClientRouteClass,
240 route: &OutboundProxyRoute,
241 ) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
242 let builder = configure_builder_for_resolved_route(builder, route_class, route)?;
243 build_reqwest_client_with_custom_ca(builder).map_err(Into::into)
244 }
245}
246
247fn resolve_proxy_route(
248 env: &dyn EnvSource,
249 request_url: &str,
250 outbound_proxy_policy: OutboundProxyPolicy,
251 resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision,
252) -> OutboundProxyRoute {
253 if matches!(outbound_proxy_policy, OutboundProxyPolicy::ReqwestDefault) {
254 return OutboundProxyRoute::TransportDefault;
255 }
256
257 let env_proxy_kind = EnvProxyKind::from_request_url(request_url);
258 let request_url = proxy_resolution_url(request_url);
259 let Some(origin) = RequestOrigin::parse(&request_url) else {
260 return OutboundProxyRoute::Direct;
261 };
262
263 route_from_system_decision(
264 env,
265 env_proxy_kind,
266 resolve_system_proxy(&request_url, &origin),
267 )
268}
269
270fn route_from_system_decision(
271 env: &dyn EnvSource,
272 env_proxy_kind: EnvProxyKind,
273 decision: SystemProxyDecision,
274) -> OutboundProxyRoute {
275 match decision {
276 SystemProxyDecision::Direct => OutboundProxyRoute::Direct,
277 SystemProxyDecision::Proxy { url } => OutboundProxyRoute::Proxy {
278 url,
279 no_proxy: None,
280 },
281 SystemProxyDecision::Unavailable { .. } => resolve_env_proxy_route(env, env_proxy_kind),
282 }
283}
284
285fn resolve_env_proxy_route(
286 env: &dyn EnvSource,
287 env_proxy_kind: EnvProxyKind,
288) -> OutboundProxyRoute {
289 let proxy_url = match env_proxy_kind {
290 EnvProxyKind::Https => {
291 proxy_env_value(env, "HTTPS_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY"))
292 }
293 EnvProxyKind::SecureWebSocket => proxy_env_value(env, "HTTPS_PROXY")
294 .or_else(|| proxy_env_value(env, "HTTP_PROXY"))
295 .or_else(|| proxy_env_value(env, "ALL_PROXY")),
296 EnvProxyKind::Http => {
297 proxy_env_value(env, "HTTP_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY"))
298 }
299 EnvProxyKind::Other => proxy_env_value(env, "ALL_PROXY"),
300 };
301 match proxy_url {
302 Some(url) => OutboundProxyRoute::Proxy {
303 url,
304 no_proxy: proxy_env_value(env, "NO_PROXY"),
305 },
306 None => OutboundProxyRoute::Direct,
307 }
308}
309
310#[derive(Clone, Copy)]
311enum EnvProxyKind {
312 Http,
313 Https,
314 SecureWebSocket,
315 Other,
316}
317
318impl EnvProxyKind {
319 fn from_request_url(request_url: &str) -> Self {
320 let scheme = request_url
321 .parse::<http::Uri>()
322 .ok()
323 .and_then(|uri| uri.scheme_str().map(str::to_ascii_lowercase));
324 match scheme.as_deref() {
325 Some("http" | "ws") => Self::Http,
326 Some("https") => Self::Https,
327 Some("wss") => Self::SecureWebSocket,
328 Some(_) | None => Self::Other,
329 }
330 }
331}
332
333fn proxy_resolution_url(request_url: &str) -> Cow<'_, str> {
334 if let Some(suffix) = request_url.strip_prefix("wss://") {
335 Cow::Owned(format!("https://{suffix}"))
336 } else if let Some(suffix) = request_url.strip_prefix("ws://") {
337 Cow::Owned(format!("http://{suffix}"))
338 } else {
339 Cow::Borrowed(request_url)
340 }
341}
342
343#[derive(Debug, Error)]
345pub enum BuildRouteAwareHttpClientError {
346 #[error(transparent)]
347 CustomCa(#[from] BuildCustomCaTransportError),
348
349 #[error("Failed to configure outbound proxy selected for {route_class}")]
350 InvalidProxyConfig { route_class: ClientRouteClass },
351}
352
353impl From<BuildRouteAwareHttpClientError> for io::Error {
354 fn from(error: BuildRouteAwareHttpClientError) -> Self {
355 match error {
356 BuildRouteAwareHttpClientError::CustomCa(error) => error.into(),
357 BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } => io::Error::other(error),
358 }
359 }
360}
361
362fn build_reqwest_client_for_route(
369 builder: reqwest::ClientBuilder,
370 request_url: &str,
371 route_class: ClientRouteClass,
372 outbound_proxy_policy: OutboundProxyPolicy,
373) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
374 let builder = configure_proxy_for_route(
375 &ProcessEnv,
376 builder,
377 request_url,
378 route_class,
379 outbound_proxy_policy,
380 resolve_system_proxy,
381 )?;
382 build_reqwest_client_with_custom_ca(builder).map_err(Into::into)
383}
384
385fn configure_proxy_for_route(
386 env: &dyn EnvSource,
387 builder: reqwest::ClientBuilder,
388 request_url: &str,
389 route_class: ClientRouteClass,
390 outbound_proxy_policy: OutboundProxyPolicy,
391 resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision,
392) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
393 let route = resolve_proxy_route(
394 env,
395 request_url,
396 outbound_proxy_policy,
397 resolve_system_proxy,
398 );
399 configure_builder_for_resolved_route(builder, route_class, &route)
400}
401
402fn configure_builder_for_resolved_route(
403 builder: reqwest::ClientBuilder,
404 route_class: ClientRouteClass,
405 route: &OutboundProxyRoute,
406) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
407 match route {
408 OutboundProxyRoute::TransportDefault => Ok(builder),
409 OutboundProxyRoute::Direct => Ok(builder.no_proxy()),
410 OutboundProxyRoute::Proxy { url, no_proxy } => {
411 let no_proxy = no_proxy.as_deref().and_then(reqwest::NoProxy::from_string);
412 configure_concrete_proxy(builder, route_class, url, no_proxy)
413 }
414 }
415}
416
417fn configure_concrete_proxy(
418 builder: reqwest::ClientBuilder,
419 route_class: ClientRouteClass,
420 proxy_url: &str,
421 no_proxy: Option<reqwest::NoProxy>,
422) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
423 let proxy = match reqwest::Proxy::all(proxy_url) {
424 Ok(proxy) => proxy,
425 Err(_source) => {
426 return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class });
427 }
428 };
429 Ok(builder.proxy(proxy.no_proxy(no_proxy)))
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
433#[allow(dead_code)]
434struct RequestOrigin {
435 scheme: String,
436 host: String,
437 port: u16,
438}
439
440impl RequestOrigin {
441 fn parse(request_url: &str) -> Option<Self> {
442 let uri = request_url.parse::<http::Uri>().ok()?;
443 let scheme = uri.scheme_str()?.to_ascii_lowercase();
444 let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase();
445 let port = uri.port_u16().or(match scheme.as_str() {
446 "http" | "ws" => Some(80),
447 "https" | "wss" => Some(443),
448 _ => None,
449 })?;
450 Some(Self { scheme, host, port })
451 }
452}
453
454#[cfg_attr(
455 not(any(target_os = "windows", target_os = "macos")),
456 allow(
457 dead_code,
458 reason = "Direct and Proxy are constructed only by platform-specific resolvers"
459 )
460)]
461#[derive(Debug, Clone, PartialEq, Eq)]
462enum SystemProxyDecision {
463 Direct,
464 Proxy { url: String },
465 Unavailable { failure: RouteFailureClass },
466}
467
468fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
469 let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
470 resolve_system_proxy_with(cache, request_url, origin, resolve_platform_system_proxy)
471}
472
473fn resolve_system_proxy_with(
474 cache: &Mutex<HashMap<String, CachedSystemProxyDecision>>,
475 request_url: &str,
476 origin: &RequestOrigin,
477 resolve_platform_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision,
478) -> SystemProxyDecision {
479 let mut cache = match cache.lock() {
480 Ok(cache) => cache,
481 Err(error) => panic!("system proxy cache lock should not be poisoned: {error}"),
482 };
483 let cache_key = system_proxy_cache_key(request_url);
484 if let Some(decision) =
485 cached_system_proxy_decision_from_cache(&mut cache, &cache_key, Instant::now())
486 {
487 return decision;
488 }
489
490 let decision = resolve_platform_system_proxy(request_url, origin);
494 insert_system_proxy_cache_entry(&mut cache, &cache_key, decision.clone(), Instant::now());
495 decision
496}
497
498#[cfg(target_os = "macos")]
499fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
500 macos::resolve(request_url, origin)
501}
502
503#[cfg(target_os = "windows")]
504fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
505 windows::resolve(request_url, origin)
506}
507
508#[cfg(not(any(target_os = "windows", target_os = "macos")))]
509fn resolve_platform_system_proxy(
510 _request_url: &str,
511 _origin: &RequestOrigin,
512) -> SystemProxyDecision {
513 SystemProxyDecision::Unavailable {
514 failure: RouteFailureClass::ProxyResolutionUnavailable,
515 }
516}
517
518#[derive(Debug, Clone)]
519struct CachedSystemProxyDecision {
520 decision: SystemProxyDecision,
521 expires_at: Instant,
522}
523
524static SYSTEM_PROXY_CACHE: OnceLock<Mutex<HashMap<String, CachedSystemProxyDecision>>> =
525 OnceLock::new();
526
527fn cached_system_proxy_decision(request_url: &str) -> Option<SystemProxyDecision> {
528 let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
529 let mut cache = cache.lock().ok()?;
530 let key = system_proxy_cache_key(request_url);
531 cached_system_proxy_decision_from_cache(&mut cache, &key, Instant::now())
532}
533
534fn cached_system_proxy_decision_from_cache(
535 cache: &mut HashMap<String, CachedSystemProxyDecision>,
536 cache_key: &str,
537 now: Instant,
538) -> Option<SystemProxyDecision> {
539 let cached = cache.get(cache_key)?;
540 if cached.expires_at > now {
541 return Some(cached.decision.clone());
542 }
543 cache.remove(cache_key);
544 None
545}
546
547#[cfg(test)]
548fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) {
549 let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
550 if let Ok(mut cache) = cache.lock() {
551 let cache_key = system_proxy_cache_key(request_url);
552 insert_system_proxy_cache_entry(&mut cache, &cache_key, decision, Instant::now());
553 }
554}
555
556fn insert_system_proxy_cache_entry(
557 cache: &mut HashMap<String, CachedSystemProxyDecision>,
558 cache_key: &str,
559 decision: SystemProxyDecision,
560 now: Instant,
561) {
562 let ttl = match &decision {
563 SystemProxyDecision::Direct | SystemProxyDecision::Proxy { .. } => {
564 SYSTEM_PROXY_SUCCESS_CACHE_TTL
565 }
566 SystemProxyDecision::Unavailable { .. } => SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL,
567 };
568
569 cache.retain(|_, cached| cached.expires_at > now);
570 if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES
571 && !cache.contains_key(cache_key)
572 && let Some(cache_key_to_evict) = cache
573 .iter()
574 .min_by_key(|(_, cached)| cached.expires_at)
575 .map(|(cache_key, _)| cache_key.clone())
576 {
577 cache.remove(&cache_key_to_evict);
578 }
579 cache.insert(
580 cache_key.to_string(),
581 CachedSystemProxyDecision {
582 decision,
583 expires_at: now + ttl,
584 },
585 );
586}
587
588fn system_proxy_cache_key(request_url: &str) -> String {
589 let mut hasher = Sha256::new();
591 hasher.update(b"system-proxy-cache-v1\0");
592 hasher.update(request_url.as_bytes());
593 format!("{:x}", hasher.finalize())
594}
595
596#[cfg(any(test, target_os = "windows"))]
597fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool {
598 no_proxy
599 .split(',')
600 .map(str::trim)
601 .filter(|entry| !entry.is_empty())
602 .any(|entry| no_proxy_entry_matches_origin(entry, origin))
603}
604
605#[cfg(any(test, target_os = "windows"))]
606fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool {
607 if entry == "*" {
608 return true;
609 }
610
611 let mut entry = entry
612 .strip_prefix("http://")
613 .or_else(|| entry.strip_prefix("https://"))
614 .unwrap_or(entry)
615 .trim_matches(['[', ']'])
616 .to_ascii_lowercase();
617 let mut port = None;
618 let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| {
619 if host.contains(':') {
620 return None;
621 }
622 candidate_port
623 .parse::<u16>()
624 .ok()
625 .map(|parsed_port| (host.to_string(), parsed_port))
626 });
627 if let Some((host, parsed_port)) = parsed_host_port {
628 entry = host;
629 port = Some(parsed_port);
630 }
631 if port.is_some_and(|port| port != origin.port) {
632 return false;
633 }
634
635 if let Some(suffix) = entry.strip_prefix('.') {
636 return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}"));
637 }
638
639 if entry.contains('*') {
640 return wildcard_host_match(&entry, &origin.host);
641 }
642
643 origin.host == entry
644}
645
646#[cfg(any(test, target_os = "windows"))]
647fn wildcard_host_match(pattern: &str, host: &str) -> bool {
648 let mut remaining = host;
649 let mut first = true;
650 for part in pattern.split('*') {
651 if part.is_empty() {
652 continue;
653 }
654 if first && !pattern.starts_with('*') {
655 let Some(stripped) = remaining.strip_prefix(part) else {
656 return false;
657 };
658 remaining = stripped;
659 } else {
660 let Some(index) = remaining.find(part) else {
661 return false;
662 };
663 remaining = &remaining[index + part.len()..];
664 }
665 first = false;
666 }
667 pattern.ends_with('*') || remaining.is_empty()
668}
669
670#[cfg(any(test, target_os = "windows"))]
671#[derive(Debug, Clone, PartialEq, Eq)]
672enum ParsedProxyListDecision {
673 Direct,
674 Proxy(String),
675 UnsupportedScheme,
676 Unavailable,
677}
678
679#[cfg(any(test, target_os = "windows"))]
680fn parse_proxy_list(input: &str, target_scheme: &str) -> ParsedProxyListDecision {
681 let mut saw_unsupported = false;
682
683 {
684 let mut process_token = |token: &str| {
685 let decision = parse_proxy_token(token, target_scheme);
686 match decision {
687 ParsedProxyListDecision::Direct => Some(ParsedProxyListDecision::Direct),
688 ParsedProxyListDecision::Proxy(url) => Some(ParsedProxyListDecision::Proxy(url)),
689 ParsedProxyListDecision::UnsupportedScheme => {
690 saw_unsupported = true;
691 None
692 }
693 ParsedProxyListDecision::Unavailable => None,
694 }
695 };
696
697 for segment in input
698 .split(';')
699 .map(str::trim)
700 .filter(|segment| !segment.is_empty())
701 {
702 let mut parts = segment.split_whitespace();
703 let directive = parts.next();
704 let hostport = parts.next();
705 let extra = parts.next();
706 let is_proxy_directive = matches!(
707 directive.map(str::to_ascii_lowercase).as_deref(),
708 Some("proxy" | "http" | "https" | "socks" | "socks4" | "socks5")
709 ) && hostport.is_some()
710 && extra.is_none();
711
712 if is_proxy_directive {
713 if let Some(decision) = process_token(segment) {
714 return decision;
715 }
716 } else {
717 for token in segment.split_whitespace() {
718 if let Some(decision) = process_token(token) {
719 return decision;
720 }
721 }
722 }
723 }
724 }
725
726 if saw_unsupported {
727 ParsedProxyListDecision::UnsupportedScheme
728 } else {
729 ParsedProxyListDecision::Unavailable
730 }
731}
732
733#[cfg(any(test, target_os = "windows"))]
734fn parse_proxy_token(token: &str, target_scheme: &str) -> ParsedProxyListDecision {
735 if token.eq_ignore_ascii_case("DIRECT") {
736 return ParsedProxyListDecision::Direct;
737 }
738
739 if let Some(decision) = parse_proxy_key_token(token, target_scheme) {
740 return decision;
741 }
742 if token.contains('=') {
743 return ParsedProxyListDecision::Unavailable;
744 }
745
746 let mut parts = token.split_whitespace();
747 let directive = parts.next();
748 let hostport = parts.next();
749 if let (Some(directive), Some(hostport), None) = (directive, hostport, parts.next()) {
750 return match directive.to_ascii_lowercase().as_str() {
751 "proxy" | "http" => proxy_url_from_hostport("http", hostport),
752 "https" => proxy_url_from_hostport("https", hostport),
753 "socks" | "socks4" | "socks5" => ParsedProxyListDecision::UnsupportedScheme,
754 _ => ParsedProxyListDecision::Unavailable,
755 };
756 }
757
758 proxy_url_from_hostport("http", token)
759}
760
761#[cfg(any(test, target_os = "windows"))]
762fn parse_proxy_key_token(token: &str, target_scheme: &str) -> Option<ParsedProxyListDecision> {
763 let (key, value) = token.split_once('=')?;
764 if key.trim().eq_ignore_ascii_case(target_scheme) {
765 Some(proxy_url_from_hostport("http", value.trim()))
766 } else {
767 Some(ParsedProxyListDecision::Unavailable)
768 }
769}
770
771#[cfg(any(test, target_os = "windows"))]
772fn proxy_url_from_hostport(proxy_scheme: &str, hostport: &str) -> ParsedProxyListDecision {
773 if hostport.is_empty() {
774 return ParsedProxyListDecision::Unavailable;
775 }
776 if hostport.contains("://") {
777 return ParsedProxyListDecision::Proxy(hostport.to_string());
778 }
779 ParsedProxyListDecision::Proxy(format!("{proxy_scheme}://{hostport}"))
780}
781
782trait EnvSource {
783 fn var(&self, key: &str) -> Option<String>;
784}
785
786struct ProcessEnv;
787
788impl EnvSource for ProcessEnv {
789 fn var(&self, key: &str) -> Option<String> {
790 std::env::var(key).ok()
791 }
792}
793
794fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option<String> {
795 let lower = upper.to_ascii_lowercase();
796 env.var(upper)
797 .or_else(|| env.var(&lower))
798 .filter(|value| !value.is_empty())
799}
800
801#[cfg(test)]
802#[path = "route_aware_redirect_integration_tests.rs"]
803mod redirect_integration_tests;
804
805#[cfg(test)]
806#[path = "outbound_proxy_tests.rs"]
807mod tests;