diff --git a/rust/crates/api/src/http_client.rs b/rust/crates/api/src/http_client.rs index fcb697f..508a577 100644 --- a/rust/crates/api/src/http_client.rs +++ b/rust/crates/api/src/http_client.rs @@ -7,11 +7,17 @@ const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"]; /// Snapshot of the proxy-related environment variables that influence the /// outbound HTTP client. Captured up front so callers can inspect, log, and /// test the resolved configuration without re-reading the process environment. +/// +/// When `proxy_url` is set it acts as a single catch-all proxy for both +/// HTTP and HTTPS traffic, taking precedence over the per-scheme fields. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ProxyConfig { pub http_proxy: Option, pub https_proxy: Option, pub no_proxy: Option, + /// Optional unified proxy URL that applies to both HTTP and HTTPS. + /// When set, this takes precedence over `http_proxy` and `https_proxy`. + pub proxy_url: Option, } impl ProxyConfig { @@ -22,6 +28,17 @@ impl ProxyConfig { Self::from_lookup(|key| std::env::var(key).ok()) } + /// Create a proxy configuration from a single URL that applies to both + /// HTTP and HTTPS traffic. This is the config-file alternative to setting + /// `HTTP_PROXY` and `HTTPS_PROXY` environment variables separately. + #[must_use] + pub fn from_proxy_url(url: impl Into) -> Self { + Self { + proxy_url: Some(url.into()), + ..Self::default() + } + } + fn from_lookup(mut lookup: F) -> Self where F: FnMut(&str) -> Option, @@ -30,12 +47,13 @@ impl ProxyConfig { http_proxy: first_non_empty(&HTTP_PROXY_KEYS, &mut lookup), https_proxy: first_non_empty(&HTTPS_PROXY_KEYS, &mut lookup), no_proxy: first_non_empty(&NO_PROXY_KEYS, &mut lookup), + proxy_url: None, } } #[must_use] pub fn is_empty(&self) -> bool { - self.http_proxy.is_none() && self.https_proxy.is_none() + self.proxy_url.is_none() && self.http_proxy.is_none() && self.https_proxy.is_none() } } @@ -58,6 +76,10 @@ pub fn build_http_client_or_default() -> reqwest::Client { /// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests /// and by callers that want to override process-level environment lookups. +/// +/// When `config.proxy_url` is set it overrides the per-scheme `http_proxy` +/// and `https_proxy` fields and is registered as both an HTTP and HTTPS +/// proxy so a single value can route every outbound request. pub fn build_http_client_with(config: &ProxyConfig) -> Result { let mut builder = reqwest::Client::builder().no_proxy(); @@ -66,7 +88,12 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result (Some(unified), Some(unified)), + None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()), + }; + + if let Some(url) = https_proxy_url { let mut proxy = reqwest::Proxy::https(url)?; if let Some(filter) = no_proxy.clone() { proxy = proxy.no_proxy(Some(filter)); @@ -74,7 +101,7 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: Option<&str>) -> Self { + let original = std::env::var_os(key); + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + Self { key, original } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + +#[test] +fn proxy_config_from_env_reads_uppercase_proxy_vars() { + // given + let _lock = env_lock(); + let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128")); + let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129")); + let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1")); + let _http_lower = EnvVarGuard::set("http_proxy", None); + let _https_lower = EnvVarGuard::set("https_proxy", None); + let _no_lower = EnvVarGuard::set("no_proxy", None); + + // when + let config = ProxyConfig::from_env(); + + // then + assert_eq!(config.http_proxy.as_deref(), Some("http://proxy.corp:3128")); + assert_eq!( + config.https_proxy.as_deref(), + Some("http://secure.corp:3129") + ); + assert_eq!(config.no_proxy.as_deref(), Some("localhost,127.0.0.1")); + assert!(config.proxy_url.is_none()); + assert!(!config.is_empty()); +} + +#[test] +fn proxy_config_from_env_reads_lowercase_proxy_vars() { + // given + let _lock = env_lock(); + let _http = EnvVarGuard::set("HTTP_PROXY", None); + let _https = EnvVarGuard::set("HTTPS_PROXY", None); + let _no = EnvVarGuard::set("NO_PROXY", None); + let _http_lower = EnvVarGuard::set("http_proxy", Some("http://lower.corp:3128")); + let _https_lower = EnvVarGuard::set("https_proxy", Some("http://lower-secure.corp:3129")); + let _no_lower = EnvVarGuard::set("no_proxy", Some(".internal")); + + // when + let config = ProxyConfig::from_env(); + + // then + assert_eq!(config.http_proxy.as_deref(), Some("http://lower.corp:3128")); + assert_eq!( + config.https_proxy.as_deref(), + Some("http://lower-secure.corp:3129") + ); + assert_eq!(config.no_proxy.as_deref(), Some(".internal")); + assert!(!config.is_empty()); +} + +#[test] +fn proxy_config_from_env_is_empty_when_no_vars_set() { + // given + let _lock = env_lock(); + let _http = EnvVarGuard::set("HTTP_PROXY", None); + let _https = EnvVarGuard::set("HTTPS_PROXY", None); + let _no = EnvVarGuard::set("NO_PROXY", None); + let _http_lower = EnvVarGuard::set("http_proxy", None); + let _https_lower = EnvVarGuard::set("https_proxy", None); + let _no_lower = EnvVarGuard::set("no_proxy", None); + + // when + let config = ProxyConfig::from_env(); + + // then + assert!(config.is_empty()); + assert!(config.http_proxy.is_none()); + assert!(config.https_proxy.is_none()); + assert!(config.no_proxy.is_none()); +} + +#[test] +fn proxy_config_from_env_treats_empty_values_as_unset() { + // given + let _lock = env_lock(); + let _http = EnvVarGuard::set("HTTP_PROXY", Some("")); + let _https = EnvVarGuard::set("HTTPS_PROXY", Some("")); + let _http_lower = EnvVarGuard::set("http_proxy", Some("")); + let _https_lower = EnvVarGuard::set("https_proxy", Some("")); + let _no = EnvVarGuard::set("NO_PROXY", Some("")); + let _no_lower = EnvVarGuard::set("no_proxy", Some("")); + + // when + let config = ProxyConfig::from_env(); + + // then + assert!(config.is_empty()); +} + +#[test] +fn build_client_with_env_proxy_config_succeeds() { + // given + let _lock = env_lock(); + let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128")); + let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129")); + let _no = EnvVarGuard::set("NO_PROXY", Some("localhost")); + let _http_lower = EnvVarGuard::set("http_proxy", None); + let _https_lower = EnvVarGuard::set("https_proxy", None); + let _no_lower = EnvVarGuard::set("no_proxy", None); + let config = ProxyConfig::from_env(); + + // when + let result = build_http_client_with(&config); + + // then + assert!(result.is_ok()); +} + +#[test] +fn build_client_with_proxy_url_config_succeeds() { + // given + let config = ProxyConfig::from_proxy_url("http://unified.corp:3128"); + + // when + let result = build_http_client_with(&config); + + // then + assert!(result.is_ok()); +} + +#[test] +fn proxy_config_from_env_prefers_uppercase_over_lowercase() { + // given + let _lock = env_lock(); + let _http_upper = EnvVarGuard::set("HTTP_PROXY", Some("http://upper.corp:3128")); + let _http_lower = EnvVarGuard::set("http_proxy", Some("http://lower.corp:3128")); + let _https = EnvVarGuard::set("HTTPS_PROXY", None); + let _https_lower = EnvVarGuard::set("https_proxy", None); + let _no = EnvVarGuard::set("NO_PROXY", None); + let _no_lower = EnvVarGuard::set("no_proxy", None); + + // when + let config = ProxyConfig::from_env(); + + // then + assert_eq!(config.http_proxy.as_deref(), Some("http://upper.corp:3128")); +}