use std::collections::HashMap;
use keyring::Entry;
use thiserror::Error;
const SERVICE_NAME: &str = "koan";
#[derive(Debug, Error)]
pub enum CredentialError {
#[error("keyring error: {0}")]
Keyring(#[from] keyring::Error),
#[error("password not found")]
NotFound,
}
pub fn store_password(account: &str, password: &str) -> Result<(), CredentialError> {
if let Ok(mut cache) = CACHE.write() {
cache.insert(account.to_string(), Some(password.to_string()));
}
if keychain_disabled() {
return Ok(());
}
let entry = Entry::new(SERVICE_NAME, account)?;
entry.set_password(password)?;
Ok(())
}
fn keychain_disabled() -> bool {
std::env::var_os("KOAN_NO_KEYCHAIN").is_some_and(|v| v != "0")
}
static CACHE: std::sync::LazyLock<std::sync::RwLock<HashMap<String, Option<String>>>> =
std::sync::LazyLock::new(|| std::sync::RwLock::new(HashMap::new()));
pub fn get_password(account: &str) -> Result<String, CredentialError> {
if account.is_empty() || keychain_disabled() {
return Err(CredentialError::NotFound);
}
if let Ok(cache) = CACHE.read()
&& let Some(cached) = cache.get(account)
{
return cached.clone().ok_or(CredentialError::NotFound);
}
let entry = Entry::new(SERVICE_NAME, account)?;
let result = match entry.get_password() {
Ok(pw) => Ok(pw),
Err(keyring::Error::NoEntry) => Err(CredentialError::NotFound),
Err(e) => return Err(CredentialError::Keyring(e)),
};
if let Ok(mut cache) = CACHE.write() {
cache.insert(account.to_string(), result.as_ref().ok().cloned());
}
result
}
pub fn delete_password(account: &str) -> Result<(), CredentialError> {
if let Ok(mut cache) = CACHE.write() {
cache.remove(account);
}
if keychain_disabled() {
return Ok(());
}
let entry = Entry::new(SERVICE_NAME, account)?;
entry.delete_credential()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_opt_out_is_honoured() {
let previous = std::env::var_os("KOAN_NO_KEYCHAIN");
unsafe { std::env::set_var("KOAN_NO_KEYCHAIN", "1") };
assert!(keychain_disabled());
assert!(matches!(
get_password("https://example.invalid"),
Err(CredentialError::NotFound)
));
unsafe {
match previous {
Some(v) => std::env::set_var("KOAN_NO_KEYCHAIN", v),
None => std::env::remove_var("KOAN_NO_KEYCHAIN"),
}
}
}
#[test]
fn an_empty_account_never_reaches_the_store() {
assert!(matches!(get_password(""), Err(CredentialError::NotFound)));
}
}