Skip to main content

uv_distribution_types/
index_url.rs

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 itertools::Either;
9use rustc_hash::{FxHashMap, FxHashSet};
10use thiserror::Error;
11use url::{ParseError, Url};
12use uv_auth::RealmRef;
13use uv_cache_key::CanonicalUrl;
14use uv_pep508::{Scheme, VerbatimUrl, VerbatimUrlError, split_scheme};
15use uv_redacted::DisplaySafeUrl;
16use uv_warnings::warn_user;
17
18use crate::{ExcludeNewerOverride, Index, IndexStatusCodeStrategy, Verbatim};
19
20pub static PYPI_URL: LazyLock<DisplaySafeUrl> =
21    LazyLock::new(|| DisplaySafeUrl::parse("https://pypi.org/simple").unwrap());
22
23static DEFAULT_INDEX: LazyLock<Index> = LazyLock::new(|| {
24    Index::from_index_url(IndexUrl::Pypi(Arc::new(VerbatimUrl::from_url(
25        PYPI_URL.clone(),
26    ))))
27});
28
29/// The URL of an index to use for fetching packages (e.g., PyPI).
30#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
31pub enum IndexUrl {
32    Pypi(Arc<VerbatimUrl>),
33    Url(Arc<VerbatimUrl>),
34    Path(Arc<VerbatimUrl>),
35}
36
37impl IndexUrl {
38    /// Parse an [`IndexUrl`] from a string, relative to an optional root directory.
39    ///
40    /// If no root directory is provided, relative paths are resolved against the current working
41    /// directory.
42    pub fn parse(path: &str, root_dir: Option<&Path>) -> Result<Self, IndexUrlError> {
43        let url = VerbatimUrl::from_url_or_path(path, root_dir)?;
44        Ok(Self::from(url))
45    }
46
47    /// Return the root [`Url`] of the index, if applicable.
48    ///
49    /// For indexes with a `/simple` endpoint, this is simply the URL with the final segment
50    /// removed. This is useful, e.g., for credential propagation to other endpoints on the index.
51    pub fn root(&self) -> Option<DisplaySafeUrl> {
52        let mut segments = self.url().path_segments()?;
53        let last = match segments.next_back()? {
54            // If the last segment is empty due to a trailing `/`, skip it (as in `pop_if_empty`)
55            "" => segments.next_back()?,
56            segment => segment,
57        };
58
59        // We also handle `/+simple` as it's used in devpi
60        if !(last.eq_ignore_ascii_case("simple") || last.eq_ignore_ascii_case("+simple")) {
61            return None;
62        }
63
64        let mut url = self.url().clone();
65        url.path_segments_mut().ok()?.pop_if_empty().pop();
66        Some(url)
67    }
68}
69
70#[cfg(feature = "schemars")]
71impl schemars::JsonSchema for IndexUrl {
72    fn schema_name() -> Cow<'static, str> {
73        Cow::Borrowed("IndexUrl")
74    }
75
76    fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
77        schemars::json_schema!({
78            "type": "string",
79            "description": "The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`), or a local path."
80        })
81    }
82}
83
84impl IndexUrl {
85    #[inline]
86    fn inner(&self) -> &VerbatimUrl {
87        match self {
88            Self::Pypi(url) | Self::Url(url) | Self::Path(url) => url,
89        }
90    }
91
92    /// Return the raw URL for the index.
93    pub fn url(&self) -> &DisplaySafeUrl {
94        self.inner().raw()
95    }
96
97    /// Convert the index URL into a [`DisplaySafeUrl`].
98    pub fn into_url(self) -> DisplaySafeUrl {
99        self.inner().to_url()
100    }
101
102    /// Return the redacted URL for the index, omitting any sensitive credentials.
103    pub fn without_credentials(&self) -> Cow<'_, DisplaySafeUrl> {
104        let url = self.url();
105        if url.username().is_empty() && url.password().is_none() {
106            Cow::Borrowed(url)
107        } else {
108            let mut url = url.clone();
109            let _ = url.set_username("");
110            let _ = url.set_password(None);
111            Cow::Owned(url)
112        }
113    }
114
115    /// Warn user if the given URL was provided as an ambiguous relative path.
116    ///
117    /// This is a temporary warning. Ambiguous values will not be
118    /// accepted in the future.
119    pub fn warn_on_disambiguated_relative_path(&self) {
120        let Self::Path(verbatim_url) = &self else {
121            return;
122        };
123
124        if let Some(path) = verbatim_url.given() {
125            if !is_disambiguated_path(path) {
126                if cfg!(windows) {
127                    warn_user!(
128                        "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"
129                    );
130                } else {
131                    warn_user!(
132                        "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"
133                    );
134                }
135            }
136        }
137    }
138}
139
140impl Display for IndexUrl {
141    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
142        Display::fmt(self.inner(), f)
143    }
144}
145
146impl Verbatim for IndexUrl {
147    fn verbatim(&self) -> Cow<'_, str> {
148        self.inner().verbatim()
149    }
150}
151
152/// Checks if a path is disambiguated.
153///
154/// Disambiguated paths are absolute paths, paths with valid schemes,
155/// and paths starting with "./" or "../" on Unix or ".\\", "..\\",
156/// "./", or "../" on Windows.
157fn is_disambiguated_path(path: &str) -> bool {
158    if cfg!(windows) {
159        if path.starts_with(".\\") || path.starts_with("..\\") || path.starts_with('/') {
160            return true;
161        }
162    }
163    if path.starts_with("./") || path.starts_with("../") || Path::new(path).is_absolute() {
164        return true;
165    }
166    // Check if the path has a scheme (like `file://`)
167    if let Some((scheme, _)) = split_scheme(path) {
168        return Scheme::parse(scheme).is_some();
169    }
170    // This is an ambiguous relative path
171    false
172}
173
174/// An error that can occur when parsing an [`IndexUrl`].
175#[derive(Error, Debug)]
176pub enum IndexUrlError {
177    #[error(transparent)]
178    Io(#[from] std::io::Error),
179    #[error(transparent)]
180    Url(#[from] ParseError),
181    #[error(transparent)]
182    VerbatimUrl(#[from] VerbatimUrlError),
183}
184
185impl FromStr for IndexUrl {
186    type Err = IndexUrlError;
187
188    fn from_str(s: &str) -> Result<Self, Self::Err> {
189        Self::parse(s, None)
190    }
191}
192
193impl serde::ser::Serialize for IndexUrl {
194    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
195    where
196        S: serde::ser::Serializer,
197    {
198        self.inner().without_credentials().serialize(serializer)
199    }
200}
201
202impl<'de> serde::de::Deserialize<'de> for IndexUrl {
203    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
204    where
205        D: serde::de::Deserializer<'de>,
206    {
207        struct Visitor;
208
209        impl serde::de::Visitor<'_> for Visitor {
210            type Value = IndexUrl;
211
212            fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
213                f.write_str("a string")
214            }
215
216            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
217                IndexUrl::from_str(v).map_err(serde::de::Error::custom)
218            }
219        }
220
221        deserializer.deserialize_str(Visitor)
222    }
223}
224
225impl From<VerbatimUrl> for IndexUrl {
226    fn from(url: VerbatimUrl) -> Self {
227        if url.scheme() == "file" {
228            Self::Path(Arc::new(url))
229        } else if *url.raw() == *PYPI_URL {
230            Self::Pypi(Arc::new(url))
231        } else {
232            Self::Url(Arc::new(url))
233        }
234    }
235}
236
237impl From<IndexUrl> for DisplaySafeUrl {
238    fn from(index: IndexUrl) -> Self {
239        index.inner().to_url()
240    }
241}
242
243impl Deref for IndexUrl {
244    type Target = Url;
245
246    fn deref(&self) -> &Self::Target {
247        self.inner()
248    }
249}
250
251/// The index locations to use for fetching packages. By default, uses the PyPI index.
252///
253/// This type merges the legacy `--index-url`, `--extra-index-url`, and `--find-links` options,
254/// along with the uv-specific `--index` and `--default-index`.
255#[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
256#[serde(rename_all = "kebab-case", deny_unknown_fields)]
257pub struct IndexLocations {
258    indexes: Vec<Index>,
259    flat_index: Vec<Index>,
260    no_index: bool,
261}
262
263impl IndexLocations {
264    /// Determine the index URLs to use for fetching packages.
265    pub fn new(indexes: Vec<Index>, flat_index: Vec<Index>, no_index: bool) -> Self {
266        Self {
267            indexes,
268            flat_index,
269            no_index,
270        }
271    }
272
273    /// Combine a set of index locations.
274    ///
275    /// If either the current or the other index locations have `no_index` set, the result will
276    /// have `no_index` set.
277    ///
278    /// If the current index location has an `index` set, it will be preserved.
279    #[must_use]
280    pub fn combine(self, indexes: Vec<Index>, flat_index: Vec<Index>, no_index: bool) -> Self {
281        Self {
282            indexes: self.indexes.into_iter().chain(indexes).collect(),
283            flat_index: self.flat_index.into_iter().chain(flat_index).collect(),
284            no_index: self.no_index || no_index,
285        }
286    }
287
288    /// Returns `true` if no index configuration is set, i.e., the [`IndexLocations`] matches the
289    /// default configuration.
290    pub fn is_none(&self) -> bool {
291        *self == Self::default()
292    }
293}
294
295/// Returns `true` if two [`IndexUrl`]s refer to the same index.
296fn is_same_index(a: &IndexUrl, b: &IndexUrl) -> bool {
297    RealmRef::from(&**b.url()) == RealmRef::from(&**a.url())
298        && CanonicalUrl::new(a.url()) == CanonicalUrl::new(b.url())
299}
300
301impl<'a> IndexLocations {
302    /// Return the default [`Index`] entry.
303    ///
304    /// If `--no-index` is set, return `None`.
305    ///
306    /// If no index is provided, use the `PyPI` index.
307    pub fn default_index(&'a self) -> Option<&'a Index> {
308        if self.no_index {
309            None
310        } else {
311            let mut seen = FxHashSet::default();
312            self.indexes
313                .iter()
314                .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
315                .find(|index| index.default)
316                .or_else(|| Some(&DEFAULT_INDEX))
317        }
318    }
319
320    /// Return an iterator over the implicit [`Index`] entries.
321    ///
322    /// Default and explicit indexes are excluded.
323    pub fn implicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
324        if self.no_index {
325            Either::Left(std::iter::empty())
326        } else {
327            let mut seen = FxHashSet::default();
328            Either::Right(
329                self.indexes
330                    .iter()
331                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
332                    .filter(|index| !index.default && !index.explicit),
333            )
334        }
335    }
336
337    /// Return an iterator over the explicit [`Index`] entries.
338    ///
339    /// Explicit indexes are only used when pinned via `tool.uv.sources`.
340    pub fn explicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
341        if self.no_index {
342            Either::Left(std::iter::empty())
343        } else {
344            let mut seen = FxHashSet::default();
345            Either::Right(
346                self.indexes
347                    .iter()
348                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
349                    .filter(|index| index.explicit),
350            )
351        }
352    }
353
354    /// Return an iterator over all [`Index`] entries in order.
355    ///
356    /// Explicit indexes are excluded.
357    ///
358    /// Prioritizes the extra indexes over the default index.
359    ///
360    /// If `no_index` was enabled, then this always returns an empty
361    /// iterator.
362    pub fn indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
363        self.implicit_indexes()
364            .chain(self.default_index())
365            .filter(|index| !index.explicit)
366    }
367
368    /// Return an iterator over all simple [`Index`] entries in order.
369    ///
370    /// If `no_index` was enabled, then this always returns an empty iterator.
371    pub fn simple_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
372        if self.no_index {
373            Either::Left(std::iter::empty())
374        } else {
375            let mut seen = FxHashSet::default();
376            Either::Right(
377                self.indexes
378                    .iter()
379                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))),
380            )
381        }
382    }
383
384    /// Return an iterator over the [`FlatIndexLocation`] entries.
385    pub fn flat_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
386        self.flat_index.iter()
387    }
388
389    /// Return the `--no-index` flag.
390    pub fn no_index(&self) -> bool {
391        self.no_index
392    }
393
394    /// Clone the index locations into a [`IndexUrls`] instance.
395    pub fn index_urls(&'a self) -> IndexUrls {
396        IndexUrls {
397            indexes: self.indexes.clone(),
398            flat_indexes: self.flat_index.clone(),
399            no_index: self.no_index,
400        }
401    }
402
403    /// Return a vector containing all allowed [`Index`] entries.
404    ///
405    /// This includes explicit indexes, implicit indexes, flat indexes, and the default index.
406    ///
407    /// The indexes will be returned in the reverse of the order in which they were defined, such
408    /// that the last-defined index is the first item in the vector.
409    pub fn allowed_indexes(&'a self) -> Vec<&'a Index> {
410        if self.no_index {
411            self.flat_index.iter().rev().collect()
412        } else {
413            let mut indexes = vec![];
414
415            let mut seen = FxHashSet::default();
416            let mut default = false;
417            for index in {
418                self.indexes
419                    .iter()
420                    .chain(self.flat_index.iter())
421                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
422            } {
423                if index.default {
424                    if default {
425                        continue;
426                    }
427                    default = true;
428                }
429                indexes.push(index);
430            }
431            if !default {
432                indexes.push(&*DEFAULT_INDEX);
433            }
434
435            indexes.reverse();
436            indexes
437        }
438    }
439
440    /// Return a vector containing all known [`Index`] entries.
441    ///
442    /// This includes explicit indexes, implicit indexes, flat indexes, and default indexes;
443    /// in short, it includes all defined indexes, even if they're overridden by some other index
444    /// definition.
445    ///
446    /// The indexes will be returned in the reverse of the order in which they were defined, such
447    /// that the last-defined index is the first item in the vector.
448    pub fn known_indexes(&'a self) -> impl Iterator<Item = &'a Index> {
449        if self.no_index {
450            Either::Left(self.flat_index.iter().rev())
451        } else {
452            Either::Right(
453                std::iter::once(&*DEFAULT_INDEX)
454                    .chain(self.flat_index.iter().rev())
455                    .chain(self.indexes.iter().rev()),
456            )
457        }
458    }
459
460    /// Return the Simple API cache control header for an [`IndexUrl`], if configured.
461    pub fn simple_api_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
462        for index in &self.indexes {
463            if is_same_index(index.url(), url) {
464                return index.simple_api_cache_control();
465            }
466        }
467        None
468    }
469
470    /// Return the artifact cache control header for an [`IndexUrl`], if configured.
471    pub fn artifact_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
472        for index in &self.indexes {
473            if is_same_index(index.url(), url) {
474                return index.artifact_cache_control();
475            }
476        }
477        None
478    }
479
480    /// Return the `exclude-newer` setting for a given index, if the index is configured.
481    pub fn exclude_newer_for(&self, url: &IndexUrl) -> Option<&ExcludeNewerOverride> {
482        for index in &self.indexes {
483            if is_same_index(index.url(), url) {
484                return index.exclude_newer();
485            }
486        }
487        None
488    }
489}
490
491impl From<&IndexLocations> for uv_auth::Indexes {
492    fn from(index_locations: &IndexLocations) -> Self {
493        Self::from_indexes(index_locations.allowed_indexes().into_iter().map(|index| {
494            let mut url = index.url().url().clone();
495            url.set_username("").ok();
496            url.set_password(None).ok();
497            let mut root_url = index.url().root().unwrap_or_else(|| url.clone());
498            root_url.set_username("").ok();
499            root_url.set_password(None).ok();
500            uv_auth::Index {
501                url,
502                root_url,
503                auth_policy: index.authenticate,
504            }
505        }))
506    }
507}
508
509/// The index URLs to use for fetching packages.
510///
511/// This type merges the legacy `--index-url` and `--extra-index-url` options, along with the
512/// uv-specific `--index` and `--default-index`.
513#[derive(Default, Debug, Clone, PartialEq, Eq)]
514pub struct IndexUrls {
515    indexes: Vec<Index>,
516    flat_indexes: Vec<Index>,
517    no_index: bool,
518}
519
520impl<'a> IndexUrls {
521    pub fn from_indexes(indexes: Vec<Index>) -> Self {
522        Self {
523            indexes,
524            flat_indexes: Vec::new(),
525            no_index: false,
526        }
527    }
528
529    /// Return an iterator over the configured flat-index locations.
530    pub fn flat_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
531        self.flat_indexes.iter()
532    }
533
534    /// Return the default [`Index`] entry.
535    ///
536    /// If `--no-index` is set, return `None`.
537    ///
538    /// If no index is provided, use the `PyPI` index.
539    fn default_index(&'a self) -> Option<&'a Index> {
540        if self.no_index {
541            None
542        } else {
543            let mut seen = FxHashSet::default();
544            self.indexes
545                .iter()
546                .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
547                .find(|index| index.default)
548                .or_else(|| Some(&DEFAULT_INDEX))
549        }
550    }
551
552    /// Return an iterator over the implicit [`Index`] entries.
553    ///
554    /// Default and explicit indexes are excluded.
555    fn implicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
556        if self.no_index {
557            Either::Left(std::iter::empty())
558        } else {
559            let mut seen = FxHashSet::default();
560            Either::Right(
561                self.indexes
562                    .iter()
563                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
564                    .filter(|index| !index.default && !index.explicit),
565            )
566        }
567    }
568
569    /// Return an iterator over all [`IndexUrl`] entries in order.
570    ///
571    /// Prioritizes the `[tool.uv.index]` definitions over the `--extra-index-url` definitions
572    /// over the `--index-url` definition.
573    ///
574    /// If `no_index` was enabled, then this always returns an empty
575    /// iterator.
576    pub fn indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
577        let mut seen = FxHashSet::default();
578        self.implicit_indexes()
579            .chain(self.default_index())
580            .filter(|index| !index.explicit)
581            .filter(move |index| seen.insert(index.raw_url())) // Filter out redundant raw URLs
582    }
583
584    /// Return an iterator over all user-defined [`Index`] entries in order.
585    ///
586    /// Prioritizes the `[tool.uv.index]` definitions over the `--extra-index-url` definitions
587    /// over the `--index-url` definition.
588    ///
589    /// Unlike [`IndexUrl::indexes`], this includes explicit indexes and does _not_ insert PyPI
590    /// as a fallback default.
591    ///
592    /// If `no_index` was enabled, then this always returns an empty
593    /// iterator.
594    pub fn defined_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
595        if self.no_index {
596            return Either::Left(std::iter::empty());
597        }
598
599        let mut seen = FxHashSet::default();
600        let (non_default, default) = self
601            .indexes
602            .iter()
603            .filter(move |index| {
604                if let Some(name) = &index.name {
605                    seen.insert(name)
606                } else {
607                    true
608                }
609            })
610            .partition::<Vec<_>, _>(|index| !index.default);
611
612        Either::Right(non_default.into_iter().chain(default))
613    }
614
615    /// Return the `--no-index` flag.
616    pub fn no_index(&self) -> bool {
617        self.no_index
618    }
619
620    /// Return the [`IndexStatusCodeStrategy`] for an [`IndexUrl`].
621    pub fn status_code_strategy_for(&self, url: &IndexUrl) -> IndexStatusCodeStrategy {
622        for index in &self.indexes {
623            if is_same_index(index.url(), url) {
624                return index.status_code_strategy();
625            }
626        }
627        IndexStatusCodeStrategy::Default
628    }
629
630    /// Return the Simple API cache control header for an [`IndexUrl`], if configured.
631    pub fn simple_api_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
632        for index in &self.indexes {
633            if is_same_index(index.url(), url) {
634                return index.simple_api_cache_control();
635            }
636        }
637        None
638    }
639
640    /// Return the artifact cache control header for an [`IndexUrl`], if configured.
641    pub fn artifact_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
642        for index in &self.indexes {
643            if is_same_index(index.url(), url) {
644                return index.artifact_cache_control();
645            }
646        }
647        None
648    }
649}
650
651bitflags::bitflags! {
652    #[derive(Debug, Copy, Clone)]
653    struct Flags: u8 {
654        /// Whether the index supports range requests.
655        const NO_RANGE_REQUESTS = 1;
656        /// Whether the index returned a `401 Unauthorized` status code.
657        const UNAUTHORIZED      = 1 << 2;
658        /// Whether the index returned a `403 Forbidden` status code.
659        const FORBIDDEN         = 1 << 1;
660    }
661}
662
663/// A map of [`IndexUrl`]s to their capabilities.
664///
665/// We only store indexes that lack capabilities (i.e., don't support range requests, aren't
666/// authorized). The benefit is that the map is almost always empty, so validating capabilities is
667/// extremely cheap.
668#[derive(Debug, Default, Clone)]
669pub struct IndexCapabilities(Arc<RwLock<FxHashMap<IndexUrl, Flags>>>);
670
671impl IndexCapabilities {
672    /// Returns `true` if the given [`IndexUrl`] supports range requests.
673    pub fn supports_range_requests(&self, index_url: &IndexUrl) -> bool {
674        !self
675            .0
676            .read()
677            .unwrap()
678            .get(index_url)
679            .is_some_and(|flags| flags.intersects(Flags::NO_RANGE_REQUESTS))
680    }
681
682    /// Mark an [`IndexUrl`] as not supporting range requests.
683    pub fn set_no_range_requests(&self, index_url: IndexUrl) {
684        self.0
685            .write()
686            .unwrap()
687            .entry(index_url)
688            .or_insert(Flags::empty())
689            .insert(Flags::NO_RANGE_REQUESTS);
690    }
691
692    /// Returns `true` if the given [`IndexUrl`] returns a `401 Unauthorized` status code.
693    pub fn unauthorized(&self, index_url: &IndexUrl) -> bool {
694        self.0
695            .read()
696            .unwrap()
697            .get(index_url)
698            .is_some_and(|flags| flags.intersects(Flags::UNAUTHORIZED))
699    }
700
701    /// Mark an [`IndexUrl`] as returning a `401 Unauthorized` status code.
702    pub(crate) fn set_unauthorized(&self, index_url: IndexUrl) {
703        self.0
704            .write()
705            .unwrap()
706            .entry(index_url)
707            .or_insert(Flags::empty())
708            .insert(Flags::UNAUTHORIZED);
709    }
710
711    /// Returns `true` if the given [`IndexUrl`] returns a `403 Forbidden` status code.
712    pub fn forbidden(&self, index_url: &IndexUrl) -> bool {
713        self.0
714            .read()
715            .unwrap()
716            .get(index_url)
717            .is_some_and(|flags| flags.intersects(Flags::FORBIDDEN))
718    }
719
720    /// Mark an [`IndexUrl`] as returning a `403 Forbidden` status code.
721    pub(crate) fn set_forbidden(&self, index_url: IndexUrl) {
722        self.0
723            .write()
724            .unwrap()
725            .entry(index_url)
726            .or_insert(Flags::empty())
727            .insert(Flags::FORBIDDEN);
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use crate::{IndexCacheControl, IndexFormat, IndexName};
735    use http::HeaderValue;
736
737    #[test]
738    fn test_index_url_parse_valid_paths() {
739        // Absolute path
740        assert!(is_disambiguated_path("/absolute/path"));
741        // Relative path
742        assert!(is_disambiguated_path("./relative/path"));
743        assert!(is_disambiguated_path("../../relative/path"));
744        if cfg!(windows) {
745            // Windows absolute path
746            assert!(is_disambiguated_path("C:/absolute/path"));
747            // Windows relative path
748            assert!(is_disambiguated_path(".\\relative\\path"));
749            assert!(is_disambiguated_path("..\\..\\relative\\path"));
750        }
751    }
752
753    #[test]
754    fn test_index_url_parse_ambiguous_paths() {
755        // Test single-segment ambiguous path
756        assert!(!is_disambiguated_path("index"));
757        // Test multi-segment ambiguous path
758        assert!(!is_disambiguated_path("relative/path"));
759    }
760
761    #[test]
762    fn test_index_url_parse_with_schemes() {
763        assert!(is_disambiguated_path("file:///absolute/path"));
764        assert!(is_disambiguated_path("https://registry.com/simple/"));
765        assert!(is_disambiguated_path(
766            "git+https://github.com/example/repo.git"
767        ));
768    }
769
770    #[test]
771    fn test_cache_control_lookup() {
772        use std::str::FromStr;
773
774        use crate::IndexFormat;
775        use crate::index_name::IndexName;
776
777        let indexes = vec![
778            Index {
779                name: Some(IndexName::from_str("index1").unwrap()),
780                url: IndexUrl::from_str("https://index1.example.com/simple").unwrap(),
781                cache_control: Some(crate::IndexCacheControl {
782                    api: Some(HeaderValue::from_static("max-age=300")),
783                    files: Some(HeaderValue::from_static("max-age=1800")),
784                }),
785                explicit: false,
786                default: false,
787                origin: None,
788                format: IndexFormat::Simple,
789                publish_url: None,
790                authenticate: uv_auth::AuthPolicy::default(),
791                ignore_error_codes: None,
792                exclude_newer: None,
793            },
794            Index {
795                name: Some(IndexName::from_str("index2").unwrap()),
796                url: IndexUrl::from_str("https://index2.example.com/simple").unwrap(),
797                cache_control: None,
798                explicit: false,
799                default: false,
800                origin: None,
801                format: IndexFormat::Simple,
802                publish_url: None,
803                authenticate: uv_auth::AuthPolicy::default(),
804                ignore_error_codes: None,
805                exclude_newer: None,
806            },
807        ];
808
809        let index_urls = IndexUrls::from_indexes(indexes);
810
811        let url1 = IndexUrl::from_str("https://index1.example.com/simple").unwrap();
812        assert_eq!(
813            index_urls.simple_api_cache_control_for(&url1),
814            Some(HeaderValue::from_static("max-age=300"))
815        );
816        assert_eq!(
817            index_urls.artifact_cache_control_for(&url1),
818            Some(HeaderValue::from_static("max-age=1800"))
819        );
820
821        let url2 = IndexUrl::from_str("https://index2.example.com/simple").unwrap();
822        assert_eq!(index_urls.simple_api_cache_control_for(&url2), None);
823        assert_eq!(index_urls.artifact_cache_control_for(&url2), None);
824
825        let url3 = IndexUrl::from_str("https://index3.example.com/simple").unwrap();
826        assert_eq!(index_urls.simple_api_cache_control_for(&url3), None);
827        assert_eq!(index_urls.artifact_cache_control_for(&url3), None);
828    }
829
830    #[test]
831    fn test_pytorch_default_cache_control() {
832        // Test that PyTorch indexes get default cache control from the getter methods
833        let indexes = vec![Index {
834            name: Some(IndexName::from_str("pytorch").unwrap()),
835            url: IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(),
836            cache_control: None, // No explicit cache control
837            explicit: false,
838            default: false,
839            origin: None,
840            format: IndexFormat::Simple,
841            publish_url: None,
842            authenticate: uv_auth::AuthPolicy::default(),
843            ignore_error_codes: None,
844            exclude_newer: None,
845        }];
846
847        let index_urls = IndexUrls::from_indexes(indexes.clone());
848        let index_locations = IndexLocations::new(indexes, Vec::new(), false);
849
850        let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap();
851
852        // IndexUrls should return the default for PyTorch
853        assert_eq!(index_urls.simple_api_cache_control_for(&pytorch_url), None);
854        assert_eq!(
855            index_urls.artifact_cache_control_for(&pytorch_url),
856            Some(HeaderValue::from_static(
857                "max-age=365000000, immutable, public",
858            ))
859        );
860
861        // IndexLocations should also return the default for PyTorch
862        assert_eq!(
863            index_locations.simple_api_cache_control_for(&pytorch_url),
864            None
865        );
866        assert_eq!(
867            index_locations.artifact_cache_control_for(&pytorch_url),
868            Some(HeaderValue::from_static(
869                "max-age=365000000, immutable, public",
870            ))
871        );
872    }
873
874    #[test]
875    fn test_pytorch_user_override_cache_control() {
876        // Test that user-specified cache control overrides PyTorch defaults
877        let indexes = vec![Index {
878            name: Some(IndexName::from_str("pytorch").unwrap()),
879            url: IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(),
880            cache_control: Some(IndexCacheControl {
881                api: Some(HeaderValue::from_static("no-cache")),
882                files: Some(HeaderValue::from_static("max-age=3600")),
883            }),
884            explicit: false,
885            default: false,
886            origin: None,
887            format: IndexFormat::Simple,
888            publish_url: None,
889            authenticate: uv_auth::AuthPolicy::default(),
890            ignore_error_codes: None,
891            exclude_newer: None,
892        }];
893
894        let index_urls = IndexUrls::from_indexes(indexes.clone());
895        let index_locations = IndexLocations::new(indexes, Vec::new(), false);
896
897        let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap();
898
899        // User settings should override defaults
900        assert_eq!(
901            index_urls.simple_api_cache_control_for(&pytorch_url),
902            Some(HeaderValue::from_static("no-cache"))
903        );
904        assert_eq!(
905            index_urls.artifact_cache_control_for(&pytorch_url),
906            Some(HeaderValue::from_static("max-age=3600"))
907        );
908
909        // Same for IndexLocations
910        assert_eq!(
911            index_locations.simple_api_cache_control_for(&pytorch_url),
912            Some(HeaderValue::from_static("no-cache"))
913        );
914        assert_eq!(
915            index_locations.artifact_cache_control_for(&pytorch_url),
916            Some(HeaderValue::from_static("max-age=3600"))
917        );
918    }
919
920    #[test]
921    fn test_nvidia_default_cache_control() {
922        // Test that NVIDIA indexes get default cache control from the getter methods
923        let indexes = vec![Index {
924            name: Some(IndexName::from_str("nvidia").unwrap()),
925            url: IndexUrl::from_str("https://pypi.nvidia.com").unwrap(),
926            cache_control: None, // No explicit cache control
927            explicit: false,
928            default: false,
929            origin: None,
930            format: IndexFormat::Simple,
931            publish_url: None,
932            authenticate: uv_auth::AuthPolicy::default(),
933            ignore_error_codes: None,
934            exclude_newer: None,
935        }];
936
937        let index_urls = IndexUrls::from_indexes(indexes.clone());
938        let index_locations = IndexLocations::new(indexes, Vec::new(), false);
939
940        let nvidia_url = IndexUrl::from_str("https://pypi.nvidia.com").unwrap();
941
942        // IndexUrls should return the default for NVIDIA
943        assert_eq!(index_urls.simple_api_cache_control_for(&nvidia_url), None);
944        assert_eq!(
945            index_urls.artifact_cache_control_for(&nvidia_url),
946            Some(HeaderValue::from_static(
947                "max-age=365000000, immutable, public",
948            ))
949        );
950
951        // IndexLocations should also return the default for NVIDIA
952        assert_eq!(
953            index_locations.simple_api_cache_control_for(&nvidia_url),
954            None
955        );
956        assert_eq!(
957            index_locations.artifact_cache_control_for(&nvidia_url),
958            Some(HeaderValue::from_static(
959                "max-age=365000000, immutable, public",
960            ))
961        );
962    }
963}