Skip to main content

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}