1use anyhow::{Context, Result, bail};
2use base64::{Engine, engine::general_purpose::STANDARD};
3use percent_encoding::percent_decode_str;
4use std::{fmt, str::FromStr};
5use url::Url;
6
7#[derive(Debug, Clone, Copy, Eq, PartialEq)]
8pub enum ProxyScheme {
9 Http,
10 Socks5,
11}
12
13impl ProxyScheme {
14 pub fn as_str(self) -> &'static str {
15 match self {
16 Self::Http => "http",
17 Self::Socks5 => "socks5",
18 }
19 }
20}
21
22#[derive(Debug, Clone, Eq, PartialEq)]
23pub struct UpstreamProxy {
24 scheme: ProxyScheme,
25 host: String,
26 port: u16,
27 username: Option<String>,
28 password: Option<String>,
29}
30
31impl UpstreamProxy {
32 pub fn parse(raw: &str) -> Result<Self> {
33 raw.parse()
34 }
35
36 pub fn scheme(&self) -> ProxyScheme {
37 self.scheme
38 }
39
40 pub fn host(&self) -> &str {
41 &self.host
42 }
43
44 pub fn port(&self) -> u16 {
45 self.port
46 }
47
48 pub fn username(&self) -> Option<&str> {
49 self.username.as_deref()
50 }
51
52 pub fn password(&self) -> Option<&str> {
53 self.password.as_deref()
54 }
55
56 pub fn has_auth(&self) -> bool {
57 self.username.is_some() || self.password.is_some()
58 }
59
60 pub fn needs_bridge(&self) -> bool {
61 self.scheme != ProxyScheme::Http || self.has_auth()
62 }
63
64 pub fn command_line_url(&self) -> String {
65 format!(
66 "{}://{}:{}",
67 self.scheme.as_str(),
68 format_host_for_url(&self.host),
69 self.port
70 )
71 }
72
73 pub fn authority(&self) -> String {
74 format!("{}:{}", self.host, self.port)
75 }
76
77 pub fn basic_proxy_authorization(&self) -> Option<String> {
78 let username = self.username.as_ref()?;
79 let password = self.password.as_deref().unwrap_or_default();
80 let token = STANDARD.encode(format!("{username}:{password}"));
81 Some(format!("Basic {token}"))
82 }
83}
84
85impl FromStr for UpstreamProxy {
86 type Err = anyhow::Error;
87
88 fn from_str(raw: &str) -> Result<Self> {
89 let trimmed = raw.trim().trim_matches('"');
90 if trimmed.is_empty() {
91 bail!("proxy URL cannot be empty");
92 }
93
94 let normalized = if trimmed.contains("://") {
95 trimmed.to_string()
96 } else {
97 format!("http://{trimmed}")
98 };
99
100 let url =
101 Url::parse(&normalized).with_context(|| format!("invalid proxy URL: {trimmed}"))?;
102
103 let scheme = match url.scheme().to_ascii_lowercase().as_str() {
104 "http" | "https" => ProxyScheme::Http,
105 "socks" | "socks5" => ProxyScheme::Socks5,
106 other => bail!("unsupported proxy scheme: {other}"),
107 };
108
109 let host = url
110 .host_str()
111 .context("proxy URL is missing a host")?
112 .to_string();
113 let port = url
114 .port_or_known_default()
115 .or_else(|| default_port(scheme))
116 .context("proxy URL is missing a port")?;
117
118 let username = decode_non_empty(url.username());
119 let password = url.password().and_then(decode_non_empty);
120
121 Ok(Self {
122 scheme,
123 host,
124 port,
125 username,
126 password,
127 })
128 }
129}
130
131impl fmt::Display for ProxyScheme {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 formatter.write_str(self.as_str())
134 }
135}
136
137fn default_port(scheme: ProxyScheme) -> Option<u16> {
138 Some(match scheme {
139 ProxyScheme::Http => 80,
140 ProxyScheme::Socks5 => 1080,
141 })
142}
143
144fn decode_non_empty(value: &str) -> Option<String> {
145 if value.is_empty() {
146 return None;
147 }
148
149 Some(percent_decode_str(value).decode_utf8_lossy().into_owned())
150}
151
152fn format_host_for_url(host: &str) -> String {
153 if host.contains(':') && !host.starts_with('[') {
154 format!("[{host}]")
155 } else {
156 host.to_string()
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn parses_http_proxy_without_scheme() {
166 let proxy = UpstreamProxy::parse("127.0.0.1:7890").unwrap();
167
168 assert_eq!(proxy.scheme(), ProxyScheme::Http);
169 assert_eq!(proxy.host(), "127.0.0.1");
170 assert_eq!(proxy.port(), 7890);
171 assert!(!proxy.needs_bridge());
172 assert_eq!(proxy.command_line_url(), "http://127.0.0.1:7890");
173 }
174
175 #[test]
176 fn parses_socks_alias_with_auth() {
177 let proxy = UpstreamProxy::parse("socks://user:p%40ss@localhost:1080").unwrap();
178
179 assert_eq!(proxy.scheme(), ProxyScheme::Socks5);
180 assert_eq!(proxy.username(), Some("user"));
181 assert_eq!(proxy.password(), Some("p@ss"));
182 assert!(proxy.needs_bridge());
183 assert_eq!(proxy.command_line_url(), "socks5://localhost:1080");
184 }
185
186 #[test]
187 fn authenticated_http_proxy_needs_bridge() {
188 let proxy = UpstreamProxy::parse("http://user:pass@example.com:8080").unwrap();
189
190 assert!(proxy.needs_bridge());
191 assert!(proxy.basic_proxy_authorization().is_some());
192 assert_eq!(proxy.command_line_url(), "http://example.com:8080");
193 }
194}