use std::{io::Write, process::Stdio};
use tokio::process::Command;
use tracing::{debug, instrument, trace, warn};
use uv_redacted::DisplaySafeUrl;
use uv_warnings::warn_user_once;
use crate::credentials::Credentials;
static UV_SERVICE_PREFIX: &str = "uv:";
#[derive(Debug)]
pub struct KeyringProvider {
backend: KeyringProviderBackend,
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Keyring(#[from] uv_keyring::Error),
#[error("The '{0}' keyring provider does not support storing credentials")]
StoreUnsupported(KeyringProviderBackend),
#[error("The '{0}' keyring provider does not support removing credentials")]
RemoveUnsupported(KeyringProviderBackend),
}
#[derive(Debug, Clone)]
pub enum KeyringProviderBackend {
Native,
Subprocess,
#[cfg(test)]
Dummy(Vec<(String, &'static str, &'static str)>),
}
impl std::fmt::Display for KeyringProviderBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Native => write!(f, "native"),
Self::Subprocess => write!(f, "subprocess"),
#[cfg(test)]
Self::Dummy(_) => write!(f, "dummy"),
}
}
}
impl KeyringProvider {
pub fn native() -> Self {
Self {
backend: KeyringProviderBackend::Native,
}
}
pub fn subprocess() -> Self {
Self {
backend: KeyringProviderBackend::Subprocess,
}
}
#[instrument(skip_all, fields(url = % url.to_string(), username))]
pub async fn store(
&self,
url: &DisplaySafeUrl,
credentials: &Credentials,
) -> Result<bool, Error> {
let Some(username) = credentials.username() else {
trace!("Unable to store credentials in keyring for {url} due to missing username");
return Ok(false);
};
let Some(password) = credentials.password() else {
trace!("Unable to store credentials in keyring for {url} due to missing password");
return Ok(false);
};
let url = url.without_credentials();
let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
let mut target = String::new();
if url.scheme() != "https" {
target.push_str(url.scheme());
target.push_str("://");
}
target.push_str(host);
if let Some(port) = url.port() {
target.push(':');
target.push_str(&port.to_string());
}
target
} else {
url.to_string()
};
match &self.backend {
KeyringProviderBackend::Native => {
self.store_native(&target, username, password).await?;
Ok(true)
}
KeyringProviderBackend::Subprocess => {
Err(Error::StoreUnsupported(self.backend.clone()))
}
#[cfg(test)]
KeyringProviderBackend::Dummy(_) => Err(Error::StoreUnsupported(self.backend.clone())),
}
}
#[instrument(skip(self))]
async fn store_native(
&self,
service: &str,
username: &str,
password: &str,
) -> Result<(), Error> {
let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
entry.set_password(password).await?;
Ok(())
}
#[instrument(skip_all, fields(url = % url.to_string(), username))]
pub async fn remove(&self, url: &DisplaySafeUrl, username: &str) -> Result<(), Error> {
let url = url.without_credentials();
let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
let mut target = String::new();
if url.scheme() != "https" {
target.push_str(url.scheme());
target.push_str("://");
}
target.push_str(host);
if let Some(port) = url.port() {
target.push(':');
target.push_str(&port.to_string());
}
target
} else {
url.to_string()
};
match &self.backend {
KeyringProviderBackend::Native => {
self.remove_native(&target, username).await?;
Ok(())
}
KeyringProviderBackend::Subprocess => {
Err(Error::RemoveUnsupported(self.backend.clone()))
}
#[cfg(test)]
KeyringProviderBackend::Dummy(_) => Err(Error::RemoveUnsupported(self.backend.clone())),
}
}
#[instrument(skip(self))]
async fn remove_native(
&self,
service_name: &str,
username: &str,
) -> Result<(), uv_keyring::Error> {
let prefixed_service = format!("{UV_SERVICE_PREFIX}{service_name}");
let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
entry.delete_credential().await?;
trace!("Removed credentials for {username}@{service_name} from system keyring");
Ok(())
}
#[instrument(skip_all, fields(url = % url.to_string(), username))]
pub async fn fetch(&self, url: &DisplaySafeUrl, username: Option<&str>) -> Option<Credentials> {
debug_assert!(
url.host_str().is_some(),
"Should only use keyring for URLs with host"
);
debug_assert!(
url.password().is_none(),
"Should only use keyring for URLs without a password"
);
debug_assert!(
!username.map(str::is_empty).unwrap_or(false),
"Should only use keyring with a non-empty username"
);
trace!("Checking keyring for URL {url}");
let mut credentials = match self.backend {
KeyringProviderBackend::Native => self.fetch_native(url.as_str(), username).await,
KeyringProviderBackend::Subprocess => {
self.fetch_subprocess(url.as_str(), username).await
}
#[cfg(test)]
KeyringProviderBackend::Dummy(ref store) => {
Self::fetch_dummy(store, url.as_str(), username)
}
};
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}");
credentials = match self.backend {
KeyringProviderBackend::Native => self.fetch_native(&host, username).await,
KeyringProviderBackend::Subprocess => self.fetch_subprocess(&host, username).await,
#[cfg(test)]
KeyringProviderBackend::Dummy(ref store) => {
Self::fetch_dummy(store, &host, username)
}
};
if credentials.is_none() && url.scheme() != "https" {
let scheme_host = format!("{}://{host}", url.scheme());
trace!("Checking keyring for scheme+host {scheme_host}");
credentials = match self.backend {
KeyringProviderBackend::Native => {
self.fetch_native(&scheme_host, username).await
}
KeyringProviderBackend::Subprocess => {
self.fetch_subprocess(&scheme_host, username).await
}
#[cfg(test)]
KeyringProviderBackend::Dummy(ref store) => {
Self::fetch_dummy(store, &scheme_host, username)
}
};
}
}
credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password)))
}
#[instrument(skip(self))]
async fn fetch_subprocess(
&self,
service_name: &str,
username: Option<&str>,
) -> Option<(String, String)> {
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(if username.is_some() {
Stdio::inherit()
} else {
Stdio::piped()
})
.spawn()
.inspect_err(|err| warn!("Failure running `keyring` command: {err}"))
.ok()?;
let output = child
.wait_with_output()
.await
.inspect_err(|err| warn!("Failed to wait for `keyring` output: {err}"))
.ok()?;
if output.status.success() {
std::io::stderr().write_all(&output.stderr).ok();
let output = String::from_utf8(output.stdout)
.inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}"))
.ok()?;
let (username, password) = if let Some(username) = username {
let password = output.trim_end();
(username, password)
} else {
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() {
warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
}
Some((username.to_string(), password.to_string()))
} else {
let stderr = std::str::from_utf8(&output.stderr).ok()?;
if stderr.contains("unrecognized arguments: --mode") {
warn_user_once!(
"Attempted to fetch credentials using the `keyring` command, but it does not support `--mode creds`; upgrade to `keyring>=v25.2.1` or provide a username"
);
} else if username.is_none() {
std::io::stderr().write_all(&output.stderr).ok();
}
None
}
}
#[instrument(skip(self))]
async fn fetch_native(
&self,
service: &str,
username: Option<&str>,
) -> Option<(String, String)> {
let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
let username = username?;
let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else {
return None;
};
match entry.get_password().await {
Ok(password) => return Some((username.to_string(), password)),
Err(uv_keyring::Error::NoEntry) => {
debug!("No entry found in system keyring for {service}");
}
Err(err) => {
warn_user_once!(
"Unable to fetch credentials for {service} from system keyring: {err}"
);
}
}
None
}
#[cfg(test)]
fn fetch_dummy(
store: &Vec<(String, &'static str, &'static str)>,
service_name: &str,
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
}
})
}
#[cfg(test)]
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))
.collect(),
),
}
}
#[cfg(test)]
pub fn empty() -> Self {
Self {
backend: KeyringProviderBackend::Dummy(Vec::new()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::FutureExt;
use url::Url;
#[tokio::test]
async fn fetch_url_no_host() {
let url = Url::parse("file:/etc/bin/").unwrap();
let keyring = KeyringProvider::empty();
let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some("user"));
if cfg!(debug_assertions) {
let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
assert!(result.is_err());
} else {
assert_eq!(fetch.await, None);
}
}
#[tokio::test]
async fn fetch_url_with_password() {
let url = Url::parse("https://user:password@example.com").unwrap();
let keyring = KeyringProvider::empty();
let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()));
if cfg!(debug_assertions) {
let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
assert!(result.is_err());
} else {
assert_eq!(fetch.await, None);
}
}
#[tokio::test]
async fn fetch_url_with_empty_username() {
let url = Url::parse("https://example.com").unwrap();
let keyring = KeyringProvider::empty();
let fetch = keyring.fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()));
if cfg!(debug_assertions) {
let result = std::panic::AssertUnwindSafe(fetch).catch_unwind().await;
assert!(result.is_err());
} else {
assert_eq!(fetch.await, None);
}
}
#[tokio::test]
async fn fetch_url_no_auth() {
let url = Url::parse("https://example.com").unwrap();
let url = DisplaySafeUrl::ref_cast(&url);
let keyring = KeyringProvider::empty();
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")]);
assert_eq!(
keyring
.fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
.await,
Some(Credentials::basic(
Some("user".to_string()),
Some("password".to_string())
))
);
assert_eq!(
keyring
.fetch(
DisplaySafeUrl::ref_cast(&url.join("test").unwrap()),
Some("user")
)
.await,
Some(Credentials::basic(
Some("user".to_string()),
Some("password".to_string())
))
);
}
#[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(DisplaySafeUrl::ref_cast(&url), Some("user"))
.await;
assert_eq!(credentials, None);
}
#[tokio::test]
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"),
]);
assert_eq!(
keyring
.fetch(
DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()),
Some("user")
)
.await,
Some(Credentials::basic(
Some("user".to_string()),
Some("password".to_string())
))
);
assert_eq!(
keyring
.fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
.await,
Some(Credentials::basic(
Some("user".to_string()),
Some("other-password".to_string())
))
);
assert_eq!(
keyring
.fetch(
DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()),
Some("user")
)
.await,
Some(Credentials::basic(
Some("user".to_string()),
Some("other-password".to_string())
))
);
}
#[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(DisplaySafeUrl::ref_cast(&url), Some("user"))
.await;
assert_eq!(
credentials,
Some(Credentials::basic(
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(DisplaySafeUrl::ref_cast(&url), None).await;
assert_eq!(
credentials,
Some(Credentials::basic(
Some("user".to_string()),
Some("password".to_string())
))
);
}
#[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(DisplaySafeUrl::ref_cast(&url), Some("bar"))
.await;
assert_eq!(credentials, None);
let url = Url::parse("https://foo@example.com").unwrap();
let credentials = keyring
.fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
.await;
assert_eq!(credentials, None);
}
#[tokio::test]
async fn fetch_http_scheme_host_fallback() {
let url = Url::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
let credentials = keyring
.fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
.await;
assert_eq!(
credentials,
Some(Credentials::basic(
Some("user".to_string()),
Some("password".to_string())
))
);
}
#[tokio::test]
async fn fetch_http_scheme_host_no_cross_scheme() {
let url = Url::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
let credentials = keyring
.fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
.await;
assert_eq!(credentials, None);
}
}