1use std::{fmt, io, str::FromStr};
4
5use reqwest::StatusCode;
6use secrecy::{ExposeSecret, SecretString};
7use thiserror::Error;
8use url::Url;
9
10use crate::{Error, Result};
11
12#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
14#[non_exhaustive]
15pub enum ProxyScheme {
16 Http,
18 Https,
20 Socks5,
22 Socks5h,
24}
25
26impl ProxyScheme {
27 pub const fn as_str(self) -> &'static str {
29 match self {
30 Self::Http => "http",
31 Self::Https => "https",
32 Self::Socks5 => "socks5",
33 Self::Socks5h => "socks5h",
34 }
35 }
36
37 pub(crate) const fn default_port(self) -> u16 {
38 match self {
39 Self::Http => 80,
40 Self::Https => 443,
41 Self::Socks5 | Self::Socks5h => 1080,
42 }
43 }
44
45 pub(crate) const fn resolves_remotely(self) -> bool {
46 matches!(self, Self::Socks5h)
47 }
48}
49
50impl fmt::Display for ProxyScheme {
51 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52 formatter.write_str(self.as_str())
53 }
54}
55
56#[derive(Clone)]
62#[must_use]
63pub struct ProxyConfig {
64 url: Url,
65 scheme: ProxyScheme,
66 credentials: Option<ProxyCredentials>,
67}
68
69#[derive(Clone)]
70struct ProxyCredentials {
71 username: SecretString,
72 password: SecretString,
73}
74
75impl ProxyConfig {
76 pub fn new(url: impl AsRef<str>) -> Result<Self> {
83 let url = Url::parse(url.as_ref())
84 .map_err(|error| Error::Configuration(format!("invalid proxy URL: {error}")))?;
85 let scheme = match url.scheme() {
86 "http" => ProxyScheme::Http,
87 "https" => ProxyScheme::Https,
88 "socks5" => ProxyScheme::Socks5,
89 "socks5h" => ProxyScheme::Socks5h,
90 _ => {
91 return Err(Error::Configuration(
92 "proxy URL scheme must be http, https, socks5, or socks5h".to_owned(),
93 ));
94 }
95 };
96 if url.host_str().is_none() {
97 return Err(Error::Configuration(
98 "proxy URL must contain a host".to_owned(),
99 ));
100 }
101 if !url.username().is_empty() || url.password().is_some() {
102 return Err(Error::Configuration(
103 "proxy URL must not contain credentials; use with_basic_auth".to_owned(),
104 ));
105 }
106 if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() {
107 return Err(Error::Configuration(
108 "proxy URL must not contain a path, query, or fragment".to_owned(),
109 ));
110 }
111 Ok(Self {
112 url,
113 scheme,
114 credentials: None,
115 })
116 }
117
118 pub fn with_basic_auth(
125 mut self,
126 username: impl Into<String>,
127 password: impl Into<String>,
128 ) -> Result<Self> {
129 let username = username.into();
130 let password = password.into();
131 if username.is_empty() {
132 return Err(Error::Configuration(
133 "proxy username must not be empty".to_owned(),
134 ));
135 }
136 if username.contains(':') {
137 return Err(Error::Configuration(
138 "proxy username must not contain ':'".to_owned(),
139 ));
140 }
141 if matches!(self.scheme, ProxyScheme::Socks5 | ProxyScheme::Socks5h)
142 && (username.len() > usize::from(u8::MAX) || password.len() > usize::from(u8::MAX))
143 {
144 return Err(Error::Configuration(
145 "SOCKS5 username and password must each be at most 255 bytes".to_owned(),
146 ));
147 }
148 self.credentials = Some(ProxyCredentials {
149 username: SecretString::from(username),
150 password: SecretString::from(password),
151 });
152 Ok(self)
153 }
154
155 pub const fn scheme(&self) -> ProxyScheme {
157 self.scheme
158 }
159
160 pub const fn url(&self) -> &Url {
162 &self.url
163 }
164
165 pub const fn has_basic_auth(&self) -> bool {
167 self.credentials.is_some()
168 }
169
170 pub(crate) fn host(&self) -> &str {
171 self.url
172 .host_str()
173 .expect("ProxyConfig construction requires a host")
174 }
175
176 pub(crate) fn port(&self) -> u16 {
177 self.url
178 .port()
179 .unwrap_or_else(|| self.scheme.default_port())
180 }
181
182 pub(crate) fn credentials(&self) -> Option<(&str, &str)> {
183 self.credentials.as_ref().map(|credentials| {
184 (
185 credentials.username.expose_secret(),
186 credentials.password.expose_secret(),
187 )
188 })
189 }
190
191 pub(crate) fn reqwest_proxy(&self) -> Result<reqwest::Proxy> {
192 let proxy = reqwest::Proxy::all(self.url.as_str())
193 .map_err(|error| Error::Configuration(format!("invalid proxy: {error}")))?;
194 Ok(match self.credentials() {
195 Some((username, password)) => proxy.basic_auth(username, password),
196 None => proxy,
197 })
198 }
199}
200
201impl fmt::Debug for ProxyConfig {
202 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
203 formatter
204 .debug_struct("ProxyConfig")
205 .field("scheme", &self.scheme)
206 .field("endpoint", &"[REDACTED]")
207 .field("authenticated", &self.has_basic_auth())
208 .finish_non_exhaustive()
209 }
210}
211
212impl FromStr for ProxyConfig {
213 type Err = Error;
214
215 fn from_str(value: &str) -> Result<Self> {
216 Self::new(value)
217 }
218}
219
220impl TryFrom<&str> for ProxyConfig {
221 type Error = Error;
222
223 fn try_from(value: &str) -> Result<Self> {
224 Self::new(value)
225 }
226}
227
228impl TryFrom<String> for ProxyConfig {
229 type Error = Error;
230
231 fn try_from(value: String) -> Result<Self> {
232 Self::new(value)
233 }
234}
235
236#[derive(Debug, Error)]
238#[non_exhaustive]
239pub enum ProxyError {
240 #[error("proxy transport failed: {0}")]
242 Io(#[source] io::Error),
243 #[error("SOCKS5 negotiation failed: {0}")]
245 Socks5(#[source] Box<tokio_socks::Error>),
246 #[error("HTTP CONNECT returned {0}")]
248 HttpStatus(StatusCode),
249 #[error("HTTP proxy returned an invalid CONNECT response")]
251 InvalidHttpResponse,
252 #[error("HTTP proxy CONNECT response exceeded {limit} bytes")]
254 HttpResponseTooLarge {
255 limit: usize,
257 },
258 #[error("proxy TLS configuration failed: {0}")]
260 TlsConfiguration(String),
261}
262
263impl ProxyError {
264 pub(crate) fn is_timeout(&self) -> bool {
265 match self {
266 Self::Io(error) => error.kind() == io::ErrorKind::TimedOut,
267 Self::Socks5(error) => {
268 matches!(error.as_ref(), tokio_socks::Error::Io(error) if error.kind() == io::ErrorKind::TimedOut)
269 }
270 _ => false,
271 }
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn schemes_define_ports_dns_and_display_values() {
281 let cases = [
282 (ProxyScheme::Http, "http", 80, false),
283 (ProxyScheme::Https, "https", 443, false),
284 (ProxyScheme::Socks5, "socks5", 1080, false),
285 (ProxyScheme::Socks5h, "socks5h", 1080, true),
286 ];
287 for (scheme, text, port, remote_dns) in cases {
288 assert_eq!(scheme.as_str(), text);
289 assert_eq!(scheme.to_string(), text);
290 assert_eq!(scheme.default_port(), port);
291 assert_eq!(scheme.resolves_remotely(), remote_dns);
292 }
293 }
294
295 #[test]
296 fn configuration_rejects_ambiguous_or_leaky_urls() {
297 for value in [
298 "relative",
299 "ftp://proxy.example.com",
300 "http://user:password@proxy.example.com",
301 "http://proxy.example.com/path",
302 "http://proxy.example.com?query=yes",
303 "http://proxy.example.com#fragment",
304 ] {
305 assert!(ProxyConfig::new(value).is_err(), "accepted {value}");
306 }
307 assert!(
308 ProxyConfig::new("http://proxy.example.com")
309 .unwrap()
310 .with_basic_auth("", "password")
311 .is_err()
312 );
313 assert!(
314 ProxyConfig::new("http://proxy.example.com")
315 .unwrap()
316 .with_basic_auth("user:name", "password")
317 .is_err()
318 );
319 let oversized = "x".repeat(256);
320 assert!(
321 ProxyConfig::new("socks5://proxy.example.com")
322 .unwrap()
323 .with_basic_auth(&oversized, "password")
324 .is_err()
325 );
326 assert!(
327 ProxyConfig::new("socks5://proxy.example.com")
328 .unwrap()
329 .with_basic_auth("user", oversized)
330 .is_err()
331 );
332 }
333
334 #[test]
335 fn conversions_and_credentials_keep_endpoint_secret_free() {
336 let plain: ProxyConfig = "http://proxy.example.com".parse().unwrap();
337 assert_eq!(plain.host(), "proxy.example.com");
338 assert_eq!(plain.port(), 80);
339 assert!(!plain.has_basic_auth());
340 assert!(plain.credentials().is_none());
341 plain.reqwest_proxy().unwrap();
342
343 let authenticated = ProxyConfig::try_from("https://proxy.example.com:8443")
344 .unwrap()
345 .with_basic_auth("service", "proxy-password-secret")
346 .unwrap();
347 assert_eq!(authenticated.port(), 8443);
348 assert_eq!(
349 authenticated.credentials(),
350 Some(("service", "proxy-password-secret"))
351 );
352 authenticated.reqwest_proxy().unwrap();
353 let output = format!("{authenticated:?}");
354 assert!(!output.contains("proxy.example.com"));
355 assert!(!output.contains("proxy-password-secret"));
356 assert!(output.contains("authenticated: true"));
357
358 let owned = String::from("socks5h://proxy.example.com");
359 assert_eq!(
360 ProxyConfig::try_from(owned).unwrap().scheme(),
361 ProxyScheme::Socks5h
362 );
363 }
364
365 #[test]
366 fn proxy_timeout_detection_handles_io_and_socks_errors() {
367 assert!(ProxyError::Io(io::Error::new(io::ErrorKind::TimedOut, "timeout")).is_timeout());
368 assert!(
369 ProxyError::Socks5(Box::new(tokio_socks::Error::Io(io::Error::new(
370 io::ErrorKind::TimedOut,
371 "timeout",
372 ))))
373 .is_timeout()
374 );
375 assert!(!ProxyError::HttpStatus(StatusCode::BAD_GATEWAY).is_timeout());
376 }
377}