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};
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 api: Option<HeaderValue>,
25 #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
27 pub files: Option<HeaderValue>,
28}
29
30impl IndexCacheControl {
31 pub fn simple_api_cache_control(_url: &Url) -> Option<HeaderValue> {
33 None
34 }
35
36 pub 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
258impl PartialEq for Index {
259 fn eq(&self, other: &Self) -> bool {
260 let Self {
261 name,
262 url,
263 explicit,
264 default,
265 origin: _,
266 format,
267 publish_url,
268 authenticate,
269 ignore_error_codes,
270 cache_control,
271 exclude_newer,
272 } = self;
273 *url == other.url
274 && *name == other.name
275 && *explicit == other.explicit
276 && *default == other.default
277 && *format == other.format
278 && *publish_url == other.publish_url
279 && *authenticate == other.authenticate
280 && *ignore_error_codes == other.ignore_error_codes
281 && *cache_control == other.cache_control
282 && *exclude_newer == other.exclude_newer
283 }
284}
285
286impl Eq for Index {}
287
288impl PartialOrd for Index {
289 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
290 Some(self.cmp(other))
291 }
292}
293
294impl Ord for Index {
295 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
296 let Self {
297 name,
298 url,
299 explicit,
300 default,
301 origin: _,
302 format,
303 publish_url,
304 authenticate,
305 ignore_error_codes,
306 cache_control,
307 exclude_newer,
308 } = self;
309 url.cmp(&other.url)
310 .then_with(|| name.cmp(&other.name))
311 .then_with(|| explicit.cmp(&other.explicit))
312 .then_with(|| default.cmp(&other.default))
313 .then_with(|| format.cmp(&other.format))
314 .then_with(|| publish_url.cmp(&other.publish_url))
315 .then_with(|| authenticate.cmp(&other.authenticate))
316 .then_with(|| ignore_error_codes.cmp(&other.ignore_error_codes))
317 .then_with(|| cache_control.cmp(&other.cache_control))
318 .then_with(|| exclude_newer.cmp(&other.exclude_newer))
319 }
320}
321
322impl std::hash::Hash for Index {
323 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
324 let Self {
325 name,
326 url,
327 explicit,
328 default,
329 origin: _,
330 format,
331 publish_url,
332 authenticate,
333 ignore_error_codes,
334 cache_control,
335 exclude_newer,
336 } = self;
337 url.hash(state);
338 name.hash(state);
339 explicit.hash(state);
340 default.hash(state);
341 format.hash(state);
342 publish_url.hash(state);
343 authenticate.hash(state);
344 ignore_error_codes.hash(state);
345 cache_control.hash(state);
346 exclude_newer.hash(state);
347 }
348}
349
350#[derive(
351 Default,
352 Debug,
353 Copy,
354 Clone,
355 Hash,
356 Eq,
357 PartialEq,
358 Ord,
359 PartialOrd,
360 serde::Serialize,
361 serde::Deserialize,
362)]
363#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
364#[serde(rename_all = "kebab-case")]
365pub enum IndexFormat {
366 #[default]
368 Simple,
369 Flat,
371}
372
373impl Index {
374 pub fn from_index_url(url: IndexUrl) -> Self {
376 Self {
377 url,
378 name: None,
379 explicit: false,
380 default: true,
381 origin: None,
382 format: IndexFormat::Simple,
383 publish_url: None,
384 authenticate: AuthPolicy::default(),
385 ignore_error_codes: None,
386 cache_control: None,
387 exclude_newer: None,
388 }
389 }
390
391 pub fn from_extra_index_url(url: IndexUrl) -> Self {
393 Self {
394 url,
395 name: None,
396 explicit: false,
397 default: false,
398 origin: None,
399 format: IndexFormat::Simple,
400 publish_url: None,
401 authenticate: AuthPolicy::default(),
402 ignore_error_codes: None,
403 cache_control: None,
404 exclude_newer: None,
405 }
406 }
407
408 pub fn from_find_links(url: IndexUrl) -> Self {
410 Self {
411 url,
412 name: None,
413 explicit: false,
414 default: false,
415 origin: None,
416 format: IndexFormat::Flat,
417 publish_url: None,
418 authenticate: AuthPolicy::default(),
419 ignore_error_codes: None,
420 cache_control: None,
421 exclude_newer: None,
422 }
423 }
424
425 #[must_use]
427 pub fn with_origin(mut self, origin: Origin) -> Self {
428 self.origin = Some(origin);
429 self
430 }
431
432 pub fn url(&self) -> &IndexUrl {
434 &self.url
435 }
436
437 pub fn into_url(self) -> IndexUrl {
439 self.url
440 }
441
442 pub fn raw_url(&self) -> &DisplaySafeUrl {
444 self.url.url()
445 }
446
447 pub fn root_url(&self) -> Option<DisplaySafeUrl> {
452 self.url.root()
453 }
454
455 #[must_use]
460 pub fn with_promoted_auth_policy(mut self) -> Self {
461 if matches!(self.authenticate, AuthPolicy::Auto) && self.credentials().is_some() {
462 self.authenticate = AuthPolicy::Always;
463 }
464 self
465 }
466
467 pub fn credentials(&self) -> Option<Credentials> {
469 if let Some(name) = self.name.as_ref() {
471 if let Some(credentials) = Credentials::from_env(name.to_env_var()) {
472 return Some(credentials);
473 }
474 }
475
476 Credentials::from_url(self.url.url())
478 }
479
480 pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
482 if let IndexUrl::Path(ref url) = self.url {
483 if let Some(given) = url.given() {
484 self.url = IndexUrl::parse(given, Some(root_dir))?;
485 }
486 }
487 Ok(self)
488 }
489
490 pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
492 if let Some(ignore_error_codes) = &self.ignore_error_codes {
493 IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
494 } else {
495 IndexStatusCodeStrategy::from_index_url(self.url.url())
496 }
497 }
498
499 pub fn artifact_cache_control(&self) -> Option<HeaderValue> {
501 self.cache_control
502 .as_ref()
503 .and_then(|cache_control| cache_control.files.clone())
504 .or_else(|| IndexCacheControl::artifact_cache_control(self.url.url()))
505 }
506
507 pub fn simple_api_cache_control(&self) -> Option<HeaderValue> {
509 self.cache_control
510 .as_ref()
511 .and_then(|cache_control| cache_control.api.clone())
512 .or_else(|| IndexCacheControl::simple_api_cache_control(self.url.url()))
513 }
514
515 pub fn exclude_newer(&self) -> Option<&ExcludeNewerOverride> {
517 self.exclude_newer.as_ref()
518 }
519}
520
521impl From<IndexUrl> for Index {
522 fn from(value: IndexUrl) -> Self {
523 Self {
524 name: None,
525 url: value,
526 explicit: false,
527 default: false,
528 origin: None,
529 format: IndexFormat::Simple,
530 publish_url: None,
531 authenticate: AuthPolicy::default(),
532 ignore_error_codes: None,
533 cache_control: None,
534 exclude_newer: None,
535 }
536 }
537}
538
539impl FromStr for Index {
540 type Err = IndexSourceError;
541
542 fn from_str(s: &str) -> Result<Self, Self::Err> {
543 if let Some((name, url)) = s.split_once('=') {
545 if !name.chars().any(|c| c == ':') {
546 let name = IndexName::from_str(name)?;
547 let url = IndexUrl::from_str(url)?;
548 return Ok(Self {
549 name: Some(name),
550 url,
551 explicit: false,
552 default: false,
553 origin: None,
554 format: IndexFormat::Simple,
555 publish_url: None,
556 authenticate: AuthPolicy::default(),
557 ignore_error_codes: None,
558 cache_control: None,
559 exclude_newer: None,
560 });
561 }
562 }
563
564 let url = IndexUrl::from_str(s)?;
566 Ok(Self {
567 name: None,
568 url,
569 explicit: false,
570 default: false,
571 origin: None,
572 format: IndexFormat::Simple,
573 publish_url: None,
574 authenticate: AuthPolicy::default(),
575 ignore_error_codes: None,
576 cache_control: None,
577 exclude_newer: None,
578 })
579 }
580}
581
582#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
584pub struct IndexMetadata {
585 pub url: IndexUrl,
587 pub format: IndexFormat,
589}
590
591impl IndexMetadata {
592 pub fn as_ref(&self) -> IndexMetadataRef<'_> {
594 let Self { url, format: kind } = self;
595 IndexMetadataRef { url, format: *kind }
596 }
597
598 pub fn into_url(self) -> IndexUrl {
600 self.url
601 }
602}
603
604#[derive(Debug, Copy, Clone)]
606pub struct IndexMetadataRef<'a> {
607 pub url: &'a IndexUrl,
609 pub format: IndexFormat,
611}
612
613impl IndexMetadata {
614 pub fn url(&self) -> &IndexUrl {
616 &self.url
617 }
618}
619
620impl IndexMetadataRef<'_> {
621 pub fn url(&self) -> &IndexUrl {
623 self.url
624 }
625}
626
627impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
628 fn from(value: &'a Index) -> Self {
629 Self {
630 url: &value.url,
631 format: value.format,
632 }
633 }
634}
635
636impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
637 fn from(value: &'a IndexMetadata) -> Self {
638 Self {
639 url: &value.url,
640 format: value.format,
641 }
642 }
643}
644
645impl From<IndexUrl> for IndexMetadata {
646 fn from(value: IndexUrl) -> Self {
647 Self {
648 url: value,
649 format: IndexFormat::Simple,
650 }
651 }
652}
653
654impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
655 fn from(value: &'a IndexUrl) -> Self {
656 Self {
657 url: value,
658 format: IndexFormat::Simple,
659 }
660 }
661}
662
663#[derive(Deserialize)]
665#[serde(rename_all = "kebab-case")]
666struct IndexWire {
667 name: Option<IndexName>,
668 url: IndexUrl,
669 #[serde(default)]
670 explicit: bool,
671 #[serde(default)]
672 default: bool,
673 #[serde(default)]
674 format: IndexFormat,
675 publish_url: Option<DisplaySafeUrl>,
676 #[serde(default)]
677 authenticate: AuthPolicy,
678 #[serde(default)]
679 ignore_error_codes: Option<Vec<SerializableStatusCode>>,
680 #[serde(default)]
681 cache_control: Option<IndexCacheControl>,
682 #[serde(default)]
683 exclude_newer: Option<ExcludeNewerOverride>,
684}
685
686impl<'de> Deserialize<'de> for Index {
687 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
688 where
689 D: serde::Deserializer<'de>,
690 {
691 let wire = IndexWire::deserialize(deserializer)?;
692
693 if wire.explicit && wire.name.is_none() {
694 return Err(serde::de::Error::custom(format!(
695 "An index with `explicit = true` requires a `name`: {}",
696 wire.url
697 )));
698 }
699
700 Ok(Self {
701 name: wire.name,
702 url: wire.url,
703 explicit: wire.explicit,
704 default: wire.default,
705 origin: None,
706 format: wire.format,
707 publish_url: wire.publish_url,
708 authenticate: wire.authenticate,
709 ignore_error_codes: wire.ignore_error_codes,
710 cache_control: wire.cache_control,
711 exclude_newer: wire.exclude_newer,
712 })
713 }
714}
715
716#[derive(Error, Debug)]
718pub enum IndexSourceError {
719 #[error(transparent)]
720 Url(#[from] IndexUrlError),
721 #[error(transparent)]
722 IndexName(#[from] IndexNameError),
723 #[error("Index included a name, but the name was empty")]
724 EmptyName,
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730 use http::HeaderValue;
731
732 #[test]
733 fn test_index_cache_control_headers() {
734 let toml_str = r#"
736 name = "test-index"
737 url = "https://test.example.com/simple"
738 cache-control = { api = "max-age=600", files = "max-age=3600" }
739 "#;
740
741 let index: Index = toml::from_str(toml_str).unwrap();
742 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
743 assert!(index.cache_control.is_some());
744 assert_eq!(index.exclude_newer, None);
745 let cache_control = index.cache_control.as_ref().unwrap();
746 assert_eq!(
747 cache_control.api,
748 Some(HeaderValue::from_static("max-age=600"))
749 );
750 assert_eq!(
751 cache_control.files,
752 Some(HeaderValue::from_static("max-age=3600"))
753 );
754 }
755
756 #[test]
757 fn test_index_without_cache_control() {
758 let toml_str = r#"
760 name = "test-index"
761 url = "https://test.example.com/simple"
762 "#;
763
764 let index: Index = toml::from_str(toml_str).unwrap();
765 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
766 assert_eq!(index.cache_control, None);
767 assert_eq!(index.exclude_newer, None);
768 }
769
770 #[test]
771 fn test_index_partial_cache_control() {
772 let toml_str = r#"
774 name = "test-index"
775 url = "https://test.example.com/simple"
776 cache-control = { api = "max-age=300" }
777 "#;
778
779 let index: Index = toml::from_str(toml_str).unwrap();
780 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
781 assert!(index.cache_control.is_some());
782 assert_eq!(index.exclude_newer, None);
783 let cache_control = index.cache_control.as_ref().unwrap();
784 assert_eq!(
785 cache_control.api,
786 Some(HeaderValue::from_static("max-age=300"))
787 );
788 assert_eq!(cache_control.files, None);
789 }
790
791 #[test]
792 fn test_index_invalid_api_cache_control() {
793 let toml_str = r#"
794 name = "test-index"
795 url = "https://test.example.com/simple"
796 cache-control = { api = "max-age=600\n" }
797 "#;
798
799 let err = toml::from_str::<Index>(toml_str).unwrap_err();
800 assert!(
801 err.to_string()
802 .contains("`cache-control.api` must be a valid HTTP header value")
803 );
804 }
805
806 #[test]
807 fn test_index_invalid_files_cache_control() {
808 let toml_str = r#"
809 name = "test-index"
810 url = "https://test.example.com/simple"
811 cache-control = { files = "max-age=3600\n" }
812 "#;
813
814 let err = toml::from_str::<Index>(toml_str).unwrap_err();
815 assert!(
816 err.to_string()
817 .contains("`cache-control.files` must be a valid HTTP header value")
818 );
819 }
820
821 #[test]
822 fn test_index_exclude_newer_disable() {
823 let toml_str = r#"
824 name = "internal"
825 url = "https://internal.example.com/simple"
826 exclude-newer = false
827 "#;
828
829 let index: Index = toml::from_str(toml_str).unwrap();
830 assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
831 assert_eq!(index.exclude_newer, Some(ExcludeNewerOverride::Disabled));
832 }
833
834 #[test]
835 fn test_index_exclude_newer_relative() {
836 let toml_str = r#"
837 name = "internal"
838 url = "https://internal.example.com/simple"
839 exclude-newer = "7 days"
840 "#;
841
842 let index: Index = toml::from_str(toml_str).unwrap();
843 assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
844 assert!(matches!(
845 index.exclude_newer,
846 Some(ExcludeNewerOverride::Enabled(_))
847 ));
848 }
849}