Promote authentication policy when saving tool receipts (#18246)

## Summary

If the user provides credentials for an index URL during tool install,
we strip the credentials. However, we now store the authentication
policy as `always` to ensure that if the user attempts to upgrade, and
we can't query the index, we correctly fail.

This won't cover credentials provided via keyring, but it will cover
embedded credentials and environment variables.

Closes https://github.com/astral-sh/uv/issues/18120.
This commit is contained in:
Charlie Marsh
2026-03-02 17:33:24 -05:00
committed by GitHub
parent fa116eff05
commit 34b7734324
4 changed files with 95 additions and 2 deletions
+12
View File
@@ -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.
+6 -1
View File
@@ -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,
+1 -1
View File
@@ -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"
"#);
});
+76
View File
@@ -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: <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(())
}