use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use secrecy::{ExposeSecret, SecretString};
use crate::error::OlError;
use super::{
CredentialStore, ERR_KEYCHAIN_PERMISSION, ERR_KEYCHAIN_UNAVAILABLE, ERR_NO_CREDENTIALS,
};
pub const SERVICE_NAME: &str = "openlatch";
const USERNAME: &str = "api-key";
pub const PROXY_USERNAME_PREFIX: &str = "proxy:";
const SKIP_KEYRING_ENV: &str = "OPENLATCH_SKIP_KEYRING";
fn keyring_disabled_by_env() -> bool {
match std::env::var(SKIP_KEYRING_ENV) {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
!matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
}
Err(_) => false,
}
}
static READ_MEMO: OnceLock<Mutex<ReadMemo>> = OnceLock::new();
type MemoKey = (String, String);
type ReadMemo = HashMap<MemoKey, Result<String, OlError>>;
fn read_memo() -> &'static Mutex<ReadMemo> {
READ_MEMO.get_or_init(|| Mutex::new(HashMap::new()))
}
fn invalidate_read_memo(service: &str, username: &str) {
if let Ok(mut memo) = read_memo().lock() {
memo.remove(&(service.to_string(), username.to_string()));
}
}
fn memoized_read<F>(key: MemoKey, read: F) -> Result<String, OlError>
where
F: FnOnce() -> Result<String, OlError>,
{
if let Ok(memo) = read_memo().lock() {
if let Some(memoized) = memo.get(&key) {
return memoized.clone();
}
}
let result = read();
if let Ok(mut memo) = read_memo().lock() {
memo.insert(key, result.clone());
}
result
}
fn skipped_no_entry_error() -> OlError {
OlError::new(
ERR_NO_CREDENTIALS,
"OS keychain disabled via OPENLATCH_SKIP_KEYRING",
)
.with_suggestion("Unset OPENLATCH_SKIP_KEYRING to use the OS keychain.")
}
fn skipped_unavailable_error() -> OlError {
OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
"OS keychain disabled via OPENLATCH_SKIP_KEYRING",
)
.with_suggestion("Unset OPENLATCH_SKIP_KEYRING to use the OS keychain.")
}
pub struct KeyringCredentialStore {
service: String,
username: String,
}
impl KeyringCredentialStore {
pub fn new() -> Self {
Self {
service: SERVICE_NAME.to_string(),
username: USERNAME.to_string(),
}
}
pub fn for_identity(service: &str, username: &str) -> Self {
Self {
service: service.to_string(),
username: username.to_string(),
}
}
}
impl Default for KeyringCredentialStore {
fn default() -> Self {
Self::new()
}
}
fn map_keyring_error(e: keyring::Error, username: &str) -> OlError {
match e {
keyring::Error::NoEntry if username.starts_with(PROXY_USERNAME_PREFIX) => {
let authority = username
.strip_prefix(PROXY_USERNAME_PREFIX)
.unwrap_or(username);
OlError::new(
ERR_NO_CREDENTIALS,
format!("No proxy password found in OS keychain for {authority}"),
)
.with_suggestion("Run 'openlatch proxy set' to store the proxy password.")
}
keyring::Error::NoEntry => {
OlError::new(ERR_NO_CREDENTIALS, "No API key found in OS keychain")
.with_suggestion("Run 'openlatch auth login' to authenticate.")
}
keyring::Error::NoStorageAccess(_) | keyring::Error::PlatformFailure(_) => OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
format!("OS keychain is not available: {e}"),
)
.with_suggestion(crate::error::keychain_suggestion()),
keyring::Error::Ambiguous(_) => OlError::new(
ERR_KEYCHAIN_PERMISSION,
format!("OS keychain access denied: {e}"),
)
.with_suggestion(crate::error::keychain_suggestion()),
other => OlError::new(ERR_KEYCHAIN_UNAVAILABLE, format!("Keychain error: {other}"))
.with_suggestion(crate::error::keychain_suggestion()),
}
}
impl KeyringCredentialStore {
fn read_entry(&self) -> Result<String, OlError> {
let entry = keyring::Entry::new(&self.service, &self.username)
.map_err(|e| map_keyring_error(e, &self.username))?;
entry
.get_password()
.map_err(|e| map_keyring_error(e, &self.username))
}
fn memo_key(&self) -> MemoKey {
(self.service.clone(), self.username.clone())
}
pub async fn store_async(&self, key: SecretString) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(skipped_unavailable_error());
}
invalidate_read_memo(&self.service, &self.username);
let service = self.service.clone();
let username = self.username.clone();
let secret_val = key.expose_secret().to_string();
tokio::task::spawn_blocking(move || {
let entry = keyring::Entry::new(&service, &username)
.map_err(|e| map_keyring_error(e, &username))?;
entry
.set_password(&secret_val)
.map_err(|e| map_keyring_error(e, &username))
})
.await
.map_err(|e| {
OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
format!("Keychain task panicked: {e}"),
)
})?
}
pub async fn retrieve_async(&self) -> Result<SecretString, OlError> {
if keyring_disabled_by_env() {
return Err(skipped_no_entry_error());
}
let store = Self {
service: self.service.clone(),
username: self.username.clone(),
};
tokio::task::spawn_blocking(move || CredentialStore::retrieve(&store))
.await
.map_err(|e| {
OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
format!("Keychain task panicked: {e}"),
)
})?
}
pub async fn delete_async(&self) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(skipped_unavailable_error());
}
invalidate_read_memo(&self.service, &self.username);
let service = self.service.clone();
let username = self.username.clone();
tokio::task::spawn_blocking(move || {
let entry = keyring::Entry::new(&service, &username)
.map_err(|e| map_keyring_error(e, &username))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(map_keyring_error(e, &username)),
}
})
.await
.map_err(|e| {
OlError::new(
ERR_KEYCHAIN_UNAVAILABLE,
format!("Keychain task panicked: {e}"),
)
})?
}
}
impl CredentialStore for KeyringCredentialStore {
fn store(&self, key: SecretString) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(skipped_unavailable_error());
}
invalidate_read_memo(&self.service, &self.username);
let entry = keyring::Entry::new(&self.service, &self.username)
.map_err(|e| map_keyring_error(e, &self.username))?;
entry
.set_password(key.expose_secret())
.map_err(|e| map_keyring_error(e, &self.username))
}
fn retrieve(&self) -> Result<SecretString, OlError> {
if keyring_disabled_by_env() {
return Err(skipped_no_entry_error());
}
memoized_read(self.memo_key(), || self.read_entry()).map(SecretString::from)
}
fn delete(&self) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(skipped_unavailable_error());
}
invalidate_read_memo(&self.service, &self.username);
let entry = keyring::Entry::new(&self.service, &self.username)
.map_err(|e| map_keyring_error(e, &self.username))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(map_keyring_error(e, &self.username)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{ERR_KEYCHAIN_UNAVAILABLE, ERR_NO_CREDENTIALS};
#[test]
fn test_keyring_credential_store_new_creates_instance() {
let store = KeyringCredentialStore::new();
assert_eq!(store.service, "openlatch");
assert_eq!(store.username, "api-key");
}
#[test]
fn test_keyring_default_creates_instance_with_correct_fields() {
let store = KeyringCredentialStore::default();
assert_eq!(store.service, "openlatch");
assert_eq!(store.username, "api-key");
}
#[test]
fn test_map_keyring_error_no_entry_maps_to_ol_1600() {
let err = map_keyring_error(keyring::Error::NoEntry, USERNAME);
assert_eq!(err.code, ERR_NO_CREDENTIALS);
assert!(err.suggestion.is_some());
assert!(
err.suggestion
.as_deref()
.is_some_and(|s| s.contains("auth login")),
"the API-key remedy must stay the API-key remedy"
);
}
#[test]
fn test_map_keyring_error_no_entry_for_a_proxy_names_the_proxy_remedy() {
let err = map_keyring_error(keyring::Error::NoEntry, "proxy:proxy.corp.example:8080");
assert_eq!(err.code, ERR_NO_CREDENTIALS);
let suggestion = err.suggestion.as_deref().unwrap_or_default();
assert!(
suggestion.contains("proxy set"),
"expected the proxy remedy, got: {suggestion}"
);
assert!(
err.message.contains("proxy.corp.example:8080"),
"the message must name the authority: {}",
err.message
);
}
#[test]
fn test_map_keyring_error_platform_failure_maps_to_ol_1602() {
let boxed: Box<dyn std::error::Error + Send + Sync> = "test failure".to_string().into();
let err = map_keyring_error(keyring::Error::PlatformFailure(boxed), USERNAME);
assert_eq!(err.code, ERR_KEYCHAIN_UNAVAILABLE);
assert!(err.suggestion.is_some());
}
#[test]
fn test_map_keyring_error_no_storage_access_maps_to_ol_1602() {
let boxed: Box<dyn std::error::Error + Send + Sync> = "no access".to_string().into();
let err = map_keyring_error(keyring::Error::NoStorageAccess(boxed), USERNAME);
assert_eq!(err.code, ERR_KEYCHAIN_UNAVAILABLE);
}
#[test]
fn test_for_identity_addresses_a_second_entry_under_the_same_service() {
let store = KeyringCredentialStore::for_identity(SERVICE_NAME, "proxy:proxy.test:8080");
assert_eq!(store.service, "openlatch");
assert_eq!(store.username, "proxy:proxy.test:8080");
assert_ne!(store.username, KeyringCredentialStore::new().username);
}
#[test]
fn test_read_memo_is_keyed_per_identity() {
let service = "openlatch-memo-isolation-test";
let api = (service.to_string(), "api-key".to_string());
let proxy = (service.to_string(), "proxy:proxy.test:8080".to_string());
let first =
memoized_read(proxy.clone(), || Ok("proxy-password".to_string())).expect("proxy read");
assert_eq!(first, "proxy-password");
let second =
memoized_read(api.clone(), || Ok("the-api-key".to_string())).expect("api read");
assert_eq!(
second, "the-api-key",
"a keyed memo must not serve the proxy password as the API key"
);
assert_eq!(
memoized_read(proxy, || Ok("never-read-again".to_string())).expect("memoized"),
"proxy-password"
);
assert_eq!(
memoized_read(api, || Ok("never-read-again".to_string())).expect("memoized"),
"the-api-key"
);
}
#[test]
fn test_invalidation_drops_only_its_own_entry() {
let service = "openlatch-memo-invalidation-test";
let api = (service.to_string(), "api-key".to_string());
let proxy = (service.to_string(), "proxy:proxy.test:8080".to_string());
memoized_read(api.clone(), || Ok("the-api-key".to_string())).expect("seed api");
memoized_read(proxy.clone(), || Ok("v1".to_string())).expect("seed proxy");
invalidate_read_memo(service, "proxy:proxy.test:8080");
assert_eq!(
memoized_read(proxy, || Ok("v2".to_string())).expect("re-read"),
"v2",
"the invalidated entry must be re-read"
);
assert_eq!(
memoized_read(api, || Ok("re-read".to_string())).expect("memoized"),
"the-api-key",
"the untouched entry must still be memoized"
);
}
#[test]
fn test_failures_are_memoized_per_identity() {
let service = "openlatch-memo-failure-test";
let key = (service.to_string(), "api-key".to_string());
let err = || Err(OlError::new(ERR_NO_CREDENTIALS, "denied"));
assert!(memoized_read(key.clone(), err).is_err());
assert!(
memoized_read(key, || Ok("would-have-prompted".to_string())).is_err(),
"a memoized failure must not fall through to a second read"
);
}
#[test]
#[ignore] fn test_keyring_skip_env_disables_retrieve() {
let key = "OPENLATCH_SKIP_KEYRING";
std::env::remove_var(key);
assert!(!keyring_disabled_by_env());
for truthy in ["1", "true", "TRUE", "yes", "on"] {
std::env::set_var(key, truthy);
assert!(keyring_disabled_by_env(), "{truthy:?} should be truthy");
}
for falsy in ["", "0", "false", "no", "off"] {
std::env::set_var(key, falsy);
assert!(!keyring_disabled_by_env(), "{falsy:?} should be falsy");
}
std::env::set_var(key, "1");
let result = KeyringCredentialStore::new().retrieve();
std::env::remove_var(key);
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ERR_NO_CREDENTIALS);
}
#[tokio::test]
#[ignore] async fn test_keyring_async_methods_compile_and_run_in_tokio_context() {
let store = KeyringCredentialStore::new();
let result = store.delete_async().await;
assert!(
result.is_ok(),
"delete_async should succeed even when no entry exists"
);
}
#[tokio::test]
#[ignore] async fn test_keyring_store_retrieve_delete_round_trip() {
let store = KeyringCredentialStore::new();
let key = SecretString::from("test-api-key-12345".to_string());
store.store_async(key).await.unwrap();
let retrieved = store.retrieve_async().await.unwrap();
use secrecy::ExposeSecret;
assert_eq!(retrieved.expose_secret(), "test-api-key-12345");
store.delete_async().await.unwrap();
assert!(store.retrieve_async().await.is_err());
}
}