1use std::borrow::Cow;
2use std::fmt::{Display, Formatter};
3use std::ops::Deref;
4use std::path::Path;
5use std::str::FromStr;
6use std::sync::{Arc, LazyLock, RwLock};
7
8use http::StatusCode;
9use itertools::Either;
10use rustc_hash::{FxHashMap, FxHashSet};
11use thiserror::Error;
12use url::{ParseError, Url};
13use uv_auth::RealmRef;
14use uv_cache_key::CanonicalUrl;
15use uv_pep508::{Scheme, VerbatimUrl, VerbatimUrlError, split_scheme};
16use uv_redacted::DisplaySafeUrl;
17use uv_warnings::warn_user;
18
19use crate::{ExcludeNewerOverride, Index, IndexStatusCodeStrategy, Verbatim};
20
21pub static PYPI_URL: LazyLock<DisplaySafeUrl> =
22 LazyLock::new(|| DisplaySafeUrl::parse("https://pypi.org/simple").unwrap());
23
24static DEFAULT_INDEX: LazyLock<Index> = LazyLock::new(|| {
25 Index::from_index_url(IndexUrl::Pypi(Arc::new(VerbatimUrl::from_url(
26 PYPI_URL.clone(),
27 ))))
28});
29
30#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
32pub enum IndexUrl {
33 Pypi(Arc<VerbatimUrl>),
34 Url(Arc<VerbatimUrl>),
35 Path(Arc<VerbatimUrl>),
36}
37
38impl IndexUrl {
39 pub fn parse(path: &str, root_dir: Option<&Path>) -> Result<Self, IndexUrlError> {
44 let url = VerbatimUrl::from_url_or_path(path, root_dir)?;
45 Ok(Self::from(url))
46 }
47
48 pub fn root(&self) -> Option<DisplaySafeUrl> {
53 let mut segments = self.url().path_segments()?;
54 let last = match segments.next_back()? {
55 "" => segments.next_back()?,
57 segment => segment,
58 };
59
60 if !(last.eq_ignore_ascii_case("simple") || last.eq_ignore_ascii_case("+simple")) {
62 return None;
63 }
64
65 let mut url = self.url().clone();
66 url.path_segments_mut().ok()?.pop_if_empty().pop();
67 Some(url)
68 }
69}
70
71#[cfg(feature = "schemars")]
72impl schemars::JsonSchema for IndexUrl {
73 fn schema_name() -> Cow<'static, str> {
74 Cow::Borrowed("IndexUrl")
75 }
76
77 fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
78 schemars::json_schema!({
79 "type": "string",
80 "description": "The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`), or a local path."
81 })
82 }
83}
84
85impl IndexUrl {
86 #[inline]
87 fn inner(&self) -> &VerbatimUrl {
88 match self {
89 Self::Pypi(url) | Self::Url(url) | Self::Path(url) => url,
90 }
91 }
92
93 pub fn url(&self) -> &DisplaySafeUrl {
95 self.inner().raw()
96 }
97
98 pub fn into_url(self) -> DisplaySafeUrl {
100 self.inner().to_url()
101 }
102
103 pub fn without_credentials(&self) -> Cow<'_, DisplaySafeUrl> {
105 let url = self.url();
106 if url.username().is_empty() && url.password().is_none() {
107 Cow::Borrowed(url)
108 } else {
109 let mut url = url.clone();
110 let _ = url.set_username("");
111 let _ = url.set_password(None);
112 Cow::Owned(url)
113 }
114 }
115
116 pub fn warn_on_disambiguated_relative_path(&self) {
121 let Self::Path(verbatim_url) = &self else {
122 return;
123 };
124
125 if let Some(path) = verbatim_url.given()
126 && !is_disambiguated_path(path)
127 {
128 if cfg!(windows) {
129 warn_user!(
130 "Relative paths passed to `--index` or `--default-index` should be disambiguated from index names (use `.\\{path}` or `./{path}`). Support for ambiguous values will be removed in the future"
131 );
132 } else {
133 warn_user!(
134 "Relative paths passed to `--index` or `--default-index` should be disambiguated from index names (use `./{path}`). Support for ambiguous values will be removed in the future"
135 );
136 }
137 }
138 }
139}
140
141impl Display for IndexUrl {
142 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
143 Display::fmt(self.inner(), f)
144 }
145}
146
147impl Verbatim for IndexUrl {
148 fn verbatim(&self) -> Cow<'_, str> {
149 self.inner().verbatim()
150 }
151}
152
153fn is_disambiguated_path(path: &str) -> bool {
159 if cfg!(windows) {
160 if path.starts_with(".\\") || path.starts_with("..\\") || path.starts_with('/') {
161 return true;
162 }
163 }
164 if path.starts_with("./") || path.starts_with("../") || Path::new(path).is_absolute() {
165 return true;
166 }
167 if let Some((scheme, _)) = split_scheme(path) {
169 return Scheme::parse(scheme).is_some();
170 }
171 false
173}
174
175#[derive(Error, Debug)]
177pub enum IndexUrlError {
178 #[error(transparent)]
179 Io(#[from] std::io::Error),
180 #[error(transparent)]
181 Url(#[from] ParseError),
182 #[error(transparent)]
183 VerbatimUrl(#[from] VerbatimUrlError),
184}
185
186impl FromStr for IndexUrl {
187 type Err = IndexUrlError;
188
189 fn from_str(s: &str) -> Result<Self, Self::Err> {
190 Self::parse(s, None)
191 }
192}
193
194impl serde::ser::Serialize for IndexUrl {
195 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
196 where
197 S: serde::ser::Serializer,
198 {
199 self.inner().without_credentials().serialize(serializer)
200 }
201}
202
203impl<'de> serde::de::Deserialize<'de> for IndexUrl {
204 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
205 where
206 D: serde::de::Deserializer<'de>,
207 {
208 struct Visitor;
209
210 impl serde::de::Visitor<'_> for Visitor {
211 type Value = IndexUrl;
212
213 fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
214 f.write_str("a string")
215 }
216
217 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
218 IndexUrl::from_str(v).map_err(serde::de::Error::custom)
219 }
220 }
221
222 deserializer.deserialize_str(Visitor)
223 }
224}
225
226impl From<VerbatimUrl> for IndexUrl {
227 fn from(url: VerbatimUrl) -> Self {
228 if url.scheme() == "file" {
229 Self::Path(Arc::new(url))
230 } else if *url.raw() == *PYPI_URL {
231 Self::Pypi(Arc::new(url))
232 } else {
233 Self::Url(Arc::new(url))
234 }
235 }
236}
237
238impl From<IndexUrl> for DisplaySafeUrl {
239 fn from(index: IndexUrl) -> Self {
240 index.inner().to_url()
241 }
242}
243
244impl Deref for IndexUrl {
245 type Target = Url;
246
247 fn deref(&self) -> &Self::Target {
248 self.inner()
249 }
250}
251
252#[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
257#[serde(rename_all = "kebab-case", deny_unknown_fields)]
258pub struct IndexLocations {
259 indexes: Vec<Index>,
260 flat_index: Vec<Index>,
261 no_index: bool,
262}
263
264impl IndexLocations {
265 pub fn new(indexes: Vec<Index>, flat_index: Vec<Index>, no_index: bool) -> Self {
267 Self {
268 indexes,
269 flat_index,
270 no_index,
271 }
272 }
273
274 #[must_use]
281 pub fn combine(self, indexes: Vec<Index>, flat_index: Vec<Index>, no_index: bool) -> Self {
282 Self {
283 indexes: self.indexes.into_iter().chain(indexes).collect(),
284 flat_index: self.flat_index.into_iter().chain(flat_index).collect(),
285 no_index: self.no_index || no_index,
286 }
287 }
288
289 pub fn is_none(&self) -> bool {
292 *self == Self::default()
293 }
294}
295
296fn is_same_index(a: &IndexUrl, b: &IndexUrl) -> bool {
298 RealmRef::from(&**b.url()) == RealmRef::from(&**a.url())
299 && CanonicalUrl::new(a.url()) == CanonicalUrl::new(b.url())
300}
301
302impl<'a> IndexLocations {
303 pub fn default_index(&'a self) -> Option<&'a Index> {
309 if self.no_index {
310 None
311 } else {
312 let mut seen = FxHashSet::default();
313 self.indexes
314 .iter()
315 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
316 .find(|index| index.default)
317 .or_else(|| Some(&DEFAULT_INDEX))
318 }
319 }
320
321 pub fn implicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
325 if self.no_index {
326 Either::Left(std::iter::empty())
327 } else {
328 let mut seen = FxHashSet::default();
329 Either::Right(
330 self.indexes
331 .iter()
332 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
333 .filter(|index| !index.default && !index.explicit),
334 )
335 }
336 }
337
338 pub fn explicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
342 if self.no_index {
343 Either::Left(std::iter::empty())
344 } else {
345 let mut seen = FxHashSet::default();
346 Either::Right(
347 self.indexes
348 .iter()
349 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
350 .filter(|index| index.explicit),
351 )
352 }
353 }
354
355 pub fn indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
364 self.implicit_indexes()
365 .chain(self.default_index())
366 .filter(|index| !index.explicit)
367 }
368
369 pub fn fetch_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
373 let mut seen = FxHashSet::default();
374 self.indexes()
375 .filter(move |index| seen.insert(index.raw_url()))
376 }
377
378 pub fn simple_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
382 if self.no_index {
383 Either::Left(std::iter::empty())
384 } else {
385 let mut seen = FxHashSet::default();
386 Either::Right(
387 self.indexes
388 .iter()
389 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))),
390 )
391 }
392 }
393
394 pub fn flat_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
396 self.flat_index.iter()
397 }
398
399 pub fn no_index(&self) -> bool {
401 self.no_index
402 }
403
404 pub fn allowed_indexes(&'a self) -> Vec<&'a Index> {
411 if self.no_index {
412 self.flat_index.iter().rev().collect()
413 } else {
414 let mut indexes = vec![];
415
416 let mut seen = FxHashSet::default();
417 let mut default = false;
418 for index in {
419 self.indexes
420 .iter()
421 .chain(self.flat_index.iter())
422 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
423 } {
424 if index.default {
425 if default {
426 continue;
427 }
428 default = true;
429 }
430 indexes.push(index);
431 }
432 if !default {
433 indexes.push(&*DEFAULT_INDEX);
434 }
435
436 indexes.reverse();
437 indexes
438 }
439 }
440
441 pub fn known_indexes(&'a self) -> impl Iterator<Item = &'a Index> {
450 if self.no_index {
451 Either::Left(self.flat_index.iter().rev())
452 } else {
453 Either::Right(
454 std::iter::once(&*DEFAULT_INDEX)
455 .chain(self.flat_index.iter().rev())
456 .chain(self.indexes.iter().rev()),
457 )
458 }
459 }
460
461 pub fn defined_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
471 if self.no_index {
472 return Either::Left(std::iter::empty());
473 }
474
475 let mut seen = FxHashSet::default();
476 let (non_default, default) = self
477 .indexes
478 .iter()
479 .filter(move |index| {
480 if let Some(name) = &index.name {
481 seen.insert(name)
482 } else {
483 true
484 }
485 })
486 .partition::<Vec<_>, _>(|index| !index.default);
487
488 Either::Right(non_default.into_iter().chain(default))
489 }
490
491 fn index_for_url(&self, url: &IndexUrl) -> Option<&Index> {
493 self.indexes
494 .iter()
495 .find(|index| is_same_index(index.url(), url))
496 }
497
498 pub fn status_code_strategy_for(&self, url: &IndexUrl) -> IndexStatusCodeStrategy {
500 self.index_for_url(url).map_or(
501 IndexStatusCodeStrategy::Default,
502 Index::status_code_strategy,
503 )
504 }
505
506 pub fn ignores_error_code_for(&self, url: &IndexUrl, status_code: StatusCode) -> bool {
508 self.index_for_url(url)
509 .is_some_and(|index| index.ignores_error_code(status_code))
510 }
511
512 pub fn simple_api_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
514 self.index_for_url(url)
515 .and_then(Index::simple_api_cache_control)
516 }
517
518 pub fn artifact_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
520 self.index_for_url(url)
521 .and_then(Index::artifact_cache_control)
522 }
523
524 pub fn exclude_newer_for(&self, url: &IndexUrl) -> Option<&ExcludeNewerOverride> {
526 self.index_for_url(url).and_then(Index::exclude_newer)
527 }
528}
529
530impl From<&IndexLocations> for uv_auth::Indexes {
531 fn from(index_locations: &IndexLocations) -> Self {
532 Self::from_indexes(index_locations.allowed_indexes().into_iter().map(|index| {
533 let mut url = index.url().url().clone();
534 url.set_username("").ok();
535 url.set_password(None).ok();
536 let mut root_url = index.url().root().unwrap_or_else(|| url.clone());
537 root_url.set_username("").ok();
538 root_url.set_password(None).ok();
539 uv_auth::Index {
540 url,
541 root_url,
542 auth_policy: index.authenticate,
543 }
544 }))
545 }
546}
547
548bitflags::bitflags! {
549 #[derive(Debug, Copy, Clone)]
550 struct Flags: u8 {
551 const NO_RANGE_REQUESTS = 1;
553 const UNAUTHORIZED = 1 << 2;
555 const FORBIDDEN = 1 << 1;
557 }
558}
559
560#[derive(Debug, Default, Clone)]
566pub struct IndexCapabilities(Arc<RwLock<FxHashMap<IndexUrl, Flags>>>);
567
568impl IndexCapabilities {
569 pub fn supports_range_requests(&self, index_url: &IndexUrl) -> bool {
571 !self
572 .0
573 .read()
574 .unwrap()
575 .get(index_url)
576 .is_some_and(|flags| flags.intersects(Flags::NO_RANGE_REQUESTS))
577 }
578
579 pub fn set_no_range_requests(&self, index_url: IndexUrl) {
581 self.0
582 .write()
583 .unwrap()
584 .entry(index_url)
585 .or_insert(Flags::empty())
586 .insert(Flags::NO_RANGE_REQUESTS);
587 }
588
589 pub fn unauthorized(&self, index_url: &IndexUrl) -> bool {
591 self.0
592 .read()
593 .unwrap()
594 .get(index_url)
595 .is_some_and(|flags| flags.intersects(Flags::UNAUTHORIZED))
596 }
597
598 pub(crate) fn set_unauthorized(&self, index_url: IndexUrl) {
600 self.0
601 .write()
602 .unwrap()
603 .entry(index_url)
604 .or_insert(Flags::empty())
605 .insert(Flags::UNAUTHORIZED);
606 }
607
608 pub fn forbidden(&self, index_url: &IndexUrl) -> bool {
610 self.0
611 .read()
612 .unwrap()
613 .get(index_url)
614 .is_some_and(|flags| flags.intersects(Flags::FORBIDDEN))
615 }
616
617 pub(crate) fn set_forbidden(&self, index_url: IndexUrl) {
619 self.0
620 .write()
621 .unwrap()
622 .entry(index_url)
623 .or_insert(Flags::empty())
624 .insert(Flags::FORBIDDEN);
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::{IndexCacheControl, IndexFormat, IndexName};
632 use http::HeaderValue;
633
634 #[test]
635 fn test_index_url_parse_valid_paths() {
636 assert!(is_disambiguated_path("/absolute/path"));
638 assert!(is_disambiguated_path("./relative/path"));
640 assert!(is_disambiguated_path("../../relative/path"));
641 if cfg!(windows) {
642 assert!(is_disambiguated_path("C:/absolute/path"));
644 assert!(is_disambiguated_path(".\\relative\\path"));
646 assert!(is_disambiguated_path("..\\..\\relative\\path"));
647 }
648 }
649
650 #[test]
651 fn test_index_url_parse_ambiguous_paths() {
652 assert!(!is_disambiguated_path("index"));
654 assert!(!is_disambiguated_path("relative/path"));
656 }
657
658 #[test]
659 fn test_index_url_parse_with_schemes() {
660 assert!(is_disambiguated_path("file:///absolute/path"));
661 assert!(is_disambiguated_path("https://registry.com/simple/"));
662 assert!(is_disambiguated_path(
663 "git+https://github.com/example/repo.git"
664 ));
665 }
666
667 #[test]
668 fn fetch_indexes_deduplicates_raw_urls() {
669 let url = IndexUrl::from_str("https://index.example.com/simple").unwrap();
670 let mut first = Index::from(url.clone());
671 first.name = Some(IndexName::from_str("first").unwrap());
672 let mut second = Index::from(url);
673 second.name = Some(IndexName::from_str("second").unwrap());
674 second.default = true;
675 let locations = IndexLocations::new(vec![first, second], Vec::new(), false);
676
677 assert_eq!(locations.indexes().count(), 2);
678 assert_eq!(locations.fetch_indexes().count(), 1);
679 }
680
681 #[test]
682 fn test_cache_control_lookup() {
683 use std::str::FromStr;
684
685 use crate::IndexFormat;
686 use crate::index_name::IndexName;
687
688 let indexes = vec![
689 Index {
690 name: Some(IndexName::from_str("index1").unwrap()),
691 url: IndexUrl::from_str("https://index1.example.com/simple").unwrap(),
692 cache_control: Some(crate::IndexCacheControl {
693 api: Some(HeaderValue::from_static("max-age=300")),
694 files: Some(HeaderValue::from_static("max-age=1800")),
695 }),
696 explicit: false,
697 default: false,
698 origin: None,
699 format: IndexFormat::Simple,
700 publish_url: None,
701 authenticate: uv_auth::AuthPolicy::default(),
702 ignore_error_codes: None,
703 exclude_newer: None,
704 },
705 Index {
706 name: Some(IndexName::from_str("index2").unwrap()),
707 url: IndexUrl::from_str("https://index2.example.com/simple").unwrap(),
708 cache_control: None,
709 explicit: false,
710 default: false,
711 origin: None,
712 format: IndexFormat::Simple,
713 publish_url: None,
714 authenticate: uv_auth::AuthPolicy::default(),
715 ignore_error_codes: None,
716 exclude_newer: None,
717 },
718 ];
719
720 let index_locations = IndexLocations::new(indexes, Vec::new(), false);
721
722 let url1 = IndexUrl::from_str("https://index1.example.com/simple").unwrap();
723 assert_eq!(
724 index_locations.simple_api_cache_control_for(&url1),
725 Some(HeaderValue::from_static("max-age=300"))
726 );
727 assert_eq!(
728 index_locations.artifact_cache_control_for(&url1),
729 Some(HeaderValue::from_static("max-age=1800"))
730 );
731
732 let url2 = IndexUrl::from_str("https://index2.example.com/simple").unwrap();
733 assert_eq!(index_locations.simple_api_cache_control_for(&url2), None);
734 assert_eq!(index_locations.artifact_cache_control_for(&url2), None);
735
736 let url3 = IndexUrl::from_str("https://index3.example.com/simple").unwrap();
737 assert_eq!(index_locations.simple_api_cache_control_for(&url3), None);
738 assert_eq!(index_locations.artifact_cache_control_for(&url3), None);
739 }
740
741 #[test]
742 fn test_pytorch_default_cache_control() {
743 let indexes = vec![Index {
745 name: Some(IndexName::from_str("pytorch").unwrap()),
746 url: IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(),
747 cache_control: None, explicit: false,
749 default: false,
750 origin: None,
751 format: IndexFormat::Simple,
752 publish_url: None,
753 authenticate: uv_auth::AuthPolicy::default(),
754 ignore_error_codes: None,
755 exclude_newer: None,
756 }];
757
758 let index_locations = IndexLocations::new(indexes, Vec::new(), false);
759
760 let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap();
761
762 assert_eq!(
763 index_locations.simple_api_cache_control_for(&pytorch_url),
764 None
765 );
766 assert_eq!(
767 index_locations.artifact_cache_control_for(&pytorch_url),
768 Some(HeaderValue::from_static(
769 "max-age=365000000, immutable, public",
770 ))
771 );
772 }
773
774 #[test]
775 fn test_pytorch_user_override_cache_control() {
776 let indexes = vec![Index {
778 name: Some(IndexName::from_str("pytorch").unwrap()),
779 url: IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(),
780 cache_control: Some(IndexCacheControl {
781 api: Some(HeaderValue::from_static("no-cache")),
782 files: Some(HeaderValue::from_static("max-age=3600")),
783 }),
784 explicit: false,
785 default: false,
786 origin: None,
787 format: IndexFormat::Simple,
788 publish_url: None,
789 authenticate: uv_auth::AuthPolicy::default(),
790 ignore_error_codes: None,
791 exclude_newer: None,
792 }];
793
794 let index_locations = IndexLocations::new(indexes, Vec::new(), false);
795
796 let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap();
797
798 assert_eq!(
799 index_locations.simple_api_cache_control_for(&pytorch_url),
800 Some(HeaderValue::from_static("no-cache"))
801 );
802 assert_eq!(
803 index_locations.artifact_cache_control_for(&pytorch_url),
804 Some(HeaderValue::from_static("max-age=3600"))
805 );
806 }
807
808 #[test]
809 fn test_nvidia_default_cache_control() {
810 let indexes = vec![Index {
812 name: Some(IndexName::from_str("nvidia").unwrap()),
813 url: IndexUrl::from_str("https://pypi.nvidia.com").unwrap(),
814 cache_control: None, explicit: false,
816 default: false,
817 origin: None,
818 format: IndexFormat::Simple,
819 publish_url: None,
820 authenticate: uv_auth::AuthPolicy::default(),
821 ignore_error_codes: None,
822 exclude_newer: None,
823 }];
824
825 let index_locations = IndexLocations::new(indexes, Vec::new(), false);
826
827 let nvidia_url = IndexUrl::from_str("https://pypi.nvidia.com").unwrap();
828
829 assert_eq!(
830 index_locations.simple_api_cache_control_for(&nvidia_url),
831 None
832 );
833 assert_eq!(
834 index_locations.artifact_cache_control_for(&nvidia_url),
835 Some(HeaderValue::from_static(
836 "max-age=365000000, immutable, public",
837 ))
838 );
839 }
840}