Skip to main content

codex_http_client/
outbound_proxy.rs

1//! Conservative outbound proxy selection for resolver-aware HTTP clients.
2//!
3//! When enabled, platform system discovery is tried first, explicit environment
4//! proxies are the fallback, and the final fallback is a direct connection.
5//! When disabled, callers retain the existing reqwest builder behavior.
6
7use 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/// Coarse semantic bucket for the HTTP or WebSocket client being constructed.
36///
37/// This is not the selected proxy route or a concrete endpoint. It labels the
38/// product path that owns the client so proxy-resolution diagnostics can
39/// distinguish auth, API, WebSocket, and miscellaneous traffic without exposing
40/// endpoint details.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ClientRouteClass {
43    /// Login, token refresh/revoke, PAT, and agent identity auth traffic.
44    Auth,
45    /// First-party API traffic that is not part of the auth flow.
46    Api,
47    /// WebSocket traffic.
48    WebSocket,
49    /// Call sites without a more specific route class.
50    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/// Coarse failure class for route selection errors.
65#[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/// Resolved outbound proxy behavior for HTTP clients.
91///
92/// Callers must choose a policy explicitly so omitting feature resolution cannot silently select
93/// legacy behavior.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum OutboundProxyPolicy {
96    /// Preserve reqwest's built-in proxy behavior.
97    ReqwestDefault,
98    /// Resolve system/PAC/WPAD settings, then environment settings, then direct routing.
99    RespectSystemProxy,
100}
101
102/// Resolved proxy route for a concrete outbound destination.
103///
104/// `TransportDefault` preserves the underlying transport behavior only when system-proxy support
105/// is disabled. When system resolution is enabled, environment and direct fallbacks are resolved
106/// explicitly so the transport cannot repeat system discovery. Proxy URLs and no-proxy settings
107/// are intentionally redacted from `Debug` output because they may contain credentials or private
108/// hostnames.
109#[derive(Clone, Hash, PartialEq, Eq)]
110pub enum OutboundProxyRoute {
111    /// Preserve the underlying transport's existing proxy behavior.
112    TransportDefault,
113    /// Connect directly and bypass transport-level proxy discovery.
114    Direct,
115    /// Connect through the selected proxy URL.
116    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/// Builds route-specific HTTP clients using one resolved outbound proxy policy.
137///
138/// Construct this once from the effective application configuration and carry it with the
139/// session or component that owns outbound requests. Individual request paths should supply only
140/// their destination and route class rather than resolving feature state themselves.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct HttpClientFactory {
143    outbound_proxy_policy: OutboundProxyPolicy,
144}
145
146impl HttpClientFactory {
147    /// Creates a factory from the outbound proxy policy resolved by the application.
148    pub const fn new(outbound_proxy_policy: OutboundProxyPolicy) -> Self {
149        Self {
150            outbound_proxy_policy,
151        }
152    }
153
154    /// Returns the outbound proxy policy used for clients built by this factory.
155    pub const fn outbound_proxy_policy(&self) -> OutboundProxyPolicy {
156        self.outbound_proxy_policy
157    }
158
159    /// Resolves the proxy route for a concrete destination.
160    ///
161    /// WebSocket schemes are resolved through their HTTP equivalents so platform PAC and system
162    /// proxy APIs apply the same policy to `ws`/`wss` and `http`/`https` destinations. When system
163    /// resolution is unavailable, explicit environment settings are resolved before falling back
164    /// to a direct route.
165    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    /// Resolves the proxy route for a concrete destination without blocking a Tokio worker.
175    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                // Keep the permit with the blocking task: cancelling the caller must not allow a
202                // second PAC/WinHTTP lookup to start while this one is still running.
203                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    /// Builds a reqwest client for a concrete outbound route.
222    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/// Error while building a resolver-aware reqwest client.
344#[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
362/// Builds a reqwest client with conservative route selection and shared CA handling.
363///
364/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after
365/// a route is selected are returned without trying another route. Ordered PAC candidates are
366/// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates
367/// are not retried after a connection failure.
368fn 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    // Keep cache misses single-flight. Platform PAC/WPAD APIs are synchronous, so async callers
491    // run this work on the blocking pool; serializing misses prevents concurrent requests from
492    // consuming an unbounded number of blocking workers while system lookup is pending.
493    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    // Keep URL-specific PAC decisions without retaining the raw routed URL.
590    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;