Skip to main content

uv_configuration/
proxy_url.rs

1#[cfg(feature = "schemars")]
2use std::borrow::Cow;
3use std::fmt::{self, Display, Formatter};
4use std::str::FromStr;
5
6use reqwest::Proxy;
7use serde::{Deserialize, Deserializer, Serialize};
8use url::Url;
9
10use uv_redacted::DisplaySafeUrl;
11
12/// A validated proxy URL.
13///
14/// This type validates that the [`Url`] is valid for a [`reqwest::Proxy`] on construction.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct ProxyUrl(DisplaySafeUrl);
17
18/// Mapping to [`reqwest::proxy::Intercept`] kinds which are not public API.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum ProxyUrlKind {
21    Http,
22    Https,
23}
24
25impl ProxyUrl {
26    /// Returns a reference to the underlying URL.
27    fn as_url(&self) -> &DisplaySafeUrl {
28        &self.0
29    }
30
31    /// Constructs a [`reqwest::Proxy`] from this [`ProxyUrl`] for the given [`ProxyUrlKind`].
32    pub fn as_proxy(&self, kind: ProxyUrlKind) -> Proxy {
33        // SAFETY: Constructing a [`Proxy`] from a [`Url`] is infallible.
34        match kind {
35            ProxyUrlKind::Http => Proxy::http(self.0.as_str())
36                .expect("Constructing a proxy from a url should never fail"),
37            ProxyUrlKind::Https => Proxy::https(self.0.as_str())
38                .expect("Constructing a proxy from a url should never fail"),
39        }
40    }
41}
42
43#[derive(Debug, thiserror::Error)]
44pub enum ProxyUrlError {
45    #[error("invalid proxy URL: {0}")]
46    InvalidUrl(#[from] url::ParseError),
47    #[error(
48        "invalid proxy URL scheme `{scheme}` in `{url}`: expected http, https, socks5, or socks5h"
49    )]
50    InvalidScheme { scheme: String, url: DisplaySafeUrl },
51}
52
53/// Returns true if the input likely has no scheme (no "://" present).
54fn lacks_scheme(s: &str) -> bool {
55    !s.contains("://")
56}
57
58impl FromStr for ProxyUrl {
59    type Err = ProxyUrlError;
60
61    /// Parses a proxy URL from a string, assuming `http://` if no scheme is present.
62    ///
63    /// This matches reqwest's and curl's behavior.
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        fn try_with_http_scheme(s: &str) -> Result<ProxyUrl, ProxyUrlError> {
66            let with_scheme = format!("http://{s}");
67            let url = Url::parse(&with_scheme)?;
68            ProxyUrl::try_from(url)
69        }
70
71        match Url::parse(s) {
72            Ok(url) => match Self::try_from(url) {
73                Ok(proxy) => Ok(proxy),
74                Err(ProxyUrlError::InvalidScheme { .. }) if lacks_scheme(s) => {
75                    try_with_http_scheme(s)
76                }
77                Err(e) => Err(e),
78            },
79            Err(url::ParseError::RelativeUrlWithoutBase) => try_with_http_scheme(s),
80            Err(err) => Err(ProxyUrlError::InvalidUrl(err)),
81        }
82    }
83}
84
85impl TryFrom<Url> for ProxyUrl {
86    type Error = ProxyUrlError;
87
88    fn try_from(url: Url) -> Result<Self, Self::Error> {
89        let url = DisplaySafeUrl::from_url(url);
90        match url.scheme() {
91            "http" | "https" | "socks5" | "socks5h" => Ok(Self(url)),
92            scheme => Err(ProxyUrlError::InvalidScheme {
93                scheme: scheme.to_string(),
94                url,
95            }),
96        }
97    }
98}
99
100impl Display for ProxyUrl {
101    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
102        Display::fmt(&self.0, f)
103    }
104}
105
106impl<'de> Deserialize<'de> for ProxyUrl {
107    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108    where
109        D: Deserializer<'de>,
110    {
111        let s = String::deserialize(deserializer)?;
112        Self::from_str(&s).map_err(serde::de::Error::custom)
113    }
114}
115
116impl Serialize for ProxyUrl {
117    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118    where
119        S: serde::ser::Serializer,
120    {
121        serializer.serialize_str(self.as_url().as_str())
122    }
123}
124
125#[cfg(feature = "schemars")]
126impl schemars::JsonSchema for ProxyUrl {
127    fn schema_name() -> Cow<'static, str> {
128        Cow::Borrowed("ProxyUrl")
129    }
130
131    fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
132        schemars::json_schema!({
133            "type": "string",
134            "format": "uri",
135            "description": "A proxy URL (e.g., `http://proxy.example.com:8080`)."
136        })
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn parse_valid_proxy_urls() {
146        // HTTP proxy
147        let url = "http://proxy.example.com:8080".parse::<ProxyUrl>().unwrap();
148        assert_eq!(url.to_string(), "http://proxy.example.com:8080/");
149
150        // HTTPS proxy
151        let url = "https://proxy.example.com:8080"
152            .parse::<ProxyUrl>()
153            .unwrap();
154        assert_eq!(url.to_string(), "https://proxy.example.com:8080/");
155
156        // SOCKS5 proxy (no trailing slash for socks URLs)
157        let url = "socks5://proxy.example.com:1080"
158            .parse::<ProxyUrl>()
159            .unwrap();
160        assert_eq!(url.to_string(), "socks5://proxy.example.com:1080");
161
162        // SOCKS5H proxy
163        let url = "socks5h://proxy.example.com:1080"
164            .parse::<ProxyUrl>()
165            .unwrap();
166        assert_eq!(url.to_string(), "socks5h://proxy.example.com:1080");
167
168        // Proxy with auth
169        let url = "http://user:pass@proxy.example.com:8080"
170            .parse::<ProxyUrl>()
171            .unwrap();
172        assert_eq!(
173            Url::from(url.as_url().clone()).to_string(),
174            "http://user:pass@proxy.example.com:8080/"
175        );
176    }
177
178    #[test]
179    fn parse_proxy_url_without_scheme() {
180        // URL without a scheme (no "://") should default to http://
181        // This matches curl and reqwest behavior
182        let url = "proxy.example.com:8080".parse::<ProxyUrl>().unwrap();
183        assert_eq!(url.to_string(), "http://proxy.example.com:8080/");
184
185        // With auth but no scheme
186        let url = "user:pass@proxy.example.com:8080"
187            .parse::<ProxyUrl>()
188            .unwrap();
189        assert_eq!(
190            Url::from(url.as_url().clone()).to_string(),
191            "http://user:pass@proxy.example.com:8080/"
192        );
193
194        // Just hostname
195        let url = "proxy.example.com".parse::<ProxyUrl>().unwrap();
196        assert_eq!(url.to_string(), "http://proxy.example.com/");
197    }
198
199    #[test]
200    fn parse_invalid_proxy_urls() {
201        let result = "ftp://proxy.example.com:8080".parse::<ProxyUrl>();
202        assert!(matches!(result, Err(ProxyUrlError::InvalidScheme { .. })));
203        insta::assert_snapshot!(
204            result.unwrap_err().to_string(),
205            @"invalid proxy URL scheme `ftp` in `ftp://proxy.example.com:8080/`: expected http, https, socks5, or socks5h"
206        );
207
208        // Invalid URL (spaces are not allowed)
209        let result = "not a url".parse::<ProxyUrl>();
210        assert!(matches!(result, Err(ProxyUrlError::InvalidUrl(_))));
211        insta::assert_snapshot!(
212            result.unwrap_err().to_string(),
213            @"invalid proxy URL: invalid international domain name"
214        );
215
216        // Empty string
217        let result = "".parse::<ProxyUrl>();
218        assert!(matches!(result, Err(ProxyUrlError::InvalidUrl(_))));
219        insta::assert_snapshot!(
220            result.unwrap_err().to_string(),
221            @"invalid proxy URL: empty host"
222        );
223
224        let result = "file:///path/to/file".parse::<ProxyUrl>();
225        assert!(matches!(result, Err(ProxyUrlError::InvalidScheme { .. })));
226        insta::assert_snapshot!(
227            result.unwrap_err().to_string(),
228            @"invalid proxy URL scheme `file` in `file:///path/to/file`: expected http, https, socks5, or socks5h"
229        );
230    }
231
232    #[test]
233    fn deserialize_invalid_proxy_url() {
234        let result: Result<ProxyUrl, _> = serde_json::from_str(r#""ftp://proxy.example.com:8080""#);
235        insta::assert_snapshot!(
236            result.unwrap_err().to_string(),
237            @"invalid proxy URL scheme `ftp` in `ftp://proxy.example.com:8080/`: expected http, https, socks5, or socks5h"
238        );
239
240        let result: Result<ProxyUrl, _> = serde_json::from_str(r#""not a url""#);
241        insta::assert_snapshot!(
242            result.unwrap_err().to_string(),
243            @"invalid proxy URL: invalid international domain name"
244        );
245    }
246}