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#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
18pub struct CanonicalUrl(SafeUrl);
19
20impl CanonicalUrl {
21 pub fn new(mut url: SafeUrl) -> Self {
22 if url.cannot_be_a_base() {
24 return Self(url);
25 }
26
27 let _ = url.set_password(None);
29 let _ = url.set_username("");
30
31 if url.path().ends_with('/') {
33 url.path_segments_mut().unwrap().pop_if_empty();
34 }
35
36 if url.host_str() == Some("github.com") {
42 let path = url.path().to_ascii_lowercase();
44 url.set_path(&path);
45 }
46
47 if let Some((prefix, suffix)) = url.path().rsplit_once('@') {
49 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 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 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 if memchr::memchr(b'%', url.path().as_bytes()).is_some() {
76 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 self.0.as_str().cache_key(state);
102 }
103}
104
105impl Hash for CanonicalUrl {
106 fn hash<H: Hasher>(&self, state: &mut H) {
107 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}