use std::{fmt, io, str::FromStr};
use reqwest::StatusCode;
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
use url::Url;
use crate::{Error, Result};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ProxyScheme {
Http,
Https,
Socks5,
Socks5h,
}
impl ProxyScheme {
pub const fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
Self::Socks5 => "socks5",
Self::Socks5h => "socks5h",
}
}
pub(crate) const fn default_port(self) -> u16 {
match self {
Self::Http => 80,
Self::Https => 443,
Self::Socks5 | Self::Socks5h => 1080,
}
}
pub(crate) const fn resolves_remotely(self) -> bool {
matches!(self, Self::Socks5h)
}
}
impl fmt::Display for ProxyScheme {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone)]
#[must_use]
pub struct ProxyConfig {
url: Url,
scheme: ProxyScheme,
credentials: Option<ProxyCredentials>,
}
#[derive(Clone)]
struct ProxyCredentials {
username: SecretString,
password: SecretString,
}
impl ProxyConfig {
pub fn new(url: impl AsRef<str>) -> Result<Self> {
let url = Url::parse(url.as_ref())
.map_err(|error| Error::Configuration(format!("invalid proxy URL: {error}")))?;
let scheme = match url.scheme() {
"http" => ProxyScheme::Http,
"https" => ProxyScheme::Https,
"socks5" => ProxyScheme::Socks5,
"socks5h" => ProxyScheme::Socks5h,
_ => {
return Err(Error::Configuration(
"proxy URL scheme must be http, https, socks5, or socks5h".to_owned(),
));
}
};
if url.host_str().is_none() {
return Err(Error::Configuration(
"proxy URL must contain a host".to_owned(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(Error::Configuration(
"proxy URL must not contain credentials; use with_basic_auth".to_owned(),
));
}
if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() {
return Err(Error::Configuration(
"proxy URL must not contain a path, query, or fragment".to_owned(),
));
}
Ok(Self {
url,
scheme,
credentials: None,
})
}
pub fn with_basic_auth(
mut self,
username: impl Into<String>,
password: impl Into<String>,
) -> Result<Self> {
let username = username.into();
let password = password.into();
if username.is_empty() {
return Err(Error::Configuration(
"proxy username must not be empty".to_owned(),
));
}
if username.contains(':') {
return Err(Error::Configuration(
"proxy username must not contain ':'".to_owned(),
));
}
if matches!(self.scheme, ProxyScheme::Socks5 | ProxyScheme::Socks5h)
&& (username.len() > usize::from(u8::MAX) || password.len() > usize::from(u8::MAX))
{
return Err(Error::Configuration(
"SOCKS5 username and password must each be at most 255 bytes".to_owned(),
));
}
self.credentials = Some(ProxyCredentials {
username: SecretString::from(username),
password: SecretString::from(password),
});
Ok(self)
}
pub const fn scheme(&self) -> ProxyScheme {
self.scheme
}
pub const fn url(&self) -> &Url {
&self.url
}
pub const fn has_basic_auth(&self) -> bool {
self.credentials.is_some()
}
pub(crate) fn host(&self) -> &str {
self.url
.host_str()
.expect("ProxyConfig construction requires a host")
}
pub(crate) fn port(&self) -> u16 {
self.url
.port()
.unwrap_or_else(|| self.scheme.default_port())
}
pub(crate) fn credentials(&self) -> Option<(&str, &str)> {
self.credentials.as_ref().map(|credentials| {
(
credentials.username.expose_secret(),
credentials.password.expose_secret(),
)
})
}
pub(crate) fn reqwest_proxy(&self) -> Result<reqwest::Proxy> {
let proxy = reqwest::Proxy::all(self.url.as_str())
.map_err(|error| Error::Configuration(format!("invalid proxy: {error}")))?;
Ok(match self.credentials() {
Some((username, password)) => proxy.basic_auth(username, password),
None => proxy,
})
}
}
impl fmt::Debug for ProxyConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProxyConfig")
.field("scheme", &self.scheme)
.field("endpoint", &"[REDACTED]")
.field("authenticated", &self.has_basic_auth())
.finish_non_exhaustive()
}
}
impl FromStr for ProxyConfig {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
Self::new(value)
}
}
impl TryFrom<&str> for ProxyConfig {
type Error = Error;
fn try_from(value: &str) -> Result<Self> {
Self::new(value)
}
}
impl TryFrom<String> for ProxyConfig {
type Error = Error;
fn try_from(value: String) -> Result<Self> {
Self::new(value)
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ProxyError {
#[error("proxy transport failed: {0}")]
Io(#[source] io::Error),
#[error("SOCKS5 negotiation failed: {0}")]
Socks5(#[source] Box<tokio_socks::Error>),
#[error("HTTP CONNECT returned {0}")]
HttpStatus(StatusCode),
#[error("HTTP proxy returned an invalid CONNECT response")]
InvalidHttpResponse,
#[error("HTTP proxy CONNECT response exceeded {limit} bytes")]
HttpResponseTooLarge {
limit: usize,
},
#[error("proxy TLS configuration failed: {0}")]
TlsConfiguration(String),
}
impl ProxyError {
pub(crate) fn is_timeout(&self) -> bool {
match self {
Self::Io(error) => error.kind() == io::ErrorKind::TimedOut,
Self::Socks5(error) => {
matches!(error.as_ref(), tokio_socks::Error::Io(error) if error.kind() == io::ErrorKind::TimedOut)
}
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schemes_define_ports_dns_and_display_values() {
let cases = [
(ProxyScheme::Http, "http", 80, false),
(ProxyScheme::Https, "https", 443, false),
(ProxyScheme::Socks5, "socks5", 1080, false),
(ProxyScheme::Socks5h, "socks5h", 1080, true),
];
for (scheme, text, port, remote_dns) in cases {
assert_eq!(scheme.as_str(), text);
assert_eq!(scheme.to_string(), text);
assert_eq!(scheme.default_port(), port);
assert_eq!(scheme.resolves_remotely(), remote_dns);
}
}
#[test]
fn configuration_rejects_ambiguous_or_leaky_urls() {
for value in [
"relative",
"ftp://proxy.example.com",
"http://user:password@proxy.example.com",
"http://proxy.example.com/path",
"http://proxy.example.com?query=yes",
"http://proxy.example.com#fragment",
] {
assert!(ProxyConfig::new(value).is_err(), "accepted {value}");
}
assert!(
ProxyConfig::new("http://proxy.example.com")
.unwrap()
.with_basic_auth("", "password")
.is_err()
);
assert!(
ProxyConfig::new("http://proxy.example.com")
.unwrap()
.with_basic_auth("user:name", "password")
.is_err()
);
let oversized = "x".repeat(256);
assert!(
ProxyConfig::new("socks5://proxy.example.com")
.unwrap()
.with_basic_auth(&oversized, "password")
.is_err()
);
assert!(
ProxyConfig::new("socks5://proxy.example.com")
.unwrap()
.with_basic_auth("user", oversized)
.is_err()
);
}
#[test]
fn conversions_and_credentials_keep_endpoint_secret_free() {
let plain: ProxyConfig = "http://proxy.example.com".parse().unwrap();
assert_eq!(plain.host(), "proxy.example.com");
assert_eq!(plain.port(), 80);
assert!(!plain.has_basic_auth());
assert!(plain.credentials().is_none());
plain.reqwest_proxy().unwrap();
let authenticated = ProxyConfig::try_from("https://proxy.example.com:8443")
.unwrap()
.with_basic_auth("service", "proxy-password-secret")
.unwrap();
assert_eq!(authenticated.port(), 8443);
assert_eq!(
authenticated.credentials(),
Some(("service", "proxy-password-secret"))
);
authenticated.reqwest_proxy().unwrap();
let output = format!("{authenticated:?}");
assert!(!output.contains("proxy.example.com"));
assert!(!output.contains("proxy-password-secret"));
assert!(output.contains("authenticated: true"));
let owned = String::from("socks5h://proxy.example.com");
assert_eq!(
ProxyConfig::try_from(owned).unwrap().scheme(),
ProxyScheme::Socks5h
);
}
#[test]
fn proxy_timeout_detection_handles_io_and_socks_errors() {
assert!(ProxyError::Io(io::Error::new(io::ErrorKind::TimedOut, "timeout")).is_timeout());
assert!(
ProxyError::Socks5(Box::new(tokio_socks::Error::Io(io::Error::new(
io::ErrorKind::TimedOut,
"timeout",
))))
.is_timeout()
);
assert!(!ProxyError::HttpStatus(StatusCode::BAD_GATEWAY).is_timeout());
}
}