use anyhow::{Result, anyhow};
use reqwest::IntoUrl;
use std::{
collections::{BTreeMap, HashMap},
sync::{Arc, Mutex},
};
use url::Url;
use crate::authentication_storage::{AuthenticationStorageError, backends::file::FileStorage};
use super::{StorageBackend, authentication::Authentication};
#[cfg(feature = "netrc-rs")]
use super::backends::netrc::NetRcStorage;
#[cfg(feature = "keyring")]
use crate::authentication_storage::backends::keyring::KeyringAuthenticationStorageError;
#[cfg(feature = "keyring")]
use super::backends::keyring::KeyringAuthenticationStorage;
#[derive(Debug, Clone)]
pub struct LazyListedEntry {
pub host: String,
pub source: String,
pub active: bool,
}
#[derive(Debug, Clone)]
pub struct ListedEntry {
pub host: String,
pub auth: Authentication,
pub source: String,
pub active: bool,
}
#[derive(Debug, Clone)]
pub struct AuthenticationStorage {
pub backends: Vec<Arc<dyn StorageBackend + Send + Sync>>,
cache: Arc<Mutex<HashMap<String, Option<Authentication>>>>,
}
impl AuthenticationStorage {
pub fn empty() -> Self {
Self {
backends: vec![],
cache: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn from_env_and_defaults() -> Result<Self, AuthenticationStorageError> {
let mut storage = Self::empty();
if let Ok(auth_file) = std::env::var("RATTLER_AUTH_FILE") {
let path = std::path::Path::new(&auth_file);
tracing::info!(
"\"RATTLER_AUTH_FILE\" environment variable set, using file storage at {}",
auth_file
);
storage.add_backend(Arc::from(FileStorage::from_path(path.into())?));
}
#[cfg(feature = "keyring")]
storage.add_backend(Arc::from(KeyringAuthenticationStorage::default()));
#[cfg(feature = "dirs")]
storage.add_backend(Arc::from(FileStorage::new()?));
#[cfg(feature = "netrc-rs")]
storage.add_backend(Arc::from(NetRcStorage::from_env().unwrap_or_else(
|(path, err)| {
tracing::warn!("error reading netrc file from {}: {}", path.display(), err);
NetRcStorage::default()
},
)));
Ok(storage)
}
pub fn add_backend(&mut self, backend: Arc<dyn StorageBackend + Send + Sync>) {
self.backends.push(backend);
}
pub fn store(&self, host: &str, authentication: &Authentication) -> Result<()> {
{
let mut cache = self.cache.lock().unwrap();
cache.insert(host.to_string(), Some(authentication.clone()));
}
for backend in &self.backends {
#[allow(unused_variables)]
if let Err(error) = backend.store(host, authentication) {
#[cfg(feature = "keyring")]
if matches!(
error,
AuthenticationStorageError::KeyringStorageError(
KeyringAuthenticationStorageError::StorageError(_)
| KeyringAuthenticationStorageError::UnsupportedTarget { .. }
)
) {
tracing::debug!("Error storing credentials in keyring: {}", error);
} else {
tracing::warn!("Error storing credentials from backend: {}", error);
}
} else {
return Ok(());
}
}
Err(anyhow!(
"All backends failed to store credentials. Checked the following backends: {:?}",
self.backends
))
}
pub fn get(&self, host: &str) -> Result<Option<Authentication>> {
{
let cache = self.cache.lock().unwrap();
if let Some(auth) = cache.get(host) {
return Ok(auth.clone());
}
}
for backend in &self.backends {
match backend.get(host) {
Ok(Some(auth)) => {
let mut cache = self.cache.lock().unwrap();
cache.insert(host.to_string(), Some(auth.clone()));
return Ok(Some(auth));
}
Ok(None) => {}
Err(_e) => {
#[cfg(feature = "keyring")]
if matches!(
_e,
AuthenticationStorageError::KeyringStorageError(
KeyringAuthenticationStorageError::StorageError(_)
| KeyringAuthenticationStorageError::UnsupportedTarget { .. }
)
) {
tracing::trace!("Error storing credentials in keyring: {}", _e);
} else {
tracing::warn!("Error retrieving credentials from backend: {}", _e);
}
}
}
}
let mut cache = self.cache.lock().unwrap();
cache.insert(host.to_string(), None);
Ok(None)
}
pub fn list(&self) -> Result<Vec<(String, Authentication)>> {
let mut entries: BTreeMap<String, Authentication> = BTreeMap::new();
for backend in &self.backends {
match backend.list() {
Ok(backend_entries) => {
for (host, auth) in backend_entries {
entries.entry(host).or_insert(auth);
}
}
Err(error) => {
tracing::warn!("Error listing credentials from backend: {}", error);
}
}
}
Ok(entries.into_iter().collect())
}
pub fn list_with_sources(&self) -> Result<Vec<ListedEntry>> {
let mut entries: Vec<ListedEntry> = Vec::new();
let mut seen_hosts: std::collections::HashSet<String> = std::collections::HashSet::new();
for backend in &self.backends {
match backend.list() {
Ok(backend_entries) => {
let source = backend.name();
for (host, auth) in backend_entries {
let active = seen_hosts.insert(host.clone());
entries.push(ListedEntry {
host,
auth,
source: source.clone(),
active,
});
}
}
Err(error) => {
tracing::warn!("Error listing credentials from backend: {}", error);
}
}
}
entries.sort_by(|a, b| a.host.cmp(&b.host));
Ok(entries)
}
pub fn get_by_url_with_host<U: IntoUrl>(
&self,
url: U,
) -> Result<(Url, Option<(String, Authentication)>), reqwest::Error> {
let url = url.into_url()?;
let host = match url.host_str() {
Some(h) => h.to_string(),
None => return Ok((url, None)),
};
match self.get(&host) {
Ok(None) => {}
Err(_) => return Ok((url, None)),
Ok(Some(credentials)) => {
return Ok((url, Some((host, credentials))));
}
};
if url.scheme() == "s3" {
let mut current_url = url.clone();
loop {
match self.get(current_url.as_str()) {
Ok(None) => {
let possible_rest =
current_url.as_str().rsplit_once('/').map(|(rest, _)| rest);
match possible_rest {
Some(rest) => {
if let Ok(new_url) = Url::parse(rest) {
current_url = new_url;
} else {
return Ok((url, None));
}
}
_ => return Ok((url, None)), }
}
Ok(Some(credentials)) => {
return Ok((url, Some((current_url.as_str().to_string(), credentials))));
}
Err(_) => return Ok((url, None)),
}
}
}
let Some(mut domain) = url.domain() else {
return Ok((url, None));
};
loop {
let wildcard_host = format!("*.{domain}");
let Ok(credentials) = self.get(&wildcard_host) else {
return Ok((url, None));
};
if let Some(credentials) = credentials {
return Ok((url, Some((wildcard_host, credentials))));
}
let possible_rest = domain.split_once('.').map(|(_, rest)| rest);
match possible_rest {
Some(rest) => {
domain = rest;
}
_ => return Ok((url, None)), }
}
}
pub fn get_by_url<U: IntoUrl>(
&self,
url: U,
) -> Result<(Url, Option<Authentication>), reqwest::Error> {
let (url, auth) = self.get_by_url_with_host(url)?;
Ok((url, auth.map(|(_, credentials)| credentials)))
}
pub async fn get_by_url_refreshed<U: IntoUrl>(
&self,
url: U,
) -> Result<(Url, Option<Authentication>), reqwest::Error> {
let (url, auth_with_key) = self.get_by_url_with_host(url)?;
let auth = match auth_with_key {
Some((matched_key, auth)) => {
crate::oauth_refresh::maybe_refresh_oauth(self, auth, &matched_key).await
}
None => None,
};
Ok((url, auth))
}
pub fn list_keys_with_sources(&self) -> Result<Vec<LazyListedEntry>> {
let mut entries: Vec<LazyListedEntry> = Vec::new();
let mut seen_hosts: std::collections::HashSet<String> = std::collections::HashSet::new();
for backend in &self.backends {
match backend.list_keys() {
Ok(hosts) => {
let source = backend.name();
for host in hosts {
let active = seen_hosts.insert(host.clone());
entries.push(LazyListedEntry {
host,
source: source.clone(),
active,
});
}
}
Err(error) => {
tracing::warn!("Error listing credentials from backend: {}", error);
}
}
}
entries.sort_by(|a, b| a.host.cmp(&b.host));
Ok(entries)
}
pub fn get_entry(&self, host: &str, source: &str) -> Result<Option<Authentication>> {
let backend = self
.backends
.iter()
.find(|b| b.name() == source)
.ok_or_else(|| {
anyhow!(
"No configured backend named '{source}' is available to read the entry from"
)
})?;
backend.get(host).map_err(Into::into)
}
pub fn delete_entry(&self, host: &str, source: &str) -> Result<()> {
{
let mut cache = self.cache.lock().unwrap();
cache.remove(host);
}
let backend = self
.backends
.iter()
.find(|b| b.name() == source)
.ok_or_else(|| {
anyhow!(
"No configured backend named '{source}' is available to delete the entry from"
)
})?;
backend.delete(host).map_err(Into::into)
}
pub fn delete(&self, host: &str) -> Result<()> {
{
let mut cache = self.cache.lock().unwrap();
cache.insert(host.to_string(), None);
}
let mut all_failed = true;
for backend in &self.backends {
if let Err(error) = backend.delete(host) {
if is_benign_storage_error(&error) {
tracing::debug!("Backend ignored delete request: {}", error);
} else {
tracing::warn!("Error deleting credentials from backend: {}", error);
}
} else {
all_failed = false;
}
}
if all_failed {
Err(anyhow!("All backends failed to delete credentials"))
} else {
Ok(())
}
}
}
fn is_benign_storage_error(error: &AuthenticationStorageError) -> bool {
#[cfg(feature = "keyring")]
if matches!(
error,
AuthenticationStorageError::KeyringStorageError(
KeyringAuthenticationStorageError::StorageError(_)
| KeyringAuthenticationStorageError::UnsupportedTarget { .. }
)
) {
return true;
}
#[cfg(feature = "netrc-rs")]
if matches!(
error,
AuthenticationStorageError::NetRcStorageError(
crate::authentication_storage::backends::netrc::NetRcStorageError::NotSupportedError(_)
)
) {
return true;
}
let _ = error;
false
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::authentication_storage::backends::memory::MemoryStorage;
fn storage_with(host: &str, auth: Authentication) -> AuthenticationStorage {
let mut storage = AuthenticationStorage::empty();
storage.add_backend(Arc::new(MemoryStorage::new()));
storage.store(host, &auth).unwrap();
storage
}
#[tokio::test]
async fn get_by_url_refreshed_passes_through_non_oauth() {
let cases = [
Authentication::BearerToken("bearer".into()),
Authentication::CondaToken("conda".into()),
Authentication::BasicHTTP {
username: "u".into(),
password: "p".into(),
},
Authentication::S3Credentials {
access_key_id: "k".into(),
secret_access_key: "s".into(),
session_token: None,
},
];
for auth in cases {
let storage = storage_with("example.com", auth.clone());
let (_, retrieved) = storage
.get_by_url_refreshed("https://example.com/foo")
.await
.unwrap();
assert_eq!(retrieved, Some(auth));
}
}
#[test]
fn list_returns_entries_from_backends() {
let mut storage = AuthenticationStorage::empty();
storage.add_backend(Arc::new(MemoryStorage::new()));
storage
.store(
"example.com",
&Authentication::BearerToken("token".to_string()),
)
.unwrap();
assert_eq!(
storage.list().unwrap(),
vec![(
"example.com".to_string(),
Authentication::BearerToken("token".to_string())
)]
);
}
#[test]
fn delete_entry_unshadows_other_backends_for_get() {
let mut storage = AuthenticationStorage::empty();
let backend_a = Arc::new(MemoryStorage::with_name("a"));
let backend_b = Arc::new(MemoryStorage::with_name("b"));
storage.add_backend(backend_a.clone());
storage.add_backend(backend_b.clone());
backend_a
.store("prefix.dev", &Authentication::BearerToken("tok-a".into()))
.unwrap();
backend_b
.store("prefix.dev", &Authentication::BearerToken("tok-b".into()))
.unwrap();
assert_eq!(
storage.get("prefix.dev").unwrap(),
Some(Authentication::BearerToken("tok-a".into()))
);
storage
.delete_entry("prefix.dev", &backend_a.name())
.unwrap();
assert_eq!(
storage.get("prefix.dev").unwrap(),
Some(Authentication::BearerToken("tok-b".into())),
"shadowed copy must become visible after the active copy is deleted"
);
}
#[test]
fn entry_operations_reject_unknown_source() {
let storage = storage_with("example.com", Authentication::BearerToken("t".into()));
assert!(storage.get_entry("example.com", "no-such-backend").is_err());
assert!(
storage
.delete_entry("example.com", "no-such-backend")
.is_err()
);
}
}