diff --git a/Cargo.lock b/Cargo.lock index f2c95ef28..d678f16ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4663,6 +4663,7 @@ dependencies = [ "uv-once-map", "uv-small-str", "uv-static", + "uv-warnings", "wiremock", ] diff --git a/crates/uv-auth/Cargo.toml b/crates/uv-auth/Cargo.toml index 22608cc2c..00fd67c2e 100644 --- a/crates/uv-auth/Cargo.toml +++ b/crates/uv-auth/Cargo.toml @@ -13,6 +13,7 @@ workspace = true uv-once-map = { workspace = true } uv-small-str = { workspace = true } uv-static = { workspace = true } +uv-warnings = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/crates/uv-auth/src/keyring.rs b/crates/uv-auth/src/keyring.rs index e9b7fede7..60817a82e 100644 --- a/crates/uv-auth/src/keyring.rs +++ b/crates/uv-auth/src/keyring.rs @@ -1,7 +1,8 @@ -use std::process::Stdio; +use std::{io::Write, process::Stdio}; use tokio::process::Command; use tracing::{instrument, trace, warn}; use url::Url; +use uv_warnings::warn_user_once; use crate::credentials::Credentials; @@ -19,7 +20,7 @@ pub(crate) enum KeyringProviderBackend { /// Use the `keyring` command to fetch credentials. Subprocess, #[cfg(test)] - Dummy(std::collections::HashMap<(String, &'static str), &'static str>), + Dummy(Vec<(String, &'static str, &'static str)>), } impl KeyringProvider { @@ -35,7 +36,7 @@ impl KeyringProvider { /// Returns [`None`] if no password was found for the username or if any errors /// are encountered in the keyring backend. #[instrument(skip_all, fields(url = % url.to_string(), username))] - pub async fn fetch(&self, url: &Url, username: &str) -> Option { + pub async fn fetch(&self, url: &Url, username: Option<&str>) -> Option { // Validate the request debug_assert!( url.host_str().is_some(), @@ -46,14 +47,14 @@ impl KeyringProvider { "Should only use keyring for urls without a password" ); debug_assert!( - !username.is_empty(), - "Should only use keyring with a username" + !username.map(str::is_empty).unwrap_or(false), + "Should only use keyring with a non-empty username" ); // Check the full URL first // trace!("Checking keyring for URL {url}"); - let mut password = match self.backend { + let mut credentials = match self.backend { KeyringProviderBackend::Subprocess => { self.fetch_subprocess(url.as_str(), username).await } @@ -63,14 +64,14 @@ impl KeyringProvider { } }; // And fallback to a check for the host - if password.is_none() { + if credentials.is_none() { let host = if let Some(port) = url.port() { format!("{}:{}", url.host_str()?, port) } else { url.host_str()?.to_string() }; trace!("Checking keyring for host {host}"); - password = match self.backend { + credentials = match self.backend { KeyringProviderBackend::Subprocess => self.fetch_subprocess(&host, username).await, #[cfg(test)] KeyringProviderBackend::Dummy(ref store) => { @@ -79,19 +80,36 @@ impl KeyringProvider { }; } - password.map(|password| Credentials::new(Some(username.to_string()), Some(password))) + credentials.map(|(username, password)| Credentials::new(Some(username), Some(password))) } #[instrument(skip(self))] - async fn fetch_subprocess(&self, service_name: &str, username: &str) -> Option { + async fn fetch_subprocess( + &self, + service_name: &str, + username: Option<&str>, + ) -> Option<(String, String)> { // https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141 - let child = Command::new("keyring") - .arg("get") - .arg(service_name) - .arg(username) + let mut command = Command::new("keyring"); + command.arg("get").arg(service_name); + + if let Some(username) = username { + command.arg(username); + } else { + command.arg("--mode").arg("creds"); + } + + let child = command .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) + // If we're using `--mode creds`, we need to capture the output in order to avoid + // showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr + // so the user has visibility into keyring's behavior if it's doing something slow + .stderr(if username.is_some() { + Stdio::inherit() + } else { + Stdio::piped() + }) .spawn() .inspect_err(|err| warn!("Failure running `keyring` command: {err}")) .ok()?; @@ -103,37 +121,84 @@ impl KeyringProvider { .ok()?; if output.status.success() { - // On success, parse the newline terminated password - String::from_utf8(output.stdout) + // If we captured stderr, display it in case it's helpful to the user + // TODO(zanieb): This was done when we added `--mode creds` support for parity with the + // existing behavior, but it might be a better UX to hide this on success? It also + // might be problematic that we're not streaming it. We could change this given some + // user feedback. + std::io::stderr().write_all(&output.stderr).ok(); + + // On success, parse the newline terminated credentials + let output = String::from_utf8(output.stdout) .inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}")) - .ok() - .map(|password| password.trim_end().to_string()) + .ok()?; + + let (username, password) = if let Some(username) = username { + // We're only expecting a password + let password = output.trim_end(); + (username, password) + } else { + // We're expecting a username and password + let mut lines = output.lines(); + let username = lines.next()?; + let Some(password) = lines.next() else { + warn!( + "Got username without password for `{service_name}` from `keyring` command" + ); + return None; + }; + (username, password) + }; + + if password.is_empty() { + // We allow this for backwards compatibility, but it might be better to return + // `None` instead if there's confusion from users — we haven't seen this in practice + // yet. + warn!("Got empty password for `{username}@{service_name}` from `keyring` command"); + } + + Some((username.to_string(), password.to_string())) } else { // On failure, no password was available + let stderr = std::str::from_utf8(&output.stderr).ok()?; + if stderr.contains("unrecognized arguments: --mode") { + // N.B. We do not show the `service_name` here because we'll show the warning twice + // otherwise, once for the URL and once for the realm. + warn_user_once!( + "Attempted to fetch credentials using the `keyring` command, but it does not support `--mode creds`; upgrade to `keyring>=v25.2.1` for support or provide a username" + ); + } else if username.is_none() { + // If we captured stderr, display it in case it's helpful to the user + std::io::stderr().write_all(&output.stderr).ok(); + } None } } #[cfg(test)] fn fetch_dummy( - store: &std::collections::HashMap<(String, &'static str), &'static str>, + store: &Vec<(String, &'static str, &'static str)>, service_name: &str, - username: &str, - ) -> Option { - store - .get(&(service_name.to_string(), username)) - .map(|password| (*password).to_string()) + username: Option<&str>, + ) -> Option<(String, String)> { + store.iter().find_map(|(service, user, password)| { + if service == service_name && username.is_none_or(|username| username == *user) { + Some(((*user).to_string(), (*password).to_string())) + } else { + None + } + }) } /// Create a new provider with [`KeyringProviderBackend::Dummy`]. #[cfg(test)] - pub fn dummy, T: IntoIterator>( + pub fn dummy, T: IntoIterator>( iter: T, ) -> Self { Self { backend: KeyringProviderBackend::Dummy( iter.into_iter() - .map(|((service, username), password)| ((service.into(), username), password)) + .map(|(service, username, password)| (service.into(), username, password)) .collect(), ), } @@ -142,10 +207,8 @@ impl KeyringProvider { /// Create a new provider with no credentials available. #[cfg(test)] pub fn empty() -> Self { - use std::collections::HashMap; - Self { - backend: KeyringProviderBackend::Dummy(HashMap::new()), + backend: KeyringProviderBackend::Dummy(Vec::new()), } } } @@ -160,7 +223,7 @@ mod tests { let url = Url::parse("file:/etc/bin/").unwrap(); let keyring = KeyringProvider::empty(); // Panics due to debug assertion; returns `None` in production - let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, "user")) + let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, Some("user"))) .catch_unwind() .await; assert!(result.is_err()); @@ -171,18 +234,18 @@ mod tests { let url = Url::parse("https://user:password@example.com").unwrap(); let keyring = KeyringProvider::empty(); // Panics due to debug assertion; returns `None` in production - let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, url.username())) + let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, Some(url.username()))) .catch_unwind() .await; assert!(result.is_err()); } #[tokio::test] - async fn fetch_url_with_no_username() { + async fn fetch_url_with_empty_username() { let url = Url::parse("https://example.com").unwrap(); let keyring = KeyringProvider::empty(); // Panics due to debug assertion; returns `None` in production - let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, url.username())) + let result = std::panic::AssertUnwindSafe(keyring.fetch(&url, Some(url.username()))) .catch_unwind() .await; assert!(result.is_err()); @@ -192,23 +255,25 @@ mod tests { async fn fetch_url_no_auth() { let url = Url::parse("https://example.com").unwrap(); let keyring = KeyringProvider::empty(); - let credentials = keyring.fetch(&url, "user"); + let credentials = keyring.fetch(&url, Some("user")); assert!(credentials.await.is_none()); } #[tokio::test] async fn fetch_url() { let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([((url.host_str().unwrap(), "user"), "password")]); + let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]); assert_eq!( - keyring.fetch(&url, "user").await, + keyring.fetch(&url, Some("user")).await, Some(Credentials::new( Some("user".to_string()), Some("password".to_string()) )) ); assert_eq!( - keyring.fetch(&url.join("test").unwrap(), "user").await, + keyring + .fetch(&url.join("test").unwrap(), Some("user")) + .await, Some(Credentials::new( Some("user".to_string()), Some("password".to_string()) @@ -219,8 +284,8 @@ mod tests { #[tokio::test] async fn fetch_url_no_match() { let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([(("other.com", "user"), "password")]); - let credentials = keyring.fetch(&url, "user").await; + let keyring = KeyringProvider::dummy([("other.com", "user", "password")]); + let credentials = keyring.fetch(&url, Some("user")).await; assert_eq!(credentials, None); } @@ -228,25 +293,25 @@ mod tests { async fn fetch_url_prefers_url_to_host() { let url = Url::parse("https://example.com/").unwrap(); let keyring = KeyringProvider::dummy([ - ((url.join("foo").unwrap().as_str(), "user"), "password"), - ((url.host_str().unwrap(), "user"), "other-password"), + (url.join("foo").unwrap().as_str(), "user", "password"), + (url.host_str().unwrap(), "user", "other-password"), ]); assert_eq!( - keyring.fetch(&url.join("foo").unwrap(), "user").await, + keyring.fetch(&url.join("foo").unwrap(), Some("user")).await, Some(Credentials::new( Some("user".to_string()), Some("password".to_string()) )) ); assert_eq!( - keyring.fetch(&url, "user").await, + keyring.fetch(&url, Some("user")).await, Some(Credentials::new( Some("user".to_string()), Some("other-password".to_string()) )) ); assert_eq!( - keyring.fetch(&url.join("bar").unwrap(), "user").await, + keyring.fetch(&url.join("bar").unwrap(), Some("user")).await, Some(Credentials::new( Some("user".to_string()), Some("other-password".to_string()) @@ -257,8 +322,22 @@ mod tests { #[tokio::test] async fn fetch_url_username() { let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([((url.host_str().unwrap(), "user"), "password")]); - let credentials = keyring.fetch(&url, "user").await; + let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]); + let credentials = keyring.fetch(&url, Some("user")).await; + assert_eq!( + credentials, + Some(Credentials::new( + Some("user".to_string()), + Some("password".to_string()) + )) + ); + } + + #[tokio::test] + async fn fetch_url_no_username() { + let url = Url::parse("https://example.com").unwrap(); + let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]); + let credentials = keyring.fetch(&url, None).await; assert_eq!( credentials, Some(Credentials::new( @@ -271,13 +350,13 @@ mod tests { #[tokio::test] async fn fetch_url_username_no_match() { let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([((url.host_str().unwrap(), "foo"), "password")]); - let credentials = keyring.fetch(&url, "bar").await; + let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]); + let credentials = keyring.fetch(&url, Some("bar")).await; assert_eq!(credentials, None); // Still fails if we have `foo` in the URL itself let url = Url::parse("https://foo@example.com").unwrap(); - let credentials = keyring.fetch(&url, "bar").await; + let credentials = keyring.fetch(&url, Some("bar")).await; assert_eq!(credentials, None); } } diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index ec8106135..ffd837328 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -297,7 +297,7 @@ impl Middleware for AuthMiddleware { // Then, fetch from external services. // Here, we use the username from the cache if present. if let Some(credentials) = self - .fetch_credentials(credentials.as_deref(), retry_request.url()) + .fetch_credentials(credentials.as_deref(), retry_request.url(), auth_policy) .await { retry_request = credentials.authenticate(retry_request); @@ -404,7 +404,7 @@ impl AuthMiddleware { // Do not insert already-cached credentials None } else if let Some(credentials) = self - .fetch_credentials(Some(&credentials), request.url()) + .fetch_credentials(Some(&credentials), request.url(), auth_policy) .await { request = credentials.authenticate(request); @@ -426,6 +426,7 @@ impl AuthMiddleware { &self, credentials: Option<&Credentials>, url: &Url, + auth_policy: AuthPolicy, ) -> Option> { // Fetches can be expensive, so we will only run them _once_ per realm and username combination // All other requests for the same realm will wait until the first one completes @@ -467,17 +468,25 @@ impl AuthMiddleware { }) { debug!("Found credentials in netrc file for {url}"); Some(credentials) - // N.B. The keyring provider performs lookups for the exact URL then - // falls back to the host, but we cache the result per realm so if a keyring - // implementation returns different credentials for different URLs in the - // same realm we will use the wrong credentials. + + // N.B. The keyring provider performs lookups for the exact URL then falls back to the host, + // but we cache the result per realm so if a keyring implementation returns different + // credentials for different URLs in the same realm we will use the wrong credentials. } else if let Some(credentials) = match self.keyring { Some(ref keyring) => { + // The subprocess keyring provider is _slow_ so we do not perform fetches for all + // URLs; instead, we fetch if there's a username or if the user has requested to + // always authenticate. if let Some(username) = credentials.and_then(|credentials| credentials.username()) { debug!("Checking keyring for credentials for {username}@{url}"); - keyring.fetch(url, username).await + keyring.fetch(url, Some(username)).await + } else if matches!(auth_policy, AuthPolicy::Always) { + debug!( + "Checking keyring for credentials for {url} without username due to `authenticate = always`" + ); + keyring.fetch(url, None).await } else { - debug!("Skipping keyring lookup for {url} with no username"); + debug!("Skipping keyring fetch for {url} without username; use `authenticate = always` to force"); None } } @@ -930,14 +939,12 @@ mod tests { AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_keyring(Some(KeyringProvider::dummy([( - ( - format!( - "{}:{}", - base_url.host_str().unwrap(), - base_url.port().unwrap() - ), - username, + format!( + "{}:{}", + base_url.host_str().unwrap(), + base_url.port().unwrap() ), + username, password, )]))), ) @@ -985,6 +992,64 @@ mod tests { Ok(()) } + #[test(tokio::test)] + async fn test_keyring_always_authenticate() -> Result<(), Error> { + let username = "user"; + let password = "password"; + let server = start_test_server(username, password).await; + let base_url = Url::parse(&server.uri())?; + + let auth_policies = auth_policies_for(&base_url, AuthPolicy::Always); + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(CredentialsCache::new()) + .with_keyring(Some(KeyringProvider::dummy([( + format!( + "{}:{}", + base_url.host_str().unwrap(), + base_url.port().unwrap() + ), + username, + password, + )]))) + .with_url_auth_policies(auth_policies), + ) + .build(); + + assert_eq!( + client.get(server.uri()).send().await?.status(), + 200, + "Credentials (including a username) should be pulled from the keyring" + ); + + let mut url = base_url.clone(); + url.set_username(username).unwrap(); + assert_eq!( + client.get(url).send().await?.status(), + 200, + "The password for the username should be pulled from the keyring" + ); + + let mut url = base_url.clone(); + url.set_username(username).unwrap(); + url.set_password(Some("invalid")).unwrap(); + assert_eq!( + client.get(url).send().await?.status(), + 401, + "Password in the URL should take precedence and fail" + ); + + let mut url = base_url.clone(); + url.set_username("other_user").unwrap(); + assert!( + matches!(client.get(url).send().await, Err(reqwest_middleware::Error::Middleware(_))), + "If the username does not match, a password should not be fetched, and the middleware should fail eagerly since `authenticate = always` is not satisfied" + ); + + Ok(()) + } + /// We include ports in keyring requests, e.g., `localhost:8000` should be distinct from `localhost`, /// unless the server is running on a default port, e.g., `localhost:80` is equivalent to `localhost`. /// We don't unit test the latter case because it's possible to collide with a server a developer is @@ -1002,7 +1067,8 @@ mod tests { .with_cache(CredentialsCache::new()) .with_keyring(Some(KeyringProvider::dummy([( // Omit the port from the keyring entry - (base_url.host_str().unwrap(), username), + base_url.host_str().unwrap(), + username, password, )]))), ) @@ -1037,14 +1103,12 @@ mod tests { let client = test_client_builder() .with(AuthMiddleware::new().with_cache(cache).with_keyring(Some( KeyringProvider::dummy([( - ( - format!( - "{}:{}", - base_url.host_str().unwrap(), - base_url.port().unwrap() - ), - username, + format!( + "{}:{}", + base_url.host_str().unwrap(), + base_url.port().unwrap() ), + username, password, )]), ))) @@ -1152,25 +1216,21 @@ mod tests { .with_cache(CredentialsCache::new()) .with_keyring(Some(KeyringProvider::dummy([ ( - ( - format!( - "{}:{}", - base_url_1.host_str().unwrap(), - base_url_1.port().unwrap() - ), - username_1, + format!( + "{}:{}", + base_url_1.host_str().unwrap(), + base_url_1.port().unwrap() ), + username_1, password_1, ), ( - ( - format!( - "{}:{}", - base_url_2.host_str().unwrap(), - base_url_2.port().unwrap() - ), - username_2, + format!( + "{}:{}", + base_url_2.host_str().unwrap(), + base_url_2.port().unwrap() ), + username_2, password_2, ), ]))), @@ -1406,25 +1466,21 @@ mod tests { .with_cache(CredentialsCache::new()) .with_keyring(Some(KeyringProvider::dummy([ ( - ( - format!( - "{}:{}", - base_url_1.host_str().unwrap(), - base_url_1.port().unwrap() - ), - username_1, + format!( + "{}:{}", + base_url_1.host_str().unwrap(), + base_url_1.port().unwrap() ), + username_1, password_1, ), ( - ( - format!( - "{}:{}", - base_url_2.host_str().unwrap(), - base_url_2.port().unwrap() - ), - username_2, + format!( + "{}:{}", + base_url_2.host_str().unwrap(), + base_url_2.port().unwrap() ), + username_2, password_2, ), ]))), @@ -1540,8 +1596,8 @@ mod tests { AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_keyring(Some(KeyringProvider::dummy([ - ((base_url_1.clone(), username), password_1), - ((base_url_2.clone(), username), password_2), + (base_url_1.clone(), username, password_1), + (base_url_2.clone(), username, password_2), ]))), ) .build(); diff --git a/crates/uv/src/commands/publish.rs b/crates/uv/src/commands/publish.rs index b158b28ba..3aed01770 100644 --- a/crates/uv/src/commands/publish.rs +++ b/crates/uv/src/commands/publish.rs @@ -295,7 +295,7 @@ async fn gather_credentials( if let Some(username) = &username { debug!("Fetching password from keyring"); if let Some(keyring_password) = keyring_provider - .fetch(&publish_url, username) + .fetch(&publish_url, Some(username)) .await .as_ref() .and_then(|credentials| credentials.password()) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 60339dce7..315731c6e 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -18312,6 +18312,167 @@ fn lock_keyring_credentials() -> Result<()> { Ok(()) } +/// Fetch credentials (including a username) for a named index via the keyring using `authenticate = +/// always` +#[test] +fn lock_keyring_credentials_always_authenticate_fetches_username() -> Result<()> { + let keyring_context = TestContext::new("3.12"); + + // Install our keyring plugin + keyring_context + .pip_install() + .arg( + keyring_context + .workspace_root + .join("scripts") + .join("packages") + .join("keyring_test_plugin"), + ) + // We need a newer version of keyring that supports `--mode`, so unset `EXCLUDE_NEWER` and + // pin the dependencies + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + // (from `echo "keyring==v25.6.0" | uv pip compile - --no-annotate --no-header -q`) + .arg("jaraco-classes==3.4.0") + .arg("jaraco-context==6.0.1") + .arg("jaraco-functools==4.1.0") + .arg("keyring==25.6.0") + .arg("more-itertools==10.6.0") + .assert() + .success(); + + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv] + keyring-provider = "subprocess" + + [[tool.uv.index]] + name = "proxy" + url = "https://pypi-proxy.fly.dev/basic-auth/simple" + default = true + authenticate = "always" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock() + .env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#) + .env(EnvVars::PATH, venv_bin_path(&keyring_context.venv)), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Request for https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/ + Request for pypi-proxy.fly.dev + Resolved 2 packages in [TIME] + "); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + // The lockfile shout omit the credentials. + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "foo" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi-proxy.fly.dev/basic-auth/simple" } + sdist = { url = "https://pypi-proxy.fly.dev/basic-auth/files/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://pypi-proxy.fly.dev/basic-auth/files/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + "# + ); + }); + + Ok(()) +} + +/// Fetch credentials (including a username) for a named index via the keyring using `authenticate = +/// always` — but the keyring version installed does not support `--mode creds` +#[test] +fn lock_keyring_credentials_always_authenticate_unsupported_mode() -> Result<()> { + let keyring_context = TestContext::new("3.12"); + + // Install our keyring plugin + keyring_context + .pip_install() + .arg( + keyring_context + .workspace_root + .join("scripts") + .join("packages") + .join("keyring_test_plugin"), + ) + .assert() + .success(); + + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv] + keyring-provider = "subprocess" + + [[tool.uv.index]] + name = "proxy" + url = "https://pypi-proxy.fly.dev/basic-auth/simple" + default = true + authenticate = "always" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock() + .env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#) + .env(EnvVars::PATH, venv_bin_path(&keyring_context.venv)), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Attempted to fetch credentials using the `keyring` command, but it does not support `--mode creds`; upgrade to `keyring>=v25.2.1` for support or provide a username + error: Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/` + Caused by: Missing credentials for https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/ + "); + + Ok(()) +} + #[test] fn lock_multiple_sources() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/scripts/packages/keyring_test_plugin/keyrings/test_keyring.py b/scripts/packages/keyring_test_plugin/keyrings/test_keyring.py index c1c70bff1..f06950602 100644 --- a/scripts/packages/keyring_test_plugin/keyrings/test_keyring.py +++ b/scripts/packages/keyring_test_plugin/keyrings/test_keyring.py @@ -2,7 +2,7 @@ import json import os import sys -from keyring import backend +from keyring import backend, credentials class KeyringTest(backend.KeyringBackend): @@ -10,8 +10,8 @@ class KeyringTest(backend.KeyringBackend): def get_password(self, service, username): print(f"Request for {username}@{service}", file=sys.stderr) - credentials = json.loads(os.environ.get("KEYRING_TEST_CREDENTIALS", "{}")) - return credentials.get(service, {}).get(username) + entries = json.loads(os.environ.get("KEYRING_TEST_CREDENTIALS", "{}")) + return entries.get(service, {}).get(username) def set_password(self, service, username, password): raise NotImplementedError() @@ -20,4 +20,15 @@ class KeyringTest(backend.KeyringBackend): raise NotImplementedError() def get_credential(self, service, username): - raise NotImplementedError() + print(f"Request for {service}", file=sys.stderr) + entries = json.loads(os.environ.get("KEYRING_TEST_CREDENTIALS", "{}")) + service_entries = entries.get(service, {}) + if not service_entries: + return None + if username: + password = service_entries.get(username) + if not password: + return None + return credentials.SimpleCredential(username, password) + else: + return credentials.SimpleCredential(*list(service_entries.items())[0])