mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-03 18:38:21 +00:00
Make uv’s first-index strategy more secure by default by failing early on authentication failure (#12805)
uv’s default index strategy was designed with dependency confusion attacks in mind. [According to the docs](https://docs.astral.sh/uv/configuration/indexes/#searching-across-multiple-indexes), “if a package exists on an internal index, it should always be installed from the internal index, and never from PyPI”. Unfortunately, this is not true in the case where authentication fails on that internal index. In that case, uv will simply try the next index (even on the `first-index` strategy). This means that uv is not secure by default in this common scenario. This PR causes uv to stop searching for a package if it encounters an authentication failure at an index. It is possible to opt out of this behavior for an index with a new `pyproject.toml` option `ignore-error-codes`. For example: ``` [[tool.uv.index]] name = "my-index" url = "<index-url>" ignore-error-codes = [401, 403] ``` This will also enable users to handle idiosyncratic registries in a more fine-grained way. For example, PyTorch registries return a 403 when a package is not found. In this PR, we special-case PyTorch registries to ignore 403s, but users can use `ignore-error-codes` to handle similar behaviors if they encounter them on internal registries. Depends on #12651 Closes #9429 Closes #12362
This commit is contained in:
parent
11d00d21f7
commit
f84faf726a
16 changed files with 784 additions and 99 deletions
|
@ -1,3 +1,4 @@
|
|||
use std::fmt::Display;
|
||||
use std::fmt::Formatter;
|
||||
use std::hash::BuildHasherDefault;
|
||||
use std::sync::Arc;
|
||||
|
@ -14,11 +15,28 @@ use crate::Realm;
|
|||
|
||||
type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum FetchUrl {
|
||||
/// A full index URL
|
||||
Index(Url),
|
||||
/// A realm URL
|
||||
Realm(Realm),
|
||||
}
|
||||
|
||||
impl Display for FetchUrl {
|
||||
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Index(index) => Display::fmt(index, f),
|
||||
Self::Realm(realm) => Display::fmt(realm, f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CredentialsCache {
|
||||
/// A cache per realm and username
|
||||
realms: RwLock<FxHashMap<(Realm, Username), Arc<Credentials>>>,
|
||||
/// A cache tracking the result of realm or index URL fetches from external services
|
||||
pub(crate) fetches: FxOnceMap<(String, Username), Option<Arc<Credentials>>>,
|
||||
pub(crate) fetches: FxOnceMap<(FetchUrl, Username), Option<Arc<Credentials>>>,
|
||||
/// A cache per URL, uses a trie for efficient prefix queries.
|
||||
urls: RwLock<UrlTrie>,
|
||||
}
|
||||
|
|
|
@ -60,6 +60,23 @@ pub struct Index {
|
|||
pub auth_policy: AuthPolicy,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn is_prefix_for(&self, url: &Url) -> bool {
|
||||
if self.root_url.scheme() != url.scheme()
|
||||
|| self.root_url.host_str() != url.host_str()
|
||||
|| self.root_url.port_or_known_default() != url.port_or_known_default()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
url.path().starts_with(self.root_url.path())
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(john): Multiple methods in this struct need to iterate over
|
||||
// all the indexes in the set. There are probably not many URLs to
|
||||
// iterate through, but we could use a trie instead of a HashSet here
|
||||
// for more efficient search.
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq)]
|
||||
pub struct Indexes(FxHashSet<Index>);
|
||||
|
||||
|
@ -79,36 +96,17 @@ impl Indexes {
|
|||
|
||||
/// Get the index URL prefix for a URL if one exists.
|
||||
pub fn index_url_for(&self, url: &Url) -> Option<&Url> {
|
||||
// TODO(john): There are probably not many URLs to iterate through,
|
||||
// but we could use a trie instead of a HashSet here for more
|
||||
// efficient search.
|
||||
self.0
|
||||
.iter()
|
||||
.find(|index| is_url_prefix(&index.root_url, url))
|
||||
.map(|index| &index.url)
|
||||
self.find_prefix_index(url).map(|index| &index.url)
|
||||
}
|
||||
|
||||
/// Get the [`AuthPolicy`] for a URL.
|
||||
pub fn policy_for(&self, url: &Url) -> AuthPolicy {
|
||||
// TODO(john): There are probably not many URLs to iterate through,
|
||||
// but we could use a trie instead of a HashMap here for more
|
||||
// efficient search.
|
||||
for index in &self.0 {
|
||||
if is_url_prefix(&index.root_url, url) {
|
||||
return index.auth_policy;
|
||||
}
|
||||
}
|
||||
AuthPolicy::Auto
|
||||
}
|
||||
}
|
||||
|
||||
fn is_url_prefix(base: &Url, url: &Url) -> bool {
|
||||
if base.scheme() != url.scheme()
|
||||
|| base.host_str() != url.host_str()
|
||||
|| base.port_or_known_default() != url.port_or_known_default()
|
||||
{
|
||||
return false;
|
||||
pub fn auth_policy_for(&self, url: &Url) -> AuthPolicy {
|
||||
self.find_prefix_index(url)
|
||||
.map(|index| index.auth_policy)
|
||||
.unwrap_or(AuthPolicy::Auto)
|
||||
}
|
||||
|
||||
url.path().starts_with(base.path())
|
||||
fn find_prefix_index(&self, url: &Url) -> Option<&Index> {
|
||||
self.0.iter().find(|&index| index.is_prefix_for(url))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ use http::{Extensions, StatusCode};
|
|||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
cache::FetchUrl,
|
||||
credentials::{Credentials, Username},
|
||||
index::{AuthPolicy, Indexes},
|
||||
realm::Realm,
|
||||
|
@ -182,7 +183,7 @@ impl Middleware for AuthMiddleware {
|
|||
// to the headers so for display purposes we restore some information
|
||||
let url = tracing_url(&request, request_credentials.as_ref());
|
||||
let maybe_index_url = self.indexes.index_url_for(request.url());
|
||||
let auth_policy = self.indexes.policy_for(request.url());
|
||||
let auth_policy = self.indexes.auth_policy_for(request.url());
|
||||
trace!("Handling request for {url} with authentication policy {auth_policy}");
|
||||
|
||||
let credentials: Option<Arc<Credentials>> = if matches!(auth_policy, AuthPolicy::Never) {
|
||||
|
@ -384,7 +385,7 @@ impl AuthMiddleware {
|
|||
extensions: &mut Extensions,
|
||||
next: Next<'_>,
|
||||
url: &str,
|
||||
maybe_index_url: Option<&Url>,
|
||||
index_url: Option<&Url>,
|
||||
auth_policy: AuthPolicy,
|
||||
) -> reqwest_middleware::Result<Response> {
|
||||
let credentials = Arc::new(credentials);
|
||||
|
@ -402,7 +403,7 @@ impl AuthMiddleware {
|
|||
// There's just a username, try to find a password.
|
||||
// If we have an index URL, check the cache for that URL. Otherwise,
|
||||
// check for the realm.
|
||||
let maybe_cached_credentials = if let Some(index_url) = maybe_index_url {
|
||||
let maybe_cached_credentials = if let Some(index_url) = index_url {
|
||||
self.cache()
|
||||
.get_url(index_url, credentials.as_username().as_ref())
|
||||
} else {
|
||||
|
@ -426,17 +427,12 @@ impl AuthMiddleware {
|
|||
// Do not insert already-cached credentials
|
||||
None
|
||||
} else if let Some(credentials) = self
|
||||
.fetch_credentials(
|
||||
Some(&credentials),
|
||||
request.url(),
|
||||
maybe_index_url,
|
||||
auth_policy,
|
||||
)
|
||||
.fetch_credentials(Some(&credentials), request.url(), index_url, auth_policy)
|
||||
.await
|
||||
{
|
||||
request = credentials.authenticate(request);
|
||||
Some(credentials)
|
||||
} else if maybe_index_url.is_some() {
|
||||
} else if index_url.is_some() {
|
||||
// If this is a known index, we fall back to checking for the realm.
|
||||
self.cache()
|
||||
.get_realm(Realm::from(request.url()), credentials.to_username())
|
||||
|
@ -468,9 +464,9 @@ impl AuthMiddleware {
|
|||
// Fetches can be expensive, so we will only run them _once_ per realm or index URL and username combination
|
||||
// All other requests for the same realm or index URL will wait until the first one completes
|
||||
let key = if let Some(index_url) = maybe_index_url {
|
||||
(index_url.to_string(), username)
|
||||
(FetchUrl::Index(index_url.clone()), username)
|
||||
} else {
|
||||
(Realm::from(url).to_string(), username)
|
||||
(FetchUrl::Realm(Realm::from(url)), username)
|
||||
};
|
||||
if !self.cache().fetches.register(key.clone()) {
|
||||
let credentials = self
|
||||
|
@ -520,7 +516,7 @@ impl AuthMiddleware {
|
|||
debug!("Checking keyring for credentials for index URL {}@{}", username, index_url);
|
||||
keyring.fetch(index_url, Some(username)).await
|
||||
} else {
|
||||
debug!("Checking keyring for credentials for full URL {}@{}", username, *url);
|
||||
debug!("Checking keyring for credentials for full URL {}@{}", username, url);
|
||||
keyring.fetch(url, Some(username)).await
|
||||
}
|
||||
} else if matches!(auth_policy, AuthPolicy::Always) {
|
||||
|
@ -530,10 +526,7 @@ impl AuthMiddleware {
|
|||
);
|
||||
keyring.fetch(index_url, None).await
|
||||
} else {
|
||||
debug!(
|
||||
"Checking keyring for credentials for full URL {url} without username due to `authenticate = always`"
|
||||
);
|
||||
keyring.fetch(url, None).await
|
||||
None
|
||||
}
|
||||
} else {
|
||||
debug!("Skipping keyring fetch for {url} without username; use `authenticate = always` to force");
|
||||
|
|
|
@ -7,12 +7,12 @@ use std::time::Duration;
|
|||
|
||||
use async_http_range_reader::AsyncHttpRangeReader;
|
||||
use futures::{FutureExt, StreamExt, TryStreamExt};
|
||||
use http::HeaderMap;
|
||||
use http::{HeaderMap, StatusCode};
|
||||
use itertools::Either;
|
||||
use reqwest::{Proxy, Response, StatusCode};
|
||||
use reqwest::{Proxy, Response};
|
||||
use rustc_hash::FxHashMap;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use tracing::{info_span, instrument, trace, warn, Instrument};
|
||||
use tracing::{debug, info_span, instrument, trace, warn, Instrument};
|
||||
use url::Url;
|
||||
|
||||
use uv_auth::Indexes;
|
||||
|
@ -22,7 +22,7 @@ use uv_configuration::{IndexStrategy, TrustedHost};
|
|||
use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename};
|
||||
use uv_distribution_types::{
|
||||
BuiltDist, File, FileLocation, IndexCapabilities, IndexFormat, IndexLocations,
|
||||
IndexMetadataRef, IndexUrl, IndexUrls, Name,
|
||||
IndexMetadataRef, IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name,
|
||||
};
|
||||
use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream};
|
||||
use uv_normalize::PackageName;
|
||||
|
@ -332,12 +332,29 @@ impl RegistryClient {
|
|||
let _permit = download_concurrency.acquire().await;
|
||||
match index.format {
|
||||
IndexFormat::Simple => {
|
||||
if let Some(metadata) = self
|
||||
.simple_single_index(package_name, index.url, capabilities)
|
||||
let status_code_strategy =
|
||||
self.index_urls.status_code_strategy_for(index.url);
|
||||
match self
|
||||
.simple_single_index(
|
||||
package_name,
|
||||
index.url,
|
||||
capabilities,
|
||||
&status_code_strategy,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
results.push((index.url, MetadataFormat::Simple(metadata)));
|
||||
break;
|
||||
SimpleMetadataSearchOutcome::Found(metadata) => {
|
||||
results.push((index.url, MetadataFormat::Simple(metadata)));
|
||||
break;
|
||||
}
|
||||
// Package not found, so we will continue on to the next index (if there is one)
|
||||
SimpleMetadataSearchOutcome::NotFound => {}
|
||||
// The search failed because of an HTTP status code that we don't ignore for
|
||||
// this index. We end our search here.
|
||||
SimpleMetadataSearchOutcome::StatusCodeFailure(status_code) => {
|
||||
debug!("Indexes search failed because of status code failure: {status_code}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
IndexFormat::Flat => {
|
||||
|
@ -358,9 +375,21 @@ impl RegistryClient {
|
|||
let _permit = download_concurrency.acquire().await;
|
||||
match index.format {
|
||||
IndexFormat::Simple => {
|
||||
let metadata = self
|
||||
.simple_single_index(package_name, index.url, capabilities)
|
||||
.await?;
|
||||
// For unsafe matches, ignore authentication failures.
|
||||
let status_code_strategy =
|
||||
IndexStatusCodeStrategy::ignore_authentication_error_codes();
|
||||
let metadata = match self
|
||||
.simple_single_index(
|
||||
package_name,
|
||||
index.url,
|
||||
capabilities,
|
||||
&status_code_strategy,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SimpleMetadataSearchOutcome::Found(metadata) => Some(metadata),
|
||||
_ => None,
|
||||
};
|
||||
Ok((index.url, metadata.map(MetadataFormat::Simple)))
|
||||
}
|
||||
IndexFormat::Flat => {
|
||||
|
@ -439,14 +468,13 @@ impl RegistryClient {
|
|||
///
|
||||
/// The index can either be a PEP 503-compatible remote repository, or a local directory laid
|
||||
/// out in the same format.
|
||||
///
|
||||
/// Returns `Ok(None)` if the package is not found in the index.
|
||||
async fn simple_single_index(
|
||||
&self,
|
||||
package_name: &PackageName,
|
||||
index: &IndexUrl,
|
||||
capabilities: &IndexCapabilities,
|
||||
) -> Result<Option<OwnedArchive<SimpleMetadata>>, Error> {
|
||||
status_code_strategy: &IndexStatusCodeStrategy,
|
||||
) -> Result<SimpleMetadataSearchOutcome, Error> {
|
||||
// Format the URL for PyPI.
|
||||
let mut url = index.url().clone();
|
||||
url.path_segments_mut()
|
||||
|
@ -488,27 +516,31 @@ impl RegistryClient {
|
|||
};
|
||||
|
||||
match result {
|
||||
Ok(metadata) => Ok(Some(metadata)),
|
||||
Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
|
||||
Err(err) => match err.into_kind() {
|
||||
// The package could not be found in the remote index.
|
||||
ErrorKind::WrappedReqwestError(url, err) => match err.status() {
|
||||
Some(StatusCode::NOT_FOUND) => Ok(None),
|
||||
Some(StatusCode::UNAUTHORIZED) => {
|
||||
capabilities.set_unauthorized(index.clone());
|
||||
Ok(None)
|
||||
ErrorKind::WrappedReqwestError(url, err) => {
|
||||
let Some(status_code) = err.status() else {
|
||||
return Err(ErrorKind::WrappedReqwestError(url, err).into());
|
||||
};
|
||||
let decision =
|
||||
status_code_strategy.handle_status_code(status_code, index, capabilities);
|
||||
if let IndexStatusCodeDecision::Fail(status_code) = decision {
|
||||
if !matches!(
|
||||
status_code,
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
|
||||
) {
|
||||
return Err(ErrorKind::WrappedReqwestError(url, err).into());
|
||||
}
|
||||
}
|
||||
Some(StatusCode::FORBIDDEN) => {
|
||||
capabilities.set_forbidden(index.clone());
|
||||
Ok(None)
|
||||
}
|
||||
_ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
|
||||
},
|
||||
Ok(SimpleMetadataSearchOutcome::from(decision))
|
||||
}
|
||||
|
||||
// The package is unavailable due to a lack of connectivity.
|
||||
ErrorKind::Offline(_) => Ok(None),
|
||||
ErrorKind::Offline(_) => Ok(SimpleMetadataSearchOutcome::NotFound),
|
||||
|
||||
// The package could not be found in the local index.
|
||||
ErrorKind::FileNotFound(_) => Ok(None),
|
||||
ErrorKind::FileNotFound(_) => Ok(SimpleMetadataSearchOutcome::NotFound),
|
||||
|
||||
err => Err(err.into()),
|
||||
},
|
||||
|
@ -961,6 +993,26 @@ impl RegistryClient {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum SimpleMetadataSearchOutcome {
|
||||
/// Simple metadata was found
|
||||
Found(OwnedArchive<SimpleMetadata>),
|
||||
/// Simple metadata was not found
|
||||
NotFound,
|
||||
/// A status code failure was encountered when searching for
|
||||
/// simple metadata and our strategy did not ignore it
|
||||
StatusCodeFailure(StatusCode),
|
||||
}
|
||||
|
||||
impl From<IndexStatusCodeDecision> for SimpleMetadataSearchOutcome {
|
||||
fn from(item: IndexStatusCodeDecision) -> Self {
|
||||
match item {
|
||||
IndexStatusCodeDecision::Ignore => Self::NotFound,
|
||||
IndexStatusCodeDecision::Fail(status_code) => Self::StatusCodeFailure(status_code),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A map from [`IndexUrl`] to [`FlatIndexEntry`] entries found at the given URL, indexed by
|
||||
/// [`PackageName`].
|
||||
#[derive(Default, Debug, Clone)]
|
||||
|
|
|
@ -32,6 +32,7 @@ uv-small-str = { workspace = true }
|
|||
arcstr = { workspace = true }
|
||||
bitflags = { workspace = true }
|
||||
fs-err = { workspace = true }
|
||||
http = { workspace = true }
|
||||
itertools = { workspace = true }
|
||||
jiff = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
|
@ -47,7 +48,5 @@ tracing = { workspace = true }
|
|||
url = { workspace = true }
|
||||
version-ranges = { workspace = true }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
toml = { workspace = true }
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
|
@ -8,11 +9,9 @@ use uv_auth::{AuthPolicy, Credentials};
|
|||
|
||||
use crate::index_name::{IndexName, IndexNameError};
|
||||
use crate::origin::Origin;
|
||||
use crate::{IndexUrl, IndexUrlError};
|
||||
use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode};
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct Index {
|
||||
|
@ -94,6 +93,17 @@ pub struct Index {
|
|||
/// ```
|
||||
#[serde(default)]
|
||||
pub authenticate: AuthPolicy,
|
||||
/// Status codes that uv should ignore when deciding whether
|
||||
/// to continue searching in the next index after a failure.
|
||||
///
|
||||
/// ```toml
|
||||
/// [[tool.uv.index]]
|
||||
/// name = "my-index"
|
||||
/// url = "https://<omitted>/simple"
|
||||
/// ignore-error-codes = [401, 403]
|
||||
/// ```
|
||||
#[serde(default)]
|
||||
pub ignore_error_codes: Option<Vec<SerializableStatusCode>>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
|
@ -131,6 +141,7 @@ impl Index {
|
|||
format: IndexFormat::Simple,
|
||||
publish_url: None,
|
||||
authenticate: AuthPolicy::default(),
|
||||
ignore_error_codes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -145,6 +156,7 @@ impl Index {
|
|||
format: IndexFormat::Simple,
|
||||
publish_url: None,
|
||||
authenticate: AuthPolicy::default(),
|
||||
ignore_error_codes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -159,6 +171,7 @@ impl Index {
|
|||
format: IndexFormat::Flat,
|
||||
publish_url: None,
|
||||
authenticate: AuthPolicy::default(),
|
||||
ignore_error_codes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -214,6 +227,15 @@ impl Index {
|
|||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Return the [`IndexStatusCodeStrategy`] for this index.
|
||||
pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
|
||||
if let Some(ignore_error_codes) = &self.ignore_error_codes {
|
||||
IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
|
||||
} else {
|
||||
IndexStatusCodeStrategy::from_index_url(self.url.url())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IndexUrl> for Index {
|
||||
|
@ -227,6 +249,7 @@ impl From<IndexUrl> for Index {
|
|||
format: IndexFormat::Simple,
|
||||
publish_url: None,
|
||||
authenticate: AuthPolicy::default(),
|
||||
ignore_error_codes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -249,6 +272,7 @@ impl FromStr for Index {
|
|||
format: IndexFormat::Simple,
|
||||
publish_url: None,
|
||||
authenticate: AuthPolicy::default(),
|
||||
ignore_error_codes: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -264,6 +288,7 @@ impl FromStr for Index {
|
|||
format: IndexFormat::Simple,
|
||||
publish_url: None,
|
||||
authenticate: AuthPolicy::default(),
|
||||
ignore_error_codes: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ use url::{ParseError, Url};
|
|||
|
||||
use uv_pep508::{split_scheme, Scheme, VerbatimUrl, VerbatimUrlError};
|
||||
|
||||
use crate::{Index, Verbatim};
|
||||
use crate::{Index, IndexStatusCodeStrategy, Verbatim};
|
||||
|
||||
static PYPI_URL: LazyLock<Url> = LazyLock::new(|| Url::parse("https://pypi.org/simple").unwrap());
|
||||
|
||||
|
@ -536,6 +536,16 @@ impl<'a> IndexUrls {
|
|||
pub fn no_index(&self) -> bool {
|
||||
self.no_index
|
||||
}
|
||||
|
||||
/// Return the [`IndexStatusCodeStrategy`] for an [`IndexUrl`].
|
||||
pub fn status_code_strategy_for(&self, url: &IndexUrl) -> IndexStatusCodeStrategy {
|
||||
for index in &self.indexes {
|
||||
if index.url() == url {
|
||||
return index.status_code_strategy();
|
||||
}
|
||||
}
|
||||
IndexStatusCodeStrategy::Default
|
||||
}
|
||||
}
|
||||
|
||||
bitflags::bitflags! {
|
||||
|
|
|
@ -75,6 +75,7 @@ pub use crate::requirement::*;
|
|||
pub use crate::resolution::*;
|
||||
pub use crate::resolved::*;
|
||||
pub use crate::specified_requirement::*;
|
||||
pub use crate::status_code_strategy::*;
|
||||
pub use crate::traits::*;
|
||||
|
||||
mod annotation;
|
||||
|
@ -101,6 +102,7 @@ mod requirement;
|
|||
mod resolution;
|
||||
mod resolved;
|
||||
mod specified_requirement;
|
||||
mod status_code_strategy;
|
||||
mod traits;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
291
crates/uv-distribution-types/src/status_code_strategy.rs
Normal file
291
crates/uv-distribution-types/src/status_code_strategy.rs
Normal file
|
@ -0,0 +1,291 @@
|
|||
use std::ops::Deref;
|
||||
|
||||
use http::StatusCode;
|
||||
use rustc_hash::FxHashSet;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use url::Url;
|
||||
|
||||
use crate::{IndexCapabilities, IndexUrl};
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq)]
|
||||
pub enum IndexStatusCodeStrategy {
|
||||
#[default]
|
||||
Default,
|
||||
IgnoreErrorCodes {
|
||||
status_codes: FxHashSet<StatusCode>,
|
||||
},
|
||||
}
|
||||
|
||||
impl IndexStatusCodeStrategy {
|
||||
/// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
|
||||
/// we follow the default strategy.
|
||||
pub fn from_index_url(url: &Url) -> Self {
|
||||
if url
|
||||
.host_str()
|
||||
.is_some_and(|host| host.ends_with("pytorch.org"))
|
||||
{
|
||||
// The PyTorch registry returns a 403 when a package is not found, so
|
||||
// we ignore them when deciding whether to search other indexes.
|
||||
Self::IgnoreErrorCodes {
|
||||
status_codes: FxHashSet::from_iter([StatusCode::FORBIDDEN]),
|
||||
}
|
||||
} else {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a strategy from a list of status codes to ignore.
|
||||
pub fn from_ignored_error_codes(status_codes: &[SerializableStatusCode]) -> Self {
|
||||
Self::IgnoreErrorCodes {
|
||||
status_codes: status_codes
|
||||
.iter()
|
||||
.map(SerializableStatusCode::deref)
|
||||
.copied()
|
||||
.collect::<FxHashSet<_>>(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a strategy for ignoring authentication error codes.
|
||||
pub fn ignore_authentication_error_codes() -> Self {
|
||||
Self::IgnoreErrorCodes {
|
||||
status_codes: FxHashSet::from_iter([
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::FORBIDDEN,
|
||||
StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
|
||||
StatusCode::PROXY_AUTHENTICATION_REQUIRED,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Based on the strategy, decide whether to continue searching the next index
|
||||
/// based on the status code returned by this one.
|
||||
pub fn handle_status_code(
|
||||
&self,
|
||||
status_code: StatusCode,
|
||||
index_url: &IndexUrl,
|
||||
capabilities: &IndexCapabilities,
|
||||
) -> IndexStatusCodeDecision {
|
||||
match self {
|
||||
IndexStatusCodeStrategy::Default => match status_code {
|
||||
StatusCode::NOT_FOUND => IndexStatusCodeDecision::Ignore,
|
||||
StatusCode::UNAUTHORIZED => {
|
||||
capabilities.set_unauthorized(index_url.clone());
|
||||
IndexStatusCodeDecision::Fail(status_code)
|
||||
}
|
||||
StatusCode::FORBIDDEN => {
|
||||
capabilities.set_forbidden(index_url.clone());
|
||||
IndexStatusCodeDecision::Fail(status_code)
|
||||
}
|
||||
_ => IndexStatusCodeDecision::Fail(status_code),
|
||||
},
|
||||
IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => {
|
||||
if status_codes.contains(&status_code) {
|
||||
IndexStatusCodeDecision::Ignore
|
||||
} else {
|
||||
IndexStatusCodeStrategy::Default.handle_status_code(
|
||||
status_code,
|
||||
index_url,
|
||||
capabilities,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decision on whether to continue searching the next index.
|
||||
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
|
||||
pub enum IndexStatusCodeDecision {
|
||||
Ignore,
|
||||
Fail(StatusCode),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct SerializableStatusCode(StatusCode);
|
||||
|
||||
impl Deref for SerializableStatusCode {
|
||||
type Target = StatusCode;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SerializableStatusCode {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_u16(self.0.as_u16())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SerializableStatusCode {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let code = u16::deserialize(deserializer)?;
|
||||
StatusCode::from_u16(code)
|
||||
.map(SerializableStatusCode)
|
||||
.map_err(|_| {
|
||||
serde::de::Error::custom(format!("{code} is not a valid HTTP status code"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "schemars")]
|
||||
impl schemars::JsonSchema for SerializableStatusCode {
|
||||
fn schema_name() -> String {
|
||||
"StatusCode".to_string()
|
||||
}
|
||||
|
||||
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
|
||||
let mut schema = gen.subschema_for::<u16>().into_object();
|
||||
schema.metadata().description = Some("HTTP status code (100-599)".to_string());
|
||||
schema.number().minimum = Some(100.0);
|
||||
schema.number().maximum = Some(599.0);
|
||||
|
||||
schema.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_strategy_normal_registry() {
|
||||
let url = Url::from_str("https://internal-registry.com/simple").unwrap();
|
||||
assert_eq!(
|
||||
IndexStatusCodeStrategy::from_index_url(&url),
|
||||
IndexStatusCodeStrategy::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strategy_pytorch_registry() {
|
||||
let status_codes = std::iter::once(StatusCode::FORBIDDEN).collect::<FxHashSet<_>>();
|
||||
let url = Url::from_str("https://download.pytorch.org/whl/cu118").unwrap();
|
||||
assert_eq!(
|
||||
IndexStatusCodeStrategy::from_index_url(&url),
|
||||
IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strategy_custom_error_codes() {
|
||||
let status_codes = FxHashSet::from_iter([StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN]);
|
||||
let serializable_status_codes = status_codes
|
||||
.iter()
|
||||
.map(|code| SerializableStatusCode(*code))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
IndexStatusCodeStrategy::from_ignored_error_codes(&serializable_status_codes),
|
||||
IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decision_default_400() {
|
||||
let strategy = IndexStatusCodeStrategy::Default;
|
||||
let status_code = StatusCode::BAD_REQUEST;
|
||||
let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
|
||||
let capabilities = IndexCapabilities::default();
|
||||
let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
|
||||
assert_eq!(
|
||||
decision,
|
||||
IndexStatusCodeDecision::Fail(StatusCode::BAD_REQUEST)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decision_default_401() {
|
||||
let strategy = IndexStatusCodeStrategy::Default;
|
||||
let status_code = StatusCode::UNAUTHORIZED;
|
||||
let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
|
||||
let capabilities = IndexCapabilities::default();
|
||||
let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
|
||||
assert_eq!(
|
||||
decision,
|
||||
IndexStatusCodeDecision::Fail(StatusCode::UNAUTHORIZED)
|
||||
);
|
||||
assert!(capabilities.unauthorized(&index_url));
|
||||
assert!(!capabilities.forbidden(&index_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decision_default_403() {
|
||||
let strategy = IndexStatusCodeStrategy::Default;
|
||||
let status_code = StatusCode::FORBIDDEN;
|
||||
let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
|
||||
let capabilities = IndexCapabilities::default();
|
||||
let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
|
||||
assert_eq!(
|
||||
decision,
|
||||
IndexStatusCodeDecision::Fail(StatusCode::FORBIDDEN)
|
||||
);
|
||||
assert!(capabilities.forbidden(&index_url));
|
||||
assert!(!capabilities.unauthorized(&index_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decision_default_404() {
|
||||
let strategy = IndexStatusCodeStrategy::Default;
|
||||
let status_code = StatusCode::NOT_FOUND;
|
||||
let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
|
||||
let capabilities = IndexCapabilities::default();
|
||||
let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
|
||||
assert_eq!(decision, IndexStatusCodeDecision::Ignore);
|
||||
assert!(!capabilities.forbidden(&index_url));
|
||||
assert!(!capabilities.unauthorized(&index_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decision_pytorch() {
|
||||
let index_url = IndexUrl::parse("https://download.pytorch.org/whl/cu118", None).unwrap();
|
||||
let strategy = IndexStatusCodeStrategy::from_index_url(&index_url);
|
||||
let capabilities = IndexCapabilities::default();
|
||||
// Test we continue on 403 for PyTorch registry.
|
||||
let status_code = StatusCode::FORBIDDEN;
|
||||
let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
|
||||
assert_eq!(decision, IndexStatusCodeDecision::Ignore);
|
||||
// Test we stop on 401 for PyTorch registry.
|
||||
let status_code = StatusCode::UNAUTHORIZED;
|
||||
let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
|
||||
assert_eq!(
|
||||
decision,
|
||||
IndexStatusCodeDecision::Fail(StatusCode::UNAUTHORIZED)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decision_multiple_ignored_status_codes() {
|
||||
let status_codes = vec![
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
];
|
||||
let strategy = IndexStatusCodeStrategy::IgnoreErrorCodes {
|
||||
status_codes: status_codes.iter().copied().collect::<FxHashSet<_>>(),
|
||||
};
|
||||
let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
|
||||
let capabilities = IndexCapabilities::default();
|
||||
// Test each ignored status code
|
||||
for status_code in status_codes {
|
||||
let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
|
||||
assert_eq!(decision, IndexStatusCodeDecision::Ignore);
|
||||
}
|
||||
// Test a status code that's not ignored
|
||||
let other_status_code = StatusCode::FORBIDDEN;
|
||||
let decision = strategy.handle_status_code(other_status_code, &index_url, &capabilities);
|
||||
assert_eq!(
|
||||
decision,
|
||||
IndexStatusCodeDecision::Fail(StatusCode::FORBIDDEN)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -930,12 +930,6 @@ impl PubGrubReportFormatter<'_> {
|
|||
});
|
||||
}
|
||||
if index_capabilities.forbidden(&index.url) {
|
||||
// If the index is a PyTorch index (e.g., `https://download.pytorch.org/whl/cu118`),
|
||||
// avoid noting the lack of credentials. PyTorch returns a 403 (Forbidden) status
|
||||
// code for any package that does not exist.
|
||||
if index.url.url().host_str() == Some("download.pytorch.org") {
|
||||
continue;
|
||||
}
|
||||
hints.insert(PubGrubHint::ForbiddenIndex {
|
||||
index: index.url.clone(),
|
||||
});
|
||||
|
|
|
@ -10659,10 +10659,8 @@ fn add_index_url_in_keyring() -> Result<()> {
|
|||
version = "1.0.0"
|
||||
requires-python = ">=3.11, <4"
|
||||
dependencies = []
|
||||
|
||||
[tool.uv]
|
||||
keyring-provider = "subprocess"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "proxy"
|
||||
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
|
||||
|
@ -10719,10 +10717,8 @@ fn add_full_url_in_keyring() -> Result<()> {
|
|||
version = "1.0.0"
|
||||
requires-python = ">=3.11, <4"
|
||||
dependencies = []
|
||||
|
||||
[tool.uv]
|
||||
keyring-provider = "subprocess"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "proxy"
|
||||
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
|
||||
|
@ -10751,6 +10747,244 @@ fn add_full_url_in_keyring() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// If uv receives an authentication failure from a configured index, it
|
||||
/// should not fall back to the default index.
|
||||
#[test]
|
||||
fn add_stop_index_search_early_on_auth_failure() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(indoc! { r#"
|
||||
[project]
|
||||
name = "foo"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11, <4"
|
||||
dependencies = []
|
||||
[[tool.uv.index]]
|
||||
name = "my-index"
|
||||
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
|
||||
"#
|
||||
})?;
|
||||
|
||||
uv_snapshot!(context.add().arg("anyio"), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
× No solution found when resolving dependencies:
|
||||
╰─▶ Because anyio was not found in the package registry and your project depends on anyio, we can conclude that your project's requirements are unsatisfiable.
|
||||
|
||||
hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized).
|
||||
help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
|
||||
"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// uv should continue searching the default index if it receives an
|
||||
/// authentication failure that is specified in `ignore-error-codes`.
|
||||
#[test]
|
||||
fn add_ignore_error_codes() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(indoc! { r#"
|
||||
[project]
|
||||
name = "foo"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11, <4"
|
||||
dependencies = []
|
||||
[[tool.uv.index]]
|
||||
name = "my-index"
|
||||
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
|
||||
ignore-error-codes = [401, 403]
|
||||
"#
|
||||
})?;
|
||||
|
||||
uv_snapshot!(context.add().arg("anyio"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Resolved 4 packages in [TIME]
|
||||
Prepared 3 packages in [TIME]
|
||||
Installed 3 packages in [TIME]
|
||||
+ anyio==4.3.0
|
||||
+ idna==3.6
|
||||
+ sniffio==1.3.1
|
||||
"
|
||||
);
|
||||
|
||||
context.assert_command("import anyio").success();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// uv should only fall through on 404s if an empty list is specified
|
||||
/// in `ignore-error-codes`, even for pytorch.
|
||||
#[test]
|
||||
fn add_empty_ignore_error_codes() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(indoc! { r#"
|
||||
[project]
|
||||
name = "foo"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11, <4"
|
||||
dependencies = []
|
||||
|
||||
[tool.uv.sources]
|
||||
jinja2 = { index = "pytorch" }
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
ignore-error-codes = []
|
||||
"#
|
||||
})?;
|
||||
|
||||
// The default behavior of ignoring pytorch 403s has been overridden
|
||||
// by the empty ignore-error-codes list.
|
||||
uv_snapshot!(context.add().arg("flask"), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
× No solution found when resolving dependencies:
|
||||
╰─▶ Because flask was not found in the package registry and your project depends on flask, we can conclude that your project's requirements are unsatisfiable.
|
||||
|
||||
hint: An index URL (https://download.pytorch.org/whl/cpu) could not be queried due to a lack of valid authentication credentials (403 Forbidden).
|
||||
help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
|
||||
"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// uv should not report a credential error on a missing package for pytorch since
|
||||
/// pytorch returns 403s to indicate not found.
|
||||
#[test]
|
||||
fn add_missing_package_on_pytorch() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(indoc! { r#"
|
||||
[project]
|
||||
name = "foo"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11, <4"
|
||||
dependencies = []
|
||||
|
||||
[tool.uv.sources]
|
||||
fakepkg = { index = "pytorch" }
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
"#
|
||||
})?;
|
||||
|
||||
uv_snapshot!(context.add().arg("fakepkg"), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
× No solution found when resolving dependencies:
|
||||
╰─▶ Because fakepkg was not found in the package registry and your project depends on fakepkg, we can conclude that your project's requirements are unsatisfiable.
|
||||
help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
|
||||
"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test HTTP errors other than 401s and 403s.
|
||||
#[tokio::test]
|
||||
async fn add_unexpected_error_code() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
let filters = context
|
||||
.filters()
|
||||
.into_iter()
|
||||
.chain([(r"127\.0\.0\.1(?::\d+)?", "[LOCALHOST]")])
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(indoc! { r#"
|
||||
[project]
|
||||
name = "foo"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11, <4"
|
||||
dependencies = []
|
||||
"#
|
||||
})?;
|
||||
|
||||
uv_snapshot!(filters, context.add().arg("anyio").arg("--index").arg(server.uri()), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Failed to fetch: `http://[LOCALHOST]/anyio/`
|
||||
Caused by: HTTP status server error (503 Service Unavailable) for url (http://[LOCALHOST]/anyio/)
|
||||
"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// uv should fail to parse `pyproject.toml` if `ignore-error-codes`
|
||||
/// contains an invalid status code number.
|
||||
#[test]
|
||||
fn add_invalid_ignore_error_code() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(indoc! { r#"
|
||||
[project]
|
||||
name = "foo"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11, <4"
|
||||
dependencies = []
|
||||
[[tool.uv.index]]
|
||||
name = "my-index"
|
||||
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
|
||||
ignore-error-codes = [401, 403, 1234]
|
||||
"#
|
||||
})?;
|
||||
|
||||
uv_snapshot!(context.add().arg("anyio"), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
warning: Failed to parse `pyproject.toml` during settings discovery:
|
||||
TOML parse error at line 9, column 22
|
||||
|
|
||||
9 | ignore-error-codes = [401, 403, 1234]
|
||||
| ^^^^^^^^^^^^^^^^
|
||||
1234 is not a valid HTTP status code
|
||||
|
||||
error: Failed to parse: `pyproject.toml`
|
||||
Caused by: TOML parse error at line 9, column 22
|
||||
|
|
||||
9 | ignore-error-codes = [401, 403, 1234]
|
||||
| ^^^^^^^^^^^^^^^^
|
||||
1234 is not a valid HTTP status code
|
||||
"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// In authentication "always", the normal authentication flow should still work.
|
||||
#[test]
|
||||
fn add_auth_policy_always_with_credentials() -> Result<()> {
|
||||
|
@ -11103,10 +11337,10 @@ async fn add_redirect_with_keyring() -> Result<()> {
|
|||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Request for public@http://[LOCALHOST]
|
||||
Request for public@[LOCALHOST]
|
||||
Request for public@https://pypi-proxy.fly.dev/basic-auth/simple/anyio/
|
||||
Request for public@pypi-proxy.fly.dev
|
||||
Keyring request for public@http://[LOCALHOST]
|
||||
Keyring request for public@[LOCALHOST]
|
||||
Keyring request for public@https://pypi-proxy.fly.dev/basic-auth/simple/anyio/
|
||||
Keyring request for public@pypi-proxy.fly.dev
|
||||
Resolved 4 packages in [TIME]
|
||||
Prepared 3 packages in [TIME]
|
||||
Installed 3 packages in [TIME]
|
||||
|
|
|
@ -7517,7 +7517,7 @@ fn lock_index_workspace_member() -> Result<()> {
|
|||
)?;
|
||||
|
||||
// Locking without the necessary credentials should fail.
|
||||
uv_snapshot!(context.filters(), context.lock(), @r###"
|
||||
uv_snapshot!(context.filters(), context.lock(), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
|
@ -7526,7 +7526,7 @@ fn lock_index_workspace_member() -> Result<()> {
|
|||
× No solution found when resolving dependencies:
|
||||
╰─▶ Because iniconfig was not found in the package registry and child depends on iniconfig>=2, we can conclude that child's requirements are unsatisfiable.
|
||||
And because your workspace requires child, we can conclude that your workspace's requirements are unsatisfiable.
|
||||
"###);
|
||||
");
|
||||
|
||||
uv_snapshot!(context.filters(), context.lock()
|
||||
.env("UV_INDEX_MY_INDEX_USERNAME", "public")
|
||||
|
@ -8459,7 +8459,7 @@ fn lock_env_credentials() -> Result<()> {
|
|||
)?;
|
||||
|
||||
// Without credentials, the resolution should fail.
|
||||
uv_snapshot!(context.filters(), context.lock(), @r###"
|
||||
uv_snapshot!(context.filters(), context.lock(), @r"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
|
@ -8469,7 +8469,7 @@ fn lock_env_credentials() -> Result<()> {
|
|||
╰─▶ Because iniconfig was not found in the package registry and your project depends on iniconfig, we can conclude that your project's requirements are unsatisfiable.
|
||||
|
||||
hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized).
|
||||
"###);
|
||||
");
|
||||
|
||||
// Provide credentials via environment variables.
|
||||
uv_snapshot!(context.filters(), context.lock()
|
||||
|
|
|
@ -138,6 +138,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -317,6 +318,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -497,6 +499,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -709,6 +712,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -1049,6 +1053,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -1255,6 +1260,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -1286,6 +1292,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -1469,6 +1476,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -1500,6 +1508,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -1531,6 +1540,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -1736,6 +1746,7 @@ fn resolve_find_links() -> anyhow::Result<()> {
|
|||
format: Flat,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
no_index: true,
|
||||
|
@ -2102,6 +2113,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -2133,6 +2145,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -2312,6 +2325,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -2343,6 +2357,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -3530,6 +3545,7 @@ fn resolve_both() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -3834,6 +3850,7 @@ fn resolve_config_file() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -4617,6 +4634,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -4648,6 +4666,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -4829,6 +4848,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -4860,6 +4880,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -5047,6 +5068,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -5078,6 +5100,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -5260,6 +5283,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -5291,6 +5315,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -5480,6 +5505,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -5511,6 +5537,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
@ -5693,6 +5720,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
Index {
|
||||
name: None,
|
||||
|
@ -5724,6 +5752,7 @@ fn index_priority() -> anyhow::Result<()> {
|
|||
format: Simple,
|
||||
publish_url: None,
|
||||
authenticate: Auto,
|
||||
ignore_error_codes: None,
|
||||
},
|
||||
],
|
||||
flat_index: [],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue