mirror of https://github.com/astral-sh/uv
Use `keyring --mode creds` when `authenticate = "always"` (#12316)
Previously, we required a username to perform a fetch from the keyring because the `keyring` CLI only supported fetching password for a given service and username. Unfortunately, this is different from the keyring Python API which supported fetching a username _and_ password for a given service. We can't (easily) use the Python API because we don't expect `keyring` to be installed in a specific environment during network requests. This means that we did not have parity with `pip`. Way back in https://github.com/jaraco/keyring/pull/678 we got a `--mode creds` flag added to `keyring`'s CLI which supports parity with the Python API. Since `keyring` is expensive to invoke and we cannot be certain that users are on the latest version of keyring, we've not added support for invoking keyring with this flag. However, now that we have a mode that says authentication is _required_ for an index (#11896), we might as well _try_ to invoke keyring with `--mode creds` when there is no username. This will address use-cases where the username is non-constant and move us closer to `pip` parity.
This commit is contained in:
parent
011a6de6dc
commit
37c25f2a9d
|
|
@ -4663,6 +4663,7 @@ dependencies = [
|
|||
"uv-once-map",
|
||||
"uv-small-str",
|
||||
"uv-static",
|
||||
"uv-warnings",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<Credentials> {
|
||||
pub async fn fetch(&self, url: &Url, username: Option<&str>) -> Option<Credentials> {
|
||||
// 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
|
||||
// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L376C1-L379C14>
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<S: Into<String>, T: IntoIterator<Item = ((S, &'static str), &'static str)>>(
|
||||
pub fn dummy<S: Into<String>, T: IntoIterator<Item = (S, &'static str, &'static str)>>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Arc<Credentials>> {
|
||||
// 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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
password,
|
||||
)]),
|
||||
)))
|
||||
|
|
@ -1151,7 +1215,6 @@ mod tests {
|
|||
AuthMiddleware::new()
|
||||
.with_cache(CredentialsCache::new())
|
||||
.with_keyring(Some(KeyringProvider::dummy([
|
||||
(
|
||||
(
|
||||
format!(
|
||||
"{}:{}",
|
||||
|
|
@ -1159,10 +1222,8 @@ mod tests {
|
|||
base_url_1.port().unwrap()
|
||||
),
|
||||
username_1,
|
||||
),
|
||||
password_1,
|
||||
),
|
||||
(
|
||||
(
|
||||
format!(
|
||||
"{}:{}",
|
||||
|
|
@ -1170,7 +1231,6 @@ mod tests {
|
|||
base_url_2.port().unwrap()
|
||||
),
|
||||
username_2,
|
||||
),
|
||||
password_2,
|
||||
),
|
||||
]))),
|
||||
|
|
@ -1405,7 +1465,6 @@ mod tests {
|
|||
AuthMiddleware::new()
|
||||
.with_cache(CredentialsCache::new())
|
||||
.with_keyring(Some(KeyringProvider::dummy([
|
||||
(
|
||||
(
|
||||
format!(
|
||||
"{}:{}",
|
||||
|
|
@ -1413,10 +1472,8 @@ mod tests {
|
|||
base_url_1.port().unwrap()
|
||||
),
|
||||
username_1,
|
||||
),
|
||||
password_1,
|
||||
),
|
||||
(
|
||||
(
|
||||
format!(
|
||||
"{}:{}",
|
||||
|
|
@ -1424,7 +1481,6 @@ mod tests {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Reference in New Issue