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 match self {
101 Self::Pypi(url) | Self::Url(url) | Self::Path(url) => {
102 Arc::unwrap_or_clone(url).into_url()
103 }
104 }
105 }
106
107 pub fn without_credentials(&self) -> Cow<'_, DisplaySafeUrl> {
109 let url = self.url();
110 if url.username().is_empty() && url.password().is_none() {
111 Cow::Borrowed(url)
112 } else {
113 let mut url = url.clone();
114 let _ = url.set_username("");
115 let _ = url.set_password(None);
116 Cow::Owned(url)
117 }
118 }
119
120 pub fn warn_on_disambiguated_relative_path(&self) {
125 let Self::Path(verbatim_url) = &self else {
126 return;
127 };
128
129 if let Some(path) = verbatim_url.given()
130 && !is_disambiguated_path(path)
131 {
132 if cfg!(windows) {
133 warn_user!(
134 "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"
135 );
136 } else {
137 warn_user!(
138 "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"
139 );
140 }
141 }
142 }
143}
144
145impl Display for IndexUrl {
146 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
147 Display::fmt(self.inner(), f)
148 }
149}
150
151impl Verbatim for IndexUrl {
152 fn verbatim(&self) -> Cow<'_, str> {
153 self.inner().verbatim()
154 }
155}
156
157fn is_disambiguated_path(path: &str) -> bool {
163 if cfg!(windows) {
164 if path.starts_with(".\\") || path.starts_with("..\\") || path.starts_with('/') {
165 return true;
166 }
167 }
168 if path.starts_with("./") || path.starts_with("../") || Path::new(path).is_absolute() {
169 return true;
170 }
171 if let Some((scheme, _)) = split_scheme(path) {
173 return Scheme::parse(scheme).is_some();
174 }
175 false
177}
178
179#[derive(Error, Debug)]
181pub enum IndexUrlError {
182 #[error(transparent)]
183 Io(#[from] std::io::Error),
184 #[error(transparent)]
185 Url(#[from] ParseError),
186 #[error(transparent)]
187 VerbatimUrl(#[from] VerbatimUrlError),
188}
189
190impl FromStr for IndexUrl {
191 type Err = IndexUrlError;
192
193 fn from_str(s: &str) -> Result<Self, Self::Err> {
194 Self::parse(s, None)
195 }
196}
197
198impl serde::ser::Serialize for IndexUrl {
199 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
200 where
201 S: serde::ser::Serializer,
202 {
203 self.inner().without_credentials().serialize(serializer)
204 }
205}
206
207impl<'de> serde::de::Deserialize<'de> for IndexUrl {
208 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
209 where
210 D: serde::de::Deserializer<'de>,
211 {
212 struct Visitor;
213
214 impl serde::de::Visitor<'_> for Visitor {
215 type Value = IndexUrl;
216
217 fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
218 f.write_str("a string")
219 }
220
221 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
222 IndexUrl::from_str(v).map_err(serde::de::Error::custom)
223 }
224 }
225
226 deserializer.deserialize_str(Visitor)
227 }
228}
229
230impl From<VerbatimUrl> for IndexUrl {
231 fn from(url: VerbatimUrl) -> Self {
232 if url.scheme() == "file" {
233 Self::Path(Arc::new(url))
234 } else if *url.raw() == *PYPI_URL {
235 Self::Pypi(Arc::new(url))
236 } else {
237 Self::Url(Arc::new(url))
238 }
239 }
240}
241
242impl From<IndexUrl> for DisplaySafeUrl {
243 fn from(index: IndexUrl) -> Self {
244 index.into_url()
245 }
246}
247
248impl Deref for IndexUrl {
249 type Target = Url;
250
251 fn deref(&self) -> &Self::Target {
252 self.inner()
253 }
254}
255
256#[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
261#[serde(rename_all = "kebab-case", deny_unknown_fields)]
262pub struct IndexLocations {
263 indexes: Vec<Index>,
264 flat_index: Vec<Index>,
265 no_index: bool,
266}
267
268impl IndexLocations {
269 pub fn new(indexes: Vec<Index>, flat_index: Vec<Index>, no_index: bool) -> Self {
271 Self {
272 indexes,
273 flat_index,
274 no_index,
275 }
276 }
277
278 #[must_use]
285 pub fn combine(self, indexes: Vec<Index>, flat_index: Vec<Index>, no_index: bool) -> Self {
286 Self {
287 indexes: self.indexes.into_iter().chain(indexes).collect(),
288 flat_index: self.flat_index.into_iter().chain(flat_index).collect(),
289 no_index: self.no_index || no_index,
290 }
291 }
292
293 pub fn is_none(&self) -> bool {
296 *self == Self::default()
297 }
298}
299
300fn is_same_index(a: &IndexUrl, b: &IndexUrl) -> bool {
302 RealmRef::from(&**b.url()) == RealmRef::from(&**a.url())
303 && CanonicalUrl::new(a.url().clone()) == CanonicalUrl::new(b.url().clone())
304}
305
306impl<'a> IndexLocations {
307 pub fn default_index(&'a self) -> Option<&'a Index> {
313 if self.no_index {
314 None
315 } else {
316 let mut seen = FxHashSet::default();
317 self.indexes
318 .iter()
319 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
320 .find(|index| index.default)
321 .or_else(|| Some(&DEFAULT_INDEX))
322 }
323 }
324
325 pub fn implicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
329 if self.no_index {
330 Either::Left(std::iter::empty())
331 } else {
332 let mut seen = FxHashSet::default();
333 Either::Right(
334 self.indexes
335 .iter()
336 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
337 .filter(|index| !index.default && !index.explicit),
338 )
339 }
340 }
341
342 pub fn explicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
346 if self.no_index {
347 Either::Left(std::iter::empty())
348 } else {
349 let mut seen = FxHashSet::default();
350 Either::Right(
351 self.indexes
352 .iter()
353 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
354 .filter(|index| index.explicit),
355 )
356 }
357 }
358
359 pub fn indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
368 self.implicit_indexes()
369 .chain(self.default_index())
370 .filter(|index| !index.explicit)
371 }
372
373 pub fn fetch_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
377 let mut seen = FxHashSet::default();
378 self.indexes()
379 .filter(move |index| seen.insert(index.raw_url()))
380 }
381
382 pub fn simple_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
386 if self.no_index {
387 Either::Left(std::iter::empty())
388 } else {
389 let mut seen = FxHashSet::default();
390 Either::Right(
391 self.indexes
392 .iter()
393 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))),
394 )
395 }
396 }
397
398 pub fn flat_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
400 self.flat_index.iter()
401 }
402
403 pub fn no_index(&self) -> bool {
405 self.no_index
406 }
407
408 pub fn allowed_indexes(&'a self) -> Vec<&'a Index> {
415 if self.no_index {
416 self.flat_index.iter().rev().collect()
417 } else {
418 let mut indexes = vec![];
419
420 let mut seen = FxHashSet::default();
421 let mut default = false;
422 for index in {
423 self.indexes
424 .iter()
425 .chain(self.flat_index.iter())
426 .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
427 } {
428 if index.default {
429 if default {
430 continue;
431 }
432 default = true;
433 }
434 indexes.push(index);
435 }
436 if !default {
437 indexes.push(&*DEFAULT_INDEX);
438 }
439
440 indexes.reverse();
441 indexes
442 }
443 }
444
445 pub fn known_indexes(&'a self) -> impl Iterator<Item = &'a Index> {
454 if self.no_index {
455 Either::Left(self.flat_index.iter().rev())
456 } else {
457 Either::Right(
458 std::iter::once(&*DEFAULT_INDEX)
459 .chain(self.flat_index.iter().rev())
460 .chain(self.indexes.iter().rev()),
461 )
462 }
463 }
464
465 pub fn defined_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
475 if self.no_index {
476 return Either::Left(std::iter::empty());
477 }
478
479 let mut seen = FxHashSet::default();
480 let (non_default, default) = self
481 .indexes
482 .iter()
483 .filter(move |index| {
484 if let Some(name) = &index.name {
485 seen.insert(name)
486 } else {
487 true
488 }
489 })
490 .partition::<Vec<_>, _>(|index| !index.default);
491
492 Either::Right(non_default.into_iter().chain(default))
493 }
494
495 fn index_for_url(&self, url: &IndexUrl) -> Option<&Index> {
497 self.indexes
498 .iter()
499 .find(|index| is_same_index(index.url(), url))
500 }
501
502 pub fn status_code_strategy_for(&self, url: &IndexUrl) -> IndexStatusCodeStrategy {
504 self.index_for_url(url).map_or(
505 IndexStatusCodeStrategy::Default,
506 Index::status_code_strategy,
507 )
508 }
509
510 pub fn ignores_error_code_for(&self, url: &IndexUrl, status_code: StatusCode) -> bool {
512 self.index_for_url(url)
513 .is_some_and(|index| index.ignores_error_code(status_code))
514 }
515
516 pub fn simple_api_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
518 self.index_for_url(url)
519 .and_then(Index::simple_api_cache_control)
520 }
521
522 pub fn artifact_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
524 self.index_for_url(url)
525 .and_then(Index::artifact_cache_control)
526 }
527
528 pub fn exclude_newer_for(&self, url: &IndexUrl) -> Option<&ExcludeNewerOverride> {
530 self.index_for_url(url).and_then(Index::exclude_newer)
531 }
532}
533
534impl From<&IndexLocations> for uv_auth::Indexes {
535 fn from(index_locations: &IndexLocations) -> Self {
536 Self::from_indexes(index_locations.allowed_indexes().into_iter().map(|index| {
537 let mut url = index.url().url().clone();
538 url.set_username("").ok();
539 url.set_password(None).ok();
540 let mut root_url = index.url().root().unwrap_or_else(|| url.clone());
541 root_url.set_username("").ok();
542 root_url.set_password(None).ok();
543 uv_auth::Index {
544 url,
545 root_url,
546 auth_policy: index.authenticate,
547 }
548 }))
549 }
550}
551
552bitflags::bitflags! {
553 #[derive(Debug, Copy, Clone)]
554 struct Flags: u8 {
555 const NO_RANGE_REQUESTS = 1;
557 const UNAUTHORIZED = 1 << 2;
559 const FORBIDDEN = 1 << 1;
561 }
562}
563
564#[derive(Debug, Default, Clone)]
570pub struct IndexCapabilities(Arc<RwLock<FxHashMap<IndexUrl, Flags>>>);
571
572impl IndexCapabilities {
573 pub fn supports_range_requests(&self, index_url: &IndexUrl) -> bool {
575 !self
576 .0
577 .read()
578 .unwrap()
579 .get(index_url)
580 .is_some_and(|flags| flags.intersects(Flags::NO_RANGE_REQUESTS))
581 }
582
583 pub fn set_no_range_requests(&self, index_url: IndexUrl) {
585 self.0
586 .write()
587 .unwrap()
588 .entry(index_url)
589 .or_insert(Flags::empty())
590 .insert(Flags::NO_RANGE_REQUESTS);
591 }
592
593 pub fn unauthorized(&self, index_url: &IndexUrl) -> bool {
595 self.0
596 .read()
597 .unwrap()
598 .get(index_url)
599 .is_some_and(|flags| flags.intersects(Flags::UNAUTHORIZED))
600 }
601
602 pub(crate) fn set_unauthorized(&self, index_url: IndexUrl) {
604 self.0
605 .write()
606 .unwrap()
607 .entry(index_url)
608 .or_insert(Flags::empty())
609 .insert(Flags::UNAUTHORIZED);
610 }
611
612 pub fn forbidden(&self, index_url: &IndexUrl) -> bool {
614 self.0
615 .read()
616 .unwrap()
617 .get(index_url)
618 .is_some_and(|flags| flags.intersects(Flags::FORBIDDEN))
619 }
620
621 pub(crate) fn set_forbidden(&self, index_url: IndexUrl) {
623 self.0
624 .write()
625 .unwrap()
626 .entry(index_url)
627 .or_insert(Flags::empty())
628 .insert(Flags::FORBIDDEN);
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::{IndexCacheControl, IndexFormat, IndexName};
636 use http::HeaderValue;
637
638 #[test]
639 fn test_index_url_parse_valid_paths() {
640 assert!(is_disambiguated_path("/absolute/path"));
642 assert!(is_disambiguated_path("./relative/path"));
644 assert!(is_disambiguated_path("../../relative/path"));
645 if cfg!(windows) {
646 assert!(is_disambiguated_path("C:/absolute/path"));
648 assert!(is_disambiguated_path(".\\relative\\path"));
650 assert!(is_disambiguated_path("..\\..\\relative\\path"));
651 }
652 }
653
654 #[test]
655 fn test_index_url_parse_ambiguous_paths() {
656 assert!(!is_disambiguated_path("index"));
658 assert!(!is_disambiguated_path("relative/path"));
660 }
661
662 #[test]
663 fn test_index_url_parse_with_schemes() {
664 assert!(is_disambiguated_path("file:///absolute/path"));
665 assert!(is_disambiguated_path("https://registry.com/simple/"));
666 assert!(is_disambiguated_path(
667 "git+https://github.com/example/repo.git"
668 ));
669 }
670
671 #[test]
672 fn fetch_indexes_deduplicates_raw_urls() {
673 let url = IndexUrl::from_str("https://index.example.com/simple").unwrap();
674 let mut first = Index::from(url.clone());
675 first.name = Some(IndexName::from_str("first").unwrap());
676 let mut second = Index::from(url);
677 second.name = Some(IndexName::from_str("second").unwrap());
678 second.default = true;
679 let locations = IndexLocations::new(vec![first, second], Vec::new(), false);
680
681 assert_eq!(locations.indexes().count(), 2);
682 assert_eq!(locations.fetch_indexes().count(), 1);
683 }
684
685 #[test]
686 fn test_cache_control_lookup() {
687 use std::str::FromStr;
688
689 use crate::IndexFormat;
690 use crate::index_name::IndexName;
691
692 let indexes = vec![
693 Index {
694 name: Some(IndexName::from_str("index1").unwrap()),
695 url: IndexUrl::from_str("https://index1.example.com/simple").unwrap(),
696 cache_control: Some(crate::IndexCacheControl {
697 api: Some(HeaderValue::from_static("max-age=300")),
698 files: Some(HeaderValue::from_static("max-age=1800")),
699 }),
700 explicit: false,
701 default: false,
702 origin: None,
703 format: IndexFormat::Simple,
704 publish_url: None,
705 authenticate: uv_auth::AuthPolicy::default(),
706 ignore_error_codes: None,
707 exclude_newer: None,
708 },
709 Index {
710 name: Some(IndexName::from_str("index2").unwrap()),
711 url: IndexUrl::from_str("https://index2.example.com/simple").unwrap(),
712 cache_control: None,
713 explicit: false,
714 default: false,
715 origin: None,
716 format: IndexFormat::Simple,
717 publish_url: None,
718 authenticate: uv_auth::AuthPolicy::default(),
719 ignore_error_codes: None,
720 exclude_newer: None,
721 },
722 ];
723
724 let index_locations = IndexLocations::new(indexes, Vec::new(), false);
725
726 let url1 = IndexUrl::from_str("https://index1.example.com/simple").unwrap();
727 assert_eq!(
728 index_locations.simple_api_cache_control_for(&url1),
729 Some(HeaderValue::from_static("max-age=300"))
730 );
731 assert_eq!(
732 index_locations.artifact_cache_control_for(&url1),
733 Some(HeaderValue::from_static("max-age=1800"))
734 );
735
736 let url2 = IndexUrl::from_str("https://index2.example.com/simple").unwrap();
737 assert_eq!(index_locations.simple_api_cache_control_for(&url2), None);
738 assert_eq!(index_locations.artifact_cache_control_for(&url2), None);
739
740 let url3 = IndexUrl::from_str("https://index3.example.com/simple").unwrap();
741 assert_eq!(index_locations.simple_api_cache_control_for(&url3), None);
742 assert_eq!(index_locations.artifact_cache_control_for(&url3), None);
743 }
744
745 #[test]
746 fn test_pytorch_default_cache_control() {
747 let indexes = vec![Index {
749 name: Some(IndexName::from_str("pytorch").unwrap()),
750 url: IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(),
751 cache_control: None, explicit: false,
753 default: false,
754 origin: None,
755 format: IndexFormat::Simple,
756 publish_url: None,
757 authenticate: uv_auth::AuthPolicy::default(),
758 ignore_error_codes: None,
759 exclude_newer: None,
760 }];
761
762 let index_locations = IndexLocations::new(indexes, Vec::new(), false);
763
764 let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap();
765
766 assert_eq!(
767 index_locations.simple_api_cache_control_for(&pytorch_url),
768 None
769 );
770 assert_eq!(
771 index_locations.artifact_cache_control_for(&pytorch_url),
772 Some(HeaderValue::from_static(
773 "max-age=365000000, immutable, public",
774 ))
775 );
776 }
777
778 #[test]
779 fn test_pytorch_user_override_cache_control() {
780 let indexes = vec![Index {
782 name: Some(IndexName::from_str("pytorch").unwrap()),
783 url: IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(),
784 cache_control: Some(IndexCacheControl {
785 api: Some(HeaderValue::from_static("no-cache")),
786 files: Some(HeaderValue::from_static("max-age=3600")),
787 }),
788 explicit: false,
789 default: false,
790 origin: None,
791 format: IndexFormat::Simple,
792 publish_url: None,
793 authenticate: uv_auth::AuthPolicy::default(),
794 ignore_error_codes: None,
795 exclude_newer: None,
796 }];
797
798 let index_locations = IndexLocations::new(indexes, Vec::new(), false);
799
800 let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap();
801
802 assert_eq!(
803 index_locations.simple_api_cache_control_for(&pytorch_url),
804 Some(HeaderValue::from_static("no-cache"))
805 );
806 assert_eq!(
807 index_locations.artifact_cache_control_for(&pytorch_url),
808 Some(HeaderValue::from_static("max-age=3600"))
809 );
810 }
811
812 #[test]
813 fn test_nvidia_default_cache_control() {
814 let indexes = vec![Index {
816 name: Some(IndexName::from_str("nvidia").unwrap()),
817 url: IndexUrl::from_str("https://pypi.nvidia.com").unwrap(),
818 cache_control: None, explicit: false,
820 default: false,
821 origin: None,
822 format: IndexFormat::Simple,
823 publish_url: None,
824 authenticate: uv_auth::AuthPolicy::default(),
825 ignore_error_codes: None,
826 exclude_newer: None,
827 }];
828
829 let index_locations = IndexLocations::new(indexes, Vec::new(), false);
830
831 let nvidia_url = IndexUrl::from_str("https://pypi.nvidia.com").unwrap();
832
833 assert_eq!(
834 index_locations.simple_api_cache_control_for(&nvidia_url),
835 None
836 );
837 assert_eq!(
838 index_locations.artifact_cache_control_for(&nvidia_url),
839 Some(HeaderValue::from_static(
840 "max-age=365000000, immutable, public",
841 ))
842 );
843 }
844}