Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/uv-distribution-types/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,18 @@ impl Index {
self.url.root()
}

/// If credentials are available (via the URL or environment) and [`AuthPolicy`] is
/// [`AuthPolicy::Auto`], promote to [`AuthPolicy::Always`] so that future operations
/// (e.g., `uv tool upgrade`) know that authentication is required even after the credentials
/// are stripped from the stored URL.
#[must_use]
pub fn with_promoted_auth_policy(mut self) -> Self {
if matches!(self.authenticate, AuthPolicy::Auto) && self.credentials().is_some() {
self.authenticate = AuthPolicy::Always;
}
self
}

/// Retrieve the credentials for the index, either from the environment, or from the URL itself.
pub fn credentials(&self) -> Option<Credentials> {
// If the index is named, and credentials are provided via the environment, prefer those.
Expand Down
7 changes: 6 additions & 1 deletion crates/uv-settings/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2107,7 +2107,12 @@ pub struct ToolOptions {
impl From<ResolverInstallerOptions> for ToolOptions {
fn from(value: ResolverInstallerOptions) -> Self {
Self {
index: value.index,
index: value.index.map(|indexes| {
indexes
.into_iter()
.map(Index::with_promoted_auth_policy)
.collect()
}),
index_url: value.index_url,
extra_index_url: value.extra_index_url,
no_index: value.no_index,
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/tests/it/tool_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4249,7 +4249,7 @@ async fn tool_install_credentials() {
]

[tool.options]
index = [{ url = "http://[LOCALHOST]/basic-auth/simple", explicit = false, default = false, format = "simple", authenticate = "auto" }]
index = [{ url = "http://[LOCALHOST]/basic-auth/simple", explicit = false, default = false, format = "simple", authenticate = "always" }]
exclude-newer = "2025-01-18T00:00:00Z"
"#);
});
Expand Down
76 changes: 76 additions & 0 deletions crates/uv/tests/it/tool_upgrade.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use anyhow::Result;
use assert_fs::prelude::*;
use insta::assert_snapshot;

Expand Down Expand Up @@ -1008,3 +1009,78 @@ fn test_tool_upgrade_additional_entrypoints() {
Upgraded tool environment for `babel` to Python 3.12
");
}

/// When upgrading a tool from an authenticated index with invalid credentials,
/// the command should fail with an auth error rather than silently reporting
/// "Nothing to upgrade".
///
/// See: <https://github.com/astral-sh/uv/issues/18120>
#[tokio::test]
async fn tool_upgrade_invalid_auth() -> Result<()> {
let proxy = crate::pypi_proxy::start().await;
let context = uv_test::test_context!("3.12")
.with_exclude_newer("2025-01-18T00:00:00Z")
.with_filtered_counts()
.with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");

// Install `executable-application` from an authenticated index using `--index`.
// The receipt will store `authenticate = "auto"` (not "always").
uv_snapshot!(context.filters(), context.tool_install()
.arg("executable-application")
.arg("--index")
.arg(proxy.authenticated_url("public", "heron", "/basic-auth/simple"))
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ executable-application==0.3.0
Installed 1 executable: app
");

insta::with_settings!({
filters => context.filters(),
}, {
// Verify the receipt has `authenticate = "always"` (promoted from "auto" because the
// original URL had embedded credentials).
assert_snapshot!(fs_err::read_to_string(tool_dir.join("executable-application").join("uv-receipt.toml")).unwrap(), @r#"
[tool]
requirements = [{ name = "executable-application" }]
entrypoints = [
{ name = "app", install-path = "[TEMP_DIR]/bin/app", from = "executable-application" },
]

[tool.options]
index = [{ url = "http://[LOCALHOST]/basic-auth/simple", explicit = false, default = false, format = "simple", authenticate = "always" }]
exclude-newer = "2025-01-18T00:00:00Z"
"#);
});

// Attempt to upgrade without providing credentials.
// Because the receipt now stores `authenticate = "always"`, the upgrade should fail
// with a credentials error rather than silently reporting "Nothing to upgrade".
uv_snapshot!(context.filters(), context.tool_upgrade()
.arg("executable-application")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @"
success: false
exit_code: 1
----- stdout -----

----- stderr -----
error: Failed to upgrade executable-application
Caused by: Failed to fetch: `http://[LOCALHOST]/basic-auth/simple/executable-application/`
Caused by: Missing credentials for http://[LOCALHOST]/basic-auth/simple/executable-application/
");

Ok(())
}
Loading