mermaid_cli/utils/
credentials.rs1use anyhow::Result;
17
18pub trait CredentialStore: Send + Sync {
21 fn get(&self, provider: &str) -> Option<String>;
25 fn set(&self, provider: &str, key: &str) -> Result<()>;
27 fn delete(&self, provider: &str) -> Result<bool>;
29 fn label(&self) -> &'static str;
31}
32
33pub struct KeyringStore;
35
36fn 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
89pub 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
111pub 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 #[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 #[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}