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