Skip to main content

uv_cache_key/
canonical_url.rs

1use std::borrow::Cow;
2use std::fmt::{Debug, Formatter};
3use std::hash::{Hash, Hasher};
4use std::ops::Deref;
5
6use uv_redacted::{SafeUrl, SafeUrlError};
7
8use crate::cache_key::{CacheKey, CacheKeyHasher};
9
10/// A wrapper around [`SafeUrl`] which represents a "canonical" version of an original URL.
11///
12/// A "canonical" URL is only intended for internal comparison purposes. It's to help paper over
13/// mistakes such as depending on `github.com/foo/bar` vs. `github.com/foo/bar.git`.
14///
15/// This is **only** for internal purposes. All fetching should happen within the context of the
16/// original URL, since canonicalization can remove credentials and change paths.
17#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
18pub struct CanonicalUrl(SafeUrl);
19
20impl CanonicalUrl {
21    pub fn new(mut url: SafeUrl) -> Self {
22        // If the URL cannot be a base, then it's not a valid URL anyway.
23        if url.cannot_be_a_base() {
24            return Self(url);
25        }
26
27        // Strip credentials.
28        let _ = url.set_password(None);
29        let _ = url.set_username("");
30
31        // Strip a trailing slash.
32        if url.path().ends_with('/') {
33            url.path_segments_mut().unwrap().pop_if_empty();
34        }
35
36        // For GitHub URLs specifically, just lower-case everything. GitHub
37        // treats both the same, but they hash differently, and we're gonna be
38        // hashing them. This wants a more general solution, and also we're
39        // almost certainly not using the same case conversion rules that GitHub
40        // does. (See issue #84)
41        if url.host_str() == Some("github.com") {
42            // The scheme is already lowercase, and hierarchical paths are percent-encoded.
43            let path = url.path().to_ascii_lowercase();
44            url.set_path(&path);
45        }
46
47        // Repos can generally be accessed with or without `.git` extension.
48        if let Some((prefix, suffix)) = url.path().rsplit_once('@') {
49            // Ex) `git+https://github.com/pypa/sample-namespace-packages.git@2.0.0`
50            let needs_chopping = std::path::Path::new(prefix)
51                .extension()
52                .is_some_and(|ext| ext.eq_ignore_ascii_case("git"));
53            if needs_chopping {
54                let prefix = &prefix[..prefix.len() - 4];
55                let path = format!("{prefix}@{suffix}");
56                url.set_path(&path);
57            }
58        } else {
59            // Ex) `git+https://github.com/pypa/sample-namespace-packages.git`
60            let needs_chopping = std::path::Path::new(url.path())
61                .extension()
62                .is_some_and(|ext| ext.eq_ignore_ascii_case("git"));
63            if needs_chopping {
64                let last = {
65                    // Unwrap safety: We checked `url.cannot_be_a_base()`, and `url.path()` having
66                    // an extension implies at least one segment.
67                    let last = url.path_segments().unwrap().next_back().unwrap();
68                    last[..last.len() - 4].to_owned()
69                };
70                url.path_segments_mut().unwrap().pop().push(&last);
71            }
72        }
73
74        // Decode any percent-encoded characters in the path.
75        if memchr::memchr(b'%', url.path().as_bytes()).is_some() {
76            // Unwrap safety: We checked `url.cannot_be_a_base()`.
77            let decoded = url
78                .path_segments()
79                .unwrap()
80                .map(|segment| {
81                    percent_encoding::percent_decode_str(segment)
82                        .decode_utf8()
83                        .unwrap_or(Cow::Borrowed(segment))
84                        .into_owned()
85                })
86                .collect::<Vec<_>>();
87
88            let mut path_segments = url.path_segments_mut().unwrap();
89            path_segments.clear();
90            path_segments.extend(decoded);
91        }
92
93        Self(url)
94    }
95}
96
97impl CacheKey for CanonicalUrl {
98    fn cache_key(&self, state: &mut CacheKeyHasher) {
99        // `as_str` gives the serialisation of a url (which has a spec) and so insulates against
100        // possible changes in how the URL crate does hashing.
101        self.0.as_str().cache_key(state);
102    }
103}
104
105impl Hash for CanonicalUrl {
106    fn hash<H: Hasher>(&self, state: &mut H) {
107        // `as_str` gives the serialisation of a url (which has a spec) and so insulates against
108        // possible changes in how the URL crate does hashing.
109        self.0.as_str().hash(state);
110    }
111}
112
113impl From<CanonicalUrl> for SafeUrl {
114    fn from(value: CanonicalUrl) -> Self {
115        value.0
116    }
117}
118
119impl std::fmt::Display for CanonicalUrl {
120    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
121        std::fmt::Display::fmt(&self.0, f)
122    }
123}
124
125/// Like [`CanonicalUrl`], but attempts to represent an underlying source repository, abstracting
126/// away details like the specific commit or branch, or the subdirectory to build within the
127/// repository.
128///
129/// For example, `https://github.com/pypa/package.git#subdirectory=pkg_a` and
130/// `https://github.com/pypa/package.git#subdirectory=pkg_b` would map to different
131/// [`CanonicalUrl`] values, but the same [`RepositoryUrl`], since they map to the same
132/// resource.
133///
134/// The additional information it holds should only be used to discriminate between
135/// sources that hold the exact same commit in their canonical representation,
136/// but may differ in the contents such as when Git LFS is enabled.
137///
138/// A different cache key will be computed when Git LFS is enabled.
139/// When Git LFS is `false` or `None`, the cache key remains unchanged.
140#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
141pub struct RepositoryUrl {
142    repo_url: SafeUrl,
143    with_lfs: Option<bool>,
144}
145
146impl RepositoryUrl {
147    pub fn new(url: SafeUrl) -> Self {
148        let mut url = CanonicalUrl::new(url).0;
149
150        // If a Git URL ends in a reference (like a branch, tag, or commit), remove it.
151        if url.scheme().starts_with("git+") {
152            if let Some(prefix) = url
153                .path()
154                .rsplit_once('@')
155                .map(|(prefix, _suffix)| prefix.to_string())
156            {
157                url.set_path(&prefix);
158            }
159        }
160
161        // Drop any fragments and query parameters.
162        url.set_fragment(None);
163        url.set_query(None);
164
165        Self {
166            repo_url: url,
167            with_lfs: None,
168        }
169    }
170
171    pub fn parse(url: &str) -> Result<Self, SafeUrlError> {
172        Ok(Self::new(SafeUrl::parse(url)?))
173    }
174
175    #[must_use]
176    pub fn with_lfs(mut self, lfs: Option<bool>) -> Self {
177        self.with_lfs = lfs;
178        self
179    }
180}
181
182impl CacheKey for RepositoryUrl {
183    fn cache_key(&self, state: &mut CacheKeyHasher) {
184        // `as_str` gives the serialisation of a url (which has a spec) and so insulates against
185        // possible changes in how the URL crate does hashing.
186        self.repo_url.as_str().cache_key(state);
187        if let Some(true) = self.with_lfs {
188            1u8.cache_key(state);
189        }
190    }
191}
192
193impl Hash for RepositoryUrl {
194    fn hash<H: Hasher>(&self, state: &mut H) {
195        // `as_str` gives the serialisation of a url (which has a spec) and so insulates against
196        // possible changes in how the URL crate does hashing.
197        self.repo_url.as_str().hash(state);
198        if let Some(true) = self.with_lfs {
199            1u8.hash(state);
200        }
201    }
202}
203
204impl Deref for RepositoryUrl {
205    type Target = SafeUrl;
206
207    fn deref(&self) -> &Self::Target {
208        &self.repo_url
209    }
210}
211
212impl std::fmt::Display for RepositoryUrl {
213    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
214        std::fmt::Display::fmt(&self.repo_url, f)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn user_credential_does_not_affect_cache_key() -> Result<(), SafeUrlError> {
224        let mut hasher = CacheKeyHasher::new();
225        CanonicalUrl::new(SafeUrl::parse(
226            "https://example.com/pypa/sample-namespace-packages.git@2.0.0",
227        )?)
228        .cache_key(&mut hasher);
229        let hash_without_creds = hasher.finish();
230
231        let mut hasher = CacheKeyHasher::new();
232        CanonicalUrl::new(SafeUrl::parse(
233            "https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0",
234        )?)
235        .cache_key(&mut hasher);
236        let hash_with_creds = hasher.finish();
237        assert_eq!(
238            hash_without_creds, hash_with_creds,
239            "URLs with no user credentials should hash the same as URLs with different user credentials",
240        );
241
242        let mut hasher = CacheKeyHasher::new();
243        CanonicalUrl::new(SafeUrl::parse(
244            "https://user:bar@example.com/pypa/sample-namespace-packages.git@2.0.0",
245        )?)
246        .cache_key(&mut hasher);
247        let hash_with_creds = hasher.finish();
248        assert_eq!(
249            hash_without_creds, hash_with_creds,
250            "URLs with different user credentials should hash the same",
251        );
252
253        let mut hasher = CacheKeyHasher::new();
254        CanonicalUrl::new(SafeUrl::parse(
255            "https://:bar@example.com/pypa/sample-namespace-packages.git@2.0.0",
256        )?)
257        .cache_key(&mut hasher);
258        let hash_with_creds = hasher.finish();
259        assert_eq!(
260            hash_without_creds, hash_with_creds,
261            "URLs with no username, though with a password, should hash the same as URLs with different user credentials",
262        );
263
264        let mut hasher = CacheKeyHasher::new();
265        CanonicalUrl::new(SafeUrl::parse(
266            "https://user:@example.com/pypa/sample-namespace-packages.git@2.0.0",
267        )?)
268        .cache_key(&mut hasher);
269        let hash_with_creds = hasher.finish();
270        assert_eq!(
271            hash_without_creds, hash_with_creds,
272            "URLs with no password, though with a username, should hash the same as URLs with different user credentials",
273        );
274
275        Ok(())
276    }
277
278    #[test]
279    fn canonical_url() -> Result<(), SafeUrlError> {
280        // Two URLs should be considered equal regardless of the `.git` suffix.
281        assert_eq!(
282            CanonicalUrl::new(SafeUrl::parse(
283                "git+https://github.com/pypa/sample-namespace-packages.git"
284            )?),
285            CanonicalUrl::new(SafeUrl::parse(
286                "git+https://github.com/pypa/sample-namespace-packages"
287            )?),
288        );
289
290        // Two URLs should be considered equal regardless of the `.git` suffix.
291        assert_eq!(
292            CanonicalUrl::new(SafeUrl::parse(
293                "git+https://github.com/pypa/sample-namespace-packages.git@2.0.0"
294            )?),
295            CanonicalUrl::new(SafeUrl::parse(
296                "git+https://github.com/pypa/sample-namespace-packages@2.0.0"
297            )?),
298        );
299
300        // Two URLs should be _not_ considered equal if they point to different repositories.
301        assert_ne!(
302            CanonicalUrl::new(SafeUrl::parse(
303                "git+https://github.com/pypa/sample-namespace-packages.git"
304            )?),
305            CanonicalUrl::new(SafeUrl::parse(
306                "git+https://github.com/pypa/sample-packages.git"
307            )?),
308        );
309
310        // Two URLs should _not_ be considered equal if they request different subdirectories.
311        assert_ne!(
312            CanonicalUrl::new(SafeUrl::parse(
313                "git+https://github.com/pypa/sample-namespace-packages.git#subdirectory=pkg_resources/pkg_a"
314            )?),
315            CanonicalUrl::new(SafeUrl::parse(
316                "git+https://github.com/pypa/sample-namespace-packages.git#subdirectory=pkg_resources/pkg_b"
317            )?),
318        );
319
320        // Two URLs should _not_ be considered equal if they differ in Git LFS enablement.
321        assert_ne!(
322            CanonicalUrl::new(SafeUrl::parse(
323                "git+https://github.com/pypa/sample-namespace-packages.git#lfs=true"
324            )?),
325            CanonicalUrl::new(SafeUrl::parse(
326                "git+https://github.com/pypa/sample-namespace-packages.git"
327            )?),
328        );
329
330        // Two URLs should _not_ be considered equal if they request different commit tags.
331        assert_ne!(
332            CanonicalUrl::new(SafeUrl::parse(
333                "git+https://github.com/pypa/sample-namespace-packages.git@v1.0.0"
334            )?),
335            CanonicalUrl::new(SafeUrl::parse(
336                "git+https://github.com/pypa/sample-namespace-packages.git@v2.0.0"
337            )?),
338        );
339
340        // Two URLs that cannot be a base should be considered equal.
341        assert_eq!(
342            CanonicalUrl::new(SafeUrl::parse(
343                "git+https:://github.com/pypa/sample-namespace-packages.git"
344            )?),
345            CanonicalUrl::new(SafeUrl::parse(
346                "git+https:://github.com/pypa/sample-namespace-packages.git"
347            )?),
348        );
349
350        // Two URLs should _not_ be considered equal based on percent-decoding slashes.
351        assert_ne!(
352            CanonicalUrl::new(SafeUrl::parse(
353                "https://github.com/pypa/sample%2Fnamespace%2Fpackages"
354            )?),
355            CanonicalUrl::new(SafeUrl::parse(
356                "https://github.com/pypa/sample/namespace/packages"
357            )?),
358        );
359
360        // Two URLs should be considered equal regardless of percent-encoding.
361        assert_eq!(
362            CanonicalUrl::new(SafeUrl::parse(
363                "https://github.com/pypa/sample%2Bnamespace%2Bpackages"
364            )?),
365            CanonicalUrl::new(SafeUrl::parse(
366                "https://github.com/pypa/sample+namespace+packages"
367            )?),
368        );
369
370        assert_eq!(
371            CanonicalUrl::new(SafeUrl::parse("HTTPS://github.com/PYPA/Éxample")?),
372            CanonicalUrl::new(SafeUrl::parse("https://github.com/pypa/%C3%89xample")?),
373        );
374        assert_ne!(
375            CanonicalUrl::new(SafeUrl::parse("https://github.com/pypa/Éxample")?),
376            CanonicalUrl::new(SafeUrl::parse("https://github.com/pypa/éxample")?),
377        );
378
379        // Two URLs should _not_ be considered equal based on percent-decoding slashes.
380        assert_ne!(
381            CanonicalUrl::new(SafeUrl::parse(
382                "file:///home/ferris/my_project%2Fmy_project-0.1.0-py3-none-any.whl"
383            )?),
384            CanonicalUrl::new(SafeUrl::parse(
385                "file:///home/ferris/my_project/my_project-0.1.0-py3-none-any.whl"
386            )?),
387        );
388
389        // Two URLs should be considered equal regardless of percent-encoding.
390        assert_eq!(
391            CanonicalUrl::new(SafeUrl::parse(
392                "file:///home/ferris/my_project/my_project-0.1.0+foo-py3-none-any.whl"
393            )?),
394            CanonicalUrl::new(SafeUrl::parse(
395                "file:///home/ferris/my_project/my_project-0.1.0%2Bfoo-py3-none-any.whl"
396            )?),
397        );
398
399        Ok(())
400    }
401
402    #[test]
403    fn repository_url() -> Result<(), SafeUrlError> {
404        // Two URLs should be considered equal regardless of the `.git` suffix.
405        assert_eq!(
406            RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
407            RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages")?,
408        );
409
410        // Two URLs should be considered equal regardless of the `.git` suffix.
411        assert_eq!(
412            RepositoryUrl::parse(
413                "git+https://github.com/pypa/sample-namespace-packages.git@2.0.0"
414            )?,
415            RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages@2.0.0")?,
416        );
417
418        // Two URLs should be _not_ considered equal if they point to different repositories.
419        assert_ne!(
420            RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
421            RepositoryUrl::parse("git+https://github.com/pypa/sample-packages.git")?,
422        );
423
424        // Two URLs should be considered equal if they map to the same repository, even if they
425        // request different subdirectories.
426        assert_eq!(
427            RepositoryUrl::parse(
428                "git+https://github.com/pypa/sample-namespace-packages.git#subdirectory=pkg_resources/pkg_a"
429            )?,
430            RepositoryUrl::parse(
431                "git+https://github.com/pypa/sample-namespace-packages.git#subdirectory=pkg_resources/pkg_b"
432            )?,
433        );
434
435        // Two URLs should be considered equal if they map to the same repository, even if they
436        // request different commit tags.
437        assert_eq!(
438            RepositoryUrl::parse(
439                "git+https://github.com/pypa/sample-namespace-packages.git@v1.0.0"
440            )?,
441            RepositoryUrl::parse(
442                "git+https://github.com/pypa/sample-namespace-packages.git@v2.0.0"
443            )?,
444        );
445
446        // Two URLs should be considered equal if they map to the same repository, even if they
447        // differ in Git LFS enablement.
448        assert_eq!(
449            RepositoryUrl::parse(
450                "git+https://github.com/pypa/sample-namespace-packages.git#lfs=true"
451            )?,
452            RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
453        );
454
455        Ok(())
456    }
457
458    #[test]
459    fn repository_url_with_lfs() -> Result<(), SafeUrlError> {
460        let mut hasher = CacheKeyHasher::new();
461        RepositoryUrl::parse("https://example.com/pypa/sample-namespace-packages.git@2.0.0")?
462            .cache_key(&mut hasher);
463        let repo_url_basic = hasher.finish();
464
465        let mut hasher = CacheKeyHasher::new();
466        RepositoryUrl::parse(
467            "https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0#foo=bar",
468        )?
469        .cache_key(&mut hasher);
470        let repo_url_with_fragments = hasher.finish();
471
472        assert_eq!(
473            repo_url_basic, repo_url_with_fragments,
474            "repository urls should have the exact cache keys as fragments are removed",
475        );
476
477        let mut hasher = CacheKeyHasher::new();
478        RepositoryUrl::parse(
479            "https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0#foo=bar",
480        )?
481        .with_lfs(None)
482        .cache_key(&mut hasher);
483        let git_url_with_fragments = hasher.finish();
484
485        assert_eq!(
486            repo_url_with_fragments, git_url_with_fragments,
487            "both structs should have the exact cache keys as fragments are still removed",
488        );
489
490        let mut hasher = CacheKeyHasher::new();
491        RepositoryUrl::parse(
492            "https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0#foo=bar",
493        )?
494        .with_lfs(Some(false))
495        .cache_key(&mut hasher);
496        let git_url_with_fragments_and_lfs_false = hasher.finish();
497
498        assert_eq!(
499            git_url_with_fragments, git_url_with_fragments_and_lfs_false,
500            "both structs should have the exact cache keys as lfs false should not influence them",
501        );
502
503        let mut hasher = CacheKeyHasher::new();
504        RepositoryUrl::parse(
505            "https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0#foo=bar",
506        )?
507        .with_lfs(Some(true))
508        .cache_key(&mut hasher);
509        let git_url_with_fragments_and_lfs_true = hasher.finish();
510
511        assert_ne!(
512            git_url_with_fragments, git_url_with_fragments_and_lfs_true,
513            "both structs should have different cache keys as one has Git LFS enabled",
514        );
515
516        Ok(())
517    }
518}