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#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
20pub struct CanonicalUrl(DisplaySafeUrl);
21
22impl CanonicalUrl {
23 pub fn new(mut url: DisplaySafeUrl) -> Self {
24 if url.cannot_be_a_base() {
26 return Self(url);
27 }
28
29 let _ = url.set_password(None);
31 let _ = url.set_username("");
32
33 if url.path().ends_with('/') {
35 url.path_segments_mut().unwrap().pop_if_empty();
36 }
37
38 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 if let Some((prefix, suffix)) = url.path().rsplit_once('@') {
52 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 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 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 if memchr::memchr(b'%', url.path().as_bytes()).is_some() {
79 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 self.0.as_str().cache_key(state);
109 }
110}
111
112impl Hash for CanonicalUrl {
113 fn hash<H: Hasher>(&self, state: &mut H) {
114 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}