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(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
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 raw_url(&self) -> &DisplaySafeUrl {
439 self.url.url()
440 }
441
442 pub fn root_url(&self) -> Option<DisplaySafeUrl> {
447 self.url.root()
448 }
449
450 #[must_use]
455 pub fn with_promoted_auth_policy(mut self) -> Self {
456 if matches!(self.authenticate, AuthPolicy::Auto) && self.credentials().is_some() {
457 self.authenticate = AuthPolicy::Always;
458 }
459 self
460 }
461
462 pub fn credentials(&self) -> Option<Credentials> {
464 if let Some(name) = self.name.as_ref() {
466 if let Some(credentials) = Credentials::from_env(name.to_env_var()) {
467 return Some(credentials);
468 }
469 }
470
471 Credentials::from_url(self.url.url())
473 }
474
475 pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
477 if let IndexUrl::Path(ref url) = self.url {
478 if let Some(given) = url.given() {
479 self.url = IndexUrl::parse(given, Some(root_dir))?;
480 }
481 }
482 Ok(self)
483 }
484
485 pub(crate) fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
487 if let Some(ignore_error_codes) = &self.ignore_error_codes {
488 IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
489 } else {
490 IndexStatusCodeStrategy::from_index_url(self.url.url())
491 }
492 }
493
494 pub(crate) fn artifact_cache_control(&self) -> Option<HeaderValue> {
496 self.cache_control
497 .as_ref()
498 .and_then(|cache_control| cache_control.files.clone())
499 .or_else(|| IndexCacheControl::artifact_cache_control(self.url.url()))
500 }
501
502 pub(crate) fn simple_api_cache_control(&self) -> Option<HeaderValue> {
504 self.cache_control
505 .as_ref()
506 .and_then(|cache_control| cache_control.api.clone())
507 .or_else(|| IndexCacheControl::simple_api_cache_control(self.url.url()))
508 }
509
510 pub(crate) fn exclude_newer(&self) -> Option<&ExcludeNewerOverride> {
512 self.exclude_newer.as_ref()
513 }
514}
515
516impl From<IndexUrl> for Index {
517 fn from(value: IndexUrl) -> Self {
518 Self {
519 name: None,
520 url: value,
521 explicit: false,
522 default: false,
523 origin: None,
524 format: IndexFormat::Simple,
525 publish_url: None,
526 authenticate: AuthPolicy::default(),
527 ignore_error_codes: None,
528 cache_control: None,
529 exclude_newer: None,
530 }
531 }
532}
533
534impl FromStr for Index {
535 type Err = IndexSourceError;
536
537 fn from_str(s: &str) -> Result<Self, Self::Err> {
538 if let Some((name, url)) = s.split_once('=') {
540 if !name.chars().any(|c| c == ':') {
541 let name = IndexName::from_str(name)?;
542 let url = IndexUrl::from_str(url)?;
543 return Ok(Self {
544 name: Some(name),
545 url,
546 explicit: false,
547 default: false,
548 origin: None,
549 format: IndexFormat::Simple,
550 publish_url: None,
551 authenticate: AuthPolicy::default(),
552 ignore_error_codes: None,
553 cache_control: None,
554 exclude_newer: None,
555 });
556 }
557 }
558
559 let url = IndexUrl::from_str(s)?;
561 Ok(Self {
562 name: None,
563 url,
564 explicit: false,
565 default: false,
566 origin: None,
567 format: IndexFormat::Simple,
568 publish_url: None,
569 authenticate: AuthPolicy::default(),
570 ignore_error_codes: None,
571 cache_control: None,
572 exclude_newer: None,
573 })
574 }
575}
576
577#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
579pub struct IndexMetadata {
580 pub url: IndexUrl,
582 pub format: IndexFormat,
584}
585
586impl IndexMetadata {
587 pub fn into_url(self) -> IndexUrl {
589 self.url
590 }
591}
592
593#[derive(Debug, Copy, Clone)]
595pub struct IndexMetadataRef<'a> {
596 pub url: &'a IndexUrl,
598 pub format: IndexFormat,
600}
601
602impl IndexMetadata {
603 pub fn url(&self) -> &IndexUrl {
605 &self.url
606 }
607}
608
609impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
610 fn from(value: &'a Index) -> Self {
611 Self {
612 url: &value.url,
613 format: value.format,
614 }
615 }
616}
617
618impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
619 fn from(value: &'a IndexMetadata) -> Self {
620 Self {
621 url: &value.url,
622 format: value.format,
623 }
624 }
625}
626
627impl From<IndexUrl> for IndexMetadata {
628 fn from(value: IndexUrl) -> Self {
629 Self {
630 url: value,
631 format: IndexFormat::Simple,
632 }
633 }
634}
635
636impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
637 fn from(value: &'a IndexUrl) -> Self {
638 Self {
639 url: value,
640 format: IndexFormat::Simple,
641 }
642 }
643}
644
645#[derive(Deserialize)]
647#[serde(rename_all = "kebab-case")]
648struct IndexWire {
649 name: Option<IndexName>,
650 url: IndexUrl,
651 #[serde(default)]
652 explicit: bool,
653 #[serde(default)]
654 default: bool,
655 #[serde(default)]
656 format: IndexFormat,
657 publish_url: Option<DisplaySafeUrl>,
658 #[serde(default)]
659 authenticate: AuthPolicy,
660 #[serde(default)]
661 ignore_error_codes: Option<Vec<SerializableStatusCode>>,
662 #[serde(default)]
663 cache_control: Option<IndexCacheControl>,
664 #[serde(default)]
665 exclude_newer: Option<ExcludeNewerOverride>,
666}
667
668impl<'de> Deserialize<'de> for Index {
669 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
670 where
671 D: serde::Deserializer<'de>,
672 {
673 let wire = IndexWire::deserialize(deserializer)?;
674
675 if wire.explicit && wire.name.is_none() {
676 return Err(serde::de::Error::custom(format!(
677 "An index with `explicit = true` requires a `name`: {}",
678 wire.url
679 )));
680 }
681
682 Ok(Self {
683 name: wire.name,
684 url: wire.url,
685 explicit: wire.explicit,
686 default: wire.default,
687 origin: None,
688 format: wire.format,
689 publish_url: wire.publish_url,
690 authenticate: wire.authenticate,
691 ignore_error_codes: wire.ignore_error_codes,
692 cache_control: wire.cache_control,
693 exclude_newer: wire.exclude_newer,
694 })
695 }
696}
697
698#[derive(Error, Debug)]
700pub enum IndexSourceError {
701 #[error(transparent)]
702 Url(#[from] IndexUrlError),
703 #[error(transparent)]
704 IndexName(#[from] IndexNameError),
705 #[error("Index included a name, but the name was empty")]
706 EmptyName,
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use http::HeaderValue;
713
714 #[test]
715 fn test_index_cache_control_headers() {
716 let toml_str = r#"
718 name = "test-index"
719 url = "https://test.example.com/simple"
720 cache-control = { api = "max-age=600", files = "max-age=3600" }
721 "#;
722
723 let index: Index = toml::from_str(toml_str).unwrap();
724 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
725 assert!(index.cache_control.is_some());
726 assert_eq!(index.exclude_newer, None);
727 let cache_control = index.cache_control.as_ref().unwrap();
728 assert_eq!(
729 cache_control.api,
730 Some(HeaderValue::from_static("max-age=600"))
731 );
732 assert_eq!(
733 cache_control.files,
734 Some(HeaderValue::from_static("max-age=3600"))
735 );
736 }
737
738 #[test]
739 fn test_index_without_cache_control() {
740 let toml_str = r#"
742 name = "test-index"
743 url = "https://test.example.com/simple"
744 "#;
745
746 let index: Index = toml::from_str(toml_str).unwrap();
747 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
748 assert_eq!(index.cache_control, None);
749 assert_eq!(index.exclude_newer, None);
750 }
751
752 #[test]
753 fn test_index_partial_cache_control() {
754 let toml_str = r#"
756 name = "test-index"
757 url = "https://test.example.com/simple"
758 cache-control = { api = "max-age=300" }
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=300"))
769 );
770 assert_eq!(cache_control.files, None);
771 }
772
773 #[test]
774 fn test_index_invalid_api_cache_control() {
775 let toml_str = r#"
776 name = "test-index"
777 url = "https://test.example.com/simple"
778 cache-control = { api = "max-age=600\n" }
779 "#;
780
781 let err = toml::from_str::<Index>(toml_str).unwrap_err();
782 assert!(
783 err.to_string()
784 .contains("`cache-control.api` must be a valid HTTP header value")
785 );
786 }
787
788 #[test]
789 fn test_index_invalid_files_cache_control() {
790 let toml_str = r#"
791 name = "test-index"
792 url = "https://test.example.com/simple"
793 cache-control = { files = "max-age=3600\n" }
794 "#;
795
796 let err = toml::from_str::<Index>(toml_str).unwrap_err();
797 assert!(
798 err.to_string()
799 .contains("`cache-control.files` must be a valid HTTP header value")
800 );
801 }
802
803 #[test]
804 fn test_index_exclude_newer_disable() {
805 let toml_str = r#"
806 name = "internal"
807 url = "https://internal.example.com/simple"
808 exclude-newer = false
809 "#;
810
811 let index: Index = toml::from_str(toml_str).unwrap();
812 assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
813 assert_eq!(index.exclude_newer, Some(ExcludeNewerOverride::Disabled));
814 }
815
816 #[test]
817 fn test_index_exclude_newer_relative() {
818 let toml_str = r#"
819 name = "internal"
820 url = "https://internal.example.com/simple"
821 exclude-newer = "7 days"
822 "#;
823
824 let index: Index = toml::from_str(toml_str).unwrap();
825 assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
826 assert!(matches!(
827 index.exclude_newer,
828 Some(ExcludeNewerOverride::Enabled(_))
829 ));
830 }
831}