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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
"#);
|
||||
});
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user