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