[go: up one dir, main page]

uv_auth/
realm.rs

1use std::hash::{Hash, Hasher};
2use std::{fmt::Display, fmt::Formatter};
3use url::Url;
4use uv_redacted::DisplaySafeUrl;
5use uv_small_str::SmallString;
6
7/// Used to determine if authentication information should be retained on a new URL.
8/// Based on the specification defined in RFC 7235 and 7230.
9///
10/// <https://datatracker.ietf.org/doc/html/rfc7235#section-2.2>
11/// <https://datatracker.ietf.org/doc/html/rfc7230#section-5.5>
12//
13// The "scheme" and "authority" components must match to retain authentication
14// The "authority", is composed of the host and port.
15//
16// The scheme must always be an exact match.
17// Note some clients such as Python's `requests` library allow an upgrade
18// from `http` to `https` but this is not spec-compliant.
19// <https://github.com/pypa/pip/blob/75f54cae9271179b8cc80435f92336c97e349f9d/src/pip/_vendor/requests/sessions.py#L133-L136>
20//
21// The host must always be an exact match.
22//
23// The port is only allowed to differ if it matches the "default port" for the scheme.
24// However, `url` (and therefore `reqwest`) sets the `port` to `None` if it matches the default port
25// so we do not need any special handling here.
26#[derive(Debug, Clone)]
27pub struct Realm {
28    scheme: SmallString,
29    host: Option<SmallString>,
30    port: Option<u16>,
31}
32
33impl From<&DisplaySafeUrl> for Realm {
34    fn from(url: &DisplaySafeUrl) -> Self {
35        Self::from(&**url)
36    }
37}
38
39impl From<&Url> for Realm {
40    fn from(url: &Url) -> Self {
41        Self {
42            scheme: SmallString::from(url.scheme()),
43            host: url.host_str().map(SmallString::from),
44            port: url.port(),
45        }
46    }
47}
48
49impl Display for Realm {
50    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
51        if let Some(port) = self.port {
52            write!(
53                f,
54                "{}://{}:{port}",
55                self.scheme,
56                self.host.as_deref().unwrap_or_default()
57            )
58        } else {
59            write!(
60                f,
61                "{}://{}",
62                self.scheme,
63                self.host.as_deref().unwrap_or_default()
64            )
65        }
66    }
67}
68
69impl PartialEq for Realm {
70    fn eq(&self, other: &Self) -> bool {
71        RealmRef::from(self) == RealmRef::from(other)
72    }
73}
74
75impl Eq for Realm {}
76
77impl Hash for Realm {
78    fn hash<H: Hasher>(&self, state: &mut H) {
79        RealmRef::from(self).hash(state);
80    }
81}
82
83/// A reference to a [`Realm`] that can be used for zero-allocation comparisons.
84#[derive(Debug, Copy, Clone)]
85pub struct RealmRef<'a> {
86    scheme: &'a str,
87    host: Option<&'a str>,
88    port: Option<u16>,
89}
90
91impl RealmRef<'_> {
92    /// Returns true if this realm is a subdomain of the other realm.
93    pub(crate) fn is_subdomain_of(&self, other: Self) -> bool {
94        other.scheme == self.scheme
95            && other.port == self.port
96            && other.host.is_some_and(|other_host| {
97                self.host.is_some_and(|self_host| {
98                    self_host
99                        .strip_suffix(other_host)
100                        .is_some_and(|prefix| prefix.ends_with('.'))
101                })
102            })
103    }
104}
105
106impl<'a> From<&'a Url> for RealmRef<'a> {
107    fn from(url: &'a Url) -> Self {
108        Self {
109            scheme: url.scheme(),
110            host: url.host_str(),
111            port: url.port(),
112        }
113    }
114}
115
116impl PartialEq for RealmRef<'_> {
117    fn eq(&self, other: &Self) -> bool {
118        self.scheme == other.scheme && self.host == other.host && self.port == other.port
119    }
120}
121
122impl Eq for RealmRef<'_> {}
123
124impl Hash for RealmRef<'_> {
125    fn hash<H: Hasher>(&self, state: &mut H) {
126        self.scheme.hash(state);
127        self.host.hash(state);
128        self.port.hash(state);
129    }
130}
131
132impl<'a> PartialEq<RealmRef<'a>> for Realm {
133    fn eq(&self, rhs: &RealmRef<'a>) -> bool {
134        RealmRef::from(self) == *rhs
135    }
136}
137
138impl PartialEq<Realm> for RealmRef<'_> {
139    fn eq(&self, rhs: &Realm) -> bool {
140        *self == RealmRef::from(rhs)
141    }
142}
143
144impl<'a> From<&'a Realm> for RealmRef<'a> {
145    fn from(realm: &'a Realm) -> Self {
146        Self {
147            scheme: &realm.scheme,
148            host: realm.host.as_deref(),
149            port: realm.port,
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use url::{ParseError, Url};
157
158    use crate::Realm;
159
160    #[test]
161    fn test_should_retain_auth() -> Result<(), ParseError> {
162        // Exact match (https)
163        assert_eq!(
164            Realm::from(&Url::parse("https://example.com")?),
165            Realm::from(&Url::parse("https://example.com")?)
166        );
167
168        // Exact match (with port)
169        assert_eq!(
170            Realm::from(&Url::parse("https://example.com:1234")?),
171            Realm::from(&Url::parse("https://example.com:1234")?)
172        );
173
174        // Exact match (http)
175        assert_eq!(
176            Realm::from(&Url::parse("http://example.com")?),
177            Realm::from(&Url::parse("http://example.com")?)
178        );
179
180        // Okay, path differs
181        assert_eq!(
182            Realm::from(&Url::parse("http://example.com/foo")?),
183            Realm::from(&Url::parse("http://example.com/bar")?)
184        );
185
186        // Okay, default port differs (https)
187        assert_eq!(
188            Realm::from(&Url::parse("https://example.com:443")?),
189            Realm::from(&Url::parse("https://example.com")?)
190        );
191
192        // Okay, default port differs (http)
193        assert_eq!(
194            Realm::from(&Url::parse("http://example.com:80")?),
195            Realm::from(&Url::parse("http://example.com")?)
196        );
197
198        // Mismatched scheme
199        assert_ne!(
200            Realm::from(&Url::parse("https://example.com")?),
201            Realm::from(&Url::parse("http://example.com")?)
202        );
203
204        // Mismatched scheme, we explicitly do not allow upgrade to https
205        assert_ne!(
206            Realm::from(&Url::parse("http://example.com")?),
207            Realm::from(&Url::parse("https://example.com")?)
208        );
209
210        // Mismatched host
211        assert_ne!(
212            Realm::from(&Url::parse("https://foo.com")?),
213            Realm::from(&Url::parse("https://bar.com")?)
214        );
215
216        // Mismatched port
217        assert_ne!(
218            Realm::from(&Url::parse("https://example.com:1234")?),
219            Realm::from(&Url::parse("https://example.com:5678")?)
220        );
221
222        // Mismatched port, with one as default for scheme
223        assert_ne!(
224            Realm::from(&Url::parse("https://example.com:443")?),
225            Realm::from(&Url::parse("https://example.com:5678")?)
226        );
227        assert_ne!(
228            Realm::from(&Url::parse("https://example.com:1234")?),
229            Realm::from(&Url::parse("https://example.com:443")?)
230        );
231
232        // Mismatched port, with default for a different scheme
233        assert_ne!(
234            Realm::from(&Url::parse("https://example.com:80")?),
235            Realm::from(&Url::parse("https://example.com")?)
236        );
237
238        Ok(())
239    }
240
241    #[test]
242    fn test_is_subdomain_of() -> Result<(), ParseError> {
243        use crate::realm::RealmRef;
244
245        // Subdomain relationship: sub.example.com is a subdomain of example.com
246        let subdomain_url = Url::parse("https://sub.example.com")?;
247        let domain_url = Url::parse("https://example.com")?;
248        let subdomain = RealmRef::from(&subdomain_url);
249        let domain = RealmRef::from(&domain_url);
250        assert!(subdomain.is_subdomain_of(domain));
251
252        // Deeper subdomain: foo.bar.example.com is a subdomain of example.com
253        let deep_subdomain_url = Url::parse("https://foo.bar.example.com")?;
254        let deep_subdomain = RealmRef::from(&deep_subdomain_url);
255        assert!(deep_subdomain.is_subdomain_of(domain));
256
257        // Deeper subdomain: foo.bar.example.com is also a subdomain of bar.example.com
258        let parent_subdomain_url = Url::parse("https://bar.example.com")?;
259        let parent_subdomain = RealmRef::from(&parent_subdomain_url);
260        assert!(deep_subdomain.is_subdomain_of(parent_subdomain));
261
262        // Not a subdomain: example.com is not a subdomain of sub.example.com
263        assert!(!domain.is_subdomain_of(subdomain));
264
265        // Same domain is not a subdomain of itself
266        assert!(!domain.is_subdomain_of(domain));
267
268        // Different TLD: example.org is not a subdomain of example.com
269        let different_tld_url = Url::parse("https://example.org")?;
270        let different_tld = RealmRef::from(&different_tld_url);
271        assert!(!different_tld.is_subdomain_of(domain));
272
273        // Partial match but not a subdomain: notexample.com is not a subdomain of example.com
274        let partial_match_url = Url::parse("https://notexample.com")?;
275        let partial_match = RealmRef::from(&partial_match_url);
276        assert!(!partial_match.is_subdomain_of(domain));
277
278        // Different scheme: http subdomain is not a subdomain of https domain
279        let http_subdomain_url = Url::parse("http://sub.example.com")?;
280        let https_domain_url = Url::parse("https://example.com")?;
281        let http_subdomain = RealmRef::from(&http_subdomain_url);
282        let https_domain = RealmRef::from(&https_domain_url);
283        assert!(!http_subdomain.is_subdomain_of(https_domain));
284
285        // Different port: same subdomain with different port is not a subdomain
286        let subdomain_port_8080_url = Url::parse("https://sub.example.com:8080")?;
287        let domain_port_9090_url = Url::parse("https://example.com:9090")?;
288        let subdomain_port_8080 = RealmRef::from(&subdomain_port_8080_url);
289        let domain_port_9090 = RealmRef::from(&domain_port_9090_url);
290        assert!(!subdomain_port_8080.is_subdomain_of(domain_port_9090));
291
292        // Same port: subdomain with same explicit port is a subdomain
293        let subdomain_with_port_url = Url::parse("https://sub.example.com:8080")?;
294        let domain_with_port_url = Url::parse("https://example.com:8080")?;
295        let subdomain_with_port = RealmRef::from(&subdomain_with_port_url);
296        let domain_with_port = RealmRef::from(&domain_with_port_url);
297        assert!(subdomain_with_port.is_subdomain_of(domain_with_port));
298
299        // Default port handling: subdomain with implicit port is a subdomain
300        let subdomain_default_url = Url::parse("https://sub.example.com")?;
301        let domain_explicit_443_url = Url::parse("https://example.com:443")?;
302        let subdomain_default = RealmRef::from(&subdomain_default_url);
303        let domain_explicit_443 = RealmRef::from(&domain_explicit_443_url);
304        assert!(subdomain_default.is_subdomain_of(domain_explicit_443));
305
306        // Edge case: empty host (shouldn't happen with valid URLs but testing defensive code)
307        let file_url = Url::parse("file:///path/to/file")?;
308        let https_url = Url::parse("https://example.com")?;
309        let file_realm = RealmRef::from(&file_url);
310        let https_realm = RealmRef::from(&https_url);
311        assert!(!file_realm.is_subdomain_of(https_realm));
312        assert!(!https_realm.is_subdomain_of(file_realm));
313
314        // Subdomain with path (path should be ignored)
315        let subdomain_with_path_url = Url::parse("https://sub.example.com/path")?;
316        let domain_with_path_url = Url::parse("https://example.com/other")?;
317        let subdomain_with_path = RealmRef::from(&subdomain_with_path_url);
318        let domain_with_path = RealmRef::from(&domain_with_path_url);
319        assert!(subdomain_with_path.is_subdomain_of(domain_with_path));
320
321        Ok(())
322    }
323}