use crate::{error::Error, util::read_env_var};
#[derive(Debug, Clone)]
pub struct Proxy {
kind: ProxyKind,
url: String,
creds: Option<(String, String)>,
}
#[derive(Debug, Clone)]
enum ProxyKind {
All,
Http,
Https,
}
fn validate_proxy_url(url: &str) -> crate::Result<()> {
if url.is_empty() {
return Err(Error::builder("proxy URL must not be empty"));
}
if url.starts_with("socks5://") || url.starts_with("socks4://") {
return Err(Error::builder(format!(
"SOCKS proxies are not supported by WinHTTP -- got {url:?}"
)));
}
let rest = if let Some(r) = url.strip_prefix("http://") {
r
} else if let Some(r) = url.strip_prefix("https://") {
r
} else {
return Err(Error::builder(format!(
"proxy URL must start with http:// or https:// -- got {url:?}"
)));
};
let host_part = rest.split('/').next().unwrap_or("");
let host_part = host_part.split(':').next().unwrap_or("");
if host_part.is_empty() {
return Err(Error::builder(format!("proxy URL has no host -- got {url:?}")));
}
Ok(())
}
impl Proxy {
fn new_validated(kind: ProxyKind, url: &str) -> crate::Result<Self> {
validate_proxy_url(url)?;
Ok(Self {
kind,
url: url.to_owned(),
creds: None,
})
}
pub fn all(url: &str) -> crate::Result<Self> {
Self::new_validated(ProxyKind::All, url)
}
pub fn http(url: &str) -> crate::Result<Self> {
Self::new_validated(ProxyKind::Http, url)
}
pub fn https(url: &str) -> crate::Result<Self> {
Self::new_validated(ProxyKind::Https, url)
}
pub(crate) fn apply_to(self, config: &mut ProxyConfig) {
match self.kind {
ProxyKind::All => {
config.http_proxy_url = Some(self.url.clone());
config.https_proxy_url = Some(self.url);
config.http_proxy_creds = self.creds.clone();
config.https_proxy_creds = self.creds;
}
ProxyKind::Http => {
config.http_proxy_url = Some(self.url);
config.http_proxy_creds = self.creds;
}
ProxyKind::Https => {
config.https_proxy_url = Some(self.url);
config.https_proxy_creds = self.creds;
}
}
}
#[must_use]
pub fn basic_auth(mut self, username: &str, password: &str) -> Proxy {
self.creds = Some((username.to_owned(), password.to_owned()));
self
}
#[cfg(feature = "noop-compat")]
#[must_use]
pub fn no_proxy(self, _no_proxy: Option<NoProxy>) -> Proxy {
self
}
}
#[derive(Debug, Clone)]
pub struct NoProxy {
#[cfg_attr(not(test), expect(dead_code))]
patterns: Vec<NoProxyPattern>,
}
impl NoProxy {
pub fn from_string(s: &str) -> Option<Self> {
let patterns = parse_no_proxy(s);
if patterns.is_empty() {
None
} else {
Some(Self { patterns })
}
}
pub fn from_env() -> Option<Self> {
let raw = read_env_var("NO_PROXY")?;
Self::from_string(&raw)
}
#[cfg(test)]
pub(crate) fn apply_to(self, config: &mut ProxyConfig) {
config.no_proxy = self.patterns;
}
}
impl Default for NoProxy {
fn default() -> Self {
Self {
patterns: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ProxyConfig {
pub http_proxy_url: Option<String>,
pub https_proxy_url: Option<String>,
pub no_proxy: Vec<NoProxyPattern>,
pub http_proxy_creds: Option<(String, String)>,
pub https_proxy_creds: Option<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ProxyAction {
Direct,
Named(String, Option<(String, String)>),
Automatic,
}
#[derive(Debug, Clone)]
pub(crate) enum NoProxyPattern {
Wildcard,
Exact(String),
DomainSuffix(String),
}
impl ProxyConfig {
pub fn from_env() -> Self {
let http_proxy_url = read_env_var("HTTP_PROXY");
let https_proxy_url = read_env_var("HTTPS_PROXY");
let no_proxy_raw = read_env_var("NO_PROXY");
let no_proxy = no_proxy_raw.map(|s| parse_no_proxy(&s)).unwrap_or_default();
trace!(
http_proxy = http_proxy_url.as_deref().unwrap_or("<none>"),
https_proxy = https_proxy_url.as_deref().unwrap_or("<none>"),
no_proxy_count = no_proxy.len(),
"proxy config from env",
);
Self {
http_proxy_url,
https_proxy_url,
no_proxy,
http_proxy_creds: None,
https_proxy_creds: None,
}
}
pub fn none() -> Self {
Self {
http_proxy_url: None,
https_proxy_url: None,
no_proxy: Vec::new(),
http_proxy_creds: None,
https_proxy_creds: None,
}
}
pub fn none_from_env() -> Self {
Self::none()
}
pub fn resolve(&self, host: &str, is_https: bool) -> ProxyAction {
if self.no_proxy.iter().any(|p| p.matches(host)) {
trace!(host, "proxy resolve: NO_PROXY match -> Direct");
return ProxyAction::Direct;
}
let (proxy_url, proxy_creds) = if is_https {
(&self.https_proxy_url, &self.https_proxy_creds)
} else {
(&self.http_proxy_url, &self.http_proxy_creds)
};
if let Some(url) = proxy_url {
return ProxyAction::Named(url.clone(), proxy_creds.clone());
}
ProxyAction::Automatic
}
}
impl NoProxyPattern {
pub fn matches(&self, host: &str) -> bool {
let host_lower = host.to_ascii_lowercase();
match self {
NoProxyPattern::Wildcard => true,
NoProxyPattern::Exact(exact) => host_lower == *exact,
NoProxyPattern::DomainSuffix(suffix) => {
host_lower == *suffix || host_lower.ends_with(&format!(".{suffix}"))
}
}
}
}
fn parse_no_proxy(value: &str) -> Vec<NoProxyPattern> {
value
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| {
if s == "*" {
NoProxyPattern::Wildcard
} else if let Some(suffix) = s.strip_prefix('.') {
NoProxyPattern::DomainSuffix(suffix.to_ascii_lowercase())
} else if s.contains('.') {
NoProxyPattern::DomainSuffix(s.to_ascii_lowercase())
} else {
NoProxyPattern::Exact(s.to_ascii_lowercase())
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_proxy_pattern_matches() {
let cases: &[(NoProxyPattern, &str, bool)] = &[
(NoProxyPattern::Wildcard, "example.com", true),
(NoProxyPattern::Wildcard, "anything.at.all", true),
(NoProxyPattern::Exact("localhost".into()), "localhost", true),
(NoProxyPattern::Exact("localhost".into()), "LOCALHOST", true),
(NoProxyPattern::Exact("localhost".into()), "Localhost", true),
(NoProxyPattern::Exact("localhost".into()), "localhost.localdomain", false),
(NoProxyPattern::DomainSuffix("example.com".into()), "example.com", true),
(NoProxyPattern::DomainSuffix("example.com".into()), "foo.example.com", true),
(NoProxyPattern::DomainSuffix("example.com".into()), "bar.foo.example.com", true),
(NoProxyPattern::DomainSuffix("example.com".into()), "notexample.com", false),
(NoProxyPattern::DomainSuffix("example.com".into()), "example.com.evil.com", false),
(NoProxyPattern::DomainSuffix("example.com".into()), "EXAMPLE.COM", true),
(NoProxyPattern::DomainSuffix("example.com".into()), "FOO.Example.Com", true),
];
for (pat, host, expected) in cases {
assert_eq!(pat.matches(host), *expected, "{pat:?}.matches({host:?})");
}
}
#[test]
fn parse_no_proxy_lengths() {
let cases: &[(&str, usize)] = &[
("*", 1),
(".example.com", 1),
("example.com", 1),
("localhost, .internal.corp, example.com", 3),
("localhost,,, .example.com,", 2),
("", 0),
];
for &(input, expected_len) in cases {
let patterns = parse_no_proxy(input);
assert_eq!(patterns.len(), expected_len, "parse_no_proxy({input:?}).len()");
}
}
#[test]
fn parse_no_proxy_matching_behavior() {
let cases: &[(&str, &str, bool)] = &[
("*", "anything", true),
(".example.com", "foo.example.com", true),
(".example.com", "example.com", true),
("example.com", "example.com", true),
("example.com", "foo.example.com", true),
("localhost", "localhost", true),
("localhost", "foo.localhost", false),
];
for &(input, host, expected) in cases {
let patterns = parse_no_proxy(input);
assert!(!patterns.is_empty(), "parse_no_proxy({input:?}) should not be empty");
assert_eq!(
patterns[0].matches(host),
expected,
"parse_no_proxy({input:?})[0].matches({host:?})"
);
}
}
fn config(
http: Option<&str>,
https: Option<&str>,
no_proxy: Vec<NoProxyPattern>,
) -> ProxyConfig {
ProxyConfig {
http_proxy_url: http.map(String::from),
https_proxy_url: https.map(String::from),
no_proxy,
http_proxy_creds: None,
https_proxy_creds: None,
}
}
#[test]
fn resolve_table() {
let cases: &[(ProxyConfig, &str, bool, ProxyAction)] = &[
(config(None, None, vec![]), "example.com", true, ProxyAction::Automatic),
(
config(Some("http://http-proxy:8080"), Some("http://https-proxy:8080"), vec![]),
"example.com",
true,
ProxyAction::Named("http://https-proxy:8080".into(), None),
),
(
config(Some("http://http-proxy:8080"), Some("http://https-proxy:8080"), vec![]),
"example.com",
false,
ProxyAction::Named("http://http-proxy:8080".into(), None),
),
(
config(
Some("http://proxy:8080"),
Some("http://proxy:8080"),
vec![NoProxyPattern::DomainSuffix("internal.corp".into())],
),
"api.internal.corp",
true,
ProxyAction::Direct,
),
(
config(
Some("http://proxy:8080"),
Some("http://proxy:8080"),
vec![NoProxyPattern::Wildcard],
),
"anything.com",
true,
ProxyAction::Direct,
),
];
for (cfg, host, is_https, expected) in cases {
let actual = cfg.resolve(host, *is_https);
assert_eq!(actual, *expected, "resolve({host:?}, is_https={is_https})");
}
}
#[test]
fn resolve_priority_no_proxy_before_named() {
let cfg = config(
Some("http://proxy:8080"),
Some("http://proxy:8080"),
vec![NoProxyPattern::DomainSuffix("example.com".into())],
);
assert_eq!(cfg.resolve("api.example.com", false), ProxyAction::Direct);
assert_eq!(
cfg.resolve("other.com", false),
ProxyAction::Named("http://proxy:8080".into(), None)
);
}
#[test]
fn parse_no_proxy_exact_vs_domain_suffix() {
let cases: &[(&str, &str, bool, &str)] = &[
("localhost", "localhost", true, "exact self-match"),
("localhost", "foo.localhost", false, "exact rejects subdomain"),
("example.com", "example.com", true, "suffix exact match"),
("example.com", "foo.example.com", true, "suffix subdomain match"),
];
for &(input, host, expected, desc) in cases {
let patterns = parse_no_proxy(input);
assert_eq!(patterns.len(), 1);
assert_eq!(
patterns[0].matches(host),
expected,
"{desc}: parse_no_proxy({input:?})[0].matches({host:?})"
);
}
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env_vars<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let saved: Vec<(&str, Option<String>)> = vars
.iter()
.map(|(k, _)| (*k, std::env::var(k).ok()))
.collect();
for (k, v) in vars {
unsafe {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
f();
for (k, orig) in &saved {
unsafe {
match orig {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}
#[test]
fn from_env_reads_proxy_vars() {
with_env_vars(
&[
("HTTPS_PROXY", Some("http://proxy:8080")),
("HTTP_PROXY", Some("http://http-proxy:8080")),
("NO_PROXY", None),
],
|| {
let config = ProxyConfig::from_env();
assert_eq!(config.https_proxy_url.as_deref(), Some("http://proxy:8080"));
assert_eq!(config.http_proxy_url.as_deref(), Some("http://http-proxy:8080"));
},
);
}
#[test]
fn from_env_cached_at_build_time() {
with_env_vars(
&[
("HTTPS_PROXY", Some("http://proxy:8080")),
("HTTP_PROXY", None),
("NO_PROXY", Some("internal.corp")),
],
|| {
let config = ProxyConfig::from_env();
assert_eq!(config.https_proxy_url.as_deref(), Some("http://proxy:8080"));
unsafe {
std::env::set_var("HTTPS_PROXY", "http://changed:9090");
std::env::remove_var("NO_PROXY");
}
assert_eq!(config.https_proxy_url.as_deref(), Some("http://proxy:8080"));
assert_eq!(config.no_proxy.len(), 1);
assert!(config.no_proxy[0].matches("api.internal.corp"));
},
);
}
#[test]
fn no_proxy_from_string_option() {
let cases: &[(&str, bool)] =
&[("localhost,.example.com", true), ("", false), (" ", false)];
for &(input, expected_some) in cases {
assert_eq!(
NoProxy::from_string(input).is_some(),
expected_some,
"NoProxy::from_string({input:?})"
);
}
}
#[test]
fn no_proxy_default_is_empty() {
let np = NoProxy::default();
assert!(np.patterns.is_empty());
}
#[test]
fn proxy_basic_auth_stores_credentials() {
let proxy = Proxy::all("http://proxy:8080").unwrap();
let proxy = proxy.basic_auth("user", "pass");
assert_eq!(proxy.url, "http://proxy:8080");
assert_eq!(proxy.creds, Some(("user".to_owned(), "pass".to_owned())));
}
#[test]
fn proxy_basic_auth_creds_flow_through_resolve() {
let proxy = Proxy::all("http://proxy:8080")
.unwrap()
.basic_auth("alice", "s3cret");
let mut config = ProxyConfig::none();
proxy.apply_to(&mut config);
assert_eq!(config.http_proxy_creds, Some(("alice".to_owned(), "s3cret".to_owned())));
assert_eq!(config.https_proxy_creds, Some(("alice".to_owned(), "s3cret".to_owned())));
let action = config.resolve("example.com", true);
assert_eq!(
action,
ProxyAction::Named(
"http://proxy:8080".into(),
Some(("alice".to_owned(), "s3cret".to_owned()))
)
);
}
#[test]
#[cfg(feature = "noop-compat")]
fn proxy_no_proxy_table() {
let cases: &[(Option<NoProxy>, &str)] =
&[(NoProxy::from_string("localhost"), "Some"), (None, "None")];
for (np, label) in cases {
let proxy = Proxy::all("http://proxy:8080").unwrap();
let proxy = proxy.no_proxy(np.clone());
assert_eq!(proxy.url, "http://proxy:8080", "{label}");
}
}
const PROXY_VALIDATION_CASES: &[(&str, bool)] = &[
("", false),
("proxy:8080", false), ("ftp://proxy:8080", false), ("http://", false), ("http://proxy:8080", true), ("https://proxy:8080", true), ];
#[test]
fn proxy_all_validation() {
for &(input, expected_ok) in PROXY_VALIDATION_CASES {
assert_eq!(Proxy::all(input).is_ok(), expected_ok, "Proxy::all({input:?})");
}
}
#[test]
fn proxy_constructor_rejects_bad_input() {
type TestCase<'a> = (&'a str, fn(&str) -> Result<Proxy, Error>, &'a str);
let cases: &[TestCase] = &[
("all(socks5)", |u| Proxy::all(u), "socks5://proxy:1080"),
("all(socks4)", |u| Proxy::all(u), "socks4://proxy:1080"),
("http(bad)", |u| Proxy::http(u), "not-a-url"),
("https(bad)", |u| Proxy::https(u), "not-a-url"),
];
for &(label, ctor, url) in cases {
assert!(ctor(url).is_err(), "{label} should fail");
}
}
#[test]
fn proxy_apply_to_table() {
type TestCase<'a> = (&'a str, &'a str, Option<&'a str>, Option<&'a str>, &'a str);
let cases: &[TestCase] = &[
("http", "http://http-only:8080", Some("http://http-only:8080"), None, "http only"),
("https", "http://https-only:8080", None, Some("http://https-only:8080"), "https only"),
];
let creds = Some(("u".to_owned(), "p".to_owned()));
for &(scheme, url, exp_http, exp_https, label) in cases {
let proxy = match scheme {
"http" => Proxy::http(url),
"https" => Proxy::https(url),
_ => unreachable!(),
}
.unwrap()
.basic_auth("u", "p");
let mut cfg = ProxyConfig::none();
proxy.apply_to(&mut cfg);
assert_eq!(cfg.http_proxy_url.as_deref(), exp_http, "{label}: http_proxy_url");
assert_eq!(cfg.https_proxy_url.as_deref(), exp_https, "{label}: https_proxy_url");
let exp_http_creds = if exp_http.is_some() {
creds.clone()
} else {
None
};
let exp_https_creds = if exp_https.is_some() {
creds.clone()
} else {
None
};
assert_eq!(cfg.http_proxy_creds, exp_http_creds, "{label}: http creds");
assert_eq!(cfg.https_proxy_creds, exp_https_creds, "{label}: https creds");
}
}
#[test]
fn no_proxy_from_env_table() {
let cases: &[(Option<&str>, bool, usize, &str)] = &[
(Some("localhost,.internal.corp"), true, 2, "set with patterns"),
(None, false, 0, "unset"),
];
for &(env_val, expected_some, expected_count, label) in cases {
with_env_vars(&[("NO_PROXY", env_val)], || {
let np = NoProxy::from_env();
assert_eq!(np.is_some(), expected_some, "{label}");
if let Some(np) = np {
assert_eq!(np.patterns.len(), expected_count, "{label}: count");
assert!(np.patterns[0].matches("localhost"), "{label}: first");
assert!(np.patterns[1].matches("api.internal.corp"), "{label}: second");
}
});
}
}
#[test]
fn no_proxy_apply_to_sets_patterns() {
let np = NoProxy::from_string("localhost,.example.com").unwrap();
let mut cfg = ProxyConfig::none();
np.apply_to(&mut cfg);
assert_eq!(cfg.no_proxy.len(), 2);
}
#[test]
fn proxy_config_none_from_env_is_empty() {
let cfg = ProxyConfig::none_from_env();
assert!(cfg.http_proxy_url.is_none());
assert!(cfg.https_proxy_url.is_none());
assert!(cfg.no_proxy.is_empty());
assert!(cfg.http_proxy_creds.is_none());
assert!(cfg.https_proxy_creds.is_none());
}
}