Skip to main content

uv_auth/
realm.rs

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