use anyhow::Result;
pub trait CredentialStore: Send + Sync {
fn get(&self, provider: &str) -> Option<String>;
fn set(&self, provider: &str, key: &str) -> Result<()>;
fn delete(&self, provider: &str) -> Result<bool>;
fn label(&self) -> &'static str;
}
pub struct KeyringStore;
fn warn_backend_unavailable(err: &keyring::Error) {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
tracing::warn!(
error = %err,
"OS keyring unavailable; stored provider keys are inaccessible \
(env vars still work; set MERMAID_NO_KEYRING=1 to silence)"
);
});
}
impl CredentialStore for KeyringStore {
fn get(&self, provider: &str) -> Option<String> {
let entry = match keyring::Entry::new("mermaid", provider) {
Ok(entry) => entry,
Err(err) => {
warn_backend_unavailable(&err);
return None;
},
};
match entry.get_password() {
Ok(key) => {
let key = key.trim().to_string();
if key.is_empty() { None } else { Some(key) }
},
Err(keyring::Error::NoEntry) => None,
Err(err) => {
warn_backend_unavailable(&err);
None
},
}
}
fn set(&self, provider: &str, key: &str) -> Result<()> {
keyring::Entry::new("mermaid", provider)?.set_password(key)?;
Ok(())
}
fn delete(&self, provider: &str) -> Result<bool> {
match keyring::Entry::new("mermaid", provider)?.delete_credential() {
Ok(()) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(err) => Err(err.into()),
}
}
fn label(&self) -> &'static str {
"the OS keyring"
}
}
pub struct NoopStore;
impl CredentialStore for NoopStore {
fn get(&self, _provider: &str) -> Option<String> {
None
}
fn set(&self, _provider: &str, _key: &str) -> Result<()> {
anyhow::bail!("keyring is disabled (MERMAID_NO_KEYRING is set)")
}
fn delete(&self, _provider: &str) -> Result<bool> {
anyhow::bail!("keyring is disabled (MERMAID_NO_KEYRING is set)")
}
fn label(&self) -> &'static str {
"keyring (disabled via MERMAID_NO_KEYRING)"
}
}
pub fn default_store() -> &'static dyn CredentialStore {
static KEYRING: KeyringStore = KeyringStore;
static NOOP: NoopStore = NoopStore;
static DISABLED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
std::env::var_os("MERMAID_NO_KEYRING").is_some_and(|v| !v.is_empty())
});
if *DISABLED { &NOOP } else { &KEYRING }
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
pub struct FakeStore {
pub entries: Mutex<HashMap<String, String>>,
}
impl CredentialStore for FakeStore {
fn get(&self, provider: &str) -> Option<String> {
self.entries.lock().unwrap().get(provider).cloned()
}
fn set(&self, provider: &str, key: &str) -> Result<()> {
self.entries
.lock()
.unwrap()
.insert(provider.to_string(), key.to_string());
Ok(())
}
fn delete(&self, provider: &str) -> Result<bool> {
Ok(self.entries.lock().unwrap().remove(provider).is_some())
}
fn label(&self) -> &'static str {
"fake store"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noop_store_reads_nothing_and_refuses_writes() {
let store = NoopStore;
assert!(store.get("groq").is_none());
assert!(store.set("groq", "k").is_err());
assert!(store.delete("groq").is_err());
}
#[test]
#[ignore]
fn keyring_round_trip() {
let store = KeyringStore;
let provider = format!("mermaid-test-{}", std::process::id());
store.set(&provider, "s3cret").expect("set");
assert_eq!(store.get(&provider).as_deref(), Some("s3cret"));
assert!(store.delete(&provider).expect("delete"));
assert!(store.get(&provider).is_none());
assert!(!store.delete(&provider).expect("second delete"));
}
}