diff --git a/crates/uv-distribution-types/src/index.rs b/crates/uv-distribution-types/src/index.rs index 1ac47c9f2..c3d962d76 100644 --- a/crates/uv-distribution-types/src/index.rs +++ b/crates/uv-distribution-types/src/index.rs @@ -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 { // If the index is named, and credentials are provided via the environment, prefer those. diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 672deb8d7..f1fc8702a 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -2107,7 +2107,12 @@ pub struct ToolOptions { impl From 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, diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 7d3897fc1..143928a47 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -4253,7 +4253,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" "#); }); diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 016088703..3e3137b8c 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use assert_fs::prelude::*; use insta::assert_snapshot; @@ -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: +#[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(()) +}