//! Conservative outbound proxy selection for resolver-aware HTTP clients. //! //! When enabled, platform system discovery is tried first, explicit environment //! proxies are the fallback, and the final fallback is a direct connection. //! When disabled, callers retain the existing reqwest builder behavior. use std::borrow::Cow; use std::collections::HashMap; use std::fmt; use std::io; use std::sync::Arc; use std::sync::Mutex; use std::sync::OnceLock; use std::time::Duration; use std::time::Instant; #[cfg(any(target_os = "windows", target_os = "macos"))] use tokio::sync::Semaphore; use http::HeaderValue; use crate::NetworkPolicy; use crate::chatgpt_cloudflare_cookies::ChatGptCookieStore; use crate::custom_ca::BuildCustomCaTransportError; use crate::custom_ca::build_reqwest_client_with_custom_ca; use sha2::Digest; use sha2::Sha256; use thiserror::Error; const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60); const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5); const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256; #[cfg(any(target_os = "windows", target_os = "macos"))] static ASYNC_SYSTEM_PROXY_RESOLUTION_PERMIT: Semaphore = Semaphore::const_new(1); #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "windows")] mod windows; /// Coarse semantic bucket for the HTTP or WebSocket client being constructed. /// /// This is not the selected proxy route or a concrete endpoint. It labels the /// product path that owns the client so proxy-resolution diagnostics can /// distinguish auth, API, WebSocket, and miscellaneous traffic without exposing /// endpoint details. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClientRouteClass { /// Login, token refresh/revoke, PAT, and agent identity auth traffic. Auth, /// First-party API traffic that is not part of the auth flow. Api, /// WebSocket traffic. WebSocket, /// Call sites without a more specific route class. Other, } impl fmt::Display for ClientRouteClass { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Auth => "auth", Self::Api => "api", Self::WebSocket => "wss", Self::Other => "other", }) } } /// Coarse failure class for route selection errors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RouteFailureClass { ProxyResolutionUnavailable, ConnectTimeout, ProxyAuthenticationRequired, TlsError, InvalidProxyConfig, UnsupportedProxyScheme, ResolverError, } impl fmt::Display for RouteFailureClass { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable", Self::ConnectTimeout => "connect_timeout", Self::ProxyAuthenticationRequired => "proxy_407", Self::TlsError => "tls_error", Self::InvalidProxyConfig => "invalid_proxy_config", Self::UnsupportedProxyScheme => "unsupported_proxy_scheme", Self::ResolverError => "resolver_error", }) } } /// Resolved outbound proxy behavior for HTTP clients. /// /// Callers must choose a policy explicitly so omitting feature resolution cannot silently select /// legacy behavior. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutboundProxyPolicy { /// Preserve reqwest's built-in proxy behavior. ReqwestDefault, /// Resolve system/PAC/WPAD settings, then environment settings, then direct routing. RespectSystemProxy, } /// Privacy-safe macOS system proxy configuration for one outbound destination. #[cfg(target_os = "macos")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MacosSystemProxyConfiguration { /// A PAC script or automatic proxy-discovery route applies to the destination. Automatic, /// An explicitly configured HTTP or HTTPS proxy applies. Manual, /// macOS selected a direct connection for the destination. Direct, /// The destination or system proxy settings could not be inspected. Unavailable, } /// Inspects macOS proxy configuration without executing PAC scripts or exposing proxy URLs. #[cfg(target_os = "macos")] pub fn macos_system_proxy_configuration(request_url: &str) -> MacosSystemProxyConfiguration { macos::configuration(request_url) } /// Resolved proxy route for a concrete outbound destination. /// /// `TransportDefault` preserves the underlying transport behavior only when system-proxy support /// is disabled. When system resolution is enabled, environment and direct fallbacks are resolved /// explicitly so the transport cannot repeat system discovery. Proxy URLs and no-proxy settings /// are intentionally redacted from `Debug` output because they may contain credentials or private /// hostnames. #[derive(Clone, Hash, PartialEq, Eq)] pub enum OutboundProxyRoute { /// Preserve the underlying transport's existing proxy behavior. TransportDefault, /// Connect directly and bypass transport-level proxy discovery. Direct, /// Connect through the selected proxy URL. Proxy { url: String, no_proxy: Option, }, } impl fmt::Debug for OutboundProxyRoute { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::TransportDefault => f.write_str("TransportDefault"), Self::Direct => f.write_str("Direct"), Self::Proxy { .. } => f .debug_struct("Proxy") .field("url", &"") .field("no_proxy", &"") .finish(), } } } /// Builds route-specific HTTP clients using one resolved outbound proxy policy. /// /// Construct this once from the effective application configuration and carry it with the /// session or component that owns outbound requests. Individual request paths should supply only /// their destination and route class rather than resolving feature state themselves. #[derive(Clone)] pub struct HttpClientFactory { outbound_proxy_policy: OutboundProxyPolicy, system_proxy_fallback: bool, chatgpt_cookie_store: Option>, network_policy: NetworkPolicy, } impl PartialEq for HttpClientFactory { fn eq(&self, other: &Self) -> bool { self.outbound_proxy_policy == other.outbound_proxy_policy && self.system_proxy_fallback == other.system_proxy_fallback && self.network_policy == other.network_policy && self .chatgpt_cookie_store .as_ref() .map(|store| store.configured_cookies()) == other .chatgpt_cookie_store .as_ref() .map(|store| store.configured_cookies()) } } impl Eq for HttpClientFactory {} impl fmt::Debug for HttpClientFactory { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("HttpClientFactory") .field("outbound_proxy_policy", &self.outbound_proxy_policy) .field("system_proxy_fallback", &self.system_proxy_fallback) .field("network_policy", &self.network_policy) .finish() } } impl HttpClientFactory { /// Creates a factory from the outbound proxy policy resolved by the application. pub const fn new(outbound_proxy_policy: OutboundProxyPolicy) -> Self { Self { outbound_proxy_policy, system_proxy_fallback: false, chatgpt_cookie_store: None, network_policy: NetworkPolicy::unmanaged(), } } /// Carries the account/configuration owner's application policy into every transport. pub fn with_network_policy(mut self, policy: NetworkPolicy) -> Self { self.network_policy = policy; self } pub fn network_policy(&self) -> &NetworkPolicy { &self.network_policy } /// Changes the route policy while retaining the configured cookies and network policy. pub fn with_outbound_proxy_policy(mut self, policy: OutboundProxyPolicy) -> Self { self.outbound_proxy_policy = policy; self } /// Allows bootstrap callers to retry through the system proxy when their request is safe /// to replay. This does not change routing or enable retries for ordinary HTTP requests. pub fn with_system_proxy_fallback(mut self) -> Self { self.system_proxy_fallback = true; self } pub fn allows_system_proxy_fallback(&self) -> bool { self.system_proxy_fallback && self.outbound_proxy_policy == OutboundProxyPolicy::ReqwestDefault } /// Adds process-scoped cookies to requests made by ChatGPT cookie-store clients. pub fn with_chatgpt_cookies(mut self, cookies: impl IntoIterator) -> Self { let cookies = cookies.into_iter().collect::>(); self.chatgpt_cookie_store = (!cookies.is_empty()).then(|| Arc::new(ChatGptCookieStore::new(cookies))); self } /// Returns whether ChatGPT cookie-store clients have additional configured cookies. pub fn has_chatgpt_cookies(&self) -> bool { self.chatgpt_cookie_store.is_some() } pub(crate) fn chatgpt_cookie_store(&self) -> Option> { self.chatgpt_cookie_store.clone() } /// Returns the outbound proxy policy used for clients built by this factory. pub const fn outbound_proxy_policy(&self) -> OutboundProxyPolicy { self.outbound_proxy_policy } /// Resolves the proxy route for a concrete destination. /// /// WebSocket schemes are resolved through their HTTP equivalents so platform PAC and system /// proxy APIs apply the same policy to `ws`/`wss` and `http`/`https` destinations. When system /// resolution is unavailable, explicit environment settings are resolved before falling back /// to a direct route. Ordered PAC candidates are collapsed to one route on Windows and macOS; /// later proxy or `DIRECT` candidates are not retried after a connection failure. pub fn resolve_proxy_route(&self, request_url: &str) -> OutboundProxyRoute { resolve_proxy_route( &ProcessEnv, request_url, self.outbound_proxy_policy, resolve_system_proxy, ) } /// Resolves the proxy route for a concrete destination without blocking a Tokio worker. pub async fn resolve_proxy_route_async( &self, request_url: String, ) -> io::Result { if matches!( self.outbound_proxy_policy, OutboundProxyPolicy::ReqwestDefault ) { return Ok(OutboundProxyRoute::TransportDefault); } if let Some(route) = self.cached_proxy_route(&request_url) { return Ok(route); } #[cfg(not(any(target_os = "windows", target_os = "macos")))] return Ok(self.resolve_proxy_route(&request_url)); #[cfg(any(target_os = "windows", target_os = "macos"))] { let permit = ASYNC_SYSTEM_PROXY_RESOLUTION_PERMIT .acquire() .await .map_err(io::Error::other)?; let factory = self.clone(); tokio::task::spawn_blocking(move || { // Keep the permit with the blocking task: cancelling the caller must not allow a // second PAC/WinHTTP lookup to start while this one is still running. let _permit = permit; factory.resolve_proxy_route(&request_url) }) .await .map_err(io::Error::other) } } fn cached_proxy_route(&self, request_url: &str) -> Option { let env_proxy_kind = EnvProxyKind::from_request_url(request_url); let request_url = proxy_resolution_url(request_url); if RequestOrigin::parse(&request_url).is_none() { return Some(OutboundProxyRoute::Direct); } cached_system_proxy_decision(&request_url) .map(|decision| route_from_system_decision(&ProcessEnv, env_proxy_kind, decision)) } } fn resolve_proxy_route( env: &dyn EnvSource, request_url: &str, outbound_proxy_policy: OutboundProxyPolicy, resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, ) -> OutboundProxyRoute { if matches!(outbound_proxy_policy, OutboundProxyPolicy::ReqwestDefault) { return OutboundProxyRoute::TransportDefault; } let env_proxy_kind = EnvProxyKind::from_request_url(request_url); let request_url = proxy_resolution_url(request_url); let Some(origin) = RequestOrigin::parse(&request_url) else { return OutboundProxyRoute::Direct; }; route_from_system_decision( env, env_proxy_kind, resolve_system_proxy(&request_url, &origin), ) } fn route_from_system_decision( env: &dyn EnvSource, env_proxy_kind: EnvProxyKind, decision: SystemProxyDecision, ) -> OutboundProxyRoute { match decision { SystemProxyDecision::Direct => OutboundProxyRoute::Direct, SystemProxyDecision::Proxy { url } => OutboundProxyRoute::Proxy { url, no_proxy: None, }, SystemProxyDecision::Unavailable { .. } => resolve_env_proxy_route(env, env_proxy_kind), } } fn resolve_env_proxy_route( env: &dyn EnvSource, env_proxy_kind: EnvProxyKind, ) -> OutboundProxyRoute { let proxy_url = match env_proxy_kind { EnvProxyKind::Https => { proxy_env_value(env, "HTTPS_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) } EnvProxyKind::SecureWebSocket => proxy_env_value(env, "HTTPS_PROXY") .or_else(|| proxy_env_value(env, "HTTP_PROXY")) .or_else(|| proxy_env_value(env, "ALL_PROXY")), EnvProxyKind::Http => { proxy_env_value(env, "HTTP_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) } EnvProxyKind::Other => proxy_env_value(env, "ALL_PROXY"), }; match proxy_url { Some(url) => OutboundProxyRoute::Proxy { url, no_proxy: proxy_env_value(env, "NO_PROXY"), }, None => OutboundProxyRoute::Direct, } } #[derive(Clone, Copy)] enum EnvProxyKind { Http, Https, SecureWebSocket, Other, } impl EnvProxyKind { fn from_request_url(request_url: &str) -> Self { let scheme = request_url .parse::() .ok() .and_then(|uri| uri.scheme_str().map(str::to_ascii_lowercase)); match scheme.as_deref() { Some("http" | "ws") => Self::Http, Some("https") => Self::Https, Some("wss") => Self::SecureWebSocket, Some(_) | None => Self::Other, } } } fn proxy_resolution_url(request_url: &str) -> Cow<'_, str> { if let Some(suffix) = request_url.strip_prefix("wss://") { Cow::Owned(format!("https://{suffix}")) } else if let Some(suffix) = request_url.strip_prefix("ws://") { Cow::Owned(format!("http://{suffix}")) } else { Cow::Borrowed(request_url) } } /// Error while building a resolver-aware reqwest client. #[derive(Debug, Error)] pub enum BuildRouteAwareHttpClientError { #[error(transparent)] CustomCa(#[from] BuildCustomCaTransportError), #[error("Failed to build HTTP client with explicit TLS configuration: {0}")] ExplicitTls(reqwest::Error), #[error("Failed to configure outbound proxy selected for {route_class}")] InvalidProxyConfig { route_class: ClientRouteClass }, } impl From for io::Error { fn from(error: BuildRouteAwareHttpClientError) -> Self { match error { BuildRouteAwareHttpClientError::CustomCa(error) => error.into(), BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } | BuildRouteAwareHttpClientError::ExplicitTls(_) => io::Error::other(error), } } } /// Builds a reqwest client with the resolved route and shared CA handling. pub(crate) fn build_reqwest_client_for_route( builder: reqwest::ClientBuilder, route_class: ClientRouteClass, route: &OutboundProxyRoute, ) -> Result { let builder = configure_builder_for_resolved_route(builder, route_class, route)?; build_reqwest_client_with_custom_ca(builder).map_err(Into::into) } #[cfg(test)] fn configure_proxy_for_route( env: &dyn EnvSource, builder: reqwest::ClientBuilder, request_url: &str, route_class: ClientRouteClass, outbound_proxy_policy: OutboundProxyPolicy, resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, ) -> Result { let route = resolve_proxy_route( env, request_url, outbound_proxy_policy, resolve_system_proxy, ); configure_builder_for_resolved_route(builder, route_class, &route) } pub(crate) fn configure_builder_for_resolved_route( builder: reqwest::ClientBuilder, route_class: ClientRouteClass, route: &OutboundProxyRoute, ) -> Result { match route { OutboundProxyRoute::TransportDefault => Ok(builder), OutboundProxyRoute::Direct => Ok(builder.no_proxy()), OutboundProxyRoute::Proxy { url, no_proxy } => { let no_proxy = no_proxy.as_deref().and_then(reqwest::NoProxy::from_string); configure_concrete_proxy(builder, route_class, url, no_proxy) } } } fn configure_concrete_proxy( builder: reqwest::ClientBuilder, route_class: ClientRouteClass, proxy_url: &str, no_proxy: Option, ) -> Result { let proxy = match reqwest::Proxy::all(proxy_url) { Ok(proxy) => proxy, Err(_source) => { return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class }); } }; Ok(builder.proxy(proxy.no_proxy(no_proxy))) } #[derive(Debug, Clone, PartialEq, Eq)] #[allow(dead_code)] struct RequestOrigin { scheme: String, host: String, port: u16, } impl RequestOrigin { fn parse(request_url: &str) -> Option { let uri = request_url.parse::().ok()?; let scheme = uri.scheme_str()?.to_ascii_lowercase(); let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase(); let port = uri.port_u16().or(match scheme.as_str() { "http" | "ws" => Some(80), "https" | "wss" => Some(443), _ => None, })?; Some(Self { scheme, host, port }) } } #[cfg_attr( not(any(target_os = "windows", target_os = "macos")), allow( dead_code, reason = "Direct and Proxy are constructed only by platform-specific resolvers" ) )] #[derive(Debug, Clone, PartialEq, Eq)] enum SystemProxyDecision { Direct, Proxy { url: String }, Unavailable { failure: RouteFailureClass }, } fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); resolve_system_proxy_with(cache, request_url, origin, resolve_platform_system_proxy) } fn resolve_system_proxy_with( cache: &Mutex>, request_url: &str, origin: &RequestOrigin, resolve_platform_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, ) -> SystemProxyDecision { let mut cache = match cache.lock() { Ok(cache) => cache, Err(error) => panic!("system proxy cache lock should not be poisoned: {error}"), }; let cache_key = system_proxy_cache_key(request_url); if let Some(decision) = cached_system_proxy_decision_from_cache(&mut cache, &cache_key, Instant::now()) { return decision; } // Keep cache misses single-flight. Platform PAC/WPAD APIs are synchronous, so async callers // run this work on the blocking pool; serializing misses prevents concurrent requests from // consuming an unbounded number of blocking workers while system lookup is pending. let decision = resolve_platform_system_proxy(request_url, origin); insert_system_proxy_cache_entry(&mut cache, &cache_key, decision.clone(), Instant::now()); decision } #[cfg(target_os = "macos")] fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { macos::resolve(request_url, origin) } #[cfg(target_os = "windows")] fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { windows::resolve(request_url, origin) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] fn resolve_platform_system_proxy( _request_url: &str, _origin: &RequestOrigin, ) -> SystemProxyDecision { SystemProxyDecision::Unavailable { failure: RouteFailureClass::ProxyResolutionUnavailable, } } #[derive(Debug, Clone)] struct CachedSystemProxyDecision { decision: SystemProxyDecision, expires_at: Instant, } static SYSTEM_PROXY_CACHE: OnceLock>> = OnceLock::new(); fn cached_system_proxy_decision(request_url: &str) -> Option { let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); // Platform resolution holds this mutex. Let contended async callers wait in the resolver // lane instead of blocking their executor while checking the cache. let mut cache = cache.try_lock().ok()?; let key = system_proxy_cache_key(request_url); cached_system_proxy_decision_from_cache(&mut cache, &key, Instant::now()) } fn cached_system_proxy_decision_from_cache( cache: &mut HashMap, cache_key: &str, now: Instant, ) -> Option { let cached = cache.get(cache_key)?; if cached.expires_at > now { return Some(cached.decision.clone()); } cache.remove(cache_key); None } fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) { let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); if let Ok(mut cache) = cache.lock() { let cache_key = system_proxy_cache_key(request_url); insert_system_proxy_cache_entry(&mut cache, &cache_key, decision, Instant::now()); } } /// Primes one proxy decision for cross-crate integration tests. /// /// This is public only so tests in HTTP-client consumers can exercise system-proxy routing /// deterministically on every supported platform. pub fn cache_system_proxy_route_for_test(request_url: &str, proxy_url: String) { cache_system_proxy_decision(request_url, SystemProxyDecision::Proxy { url: proxy_url }); } fn insert_system_proxy_cache_entry( cache: &mut HashMap, cache_key: &str, decision: SystemProxyDecision, now: Instant, ) { let ttl = match &decision { SystemProxyDecision::Direct | SystemProxyDecision::Proxy { .. } => { SYSTEM_PROXY_SUCCESS_CACHE_TTL } SystemProxyDecision::Unavailable { .. } => SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL, }; cache.retain(|_, cached| cached.expires_at > now); if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES && !cache.contains_key(cache_key) && let Some(cache_key_to_evict) = cache .iter() .min_by_key(|(_, cached)| cached.expires_at) .map(|(cache_key, _)| cache_key.clone()) { cache.remove(&cache_key_to_evict); } cache.insert( cache_key.to_string(), CachedSystemProxyDecision { decision, expires_at: now + ttl, }, ); } fn system_proxy_cache_key(request_url: &str) -> String { // Keep URL-specific PAC decisions without retaining the raw routed URL. let mut hasher = Sha256::new(); hasher.update(b"system-proxy-cache-v1\0"); hasher.update(request_url.as_bytes()); format!("{:x}", hasher.finalize()) } #[cfg(any(test, target_os = "windows"))] fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool { no_proxy .split(',') .map(str::trim) .filter(|entry| !entry.is_empty()) .any(|entry| no_proxy_entry_matches_origin(entry, origin)) } #[cfg(any(test, target_os = "windows"))] fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool { if entry == "*" { return true; } let mut entry = entry .strip_prefix("http://") .or_else(|| entry.strip_prefix("https://")) .unwrap_or(entry) .trim_matches(['[', ']']) .to_ascii_lowercase(); let mut port = None; let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| { if host.contains(':') { return None; } candidate_port .parse::() .ok() .map(|parsed_port| (host.to_string(), parsed_port)) }); if let Some((host, parsed_port)) = parsed_host_port { entry = host; port = Some(parsed_port); } if port.is_some_and(|port| port != origin.port) { return false; } if let Some(suffix) = entry.strip_prefix('.') { return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}")); } if entry.contains('*') { return wildcard_host_match(&entry, &origin.host); } origin.host == entry } #[cfg(any(test, target_os = "windows"))] fn wildcard_host_match(pattern: &str, host: &str) -> bool { let mut remaining = host; let mut first = true; for part in pattern.split('*') { if part.is_empty() { continue; } if first && !pattern.starts_with('*') { let Some(stripped) = remaining.strip_prefix(part) else { return false; }; remaining = stripped; } else { let Some(index) = remaining.find(part) else { return false; }; remaining = &remaining[index + part.len()..]; } first = false; } pattern.ends_with('*') || remaining.is_empty() } #[cfg(any(test, target_os = "windows"))] #[derive(Debug, Clone, PartialEq, Eq)] enum ParsedProxyListDecision { Direct, Proxy(String), UnsupportedScheme, Unavailable, } #[cfg(any(test, target_os = "windows"))] fn parse_proxy_list(input: &str, target_scheme: &str) -> ParsedProxyListDecision { let mut saw_unsupported = false; { let mut process_token = |token: &str| { let decision = parse_proxy_token(token, target_scheme); match decision { ParsedProxyListDecision::Direct => Some(ParsedProxyListDecision::Direct), ParsedProxyListDecision::Proxy(url) => Some(ParsedProxyListDecision::Proxy(url)), ParsedProxyListDecision::UnsupportedScheme => { saw_unsupported = true; None } ParsedProxyListDecision::Unavailable => None, } }; for segment in input .split(';') .map(str::trim) .filter(|segment| !segment.is_empty()) { let mut parts = segment.split_whitespace(); let directive = parts.next(); let hostport = parts.next(); let extra = parts.next(); let is_proxy_directive = matches!( directive.map(str::to_ascii_lowercase).as_deref(), Some("proxy" | "http" | "https" | "socks" | "socks4" | "socks5") ) && hostport.is_some() && extra.is_none(); if is_proxy_directive { if let Some(decision) = process_token(segment) { return decision; } } else { for token in segment.split_whitespace() { if let Some(decision) = process_token(token) { return decision; } } } } } if saw_unsupported { ParsedProxyListDecision::UnsupportedScheme } else { ParsedProxyListDecision::Unavailable } } #[cfg(any(test, target_os = "windows"))] fn parse_proxy_token(token: &str, target_scheme: &str) -> ParsedProxyListDecision { if token.eq_ignore_ascii_case("DIRECT") { return ParsedProxyListDecision::Direct; } if let Some(decision) = parse_proxy_key_token(token, target_scheme) { return decision; } if token.contains('=') { return ParsedProxyListDecision::Unavailable; } let mut parts = token.split_whitespace(); let directive = parts.next(); let hostport = parts.next(); if let (Some(directive), Some(hostport), None) = (directive, hostport, parts.next()) { return match directive.to_ascii_lowercase().as_str() { "proxy" | "http" => proxy_url_from_hostport("http", hostport), "https" => proxy_url_from_hostport("https", hostport), "socks" | "socks4" | "socks5" => ParsedProxyListDecision::UnsupportedScheme, _ => ParsedProxyListDecision::Unavailable, }; } proxy_url_from_hostport("http", token) } #[cfg(any(test, target_os = "windows"))] fn parse_proxy_key_token(token: &str, target_scheme: &str) -> Option { let (key, value) = token.split_once('=')?; if key.trim().eq_ignore_ascii_case(target_scheme) { Some(proxy_url_from_hostport("http", value.trim())) } else { Some(ParsedProxyListDecision::Unavailable) } } #[cfg(any(test, target_os = "windows"))] fn proxy_url_from_hostport(proxy_scheme: &str, hostport: &str) -> ParsedProxyListDecision { if hostport.is_empty() { return ParsedProxyListDecision::Unavailable; } if hostport.contains("://") { return ParsedProxyListDecision::Proxy(hostport.to_string()); } ParsedProxyListDecision::Proxy(format!("{proxy_scheme}://{hostport}")) } trait EnvSource { fn var(&self, key: &str) -> Option; } struct ProcessEnv; impl EnvSource for ProcessEnv { fn var(&self, key: &str) -> Option { std::env::var(key).ok() } } fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option { let lower = upper.to_ascii_lowercase(); env.var(upper) .or_else(|| env.var(&lower)) .filter(|value| !value.is_empty()) } #[cfg(test)] #[path = "route_aware_redirect_integration_tests.rs"] mod redirect_integration_tests; #[cfg(test)] #[path = "outbound_proxy_tests.rs"] mod tests;