1use std::path::Path;
2use std::str::FromStr;
3
4use http::HeaderValue;
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 artifact_cache_control(&self) -> Option<HeaderValue> {
525 self.cache_control
526 .as_ref()
527 .and_then(|cache_control| cache_control.files.clone())
528 .or_else(|| IndexCacheControl::artifact_cache_control(self.url.url()))
529 }
530
531 pub(crate) fn simple_api_cache_control(&self) -> Option<HeaderValue> {
533 self.cache_control
534 .as_ref()
535 .and_then(|cache_control| cache_control.api.clone())
536 .or_else(|| IndexCacheControl::simple_api_cache_control(self.url.url()))
537 }
538
539 pub(crate) fn exclude_newer(&self) -> Option<&ExcludeNewerOverride> {
541 self.exclude_newer.as_ref()
542 }
543}
544
545impl From<IndexUrl> for Index {
546 fn from(value: IndexUrl) -> Self {
547 Self {
548 name: None,
549 url: value,
550 explicit: false,
551 default: false,
552 origin: None,
553 format: IndexFormat::Simple,
554 publish_url: None,
555 authenticate: AuthPolicy::default(),
556 ignore_error_codes: None,
557 cache_control: None,
558 exclude_newer: None,
559 }
560 }
561}
562
563impl FromStr for Index {
564 type Err = IndexSourceError;
565
566 fn from_str(s: &str) -> Result<Self, Self::Err> {
567 if let Some((name, url)) = s.split_once('=')
569 && !name.chars().any(|c| c == ':')
570 {
571 let name = IndexName::from_str(name)?;
572 let url = IndexUrl::from_str(url)?;
573 return Ok(Self {
574 name: Some(name),
575 url,
576 explicit: false,
577 default: false,
578 origin: None,
579 format: IndexFormat::Simple,
580 publish_url: None,
581 authenticate: AuthPolicy::default(),
582 ignore_error_codes: None,
583 cache_control: None,
584 exclude_newer: None,
585 });
586 }
587
588 let url = IndexUrl::from_str(s)?;
590 Ok(Self {
591 name: None,
592 url,
593 explicit: false,
594 default: false,
595 origin: None,
596 format: IndexFormat::Simple,
597 publish_url: None,
598 authenticate: AuthPolicy::default(),
599 ignore_error_codes: None,
600 cache_control: None,
601 exclude_newer: None,
602 })
603 }
604}
605
606#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
608pub struct IndexMetadata {
609 pub url: IndexUrl,
611 pub format: IndexFormat,
613}
614
615impl IndexMetadata {
616 pub fn into_url(self) -> IndexUrl {
618 self.url
619 }
620}
621
622#[derive(Debug, Copy, Clone)]
624pub struct IndexMetadataRef<'a> {
625 pub url: &'a IndexUrl,
627 pub format: IndexFormat,
629}
630
631impl IndexMetadata {
632 pub fn url(&self) -> &IndexUrl {
634 &self.url
635 }
636}
637
638impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
639 fn from(value: &'a Index) -> Self {
640 Self {
641 url: &value.url,
642 format: value.format,
643 }
644 }
645}
646
647impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
648 fn from(value: &'a IndexMetadata) -> Self {
649 Self {
650 url: &value.url,
651 format: value.format,
652 }
653 }
654}
655
656impl From<IndexUrl> for IndexMetadata {
657 fn from(value: IndexUrl) -> Self {
658 Self {
659 url: value,
660 format: IndexFormat::Simple,
661 }
662 }
663}
664
665impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
666 fn from(value: &'a IndexUrl) -> Self {
667 Self {
668 url: value,
669 format: IndexFormat::Simple,
670 }
671 }
672}
673
674#[derive(Deserialize)]
676#[serde(rename_all = "kebab-case")]
677struct IndexWire {
678 name: Option<IndexName>,
679 url: IndexUrl,
680 #[serde(default)]
681 explicit: bool,
682 #[serde(default)]
683 default: bool,
684 #[serde(default)]
685 format: IndexFormat,
686 publish_url: Option<DisplaySafeUrl>,
687 #[serde(default)]
688 authenticate: AuthPolicy,
689 #[serde(default)]
690 ignore_error_codes: Option<Vec<SerializableStatusCode>>,
691 #[serde(default)]
692 cache_control: Option<IndexCacheControl>,
693 #[serde(default)]
694 exclude_newer: Option<ExcludeNewerOverride>,
695}
696
697impl<'de> Deserialize<'de> for Index {
698 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
699 where
700 D: serde::Deserializer<'de>,
701 {
702 let wire = IndexWire::deserialize(deserializer)?;
703
704 if wire.explicit && wire.name.is_none() {
705 return Err(serde::de::Error::custom(format!(
706 "An index with `explicit = true` requires a `name`: {}",
707 wire.url
708 )));
709 }
710
711 Ok(Self {
712 name: wire.name,
713 url: wire.url,
714 explicit: wire.explicit,
715 default: wire.default,
716 origin: None,
717 format: wire.format,
718 publish_url: wire.publish_url,
719 authenticate: wire.authenticate,
720 ignore_error_codes: wire.ignore_error_codes,
721 cache_control: wire.cache_control,
722 exclude_newer: wire.exclude_newer,
723 })
724 }
725}
726
727#[derive(Error, Debug)]
729pub enum IndexSourceError {
730 #[error(transparent)]
731 Url(#[from] IndexUrlError),
732 #[error(transparent)]
733 IndexName(#[from] IndexNameError),
734 #[error("Index included a name, but the name was empty")]
735 EmptyName,
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use http::HeaderValue;
742
743 #[test]
744 fn test_index_cache_control_headers() {
745 let toml_str = r#"
747 name = "test-index"
748 url = "https://test.example.com/simple"
749 cache-control = { api = "max-age=600", files = "max-age=3600" }
750 "#;
751
752 let index: Index = toml::from_str(toml_str).unwrap();
753 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
754 assert!(index.cache_control.is_some());
755 assert_eq!(index.exclude_newer, None);
756 let cache_control = index.cache_control.as_ref().unwrap();
757 assert_eq!(
758 cache_control.api,
759 Some(HeaderValue::from_static("max-age=600"))
760 );
761 assert_eq!(
762 cache_control.files,
763 Some(HeaderValue::from_static("max-age=3600"))
764 );
765 }
766
767 #[test]
768 fn test_index_without_cache_control() {
769 let toml_str = r#"
771 name = "test-index"
772 url = "https://test.example.com/simple"
773 "#;
774
775 let index: Index = toml::from_str(toml_str).unwrap();
776 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
777 assert_eq!(index.cache_control, None);
778 assert_eq!(index.exclude_newer, None);
779 }
780
781 #[test]
782 fn test_index_partial_cache_control() {
783 let toml_str = r#"
785 name = "test-index"
786 url = "https://test.example.com/simple"
787 cache-control = { api = "max-age=300" }
788 "#;
789
790 let index: Index = toml::from_str(toml_str).unwrap();
791 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
792 assert!(index.cache_control.is_some());
793 assert_eq!(index.exclude_newer, None);
794 let cache_control = index.cache_control.as_ref().unwrap();
795 assert_eq!(
796 cache_control.api,
797 Some(HeaderValue::from_static("max-age=300"))
798 );
799 assert_eq!(cache_control.files, None);
800 }
801
802 #[test]
803 fn test_index_invalid_api_cache_control() {
804 let toml_str = r#"
805 name = "test-index"
806 url = "https://test.example.com/simple"
807 cache-control = { api = "max-age=600\n" }
808 "#;
809
810 let err = toml::from_str::<Index>(toml_str).unwrap_err();
811 assert!(
812 err.to_string()
813 .contains("`cache-control.api` must be a valid HTTP header value")
814 );
815 }
816
817 #[test]
818 fn test_index_invalid_files_cache_control() {
819 let toml_str = r#"
820 name = "test-index"
821 url = "https://test.example.com/simple"
822 cache-control = { files = "max-age=3600\n" }
823 "#;
824
825 let err = toml::from_str::<Index>(toml_str).unwrap_err();
826 assert!(
827 err.to_string()
828 .contains("`cache-control.files` must be a valid HTTP header value")
829 );
830 }
831
832 #[test]
833 fn test_index_exclude_newer_disable() {
834 let toml_str = r#"
835 name = "internal"
836 url = "https://internal.example.com/simple"
837 exclude-newer = false
838 "#;
839
840 let index: Index = toml::from_str(toml_str).unwrap();
841 assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
842 assert_eq!(index.exclude_newer, Some(ExcludeNewerOverride::Disabled));
843 }
844
845 #[test]
846 fn test_index_exclude_newer_relative() {
847 let toml_str = r#"
848 name = "internal"
849 url = "https://internal.example.com/simple"
850 exclude-newer = "7 days"
851 "#;
852
853 let index: Index = toml::from_str(toml_str).unwrap();
854 assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
855 assert!(matches!(
856 index.exclude_newer,
857 Some(ExcludeNewerOverride::Enabled(_))
858 ));
859 }
860}