use anyhow::Context;
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
pub enum ProxyScope {
Http,
Https,
#[default]
All,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ProxyAuth {
Basic {
username: String,
password: String,
},
Custom(String),
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ProxyRule {
pub scope: ProxyScope,
pub url: String,
pub auth: Option<ProxyAuth>,
pub no_proxy: Option<String>,
}
impl ProxyRule {
pub fn all(url: impl Into<String>) -> Self {
Self::new(ProxyScope::All, url)
}
pub fn http(url: impl Into<String>) -> Self {
Self::new(ProxyScope::Http, url)
}
pub fn https(url: impl Into<String>) -> Self {
Self::new(ProxyScope::Https, url)
}
pub fn new(scope: ProxyScope, url: impl Into<String>) -> Self {
Self {
scope,
url: url.into(),
auth: None,
no_proxy: None,
}
}
#[must_use]
pub fn with_basic_auth(
mut self,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
self.auth = Some(ProxyAuth::Basic {
username: username.into(),
password: password.into(),
});
self
}
#[must_use]
pub fn with_custom_auth(mut self, header_value: impl Into<String>) -> Self {
self.auth = Some(ProxyAuth::Custom(header_value.into()));
self
}
#[must_use]
pub fn bypassing(mut self, no_proxy: impl Into<String>) -> Self {
self.no_proxy = Some(no_proxy.into());
self
}
fn to_reqwest(&self) -> anyhow::Result<reqwest::Proxy> {
#[cfg(not(feature = "socks"))]
{
let scheme = self
.url
.split("://")
.next()
.unwrap_or_default()
.to_ascii_lowercase();
anyhow::ensure!(
!matches!(scheme.as_str(), "socks4" | "socks4a" | "socks5" | "socks5h"),
"proxy URL {:?} needs the `socks` cargo feature",
self.url
);
}
let mut proxy = match self.scope {
ProxyScope::Http => reqwest::Proxy::http(&self.url),
ProxyScope::Https => reqwest::Proxy::https(&self.url),
ProxyScope::All => reqwest::Proxy::all(&self.url),
}
.with_context(|| format!("unusable proxy URL {:?}", self.url))?;
match self.auth {
Some(ProxyAuth::Basic {
ref username,
ref password,
}) => proxy = proxy.basic_auth(username, password),
Some(ProxyAuth::Custom(ref value)) => {
let header = value.parse().with_context(|| {
format!("proxy {:?}: invalid Proxy-Authorization value", self.url)
})?;
proxy = proxy.custom_http_auth(header);
}
None => {}
}
if let Some(ref list) = self.no_proxy {
proxy = proxy.no_proxy(reqwest::NoProxy::from_string(list));
}
Ok(proxy)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Default)]
pub enum ProxyConfig {
#[default]
System,
Disabled,
Rules(Vec<ProxyRule>),
}
impl ProxyConfig {
pub fn single(url: impl Into<String>) -> Self {
ProxyConfig::Rules(vec![ProxyRule::all(url)])
}
pub(crate) fn apply(
&self,
builder: reqwest::ClientBuilder,
) -> anyhow::Result<reqwest::ClientBuilder> {
match self {
ProxyConfig::System => Ok(builder),
ProxyConfig::Disabled => Ok(builder.no_proxy()),
ProxyConfig::Rules(rules) => {
let mut builder = builder.no_proxy();
for rule in rules {
builder = builder.proxy(rule.to_reqwest()?);
}
Ok(builder)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builders_set_scope_and_extras() {
let rule = ProxyRule::https("http://p:8080")
.with_basic_auth("u", "p")
.bypassing("localhost");
assert_eq!(rule.scope, ProxyScope::Https);
assert_eq!(rule.url, "http://p:8080");
assert_eq!(
rule.auth,
Some(ProxyAuth::Basic {
username: "u".into(),
password: "p".into()
})
);
assert_eq!(rule.no_proxy.as_deref(), Some("localhost"));
assert_eq!(ProxyRule::http("http://p:8080").scope, ProxyScope::Http);
assert_eq!(ProxyRule::all("http://p:8080").scope, ProxyScope::All);
assert!(ProxyRule::all("http://p:8080").auth.is_none());
}
#[test]
fn default_is_system() {
assert_eq!(ProxyConfig::default(), ProxyConfig::System);
}
#[test]
fn single_is_an_all_scheme_rule() {
assert_eq!(
ProxyConfig::single("http://p:8080"),
ProxyConfig::Rules(vec![ProxyRule::all("http://p:8080")])
);
}
#[test]
fn valid_rules_build() {
for rule in [
ProxyRule::all("http://proxy.example:8080"),
ProxyRule::http("http://user:pass@proxy.example:8080"),
ProxyRule::https("https://proxy.example:8443").with_basic_auth("u", "p"),
ProxyRule::all("http://proxy.example:8080").with_custom_auth("Bearer token"),
ProxyRule::all("http://proxy.example:8080").bypassing("localhost, 10.0.0.0/8"),
] {
assert!(rule.to_reqwest().is_ok(), "should build: {rule:?}");
}
}
#[test]
fn socks_urls_need_the_socks_feature() {
let built = ProxyRule::all("socks5://127.0.0.1:1080")
.to_reqwest()
.is_ok();
assert_eq!(built, cfg!(feature = "socks"));
}
#[test]
fn unusable_proxy_url_is_reported() {
let err = ProxyRule::all("not a url").to_reqwest().unwrap_err();
assert!(
err.to_string().contains("not a url"),
"error should name the offending URL, got: {err}"
);
}
#[test]
fn invalid_custom_auth_header_is_reported() {
let err = ProxyRule::all("http://proxy.example:8080")
.with_custom_auth("bad\nvalue")
.to_reqwest()
.unwrap_err();
assert!(
err.to_string().contains("Proxy-Authorization"),
"error should mention the header, got: {err}"
);
}
#[test]
fn apply_accepts_every_variant() {
for cfg in [
ProxyConfig::System,
ProxyConfig::Disabled,
ProxyConfig::Rules(vec![]),
ProxyConfig::single("http://proxy.example:8080"),
] {
assert!(
cfg.apply(reqwest::Client::builder()).is_ok(),
"should apply: {cfg:?}"
);
}
}
#[test]
fn apply_propagates_rule_errors() {
let cfg = ProxyConfig::single("not a url");
assert!(cfg.apply(reqwest::Client::builder()).is_err());
}
}