From 2bbb10afc00b4597fbc6e1c44d3cad45991ba813 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Fri, 28 Nov 2025 16:20:08 +0000 Subject: [PATCH 1/8] add credential helper --- crates/uv-cli/src/lib.rs | 11 ++ .../uv/src/commands/auth/credential_helper.rs | 130 ++++++++++++++ crates/uv/src/commands/auth/mod.rs | 1 + crates/uv/src/commands/mod.rs | 1 + crates/uv/src/lib.rs | 16 +- crates/uv/tests/it/auth.rs | 158 ++++++++++++++++++ crates/uv/tests/it/common/mod.rs | 56 ++++++- crates/uv/tests/it/ecosystem.rs | 1 + 8 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 crates/uv/src/commands/auth/credential_helper.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e39d55056e3ed..a64d53c7d100c 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4934,6 +4934,14 @@ pub enum AuthCommand { /// Credentials are only stored in this directory when the plaintext backend is used, as /// opposed to the native backend, which uses the system keyring. Dir(AuthDirArgs), + /// Act as a credential helper for external tools. + /// + /// Implements the Bazel credential helper protocol to provide credentials + /// to external tools via JSON over stdin/stdout. + /// + /// This command is typically invoked by external tools, not directly by users. + #[command(hide = true)] + CredentialHelper(AuthCredentialHelperArgs), } #[derive(Args)] @@ -6215,6 +6223,9 @@ pub struct AuthDirArgs { pub service: Option, } +#[derive(Args)] +pub struct AuthCredentialHelperArgs; + #[derive(Args)] pub struct GenerateShellCompletionArgs { /// The shell to generate the completion script for diff --git a/crates/uv/src/commands/auth/credential_helper.rs b/crates/uv/src/commands/auth/credential_helper.rs new file mode 100644 index 0000000000000..9b4147b970d85 --- /dev/null +++ b/crates/uv/src/commands/auth/credential_helper.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; +use std::fmt::Write; +use std::io::Read; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use uv_auth::{AuthBackend, Credentials, PyxTokenStore}; +use uv_client::BaseClientBuilder; +use uv_preview::Preview; +use uv_redacted::DisplaySafeUrl; + +use crate::{commands::ExitStatus, printer::Printer, settings::NetworkSettings}; + +/// Request format for the Bazel credential helper protocol. +#[derive(Debug, Deserialize)] +struct CredentialRequest { + uri: String, +} + +/// Response format for the Bazel credential helper protocol. +#[derive(Debug, Serialize)] +struct CredentialResponse { + headers: HashMap>, +} + +async fn credentials_for_url( + url: &DisplaySafeUrl, + preview: Preview, + network_settings: &NetworkSettings, +) -> Result> { + let pyx_store = PyxTokenStore::from_settings()?; + + // Use only the username from the URL, if present - discarding the password + let url_credentials = Credentials::from_url(url); + let url_username = url_credentials.as_ref().and_then(|c| c.username()); + let username = url_username.map(ToString::to_string); + + if pyx_store.is_known_domain(url) { + if username.is_some() { + bail!( + "Cannot specify a username for URLs under {}", + url.host() + .map(|host| host.to_string()) + .unwrap_or("this host".to_owned()) + ); + } + let client = BaseClientBuilder::new( + network_settings.connectivity, + network_settings.native_tls, + network_settings.allow_insecure_host.clone(), + preview, + network_settings.timeout, + network_settings.retries, + ) + .auth_integration(uv_client::AuthIntegration::NoAuthMiddleware) + .build(); + let maybe_token = pyx_store + .access_token(client.for_host(pyx_store.api()).raw_client(), 0) + .await + .context("Authentication failure")?; + let token = maybe_token.ok_or_else(|| anyhow::anyhow!("No access token found"))?; + return Ok(Some(Credentials::bearer(token.into_bytes()))); + } + let backend = AuthBackend::from_settings(preview)?; + let credentials = match &backend { + AuthBackend::System(provider) => provider.fetch(url, username.as_deref()).await, + AuthBackend::TextStore(store, _lock) => { + store.get_credentials(url, username.as_deref()).cloned() + } + }; + Ok(credentials) +} + +/// Implement the Bazel credential helper protocol. +/// +/// Reads a JSON request from stdin containing a URI, looks up credentials +/// for that URI using uv's authentication backends, and writes a JSON response +/// to stdout containing HTTP headers (if credentials are found). +/// +/// Protocol specification TLDR: +/// - Input (stdin): `{"uri": "https://example.com/path"}` +/// - Output (stdout): `{"headers": {"Authorization": ["Basic ..."]}}` or `{"headers": {}}` +/// - Errors: Written to stderr with non-zero exit code +/// +/// Full spec is [available here](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md) +pub(crate) async fn credential_helper( + preview: Preview, + network_settings: &NetworkSettings, + printer: Printer, +) -> Result { + // Read CredentialRequest from stdin + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .context("Failed to read from stdin")?; + + let request: CredentialRequest = + serde_json::from_str(&buffer).context("Failed to parse credential request as JSON")?; + + let url = Url::parse(&request.uri).context("Invalid URI in credential request")?; + let safe_url = DisplaySafeUrl::from_url(url); + + // Convert credentials to HTTP headers + let mut headers = HashMap::new(); + + let credentials = credentials_for_url(&safe_url, preview, network_settings).await?; + + if let Some(creds) = credentials { + // Only include the Authorization header if credentials are authenticated + // (i.e., not just a username without password) + if creds.is_authenticated() { + let header_value = creds.to_header_value(); + + // Convert HeaderValue to String + let header_str = header_value + .to_str() + .context("Failed to convert header value to string")? + .to_string(); + + headers.insert("Authorization".to_string(), vec![header_str]); + } + } + + let response = serde_json::to_string(&CredentialResponse { headers }) + .context("Failed to serialize response as JSON")?; + writeln!(printer.stdout(), "{response}")?; + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/auth/mod.rs b/crates/uv/src/commands/auth/mod.rs index e446e2b1bff3b..fff9cea36f4e3 100644 --- a/crates/uv/src/commands/auth/mod.rs +++ b/crates/uv/src/commands/auth/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod credential_helper; pub(crate) mod dir; pub(crate) mod login; pub(crate) mod logout; diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index a399c1daa21c0..0a1364822862f 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -9,6 +9,7 @@ use anyhow::Context; use owo_colors::OwoColorize; use tracing::debug; +pub(crate) use auth::credential_helper::credential_helper; pub(crate) use auth::dir::dir as auth_dir; pub(crate) use auth::login::login as auth_login; pub(crate) use auth::logout::logout as auth_logout; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ff3f59b035d59..833157716cfac 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -52,9 +52,9 @@ use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; use crate::commands::{ExitStatus, RunCommand, ScriptPath, ToolRunCommand}; use crate::printer::Printer; use crate::settings::{ - CacheSettings, GlobalSettings, PipCheckSettings, PipCompileSettings, PipFreezeSettings, - PipInstallSettings, PipListSettings, PipShowSettings, PipSyncSettings, PipUninstallSettings, - PublishSettings, + CacheSettings, GlobalSettings, NetworkSettings, PipCheckSettings, PipCompileSettings, + PipFreezeSettings, PipInstallSettings, PipListSettings, PipShowSettings, PipSyncSettings, + PipUninstallSettings, PublishSettings, }; pub(crate) mod child; @@ -542,6 +542,16 @@ async fn run(mut cli: Cli) -> Result { commands::auth_dir(args.service.as_ref(), printer)?; Ok(ExitStatus::Success) } + Commands::Auth(AuthNamespace { + command: AuthCommand::CredentialHelper(_args), + }) => { + let network_settings = NetworkSettings::resolve( + &cli.top_level.global_args, + filesystem.as_ref(), + &environment, + ); + commands::credential_helper(globals.preview, &network_settings, printer).await + } Commands::Help(args) => commands::help( args.command.unwrap_or_default().as_slice(), printer, diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index f6ceb6e483bd8..e41ea83b60461 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -1925,3 +1925,161 @@ fn native_auth_host_fallback() -> Result<()> { Ok(()) } + +/// Test credential helper with basic auth credentials +#[test] +fn credential_helper_basic_auth() { + let context = TestContext::new("3.12").with_filter(( + r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), + r#""Basic [REDACTED]""#.to_owned(), + )); + + // Store credentials + uv_snapshot!(context.filters(), context.auth_login() + .arg("https://test.example.com") + .arg("--username").arg("testuser") + .arg("--password").arg("testpass"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for testuser@https://test.example.com/ + "###); + + uv_snapshot!(context.filters(), context.auth_credential_helper(), + input=r#"{"uri":"https://test.example.com/path"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{"Authorization":["Basic [REDACTED]"]}} + + ----- stderr ----- + "# + ); +} + +/// Test credential helper with token credentials +#[test] +fn credential_helper_token() { + let context = TestContext::new("3.12").with_filter(( + r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), + r#""Basic [REDACTED]""#.to_owned(), + )); + + // Store token + uv_snapshot!(context.filters(), context.auth_login() + .arg("https://api.example.com") + .arg("--token").arg("mytoken123"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for https://api.example.com/ + "###); + + // Test credential helper - tokens are stored as Basic auth with __token__ username + uv_snapshot!(context.filters(), context.auth_credential_helper(), + input=r#"{"uri":"https://api.example.com/v1/endpoint"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{"Authorization":["Basic [REDACTED]"]}} + + ----- stderr ----- + "# + ); +} + +/// Test credential helper with no credentials found +#[test] +fn credential_helper_no_credentials() { + let context = TestContext::new("3.12"); + uv_snapshot!(context.filters(), context.auth_credential_helper(), + input=r#"{"uri":"https://unknown.example.com/path"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{}} + + ----- stderr ----- + "# + ); +} + +/// Test credential helper with invalid JSON input +#[test] +fn credential_helper_invalid_json() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.auth_credential_helper(), + input="not json", + @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse credential request as JSON + Caused by: expected ident at line 1 column 2 + " + ); +} + +/// Test credential helper with invalid URI +#[test] +fn credential_helper_invalid_uri() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.auth_credential_helper(), + input=r#"{"uri":"not a url"}"#, + @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Invalid URI in credential request + Caused by: relative URL without a base + " + ); +} + +/// Test credential helper with username in URI +#[test] +fn credential_helper_username_in_uri() { + let context = TestContext::new("3.12").with_filter(( + r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), + r#""Basic [REDACTED]""#.to_owned(), + )); + + // Store credentials with specific username + uv_snapshot!(context.filters(), context.auth_login() + .arg("https://test.example.com") + .arg("--username").arg("specificuser") + .arg("--password").arg("specificpass"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for specificuser@https://test.example.com/ + "###); + + // Test with username in URI + uv_snapshot!(context.filters(), context.auth_credential_helper(), + input=r#"{"uri":"https://specificuser@test.example.com/path"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{"Authorization":["Basic [REDACTED]"]}} + + ----- stderr ----- + "# + ); +} diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 869c92305b9ba..9ea997f09aeda 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -3,9 +3,10 @@ use std::borrow::BorrowMut; use std::ffi::OsString; +use std::io::Write as _; use std::iter::Iterator; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Output}; +use std::process::{Command, ExitStatus, Output, Stdio}; use std::str::FromStr; use std::{env, io}; use uv_python::downloads::ManagedPythonDownloadList; @@ -1457,6 +1458,14 @@ impl TestContext { command } + /// Create a `uv auth credential-helper` command. + pub fn auth_credential_helper(&self) -> Command { + let mut command = Self::new_command(); + command.arg("auth").arg("credential-helper"); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv auth token` command. pub fn auth_token(&self) -> Command { let mut command = Self::new_command(); @@ -1656,6 +1665,7 @@ impl TestContext { self.filters(), "diff_lock", Some(WindowsFilters::Platform), + None, ); assert!(status.success(), "{snapshot}"); let new_lock = fs_err::read_to_string(&lock_path).unwrap(); @@ -1839,9 +1849,10 @@ pub fn run_and_format>( filters: impl AsRef<[(T, T)]>, function_name: &str, windows_filters: Option, + input: Option<&str>, ) -> (String, Output) { let (snapshot, output, _) = - run_and_format_with_status(command, filters, function_name, windows_filters); + run_and_format_with_status(command, filters, function_name, windows_filters, input); (snapshot, output) } @@ -1854,6 +1865,7 @@ pub fn run_and_format_with_status>( filters: impl AsRef<[(T, T)]>, function_name: &str, windows_filters: Option, + input: Option<&str>, ) -> (String, Output, ExitStatus) { let program = command .borrow_mut() @@ -1873,10 +1885,30 @@ pub fn run_and_format_with_status>( ); } - let output = command - .borrow_mut() - .output() - .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}")); + let output = if let Some(input) = input { + let mut child = command + .borrow_mut() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}")); + child + .stdin + .as_mut() + .expect("Failed to open stdin") + .write_all(input.as_bytes()) + .expect("Failed to write to stdin"); + + child + .wait_with_output() + .unwrap_or_else(|err| panic!("Failed to read output from {program}: {err}")) + } else { + command + .borrow_mut() + .output() + .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}")) + }; eprintln!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Unfiltered output ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); eprintln!( @@ -2075,19 +2107,25 @@ macro_rules! uv_snapshot { }}; ($filters:expr, $spawnable:expr, @$snapshot:literal) => {{ // Take a reference for backwards compatibility with the vec-expecting insta filters. - let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform)); + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform), None); + ::insta::assert_snapshot!(snapshot, @$snapshot); + output + }}; + ($filters:expr, $spawnable:expr, input=$input:expr, @$snapshot:literal) => {{ + // Take a reference for backwards compatibility with the vec-expecting insta filters. + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform), Some($input)); ::insta::assert_snapshot!(snapshot, @$snapshot); output }}; ($filters:expr, windows_filters=false, $spawnable:expr, @$snapshot:literal) => {{ // Take a reference for backwards compatibility with the vec-expecting insta filters. - let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), None); + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), None, None); ::insta::assert_snapshot!(snapshot, @$snapshot); output }}; ($filters:expr, universal_windows_filters=true, $spawnable:expr, @$snapshot:literal) => {{ // Take a reference for backwards compatibility with the vec-expecting insta filters. - let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Universal)); + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Universal), None); ::insta::assert_snapshot!(snapshot, @$snapshot); output }}; diff --git a/crates/uv/tests/it/ecosystem.rs b/crates/uv/tests/it/ecosystem.rs index a3804f426fbb4..aae17583445db 100644 --- a/crates/uv/tests/it/ecosystem.rs +++ b/crates/uv/tests/it/ecosystem.rs @@ -110,6 +110,7 @@ fn lock_ecosystem_package(python_version: &str, name: &str) -> Result<()> { context.filters(), name, Some(common::WindowsFilters::Platform), + None, ); let lock = context.read("uv.lock"); From b890a10a9e945f94131629bc2383e7415f425745 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 2 Dec 2025 17:12:06 +0000 Subject: [PATCH 2/8] Add a `get` subcommand --- crates/uv-cli/src/lib.rs | 11 ++++++++++- crates/uv/src/lib.rs | 28 +++++++++++++--------------- crates/uv/tests/it/common/mod.rs | 4 ++-- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index a64d53c7d100c..e11a20d501904 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -6224,7 +6224,16 @@ pub struct AuthDirArgs { } #[derive(Args)] -pub struct AuthCredentialHelperArgs; +pub struct AuthCredentialHelperArgs { + #[command(subcommand)] + pub command: CredentialHelperCommand, +} + +#[derive(Subcommand)] +pub enum CredentialHelperCommand { + /// Retrieve credentials for a URI + Get, +} #[derive(Args)] pub struct GenerateShellCompletionArgs { diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 833157716cfac..c35481e33e196 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -27,9 +27,9 @@ use uv_cache_info::Timestamp; use uv_cli::SelfUpdateArgs; use uv_cli::{ AuthCommand, AuthNamespace, BuildBackendCommand, CacheCommand, CacheNamespace, Cli, Commands, - PipCommand, PipNamespace, ProjectCommand, PythonCommand, PythonNamespace, SelfCommand, - SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, WorkspaceCommand, WorkspaceNamespace, - compat::CompatArgs, + CredentialHelperCommand, PipCommand, PipNamespace, ProjectCommand, PythonCommand, + PythonNamespace, SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, + WorkspaceCommand, WorkspaceNamespace, compat::CompatArgs, }; use uv_client::BaseClientBuilder; use uv_configuration::min_stack_size; @@ -52,9 +52,9 @@ use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; use crate::commands::{ExitStatus, RunCommand, ScriptPath, ToolRunCommand}; use crate::printer::Printer; use crate::settings::{ - CacheSettings, GlobalSettings, NetworkSettings, PipCheckSettings, PipCompileSettings, - PipFreezeSettings, PipInstallSettings, PipListSettings, PipShowSettings, PipSyncSettings, - PipUninstallSettings, PublishSettings, + CacheSettings, GlobalSettings, PipCheckSettings, PipCompileSettings, PipFreezeSettings, + PipInstallSettings, PipListSettings, PipShowSettings, PipSyncSettings, PipUninstallSettings, + PublishSettings, }; pub(crate) mod child; @@ -543,15 +543,13 @@ async fn run(mut cli: Cli) -> Result { Ok(ExitStatus::Success) } Commands::Auth(AuthNamespace { - command: AuthCommand::CredentialHelper(_args), - }) => { - let network_settings = NetworkSettings::resolve( - &cli.top_level.global_args, - filesystem.as_ref(), - &environment, - ); - commands::credential_helper(globals.preview, &network_settings, printer).await - } + command: AuthCommand::CredentialHelper(args), + }) => match args.command { + CredentialHelperCommand::Get => { + commands::credential_helper(globals.preview, &globals.network_settings, printer) + .await + } + }, Commands::Help(args) => commands::help( args.command.unwrap_or_default().as_slice(), printer, diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 9ea997f09aeda..984cc03b42d09 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1458,10 +1458,10 @@ impl TestContext { command } - /// Create a `uv auth credential-helper` command. + /// Create a `uv auth credential-helper get` command. pub fn auth_credential_helper(&self) -> Command { let mut command = Self::new_command(); - command.arg("auth").arg("credential-helper"); + command.arg("auth").arg("credential-helper").arg("get"); self.add_shared_options(&mut command, false); command } From 0db7c2751cb25c610c5e5b6ea54b0d3ec0891b95 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 3 Dec 2025 12:12:46 +0000 Subject: [PATCH 3/8] Rename credential-helper -> helper --- crates/uv-cli/src/lib.rs | 8 +++---- .../auth/{credential_helper.rs => helper.rs} | 2 +- crates/uv/src/commands/auth/mod.rs | 2 +- crates/uv/src/commands/mod.rs | 2 +- crates/uv/src/lib.rs | 10 ++++---- crates/uv/tests/it/auth.rs | 24 +++++++++---------- crates/uv/tests/it/common/mod.rs | 6 ++--- 7 files changed, 27 insertions(+), 27 deletions(-) rename crates/uv/src/commands/auth/{credential_helper.rs => helper.rs} (99%) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e11a20d501904..c065cb694095f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4941,7 +4941,7 @@ pub enum AuthCommand { /// /// This command is typically invoked by external tools, not directly by users. #[command(hide = true)] - CredentialHelper(AuthCredentialHelperArgs), + Helper(AuthHelperArgs), } #[derive(Args)] @@ -6224,13 +6224,13 @@ pub struct AuthDirArgs { } #[derive(Args)] -pub struct AuthCredentialHelperArgs { +pub struct AuthHelperArgs { #[command(subcommand)] - pub command: CredentialHelperCommand, + pub command: HelperCommand, } #[derive(Subcommand)] -pub enum CredentialHelperCommand { +pub enum HelperCommand { /// Retrieve credentials for a URI Get, } diff --git a/crates/uv/src/commands/auth/credential_helper.rs b/crates/uv/src/commands/auth/helper.rs similarity index 99% rename from crates/uv/src/commands/auth/credential_helper.rs rename to crates/uv/src/commands/auth/helper.rs index 9b4147b970d85..1c3894adcc686 100644 --- a/crates/uv/src/commands/auth/credential_helper.rs +++ b/crates/uv/src/commands/auth/helper.rs @@ -85,7 +85,7 @@ async fn credentials_for_url( /// - Errors: Written to stderr with non-zero exit code /// /// Full spec is [available here](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md) -pub(crate) async fn credential_helper( +pub(crate) async fn helper( preview: Preview, network_settings: &NetworkSettings, printer: Printer, diff --git a/crates/uv/src/commands/auth/mod.rs b/crates/uv/src/commands/auth/mod.rs index fff9cea36f4e3..d3dd655bfca28 100644 --- a/crates/uv/src/commands/auth/mod.rs +++ b/crates/uv/src/commands/auth/mod.rs @@ -1,5 +1,5 @@ -pub(crate) mod credential_helper; pub(crate) mod dir; +pub(crate) mod helper; pub(crate) mod login; pub(crate) mod logout; pub(crate) mod token; diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 0a1364822862f..72e9daa3e1167 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -9,8 +9,8 @@ use anyhow::Context; use owo_colors::OwoColorize; use tracing::debug; -pub(crate) use auth::credential_helper::credential_helper; pub(crate) use auth::dir::dir as auth_dir; +pub(crate) use auth::helper::helper as auth_helper; pub(crate) use auth::login::login as auth_login; pub(crate) use auth::logout::logout as auth_logout; pub(crate) use auth::token::token as auth_token; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c35481e33e196..69986ba52c641 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -27,8 +27,8 @@ use uv_cache_info::Timestamp; use uv_cli::SelfUpdateArgs; use uv_cli::{ AuthCommand, AuthNamespace, BuildBackendCommand, CacheCommand, CacheNamespace, Cli, Commands, - CredentialHelperCommand, PipCommand, PipNamespace, ProjectCommand, PythonCommand, - PythonNamespace, SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, + HelperCommand, PipCommand, PipNamespace, ProjectCommand, PythonCommand, PythonNamespace, + SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, WorkspaceCommand, WorkspaceNamespace, compat::CompatArgs, }; use uv_client::BaseClientBuilder; @@ -543,10 +543,10 @@ async fn run(mut cli: Cli) -> Result { Ok(ExitStatus::Success) } Commands::Auth(AuthNamespace { - command: AuthCommand::CredentialHelper(args), + command: AuthCommand::Helper(args), }) => match args.command { - CredentialHelperCommand::Get => { - commands::credential_helper(globals.preview, &globals.network_settings, printer) + HelperCommand::Get => { + commands::auth_helper(globals.preview, &globals.network_settings, printer) .await } }, diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index e41ea83b60461..35237661d9f6d 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -1928,7 +1928,7 @@ fn native_auth_host_fallback() -> Result<()> { /// Test credential helper with basic auth credentials #[test] -fn credential_helper_basic_auth() { +fn helper_basic_auth() { let context = TestContext::new("3.12").with_filter(( r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), r#""Basic [REDACTED]""#.to_owned(), @@ -1947,7 +1947,7 @@ fn credential_helper_basic_auth() { Stored credentials for testuser@https://test.example.com/ "###); - uv_snapshot!(context.filters(), context.auth_credential_helper(), + uv_snapshot!(context.filters(), context.auth_helper(), input=r#"{"uri":"https://test.example.com/path"}"#, @r#" success: true @@ -1962,7 +1962,7 @@ fn credential_helper_basic_auth() { /// Test credential helper with token credentials #[test] -fn credential_helper_token() { +fn helper_token() { let context = TestContext::new("3.12").with_filter(( r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), r#""Basic [REDACTED]""#.to_owned(), @@ -1981,7 +1981,7 @@ fn credential_helper_token() { "###); // Test credential helper - tokens are stored as Basic auth with __token__ username - uv_snapshot!(context.filters(), context.auth_credential_helper(), + uv_snapshot!(context.filters(), context.auth_helper(), input=r#"{"uri":"https://api.example.com/v1/endpoint"}"#, @r#" success: true @@ -1996,9 +1996,9 @@ fn credential_helper_token() { /// Test credential helper with no credentials found #[test] -fn credential_helper_no_credentials() { +fn helper_no_credentials() { let context = TestContext::new("3.12"); - uv_snapshot!(context.filters(), context.auth_credential_helper(), + uv_snapshot!(context.filters(), context.auth_helper(), input=r#"{"uri":"https://unknown.example.com/path"}"#, @r#" success: true @@ -2013,10 +2013,10 @@ fn credential_helper_no_credentials() { /// Test credential helper with invalid JSON input #[test] -fn credential_helper_invalid_json() { +fn helper_invalid_json() { let context = TestContext::new("3.12"); - uv_snapshot!(context.filters(), context.auth_credential_helper(), + uv_snapshot!(context.filters(), context.auth_helper(), input="not json", @r" success: false @@ -2032,10 +2032,10 @@ fn credential_helper_invalid_json() { /// Test credential helper with invalid URI #[test] -fn credential_helper_invalid_uri() { +fn helper_invalid_uri() { let context = TestContext::new("3.12"); - uv_snapshot!(context.filters(), context.auth_credential_helper(), + uv_snapshot!(context.filters(), context.auth_helper(), input=r#"{"uri":"not a url"}"#, @r" success: false @@ -2051,7 +2051,7 @@ fn credential_helper_invalid_uri() { /// Test credential helper with username in URI #[test] -fn credential_helper_username_in_uri() { +fn helper_username_in_uri() { let context = TestContext::new("3.12").with_filter(( r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), r#""Basic [REDACTED]""#.to_owned(), @@ -2071,7 +2071,7 @@ fn credential_helper_username_in_uri() { "###); // Test with username in URI - uv_snapshot!(context.filters(), context.auth_credential_helper(), + uv_snapshot!(context.filters(), context.auth_helper(), input=r#"{"uri":"https://specificuser@test.example.com/path"}"#, @r#" success: true diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 984cc03b42d09..38ef8876d7b96 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1458,10 +1458,10 @@ impl TestContext { command } - /// Create a `uv auth credential-helper get` command. - pub fn auth_credential_helper(&self) -> Command { + /// Create a `uv auth helper get` command. + pub fn auth_helper(&self) -> Command { let mut command = Self::new_command(); - command.arg("auth").arg("credential-helper").arg("get"); + command.arg("auth").arg("helper").arg("get"); self.add_shared_options(&mut command, false); command } From a60e823acbde4a5261c98dd0efad6c3ec7a85bb0 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 3 Dec 2025 12:17:44 +0000 Subject: [PATCH 4/8] Add a --protocol required argument --- crates/uv-cli/src/lib.rs | 16 ++++++++++++++-- crates/uv/src/lib.rs | 24 ++++++++++++++++-------- crates/uv/tests/it/common/mod.rs | 9 +++++++-- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c065cb694095f..3a51cd0ab47bc 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -6226,11 +6226,23 @@ pub struct AuthDirArgs { #[derive(Args)] pub struct AuthHelperArgs { #[command(subcommand)] - pub command: HelperCommand, + pub command: AuthHelperCommand, + + /// The credential helper protocol to use + #[arg(long, value_enum, required = true)] + pub protocol: AuthHelperProtocol, +} + +/// Credential helper protocols supported by uv +#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)] +pub enum AuthHelperProtocol { + /// Bazel credential helper protocol as described in [the + /// spec](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md) + Bazel, } #[derive(Subcommand)] -pub enum HelperCommand { +pub enum AuthHelperCommand { /// Retrieve credentials for a URI Get, } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 69986ba52c641..e3878b136a710 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -26,9 +26,9 @@ use uv_cache_info::Timestamp; #[cfg(feature = "self-update")] use uv_cli::SelfUpdateArgs; use uv_cli::{ - AuthCommand, AuthNamespace, BuildBackendCommand, CacheCommand, CacheNamespace, Cli, Commands, - HelperCommand, PipCommand, PipNamespace, ProjectCommand, PythonCommand, PythonNamespace, - SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, + AuthCommand, AuthHelperCommand, AuthNamespace, BuildBackendCommand, CacheCommand, + CacheNamespace, Cli, Commands, PipCommand, PipNamespace, ProjectCommand, PythonCommand, + PythonNamespace, SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, WorkspaceCommand, WorkspaceNamespace, compat::CompatArgs, }; use uv_client::BaseClientBuilder; @@ -544,12 +544,20 @@ async fn run(mut cli: Cli) -> Result { } Commands::Auth(AuthNamespace { command: AuthCommand::Helper(args), - }) => match args.command { - HelperCommand::Get => { - commands::auth_helper(globals.preview, &globals.network_settings, printer) - .await + }) => { + use uv_cli::AuthHelperProtocol; + + // Validate protocol (currently only Bazel is supported) + match args.protocol { + AuthHelperProtocol::Bazel => {} } - }, + + match args.command { + AuthHelperCommand::Get => { + commands::auth_helper(globals.preview, &globals.network_settings, printer).await + } + } + } Commands::Help(args) => commands::help( args.command.unwrap_or_default().as_slice(), printer, diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 38ef8876d7b96..14d83383dd264 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1458,10 +1458,15 @@ impl TestContext { command } - /// Create a `uv auth helper get` command. + /// Create a `uv auth helper --protocol bazel get` command. pub fn auth_helper(&self) -> Command { let mut command = Self::new_command(); - command.arg("auth").arg("helper").arg("get"); + command + .arg("auth") + .arg("helper") + .arg("--protocol") + .arg("bazel") + .arg("get"); self.add_shared_options(&mut command, false); command } From c5514bf26987969b06a11879e61e9b20a1b7659c Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 3 Dec 2025 12:42:01 +0000 Subject: [PATCH 5/8] Address code review feedback --- crates/uv/src/commands/auth/helper.rs | 81 ++++++++++++++++----------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/crates/uv/src/commands/auth/helper.rs b/crates/uv/src/commands/auth/helper.rs index 1c3894adcc686..a832be8708ad2 100644 --- a/crates/uv/src/commands/auth/helper.rs +++ b/crates/uv/src/commands/auth/helper.rs @@ -15,16 +15,53 @@ use crate::{commands::ExitStatus, printer::Printer, settings::NetworkSettings}; /// Request format for the Bazel credential helper protocol. #[derive(Debug, Deserialize)] -struct CredentialRequest { +struct BazelCredentialRequest { uri: String, } +impl BazelCredentialRequest { + fn from_str(s: &str) -> Result { + serde_json::from_str(s).context("Failed to parse credential request as JSON") + } + + fn from_stdin() -> Result { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .context("Failed to read from stdin")?; + + Self::from_str(&buffer) + } +} + /// Response format for the Bazel credential helper protocol. -#[derive(Debug, Serialize)] -struct CredentialResponse { +#[derive(Debug, Serialize, Default)] +struct BazelCredentialResponse { headers: HashMap>, } +impl TryFrom for BazelCredentialResponse { + fn try_from(creds: Credentials) -> Result { + let mut headers = HashMap::new(); + // Only include the Authorization header if credentials are authenticated + // (i.e., not just a username without password) + if creds.is_authenticated() { + let header_value = creds.to_header_value(); + + // Convert HeaderValue to String + let header_str = header_value + .to_str() + .context("Failed to convert header value to string")? + .to_string(); + + headers.insert("Authorization".to_string(), vec![header_str]); + } + Ok(Self { headers }) + } + + type Error = anyhow::Error; +} + async fn credentials_for_url( url: &DisplaySafeUrl, preview: Preview, @@ -90,41 +127,21 @@ pub(crate) async fn helper( network_settings: &NetworkSettings, printer: Printer, ) -> Result { - // Read CredentialRequest from stdin - let mut buffer = String::new(); - std::io::stdin() - .read_to_string(&mut buffer) - .context("Failed to read from stdin")?; - - let request: CredentialRequest = - serde_json::from_str(&buffer).context("Failed to parse credential request as JSON")?; + let request = BazelCredentialRequest::from_stdin()?; + // TODO: make this logic generic over the protocol by providing `request.uri` from a + // trait - that should help with adding new protocols let url = Url::parse(&request.uri).context("Invalid URI in credential request")?; let safe_url = DisplaySafeUrl::from_url(url); - // Convert credentials to HTTP headers - let mut headers = HashMap::new(); - let credentials = credentials_for_url(&safe_url, preview, network_settings).await?; - if let Some(creds) = credentials { - // Only include the Authorization header if credentials are authenticated - // (i.e., not just a username without password) - if creds.is_authenticated() { - let header_value = creds.to_header_value(); - - // Convert HeaderValue to String - let header_str = header_value - .to_str() - .context("Failed to convert header value to string")? - .to_string(); - - headers.insert("Authorization".to_string(), vec![header_str]); - } - } - - let response = serde_json::to_string(&CredentialResponse { headers }) - .context("Failed to serialize response as JSON")?; + let response = serde_json::to_string( + &credentials + .map(BazelCredentialResponse::try_from) + .unwrap_or_else(|| Ok(BazelCredentialResponse::default()))?, + ) + .context("Failed to serialize response as JSON")?; writeln!(printer.stdout(), "{response}")?; Ok(ExitStatus::Success) } From 181978fbe46bc71a6c12e6e159568e31a48b36e1 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 4 Dec 2025 14:30:12 +0000 Subject: [PATCH 6/8] More review feedback --- crates/uv/src/commands/auth/helper.rs | 58 ++++++++++++------------- crates/uv/tests/it/auth.rs | 61 ++++++++++++++++++--------- 2 files changed, 69 insertions(+), 50 deletions(-) diff --git a/crates/uv/src/commands/auth/helper.rs b/crates/uv/src/commands/auth/helper.rs index a832be8708ad2..3c5f0a4d410df 100644 --- a/crates/uv/src/commands/auth/helper.rs +++ b/crates/uv/src/commands/auth/helper.rs @@ -4,7 +4,7 @@ use std::io::Read; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -use url::Url; +use tracing::debug; use uv_auth::{AuthBackend, Credentials, PyxTokenStore}; use uv_client::BaseClientBuilder; @@ -16,7 +16,7 @@ use crate::{commands::ExitStatus, printer::Printer, settings::NetworkSettings}; /// Request format for the Bazel credential helper protocol. #[derive(Debug, Deserialize)] struct BazelCredentialRequest { - uri: String, + uri: DisplaySafeUrl, } impl BazelCredentialRequest { @@ -42,21 +42,16 @@ struct BazelCredentialResponse { impl TryFrom for BazelCredentialResponse { fn try_from(creds: Credentials) -> Result { - let mut headers = HashMap::new(); - // Only include the Authorization header if credentials are authenticated - // (i.e., not just a username without password) - if creds.is_authenticated() { - let header_value = creds.to_header_value(); - - // Convert HeaderValue to String - let header_str = header_value - .to_str() - .context("Failed to convert header value to string")? - .to_string(); - - headers.insert("Authorization".to_string(), vec![header_str]); - } - Ok(Self { headers }) + let header_str = creds + .to_header_value() + .to_str() + // TODO: this is infallible in practice + .context("Failed to convert header value to string")? + .to_owned(); + + Ok(Self { + headers: HashMap::from([("Authorization".to_owned(), vec![header_str])]), + }) } type Error = anyhow::Error; @@ -71,8 +66,14 @@ async fn credentials_for_url( // Use only the username from the URL, if present - discarding the password let url_credentials = Credentials::from_url(url); - let url_username = url_credentials.as_ref().and_then(|c| c.username()); - let username = url_username.map(ToString::to_string); + let username = url_credentials.as_ref().and_then(|c| c.username()); + if url_credentials + .as_ref() + .map(|c| c.password().is_some()) + .unwrap_or(false) + { + debug!("URL '{url}' contain a password; ignoring"); + } if pyx_store.is_known_domain(url) { if username.is_some() { @@ -80,7 +81,7 @@ async fn credentials_for_url( "Cannot specify a username for URLs under {}", url.host() .map(|host| host.to_string()) - .unwrap_or("this host".to_owned()) + .unwrap_or(url.to_string()) ); } let client = BaseClientBuilder::new( @@ -97,15 +98,13 @@ async fn credentials_for_url( .access_token(client.for_host(pyx_store.api()).raw_client(), 0) .await .context("Authentication failure")?; - let token = maybe_token.ok_or_else(|| anyhow::anyhow!("No access token found"))?; + let token = maybe_token.context("No access token found")?; return Ok(Some(Credentials::bearer(token.into_bytes()))); } - let backend = AuthBackend::from_settings(preview)?; + let backend = AuthBackend::from_settings(preview).await?; let credentials = match &backend { - AuthBackend::System(provider) => provider.fetch(url, username.as_deref()).await, - AuthBackend::TextStore(store, _lock) => { - store.get_credentials(url, username.as_deref()).cloned() - } + AuthBackend::System(provider) => provider.fetch(url, username).await, + AuthBackend::TextStore(store, _lock) => store.get_credentials(url, username).cloned(), }; Ok(credentials) } @@ -131,10 +130,7 @@ pub(crate) async fn helper( // TODO: make this logic generic over the protocol by providing `request.uri` from a // trait - that should help with adding new protocols - let url = Url::parse(&request.uri).context("Invalid URI in credential request")?; - let safe_url = DisplaySafeUrl::from_url(url); - - let credentials = credentials_for_url(&safe_url, preview, network_settings).await?; + let credentials = credentials_for_url(&request.uri, preview, network_settings).await?; let response = serde_json::to_string( &credentials @@ -142,6 +138,6 @@ pub(crate) async fn helper( .unwrap_or_else(|| Ok(BazelCredentialResponse::default()))?, ) .context("Failed to serialize response as JSON")?; - writeln!(printer.stdout(), "{response}")?; + writeln!(printer.stdout_important(), "{response}")?; Ok(ExitStatus::Success) } diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index 35237661d9f6d..09ec3d824f108 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -1929,10 +1929,7 @@ fn native_auth_host_fallback() -> Result<()> { /// Test credential helper with basic auth credentials #[test] fn helper_basic_auth() { - let context = TestContext::new("3.12").with_filter(( - r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), - r#""Basic [REDACTED]""#.to_owned(), - )); + let context = TestContext::new("3.12"); // Store credentials uv_snapshot!(context.filters(), context.auth_login() @@ -1953,7 +1950,7 @@ fn helper_basic_auth() { success: true exit_code: 0 ----- stdout ----- - {"headers":{"Authorization":["Basic [REDACTED]"]}} + {"headers":{"Authorization":["Basic dGVzdHVzZXI6dGVzdHBhc3M="]}} ----- stderr ----- "# @@ -1963,10 +1960,7 @@ fn helper_basic_auth() { /// Test credential helper with token credentials #[test] fn helper_token() { - let context = TestContext::new("3.12").with_filter(( - r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), - r#""Basic [REDACTED]""#.to_owned(), - )); + let context = TestContext::new("3.12"); // Store token uv_snapshot!(context.filters(), context.auth_login() @@ -1987,7 +1981,7 @@ fn helper_token() { success: true exit_code: 0 ----- stdout ----- - {"headers":{"Authorization":["Basic [REDACTED]"]}} + {"headers":{"Authorization":["Basic X190b2tlbl9fOm15dG9rZW4xMjM="]}} ----- stderr ----- "# @@ -2037,25 +2031,22 @@ fn helper_invalid_uri() { uv_snapshot!(context.filters(), context.auth_helper(), input=r#"{"uri":"not a url"}"#, - @r" + @r#" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Invalid URI in credential request - Caused by: relative URL without a base - " + error: Failed to parse credential request as JSON + Caused by: relative URL without a base: "not a url" at line 1 column 18 + "# ); } /// Test credential helper with username in URI #[test] fn helper_username_in_uri() { - let context = TestContext::new("3.12").with_filter(( - r#""Basic [a-zA-Z0-9+/=]+""#.to_owned(), - r#""Basic [REDACTED]""#.to_owned(), - )); + let context = TestContext::new("3.12"); // Store credentials with specific username uv_snapshot!(context.filters(), context.auth_login() @@ -2077,7 +2068,39 @@ fn helper_username_in_uri() { success: true exit_code: 0 ----- stdout ----- - {"headers":{"Authorization":["Basic [REDACTED]"]}} + {"headers":{"Authorization":["Basic c3BlY2lmaWN1c2VyOnNwZWNpZmljcGFzcw=="]}} + + ----- stderr ----- + "# + ); +} + +/// Test credential helper with unknown username in URI +#[test] +fn helper_unknown_username_in_uri() { + let context = TestContext::new("3.12"); + + // Store credentials with specific username + uv_snapshot!(context.filters(), context.auth_login() + .arg("https://test.example.com") + .arg("--username").arg("specificuser") + .arg("--password").arg("specificpass"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for specificuser@https://test.example.com/ + "###); + + // Test with username in URI + uv_snapshot!(context.filters(), context.auth_helper(), + input=r#"{"uri":"https://differentuser@test.example.com/path"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{}} ----- stderr ----- "# From 122b73c85306a6552c14b9008669ce2c6b73c40f Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 4 Dec 2025 18:05:17 +0000 Subject: [PATCH 7/8] Even more review feedback --- crates/uv-cli/src/lib.rs | 2 +- crates/uv/src/commands/auth/helper.rs | 6 ++-- crates/uv/tests/it/auth.rs | 42 ++++++++++++++++++--------- crates/uv/tests/it/common/mod.rs | 7 +---- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 3a51cd0ab47bc..ea4fc07bfa7d6 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4939,7 +4939,7 @@ pub enum AuthCommand { /// Implements the Bazel credential helper protocol to provide credentials /// to external tools via JSON over stdin/stdout. /// - /// This command is typically invoked by external tools, not directly by users. + /// This command is typically invoked by external tools. #[command(hide = true)] Helper(AuthHelperArgs), } diff --git a/crates/uv/src/commands/auth/helper.rs b/crates/uv/src/commands/auth/helper.rs index 3c5f0a4d410df..e24d820c6fe0f 100644 --- a/crates/uv/src/commands/auth/helper.rs +++ b/crates/uv/src/commands/auth/helper.rs @@ -94,11 +94,11 @@ async fn credentials_for_url( ) .auth_integration(uv_client::AuthIntegration::NoAuthMiddleware) .build(); - let maybe_token = pyx_store + let token = pyx_store .access_token(client.for_host(pyx_store.api()).raw_client(), 0) .await - .context("Authentication failure")?; - let token = maybe_token.context("No access token found")?; + .context("Authentication failure")? + .context("No access token found")?; return Ok(Some(Credentials::bearer(token.into_bytes()))); } let backend = AuthBackend::from_settings(preview).await?; diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index 09ec3d824f108..8a60fa0ac04a9 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -1928,7 +1928,7 @@ fn native_auth_host_fallback() -> Result<()> { /// Test credential helper with basic auth credentials #[test] -fn helper_basic_auth() { +fn bazel_helper_basic_auth() { let context = TestContext::new("3.12"); // Store credentials @@ -1944,7 +1944,9 @@ fn helper_basic_auth() { Stored credentials for testuser@https://test.example.com/ "###); - uv_snapshot!(context.filters(), context.auth_helper(), + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), input=r#"{"uri":"https://test.example.com/path"}"#, @r#" success: true @@ -1959,7 +1961,7 @@ fn helper_basic_auth() { /// Test credential helper with token credentials #[test] -fn helper_token() { +fn bazel_helper_token() { let context = TestContext::new("3.12"); // Store token @@ -1975,7 +1977,9 @@ fn helper_token() { "###); // Test credential helper - tokens are stored as Basic auth with __token__ username - uv_snapshot!(context.filters(), context.auth_helper(), + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), input=r#"{"uri":"https://api.example.com/v1/endpoint"}"#, @r#" success: true @@ -1990,9 +1994,11 @@ fn helper_token() { /// Test credential helper with no credentials found #[test] -fn helper_no_credentials() { +fn bazel_helper_no_credentials() { let context = TestContext::new("3.12"); - uv_snapshot!(context.filters(), context.auth_helper(), + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), input=r#"{"uri":"https://unknown.example.com/path"}"#, @r#" success: true @@ -2007,10 +2013,12 @@ fn helper_no_credentials() { /// Test credential helper with invalid JSON input #[test] -fn helper_invalid_json() { +fn bazel_helper_invalid_json() { let context = TestContext::new("3.12"); - uv_snapshot!(context.filters(), context.auth_helper(), + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), input="not json", @r" success: false @@ -2026,10 +2034,12 @@ fn helper_invalid_json() { /// Test credential helper with invalid URI #[test] -fn helper_invalid_uri() { +fn bazel_helper_invalid_uri() { let context = TestContext::new("3.12"); - uv_snapshot!(context.filters(), context.auth_helper(), + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), input=r#"{"uri":"not a url"}"#, @r#" success: false @@ -2045,7 +2055,7 @@ fn helper_invalid_uri() { /// Test credential helper with username in URI #[test] -fn helper_username_in_uri() { +fn bazel_helper_username_in_uri() { let context = TestContext::new("3.12"); // Store credentials with specific username @@ -2062,7 +2072,9 @@ fn helper_username_in_uri() { "###); // Test with username in URI - uv_snapshot!(context.filters(), context.auth_helper(), + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), input=r#"{"uri":"https://specificuser@test.example.com/path"}"#, @r#" success: true @@ -2077,7 +2089,7 @@ fn helper_username_in_uri() { /// Test credential helper with unknown username in URI #[test] -fn helper_unknown_username_in_uri() { +fn bazel_helper_unknown_username_in_uri() { let context = TestContext::new("3.12"); // Store credentials with specific username @@ -2094,7 +2106,9 @@ fn helper_unknown_username_in_uri() { "###); // Test with username in URI - uv_snapshot!(context.filters(), context.auth_helper(), + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), input=r#"{"uri":"https://differentuser@test.example.com/path"}"#, @r#" success: true diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 14d83383dd264..4fd4a781105c8 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1461,12 +1461,7 @@ impl TestContext { /// Create a `uv auth helper --protocol bazel get` command. pub fn auth_helper(&self) -> Command { let mut command = Self::new_command(); - command - .arg("auth") - .arg("helper") - .arg("--protocol") - .arg("bazel") - .arg("get"); + command.arg("auth").arg("helper"); self.add_shared_options(&mut command, false); command } From 44690130d21aa707669c8ad9f98f4edb6845831e Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 4 Dec 2025 18:24:40 +0000 Subject: [PATCH 8/8] Add a preview flag --- crates/uv-preview/src/lib.rs | 3 +++ crates/uv/src/commands/auth/helper.rs | 10 +++++++++- crates/uv/tests/it/auth.rs | 7 +++++++ crates/uv/tests/it/show_settings.rs | 4 ++-- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index 1a7ce34d621ce..146c6e580cafe 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -26,6 +26,7 @@ bitflags::bitflags! { const WORKSPACE_DIR = 1 << 14; const WORKSPACE_LIST = 1 << 15; const SBOM_EXPORT = 1 << 16; + const AUTH_HELPER = 1 << 17; } } @@ -52,6 +53,7 @@ impl PreviewFeatures { Self::WORKSPACE_DIR => "workspace-dir", Self::WORKSPACE_LIST => "workspace-list", Self::SBOM_EXPORT => "sbom-export", + Self::AUTH_HELPER => "auth-helper", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -106,6 +108,7 @@ impl FromStr for PreviewFeatures { "workspace-dir" => Self::WORKSPACE_DIR, "workspace-list" => Self::WORKSPACE_LIST, "sbom-export" => Self::SBOM_EXPORT, + "auth-helper" => Self::AUTH_HELPER, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; diff --git a/crates/uv/src/commands/auth/helper.rs b/crates/uv/src/commands/auth/helper.rs index e24d820c6fe0f..320e0216e96de 100644 --- a/crates/uv/src/commands/auth/helper.rs +++ b/crates/uv/src/commands/auth/helper.rs @@ -8,8 +8,9 @@ use tracing::debug; use uv_auth::{AuthBackend, Credentials, PyxTokenStore}; use uv_client::BaseClientBuilder; -use uv_preview::Preview; +use uv_preview::{Preview, PreviewFeatures}; use uv_redacted::DisplaySafeUrl; +use uv_warnings::warn_user; use crate::{commands::ExitStatus, printer::Printer, settings::NetworkSettings}; @@ -126,6 +127,13 @@ pub(crate) async fn helper( network_settings: &NetworkSettings, printer: Printer, ) -> Result { + if !preview.is_enabled(PreviewFeatures::AUTH_HELPER) { + warn_user!( + "The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features {}` to disable this warning", + PreviewFeatures::AUTH_HELPER + ); + } + let request = BazelCredentialRequest::from_stdin()?; // TODO: make this logic generic over the protocol by providing `request.uri` from a diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index 8a60fa0ac04a9..4ce3efa29b032 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -1955,6 +1955,7 @@ fn bazel_helper_basic_auth() { {"headers":{"Authorization":["Basic dGVzdHVzZXI6dGVzdHBhc3M="]}} ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning "# ); } @@ -1988,6 +1989,7 @@ fn bazel_helper_token() { {"headers":{"Authorization":["Basic X190b2tlbl9fOm15dG9rZW4xMjM="]}} ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning "# ); } @@ -2007,6 +2009,7 @@ fn bazel_helper_no_credentials() { {"headers":{}} ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning "# ); } @@ -2026,6 +2029,7 @@ fn bazel_helper_invalid_json() { ----- stdout ----- ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning error: Failed to parse credential request as JSON Caused by: expected ident at line 1 column 2 " @@ -2047,6 +2051,7 @@ fn bazel_helper_invalid_uri() { ----- stdout ----- ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning error: Failed to parse credential request as JSON Caused by: relative URL without a base: "not a url" at line 1 column 18 "# @@ -2083,6 +2088,7 @@ fn bazel_helper_username_in_uri() { {"headers":{"Authorization":["Basic c3BlY2lmaWN1c2VyOnNwZWNpZmljcGFzcw=="]}} ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning "# ); } @@ -2117,6 +2123,7 @@ fn bazel_helper_unknown_username_in_uri() { {"headers":{}} ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning "# ); } diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index a4d8288ec2aef..3bc7e011cefcb 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7832,7 +7832,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER, ), }, python_preference: Managed, @@ -8060,7 +8060,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER, ), }, python_preference: Managed,