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