leviath_cli/credentials.rs
1//! Choosing and using the configured credential backend.
2//!
3//! [`leviath_core::credentials`] defines the vocabulary and the
4//! [`CredentialStore`] trait; `leviath_sys::keychain` owns the OS binding and
5//! its no-store fallback. This module is the seam between them: it turns a
6//! `[security] credential_store` setting into something the config loader and
7//! the `lev auth` command can call.
8
9use leviath_core::{CredentialStore, CredentialStoreKind};
10
11/// The provider API keys Leviath knows how to move into a credential store.
12///
13/// Fixed, because the OS stores offer no portable "list everything under this
14/// service" operation - the accounts to look for have to come from somewhere,
15/// and for providers that is this list.
16pub const PROVIDER_KEYS: &[&str] = &["anthropic", "openai", "google", "openrouter"];
17
18/// A [`CredentialStore`] backed by the OS credential store.
19///
20/// A thin adapter over `leviath_sys::keychain`: the platform work, the feature
21/// gate, and the "no store available" fallback all live there, so this is only
22/// the trait impl that lets the rest of the CLI stay generic over the backend.
23pub struct KeychainStore {
24 service: String,
25}
26
27impl KeychainStore {
28 /// A store filing credentials under `service`.
29 ///
30 /// Availability is [`store_for`]'s job, not this one's: it probes once and
31 /// reports a missing keychain there, rather than letting every individual
32 /// key read fail separately with an error that reads like a missing key.
33 pub fn new(service: &str) -> Self {
34 Self {
35 service: service.to_string(),
36 }
37 }
38}
39
40impl CredentialStore for KeychainStore {
41 fn get(&self, account: &str) -> Result<Option<String>, String> {
42 leviath_sys::keychain::get(&self.service, account)
43 }
44
45 fn set(&self, account: &str, secret: &str) -> Result<(), String> {
46 leviath_sys::keychain::set(&self.service, account, secret)
47 }
48
49 fn delete(&self, account: &str) -> Result<bool, String> {
50 leviath_sys::keychain::delete(&self.service, account)
51 }
52}
53
54/// The store named by `kind`, or `None` when secrets belong in Leviath's own
55/// files.
56///
57/// `None` is the ordinary answer, not a failure: `file` is the default backend.
58pub fn store_for(kind: CredentialStoreKind) -> Resolved {
59 store_for_with(kind, leviath_sys::keychain::probe)
60}
61
62/// The resolved backend: `Ok(None)` for the file store, `Ok(Some(_))` for a
63/// working keychain, `Err` for a keychain that was asked for but is unreachable.
64pub type Resolved = Result<Option<Box<dyn CredentialStore>>, String>;
65
66/// Core of [`store_for`] with the availability check injected.
67///
68/// A `fn` pointer (not `impl Fn`) so there is one monomorphization, matching the
69/// seam idiom used for the browser opener and the socket peer lookup. The seam
70/// is not a convenience: "no store is installed in this process" and "this
71/// machine has no credential store" are different things, and on a developer's
72/// Mac the first silently becomes the second - the real probe would install the
73/// platform store and every following operation would hit the real login
74/// keychain, prompting and writing. Injecting the probe is what makes an
75/// unavailable keychain testable without that.
76fn store_for_with(kind: CredentialStoreKind, probe: fn(&str) -> Result<(), String>) -> Resolved {
77 match kind {
78 CredentialStoreKind::File => Ok(None),
79 CredentialStoreKind::Keychain => {
80 let service = leviath_core::credentials::SERVICE;
81 probe(service).map_err(|e| {
82 format!("`[security] credential_store = \"keychain\"` is set, but {e}")
83 })?;
84 Ok(Some(Box::new(KeychainStore::new(service))))
85 }
86 }
87}
88
89/// A probe that always reports no credential store, for tests and for callers
90/// that need the "this machine has no keychain" path without having such a
91/// machine.
92#[cfg(test)]
93pub(crate) fn no_store_available(_service: &str) -> Result<(), String> {
94 Err("OS credential store unavailable: no default store".to_string())
95}
96
97/// Serialization for the process-wide credential store, shared by every test in
98/// this crate that touches it.
99///
100/// `keyring_core`'s default store is one global. Two modules each holding their
101/// *own* mutex would serialize against themselves and race each other, so this
102/// lives here - beside the backend it protects - rather than in each test module.
103#[cfg(test)]
104pub(crate) mod test_store {
105 static STORE: std::sync::Mutex<()> = std::sync::Mutex::new(());
106
107 /// Take the store lock, tolerating poisoning: a test that panicked while
108 /// holding it has already failed, and turning that into a cascade of
109 /// secondary failures in unrelated tests hides the original.
110 pub(crate) fn lock() -> std::sync::MutexGuard<'static, ()> {
111 STORE
112 .lock()
113 .unwrap_or_else(std::sync::PoisonError::into_inner)
114 }
115
116 /// Install a fresh in-memory store as the process default, holding the lock
117 /// for the caller's lifetime.
118 ///
119 /// This is what lets the real `leviath_sys::keychain` path run in tests:
120 /// no CI runner has an unlocked login keychain, and reaching a developer's
121 /// real one would both prompt and write.
122 pub(crate) fn with_mock() -> std::sync::MutexGuard<'static, ()> {
123 let guard = lock();
124 keyring_core::set_default_store(keyring_core::mock::Store::new().expect("mock store"));
125 guard
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::credentials::test_store;
133
134 fn with_mock_store() -> std::sync::MutexGuard<'static, ()> {
135 test_store::with_mock()
136 }
137
138 /// The adapter over the real `leviath_sys::keychain` path, driven against an
139 /// in-memory store installed as the process default - see the module docs in
140 /// `leviath-sys` for why reaching a real keychain is not an option in tests.
141 #[test]
142 fn the_keychain_adapter_round_trips_a_secret() {
143 let _guard = with_mock_store();
144 let store = KeychainStore::new("dev.leviath.test.adapter");
145
146 let account = leviath_core::provider_account("anthropic");
147 assert_eq!(store.get(&account).unwrap(), None);
148 store.set(&account, "sk-ant-x").unwrap();
149 assert_eq!(store.get(&account).unwrap().as_deref(), Some("sk-ant-x"));
150 assert!(store.delete(&account).unwrap());
151 assert!(!store.delete(&account).unwrap());
152 }
153
154 /// `file` is the default and must not consult the OS at all - the probe is
155 /// never even called, so a machine with no keychain is unaffected.
156 #[test]
157 fn the_file_backend_never_probes() {
158 static PROBED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
159 fn record(_: &str) -> Result<(), String> {
160 PROBED.store(true, std::sync::atomic::Ordering::Relaxed);
161 Ok(())
162 }
163
164 assert!(
165 store_for_with(CredentialStoreKind::File, record)
166 .unwrap()
167 .is_none(),
168 "the file backend is `None`, not a store"
169 );
170 assert!(
171 !PROBED.load(std::sync::atomic::Ordering::Relaxed),
172 "the file backend must not probe for an OS credential store"
173 );
174 // ...and the same probe *is* used for the keychain, so the check above
175 // is about the file path rather than about `record` never running.
176 assert!(store_for_with(CredentialStoreKind::Keychain, record).is_ok());
177 assert!(PROBED.load(std::sync::atomic::Ordering::Relaxed));
178 }
179
180 /// Asking for the keychain on a machine that has none must say which
181 /// setting caused it - otherwise the error looks like a Leviath bug rather
182 /// than a configuration choice.
183 #[test]
184 fn asking_for_an_unavailable_keychain_names_the_setting() {
185 // `.err()` rather than `expect_err`, which would need `Debug` on the
186 // boxed trait object - and leaves no unreachable `Ok` arm behind.
187 let err = store_for_with(CredentialStoreKind::Keychain, no_store_available)
188 .err()
189 .expect("a failing probe must not yield a store");
190 assert!(err.contains(r#"credential_store = "keychain""#), "{err}");
191 assert!(err.contains("credential store unavailable"), "{err}");
192 }
193
194 #[test]
195 fn the_keychain_backend_resolves_to_a_store() {
196 let _guard = with_mock_store();
197 assert!(
198 store_for(CredentialStoreKind::Keychain).unwrap().is_some(),
199 "with a store available the keychain backend resolves"
200 );
201 }
202
203 /// The provider list is what `lev auth migrate` and `lev auth status`
204 /// enumerate, so a provider missing from it is a secret that silently never
205 /// migrates.
206 #[test]
207 fn every_provider_with_a_config_key_is_listed() {
208 for p in ["anthropic", "openai", "google", "openrouter"] {
209 assert!(PROVIDER_KEYS.contains(&p), "{p} must be migratable");
210 }
211 }
212}