1use std::path::Path;
2use std::str::FromStr;
3
4use http::{HeaderValue, StatusCode};
5use serde::{Deserialize, Serialize, Serializer};
6use thiserror::Error;
7use url::Url;
8
9use uv_auth::{AuthPolicy, Credentials, CredentialsFromUrlError};
10use uv_pypi_types::HashAlgorithm;
11use uv_redacted::DisplaySafeUrl;
12use uv_small_str::SmallString;
13
14use crate::exclude_newer::ExcludeNewerOverride;
15use crate::index_name::{IndexName, IndexNameError};
16use crate::origin::Origin;
17use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode};
18
19#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Default)]
21#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
22pub struct IndexCacheControl {
23 #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
25 pub(crate) api: Option<HeaderValue>,
26 #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
28 pub(crate) files: Option<HeaderValue>,
29}
30
31impl IndexCacheControl {
32 fn simple_api_cache_control(_url: &Url) -> Option<HeaderValue> {
34 None
35 }
36
37 fn artifact_cache_control(url: &Url) -> Option<HeaderValue> {
39 let dominated_by_pytorch_or_nvidia = url.host_str().is_some_and(|host| {
40 host.eq_ignore_ascii_case("download.pytorch.org")
41 || host.eq_ignore_ascii_case("pypi.nvidia.com")
42 });
43 if dominated_by_pytorch_or_nvidia {
44 Some(HeaderValue::from_static(
52 "max-age=365000000, immutable, public",
53 ))
54 } else {
55 None
56 }
57 }
58}
59
60#[derive(Serialize)]
61#[serde(rename_all = "kebab-case")]
62struct IndexCacheControlRef<'a> {
63 #[serde(skip_serializing_if = "Option::is_none")]
64 api: Option<&'a str>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 files: Option<&'a str>,
67}
68
69impl Serialize for IndexCacheControl {
70 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
71 where
72 S: Serializer,
73 {
74 IndexCacheControlRef {
75 api: self.api.as_ref().map(|api| {
76 api.to_str()
77 .expect("cache-control.api is always parsed from a string")
78 }),
79 files: self.files.as_ref().map(|files| {
80 files
81 .to_str()
82 .expect("cache-control.files is always parsed from a string")
83 }),
84 }
85 .serialize(serializer)
86 }
87}
88
89#[derive(Debug, Clone, Deserialize)]
90#[serde(rename_all = "kebab-case")]
91struct IndexCacheControlWire {
92 api: Option<SmallString>,
93 files: Option<SmallString>,
94}
95
96impl<'de> Deserialize<'de> for IndexCacheControl {
97 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98 where
99 D: serde::Deserializer<'de>,
100 {
101 let wire = IndexCacheControlWire::deserialize(deserializer)?;
102
103 let api = wire
104 .api
105 .map(|api| {
106 HeaderValue::from_str(api.as_ref()).map_err(|_| {
107 serde::de::Error::custom(
108 "`cache-control.api` must be a valid HTTP header value",
109 )
110 })
111 })
112 .transpose()?;
113 let files = wire
114 .files
115 .map(|files| {
116 HeaderValue::from_str(files.as_ref()).map_err(|_| {
117 serde::de::Error::custom(
118 "`cache-control.files` must be a valid HTTP header value",
119 )
120 })
121 })
122 .transpose()?;
123
124 Ok(Self { api, files })
125 }
126}
127
128#[derive(Debug, Clone, Serialize)]
129#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
130#[serde(rename_all = "kebab-case")]
131pub struct Index {
132 pub name: Option<IndexName>,
146 pub url: IndexUrl,
150 #[serde(default)]
165 pub explicit: bool,
166 #[serde(default)]
176 pub default: bool,
177 #[serde(skip)]
179 pub origin: Option<Origin>,
180 #[serde(default)]
186 pub format: IndexFormat,
187 pub publish_url: Option<DisplaySafeUrl>,
200 #[serde(default)]
209 pub authenticate: AuthPolicy,
210 #[serde(default)]
220 pub ignore_error_codes: Option<Vec<SerializableStatusCode>>,
221 #[serde(default)]
233 pub cache_control: Option<IndexCacheControl>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub hash_algorithm: Option<IndexHashAlgorithm>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
270 #[cfg_attr(feature = "schemars", schemars(with = "ExcludeNewerOverride"))]
271 pub exclude_newer: Option<ExcludeNewerOverride>,
272}
273
274#[derive(Debug, Error)]
275#[error("Failed to parse credentials in index URL: {url}")]
276pub struct IndexCredentialsError {
277 url: DisplaySafeUrl,
278 #[source]
279 source: CredentialsFromUrlError,
280}
281
282impl PartialEq for Index {
283 fn eq(&self, other: &Self) -> bool {
284 let Self {
285 name,
286 url,
287 explicit,
288 default,
289 origin: _,
290 format,
291 publish_url,
292 authenticate,
293 ignore_error_codes,
294 cache_control,
295 hash_algorithm,
296 exclude_newer,
297 } = self;
298 *url == other.url
299 && *name == other.name
300 && *explicit == other.explicit
301 && *default == other.default
302 && *format == other.format
303 && *publish_url == other.publish_url
304 && *authenticate == other.authenticate
305 && *ignore_error_codes == other.ignore_error_codes
306 && *cache_control == other.cache_control
307 && *hash_algorithm == other.hash_algorithm
308 && *exclude_newer == other.exclude_newer
309 }
310}
311
312impl Eq for Index {}
313
314impl PartialOrd for Index {
315 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
316 Some(self.cmp(other))
317 }
318}
319
320impl Ord for Index {
321 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
322 let Self {
323 name,
324 url,
325 explicit,
326 default,
327 origin: _,
328 format,
329 publish_url,
330 authenticate,
331 ignore_error_codes,
332 cache_control,
333 hash_algorithm,
334 exclude_newer,
335 } = self;
336 url.cmp(&other.url)
337 .then_with(|| name.cmp(&other.name))
338 .then_with(|| explicit.cmp(&other.explicit))
339 .then_with(|| default.cmp(&other.default))
340 .then_with(|| format.cmp(&other.format))
341 .then_with(|| publish_url.cmp(&other.publish_url))
342 .then_with(|| authenticate.cmp(&other.authenticate))
343 .then_with(|| ignore_error_codes.cmp(&other.ignore_error_codes))
344 .then_with(|| cache_control.cmp(&other.cache_control))
345 .then_with(|| hash_algorithm.cmp(&other.hash_algorithm))
346 .then_with(|| exclude_newer.cmp(&other.exclude_newer))
347 }
348}
349
350impl std::hash::Hash for Index {
351 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
352 let Self {
353 name,
354 url,
355 explicit,
356 default,
357 origin: _,
358 format,
359 publish_url,
360 authenticate,
361 ignore_error_codes,
362 cache_control,
363 hash_algorithm,
364 exclude_newer,
365 } = self;
366 url.hash(state);
367 name.hash(state);
368 explicit.hash(state);
369 default.hash(state);
370 format.hash(state);
371 publish_url.hash(state);
372 authenticate.hash(state);
373 ignore_error_codes.hash(state);
374 cache_control.hash(state);
375 hash_algorithm.hash(state);
376 exclude_newer.hash(state);
377 }
378}
379
380#[derive(
381 Default,
382 Debug,
383 Copy,
384 Clone,
385 Hash,
386 Eq,
387 PartialEq,
388 Ord,
389 PartialOrd,
390 serde::Serialize,
391 serde::Deserialize,
392)]
393#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
394#[serde(rename_all = "kebab-case")]
395pub enum IndexFormat {
396 #[default]
398 Simple,
399 Flat,
401}
402
403#[derive(
405 Debug, Copy, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize,
406)]
407#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
408#[serde(rename_all = "lowercase")]
409pub enum IndexHashAlgorithm {
410 Md5,
411 Sha256,
412 Sha384,
413 Sha512,
414 Blake2b,
415}
416
417impl From<IndexHashAlgorithm> for HashAlgorithm {
418 fn from(value: IndexHashAlgorithm) -> Self {
419 match value {
420 IndexHashAlgorithm::Md5 => Self::Md5,
421 IndexHashAlgorithm::Sha256 => Self::Sha256,
422 IndexHashAlgorithm::Sha384 => Self::Sha384,
423 IndexHashAlgorithm::Sha512 => Self::Sha512,
424 IndexHashAlgorithm::Blake2b => Self::Blake2b,
425 }
426 }
427}
428
429impl Index {
430 pub fn from_index_url(url: IndexUrl) -> Self {
432 Self {
433 url,
434 name: None,
435 explicit: false,
436 default: true,
437 origin: None,
438 format: IndexFormat::Simple,
439 publish_url: None,
440 authenticate: AuthPolicy::default(),
441 ignore_error_codes: None,
442 cache_control: None,
443 hash_algorithm: None,
444 exclude_newer: None,
445 }
446 }
447
448 pub fn from_extra_index_url(url: IndexUrl) -> Self {
450 Self {
451 url,
452 name: None,
453 explicit: false,
454 default: false,
455 origin: None,
456 format: IndexFormat::Simple,
457 publish_url: None,
458 authenticate: AuthPolicy::default(),
459 ignore_error_codes: None,
460 cache_control: None,
461 hash_algorithm: None,
462 exclude_newer: None,
463 }
464 }
465
466 pub fn from_find_links(url: IndexUrl) -> Self {
468 Self {
469 url,
470 name: None,
471 explicit: false,
472 default: false,
473 origin: None,
474 format: IndexFormat::Flat,
475 publish_url: None,
476 authenticate: AuthPolicy::default(),
477 ignore_error_codes: None,
478 cache_control: None,
479 hash_algorithm: None,
480 exclude_newer: None,
481 }
482 }
483
484 #[must_use]
486 pub fn with_origin(mut self, origin: Origin) -> Self {
487 self.origin = Some(origin);
488 self
489 }
490
491 pub fn url(&self) -> &IndexUrl {
493 &self.url
494 }
495
496 pub fn raw_url(&self) -> &DisplaySafeUrl {
498 self.url.url()
499 }
500
501 pub fn root_url(&self) -> Option<DisplaySafeUrl> {
506 self.url.root()
507 }
508
509 #[must_use]
514 pub fn with_promoted_auth_policy(mut self) -> Self {
515 if matches!(self.authenticate, AuthPolicy::Auto) && self.has_credentials() {
516 self.authenticate = AuthPolicy::Always;
517 }
518 self
519 }
520
521 fn has_credentials(&self) -> bool {
527 if self
528 .name
529 .as_ref()
530 .is_some_and(|name| Credentials::from_env(name.to_env_var()).is_some())
531 {
532 return true;
533 }
534
535 let url = self.url.url();
536 !url.username().is_empty() || url.password().is_some()
537 }
538
539 pub fn credentials(&self) -> Result<Option<Credentials>, IndexCredentialsError> {
541 if let Some(name) = self.name.as_ref() {
543 if let Some(credentials) = Credentials::from_env(name.to_env_var()) {
544 return Ok(Some(credentials));
545 }
546 }
547
548 Credentials::from_url(self.url.url()).map_err(|source| IndexCredentialsError {
550 url: self.url.url().clone(),
551 source,
552 })
553 }
554
555 pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
557 if let IndexUrl::Path(ref url) = self.url
558 && let Some(given) = url.given()
559 {
560 self.url = IndexUrl::parse(given, Some(root_dir))?;
561 }
562 Ok(self)
563 }
564
565 pub(crate) fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
567 if let Some(ignore_error_codes) = &self.ignore_error_codes {
568 IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
569 } else {
570 IndexStatusCodeStrategy::from_index_url(self.url.url())
571 }
572 }
573
574 pub(crate) fn ignores_error_code(&self, status_code: StatusCode) -> bool {
576 self.ignore_error_codes.as_ref().is_some_and(|codes| {
577 codes
578 .iter()
579 .any(|ignored_status_code| **ignored_status_code == status_code)
580 })
581 }
582
583 pub(crate) fn artifact_cache_control(&self) -> Option<HeaderValue> {
585 self.cache_control
586 .as_ref()
587 .and_then(|cache_control| cache_control.files.clone())
588 .or_else(|| IndexCacheControl::artifact_cache_control(self.url.url()))
589 }
590
591 pub(crate) fn simple_api_cache_control(&self) -> Option<HeaderValue> {
593 self.cache_control
594 .as_ref()
595 .and_then(|cache_control| cache_control.api.clone())
596 .or_else(|| IndexCacheControl::simple_api_cache_control(self.url.url()))
597 }
598
599 pub(crate) fn exclude_newer(&self) -> Option<&ExcludeNewerOverride> {
601 self.exclude_newer.as_ref()
602 }
603}
604
605impl From<IndexUrl> for Index {
606 fn from(value: IndexUrl) -> Self {
607 Self {
608 name: None,
609 url: value,
610 explicit: false,
611 default: false,
612 origin: None,
613 format: IndexFormat::Simple,
614 publish_url: None,
615 authenticate: AuthPolicy::default(),
616 ignore_error_codes: None,
617 cache_control: None,
618 hash_algorithm: None,
619 exclude_newer: None,
620 }
621 }
622}
623
624impl FromStr for Index {
625 type Err = IndexSourceError;
626
627 fn from_str(s: &str) -> Result<Self, Self::Err> {
628 if let Some((name, url)) = s.split_once('=')
630 && !name.chars().any(|c| c == ':')
631 {
632 let name = IndexName::from_str(name)?;
633 let url = IndexUrl::from_str(url)?;
634 return Ok(Self {
635 name: Some(name),
636 url,
637 explicit: false,
638 default: false,
639 origin: None,
640 format: IndexFormat::Simple,
641 publish_url: None,
642 authenticate: AuthPolicy::default(),
643 ignore_error_codes: None,
644 cache_control: None,
645 hash_algorithm: None,
646 exclude_newer: None,
647 });
648 }
649
650 let url = IndexUrl::from_str(s)?;
652 Ok(Self {
653 name: None,
654 url,
655 explicit: false,
656 default: false,
657 origin: None,
658 format: IndexFormat::Simple,
659 publish_url: None,
660 authenticate: AuthPolicy::default(),
661 ignore_error_codes: None,
662 cache_control: None,
663 hash_algorithm: None,
664 exclude_newer: None,
665 })
666 }
667}
668
669#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
671pub struct IndexMetadata {
672 pub url: IndexUrl,
674 pub format: IndexFormat,
676}
677
678impl IndexMetadata {
679 pub fn into_url(self) -> IndexUrl {
681 self.url
682 }
683}
684
685#[derive(Debug, Copy, Clone)]
687pub struct IndexMetadataRef<'a> {
688 pub url: &'a IndexUrl,
690 pub format: IndexFormat,
692}
693
694impl IndexMetadata {
695 pub fn url(&self) -> &IndexUrl {
697 &self.url
698 }
699}
700
701impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
702 fn from(value: &'a Index) -> Self {
703 Self {
704 url: &value.url,
705 format: value.format,
706 }
707 }
708}
709
710impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
711 fn from(value: &'a IndexMetadata) -> Self {
712 Self {
713 url: &value.url,
714 format: value.format,
715 }
716 }
717}
718
719impl From<IndexUrl> for IndexMetadata {
720 fn from(value: IndexUrl) -> Self {
721 Self {
722 url: value,
723 format: IndexFormat::Simple,
724 }
725 }
726}
727
728impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
729 fn from(value: &'a IndexUrl) -> Self {
730 Self {
731 url: value,
732 format: IndexFormat::Simple,
733 }
734 }
735}
736
737#[derive(Deserialize)]
739#[serde(rename_all = "kebab-case")]
740struct IndexWire {
741 name: Option<IndexName>,
742 url: IndexUrl,
743 #[serde(default)]
744 explicit: bool,
745 #[serde(default)]
746 default: bool,
747 #[serde(default)]
748 format: IndexFormat,
749 publish_url: Option<DisplaySafeUrl>,
750 #[serde(default)]
751 authenticate: AuthPolicy,
752 #[serde(default)]
753 ignore_error_codes: Option<Vec<SerializableStatusCode>>,
754 #[serde(default)]
755 cache_control: Option<IndexCacheControl>,
756 #[serde(default)]
757 hash_algorithm: Option<IndexHashAlgorithm>,
758 #[serde(default)]
759 exclude_newer: Option<ExcludeNewerOverride>,
760}
761
762impl<'de> Deserialize<'de> for Index {
763 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
764 where
765 D: serde::Deserializer<'de>,
766 {
767 let wire = IndexWire::deserialize(deserializer)?;
768
769 if wire.explicit && wire.name.is_none() {
770 return Err(serde::de::Error::custom(format!(
771 "An index with `explicit = true` requires a `name`: {}",
772 wire.url
773 )));
774 }
775
776 Ok(Self {
777 name: wire.name,
778 url: wire.url,
779 explicit: wire.explicit,
780 default: wire.default,
781 origin: None,
782 format: wire.format,
783 publish_url: wire.publish_url,
784 authenticate: wire.authenticate,
785 ignore_error_codes: wire.ignore_error_codes,
786 cache_control: wire.cache_control,
787 hash_algorithm: wire.hash_algorithm,
788 exclude_newer: wire.exclude_newer,
789 })
790 }
791}
792
793#[derive(Error, Debug)]
795pub enum IndexSourceError {
796 #[error(transparent)]
797 Url(#[from] IndexUrlError),
798 #[error(transparent)]
799 IndexName(#[from] IndexNameError),
800 #[error("Index included a name, but the name was empty")]
801 EmptyName,
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use http::HeaderValue;
808
809 #[test]
810 fn test_index_cache_control_headers() {
811 let toml_str = r#"
813 name = "test-index"
814 url = "https://test.example.com/simple"
815 cache-control = { api = "max-age=600", files = "max-age=3600" }
816 "#;
817
818 let index: Index = toml::from_str(toml_str).unwrap();
819 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
820 assert!(index.cache_control.is_some());
821 assert_eq!(index.exclude_newer, None);
822 let cache_control = index.cache_control.as_ref().unwrap();
823 assert_eq!(
824 cache_control.api,
825 Some(HeaderValue::from_static("max-age=600"))
826 );
827 assert_eq!(
828 cache_control.files,
829 Some(HeaderValue::from_static("max-age=3600"))
830 );
831 }
832
833 #[test]
834 fn test_index_without_cache_control() {
835 let toml_str = r#"
837 name = "test-index"
838 url = "https://test.example.com/simple"
839 "#;
840
841 let index: Index = toml::from_str(toml_str).unwrap();
842 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
843 assert_eq!(index.cache_control, None);
844 assert_eq!(index.exclude_newer, None);
845 }
846
847 #[test]
848 fn test_index_partial_cache_control() {
849 let toml_str = r#"
851 name = "test-index"
852 url = "https://test.example.com/simple"
853 cache-control = { api = "max-age=300" }
854 "#;
855
856 let index: Index = toml::from_str(toml_str).unwrap();
857 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
858 assert!(index.cache_control.is_some());
859 assert_eq!(index.exclude_newer, None);
860 let cache_control = index.cache_control.as_ref().unwrap();
861 assert_eq!(
862 cache_control.api,
863 Some(HeaderValue::from_static("max-age=300"))
864 );
865 assert_eq!(cache_control.files, None);
866 }
867
868 #[test]
869 fn test_index_invalid_api_cache_control() {
870 let toml_str = r#"
871 name = "test-index"
872 url = "https://test.example.com/simple"
873 cache-control = { api = "max-age=600\n" }
874 "#;
875
876 let err = toml::from_str::<Index>(toml_str).unwrap_err();
877 assert!(
878 err.to_string()
879 .contains("`cache-control.api` must be a valid HTTP header value")
880 );
881 }
882
883 #[test]
884 fn test_index_invalid_files_cache_control() {
885 let toml_str = r#"
886 name = "test-index"
887 url = "https://test.example.com/simple"
888 cache-control = { files = "max-age=3600\n" }
889 "#;
890
891 let err = toml::from_str::<Index>(toml_str).unwrap_err();
892 assert!(
893 err.to_string()
894 .contains("`cache-control.files` must be a valid HTTP header value")
895 );
896 }
897
898 #[test]
899 fn test_index_exclude_newer_disable() {
900 let toml_str = r#"
901 name = "internal"
902 url = "https://internal.example.com/simple"
903 exclude-newer = false
904 "#;
905
906 let index: Index = toml::from_str(toml_str).unwrap();
907 assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
908 assert_eq!(index.exclude_newer, Some(ExcludeNewerOverride::Disabled));
909 }
910
911 #[test]
912 fn test_index_exclude_newer_relative() {
913 let toml_str = r#"
914 name = "internal"
915 url = "https://internal.example.com/simple"
916 exclude-newer = "7 days"
917 "#;
918
919 let index: Index = toml::from_str(toml_str).unwrap();
920 assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
921 assert!(matches!(
922 index.exclude_newer,
923 Some(ExcludeNewerOverride::Enabled(_))
924 ));
925 }
926}