Skip to main content

mermaid_cli/utils/
credentials.rs

1//! OS-keyring storage for provider API keys (`mermaid login`).
2//!
3//! Environment variables keep ABSOLUTE precedence — the keyring only fills
4//! the gap when no env var resolves (see `utils::auth::resolve_provider_key`).
5//! Keys are stored under service `"mermaid"`, account `<provider>`. Backends:
6//! macOS Keychain, Windows Credential Manager, and the freedesktop Secret
7//! Service over blocking dbus on Linux. Kernel keyutils was deliberately
8//! rejected: kernel keyrings are session-scoped and vanish on reboot — the
9//! wrong semantics for API keys.
10//!
11//! On a headless Linux box without a Secret Service every operation fails;
12//! that is treated as "no stored key" with ONE rate-limited warning, never a
13//! fatal error. `MERMAID_NO_KEYRING=1` disables the keyring entirely (broken
14//! dbus setups).
15
16use anyhow::Result;
17
18/// Abstract credential storage so resolution logic tests against an
19/// in-memory fake instead of the real OS keyring.
20pub trait CredentialStore: Send + Sync {
21    /// The stored key for `provider`, if any. Backend failures (no Secret
22    /// Service, locked keychain) read as `None` — resolution then simply
23    /// reports no key, exactly like an unset env var.
24    fn get(&self, provider: &str) -> Option<String>;
25    /// Store (or replace) `provider`'s key.
26    fn set(&self, provider: &str, key: &str) -> Result<()>;
27    /// Delete `provider`'s key; `Ok(false)` when nothing was stored.
28    fn delete(&self, provider: &str) -> Result<bool>;
29    /// Human name for confirmations ("Stored key for groq in <label>").
30    fn label(&self) -> &'static str;
31}
32
33/// The real OS keyring, service `"mermaid"`.
34pub struct KeyringStore;
35
36/// Warn about an unusable keyring backend once per process — a headless
37/// Linux box hits this on every provider resolution otherwise.
38fn warn_backend_unavailable(err: &keyring::Error) {
39    static ONCE: std::sync::Once = std::sync::Once::new();
40    ONCE.call_once(|| {
41        tracing::warn!(
42            error = %err,
43            "OS keyring unavailable; stored provider keys are inaccessible \
44             (env vars still work; set MERMAID_NO_KEYRING=1 to silence)"
45        );
46    });
47}
48
49impl CredentialStore for KeyringStore {
50    fn get(&self, provider: &str) -> Option<String> {
51        let entry = match keyring::Entry::new("mermaid", provider) {
52            Ok(entry) => entry,
53            Err(err) => {
54                warn_backend_unavailable(&err);
55                return None;
56            },
57        };
58        match entry.get_password() {
59            Ok(key) => {
60                let key = key.trim().to_string();
61                if key.is_empty() { None } else { Some(key) }
62            },
63            Err(keyring::Error::NoEntry) => None,
64            Err(err) => {
65                warn_backend_unavailable(&err);
66                None
67            },
68        }
69    }
70
71    fn set(&self, provider: &str, key: &str) -> Result<()> {
72        keyring::Entry::new("mermaid", provider)?.set_password(key)?;
73        Ok(())
74    }
75
76    fn delete(&self, provider: &str) -> Result<bool> {
77        match keyring::Entry::new("mermaid", provider)?.delete_credential() {
78            Ok(()) => Ok(true),
79            Err(keyring::Error::NoEntry) => Ok(false),
80            Err(err) => Err(err.into()),
81        }
82    }
83
84    fn label(&self) -> &'static str {
85        "the OS keyring"
86    }
87}
88
89/// The `MERMAID_NO_KEYRING=1` escape hatch: reads yield nothing, writes are
90/// a clear error instead of a silent no-op.
91pub struct NoopStore;
92
93impl CredentialStore for NoopStore {
94    fn get(&self, _provider: &str) -> Option<String> {
95        None
96    }
97
98    fn set(&self, _provider: &str, _key: &str) -> Result<()> {
99        anyhow::bail!("keyring is disabled (MERMAID_NO_KEYRING is set)")
100    }
101
102    fn delete(&self, _provider: &str) -> Result<bool> {
103        anyhow::bail!("keyring is disabled (MERMAID_NO_KEYRING is set)")
104    }
105
106    fn label(&self) -> &'static str {
107        "keyring (disabled via MERMAID_NO_KEYRING)"
108    }
109}
110
111/// The process-wide store: the OS keyring, or the no-op store when
112/// `MERMAID_NO_KEYRING` is set (non-empty). Resolved once — env is
113/// process-static.
114pub fn default_store() -> &'static dyn CredentialStore {
115    static KEYRING: KeyringStore = KeyringStore;
116    static NOOP: NoopStore = NoopStore;
117    static DISABLED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
118        std::env::var_os("MERMAID_NO_KEYRING").is_some_and(|v| !v.is_empty())
119    });
120    if *DISABLED { &NOOP } else { &KEYRING }
121}
122
123#[cfg(test)]
124pub(crate) mod test_support {
125    use super::*;
126    use std::collections::HashMap;
127    use std::sync::Mutex;
128
129    /// In-memory store for resolution-precedence tests.
130    #[derive(Default)]
131    pub struct FakeStore {
132        pub entries: Mutex<HashMap<String, String>>,
133    }
134
135    impl CredentialStore for FakeStore {
136        fn get(&self, provider: &str) -> Option<String> {
137            self.entries.lock().unwrap().get(provider).cloned()
138        }
139
140        fn set(&self, provider: &str, key: &str) -> Result<()> {
141            self.entries
142                .lock()
143                .unwrap()
144                .insert(provider.to_string(), key.to_string());
145            Ok(())
146        }
147
148        fn delete(&self, provider: &str) -> Result<bool> {
149            Ok(self.entries.lock().unwrap().remove(provider).is_some())
150        }
151
152        fn label(&self) -> &'static str {
153            "fake store"
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn noop_store_reads_nothing_and_refuses_writes() {
164        let store = NoopStore;
165        assert!(store.get("groq").is_none());
166        assert!(store.set("groq", "k").is_err());
167        assert!(store.delete("groq").is_err());
168    }
169
170    /// Real-keyring round-trip. Ignored in CI (no Secret Service there);
171    /// run locally with `cargo test -- --ignored keyring_round_trip`.
172    #[test]
173    #[ignore]
174    fn keyring_round_trip() {
175        let store = KeyringStore;
176        let provider = format!("mermaid-test-{}", std::process::id());
177        store.set(&provider, "s3cret").expect("set");
178        assert_eq!(store.get(&provider).as_deref(), Some("s3cret"));
179        assert!(store.delete(&provider).expect("delete"));
180        assert!(store.get(&provider).is_none());
181        assert!(!store.delete(&provider).expect("second delete"));
182    }
183}