Skip to main content

leviath_sys/
keychain.rs

1//! The OS credential store: macOS Keychain, Windows Credential Manager, and
2//! the Secret Service on other Unixes.
3//!
4//! This is the only place in the workspace that talks to a platform credential
5//! store. Callers work in terms of an *account* string and get plain
6//! `Result<_, String>` back; which OS facility answers, and whether one exists
7//! at all, is entirely this module's problem.
8//!
9//! # Platforms without a credential store
10//!
11//! There are two separate ways this can be unavailable, and both are ordinary
12//! rather than exceptional:
13//!
14//! 1. **Not built in.** The `keychain` feature is on by default but can be
15//!    turned off - for a target the `keyring` crate does not support, or for a
16//!    minimal build that should not link platform credential libraries at all.
17//!    With it off, [`is_supported`] is `false` and every operation returns a
18//!    plain "not supported in this build" error.
19//! 2. **Built in, but nothing to talk to.** A headless Linux box with no Secret
20//!    Service running, a locked keychain, a container, an SSH session with no
21//!    session keyring. The code is present, the platform simply has no store.
22//!
23//! Neither is treated as a crash or a panic, and neither is silently swallowed.
24//! Leviath's default credential backend is its own `0600` files precisely
25//! because these situations are common; the keychain is opt-in, so an
26//! unavailable one costs a clear error at startup and nothing else.
27//!
28//! # Why the split into `raw_*` and `interpret_*`
29//!
30//! The functions that touch the OS are deliberately branch-free delegation, and
31//! every decision about what a result *means* lives in a pure function beside
32//! them. That is what makes this module testable: no CI runner has an unlocked
33//! login keychain, and a test that reached a developer's real one would raise an
34//! OS authorization prompt and hang. Tests install an in-memory store as the
35//! process default and drive this same code, unchanged, all the way down.
36
37/// Whether this build can talk to an OS credential store at all.
38///
39/// `false` when the `keychain` feature is off. Note that `true` does not promise
40/// a *working* store - see the module docs; use [`probe`] for that.
41pub const fn is_supported() -> bool {
42    cfg!(feature = "keychain")
43}
44
45/// Check that a usable credential store exists, without storing anything.
46///
47/// Worth calling once at startup rather than discovering the problem at the
48/// first secret read: on a headless box every key lookup would otherwise fail
49/// one at a time with an error that reads like a missing key rather than a
50/// missing keychain.
51pub fn probe(service: &str) -> Result<(), String> {
52    imp::probe(service)
53}
54
55/// The secret stored for `account`, or `None` when there is none.
56///
57/// A *missing* entry is `Ok(None)`; an unreachable or locked store is `Err`.
58/// The distinction matters: collapsing a locked keychain into `None` would
59/// surface to the user as "no API key configured" and send them to re-run
60/// `lev setup` instead of unlocking their keychain.
61pub fn get(service: &str, account: &str) -> Result<Option<String>, String> {
62    imp::get(service, account)
63}
64
65/// Store `secret` under `account`, replacing anything already there.
66pub fn set(service: &str, account: &str, secret: &str) -> Result<(), String> {
67    imp::set(service, account, secret)
68}
69
70/// Remove `account`, reporting whether anything was actually removed.
71///
72/// Deleting something that was not there is `Ok(false)`, not an error, so a
73/// caller can clean up unconditionally.
74pub fn delete(service: &str, account: &str) -> Result<bool, String> {
75    imp::delete(service, account)
76}
77
78/// The real implementation, when the `keychain` feature is on.
79///
80/// Mirrors the crate's `platform` module idiom: exactly one of the two `imp`
81/// modules is compiled, so the unused one is never emitted and never counts as
82/// a coverage gap.
83#[cfg(feature = "keychain")]
84mod imp {
85    /// Bind the process to the OS-native store and report whether that worked.
86    ///
87    /// `keyring::Entry::new` installs the platform store on first use, so
88    /// constructing one throwaway entry is both how the installation is
89    /// triggered and how its failure is reported. Constructing an entry does not
90    /// create a credential, so this leaves nothing behind in the user's
91    /// keychain.
92    ///
93    /// If a store is *already* installed, that one is kept: `keyring::Entry::new`
94    /// would otherwise replace it with the platform store, which is both wrong
95    /// for an embedder that chose its own and actively dangerous under test.
96    pub(super) fn probe(service: &str) -> Result<(), String> {
97        // Never replace a store that is already installed. `keyring::Entry::new`
98        // installs the *platform* store unconditionally on first use, so calling
99        // it blindly would clobber a store the embedding process chose - and,
100        // in tests, silently redirect every subsequent operation at the
101        // developer's real login keychain, where it would both prompt and write.
102        if keyring_core::get_default_store().is_some() {
103            return Ok(());
104        }
105        describe(keyring::Entry::new(service, PROBE_ACCOUNT).map(drop))
106    }
107
108    /// The account used only to check that a store exists. Never written to.
109    const PROBE_ACCOUNT: &str = "__leviath_probe__";
110
111    pub(super) fn get(service: &str, account: &str) -> Result<Option<String>, String> {
112        interpret_get(raw_get(service, account))
113    }
114
115    pub(super) fn set(service: &str, account: &str, secret: &str) -> Result<(), String> {
116        describe(raw_set(service, account, secret))
117    }
118
119    pub(super) fn delete(service: &str, account: &str) -> Result<bool, String> {
120        interpret_delete(raw_delete(service, account))
121    }
122
123    // ─── The OS-touching leaves: delegation only, no decisions ───────────────
124
125    fn raw_get(service: &str, account: &str) -> Result<String, keyring::Error> {
126        keyring_core::Entry::new(service, account).and_then(|e| e.get_password())
127    }
128
129    fn raw_set(service: &str, account: &str, secret: &str) -> Result<(), keyring::Error> {
130        keyring_core::Entry::new(service, account).and_then(|e| e.set_password(secret))
131    }
132
133    fn raw_delete(service: &str, account: &str) -> Result<(), keyring::Error> {
134        keyring_core::Entry::new(service, account).and_then(|e| e.delete_credential())
135    }
136
137    // ─── The decisions: pure, and directly tested ───────────────────────────
138
139    /// "No such entry" is an answer; everything else is a failure.
140    fn interpret_get(result: Result<String, keyring::Error>) -> Result<Option<String>, String> {
141        match result {
142            Ok(secret) => Ok(Some(secret)),
143            Err(keyring::Error::NoEntry) => Ok(None),
144            Err(e) => Err(unavailable(e)),
145        }
146    }
147
148    /// Deleting something absent is a no-op, not a failure.
149    fn interpret_delete(result: Result<(), keyring::Error>) -> Result<bool, String> {
150        match result {
151            Ok(()) => Ok(true),
152            Err(keyring::Error::NoEntry) => Ok(false),
153            Err(e) => Err(unavailable(e)),
154        }
155    }
156
157    fn describe(result: Result<(), keyring::Error>) -> Result<(), String> {
158        result.map_err(unavailable)
159    }
160
161    /// Render a store failure with the one piece of context users need.
162    ///
163    /// `keyring`'s own message describes the platform call that failed; on a
164    /// headless Linux box the answer is nearly always "no Secret Service is
165    /// running", and the fix is to go back to the file backend. Naming that here
166    /// saves a support round trip.
167    fn unavailable(e: keyring::Error) -> String {
168        format!(
169            "OS credential store unavailable: {e}. Set `[security] credential_store = \"file\"` \
170             in ~/.leviath/config.toml to keep secrets in Leviath's own 0600 files instead."
171        )
172    }
173
174    #[cfg(test)]
175    mod tests {
176        use super::*;
177
178        /// `keyring_core`'s default store is process-wide, so a test that
179        /// installs one races every test that reads it.
180        static STORE: std::sync::Mutex<()> = std::sync::Mutex::new(());
181
182        /// Install a fresh in-memory store as the process default, holding the
183        /// lock for the caller's lifetime. This is what lets the `raw_*`
184        /// functions - the real `keyring_core::Entry` path, unchanged - run in
185        /// CI on every platform.
186        fn with_mock_store() -> std::sync::MutexGuard<'static, ()> {
187            let guard = STORE.lock().expect("store lock");
188            keyring_core::set_default_store(keyring_core::mock::Store::new().expect("mock store"));
189            guard
190        }
191
192        const SVC: &str = "dev.leviath.test";
193
194        #[test]
195        fn a_secret_survives_a_set_get_delete_round_trip() {
196            let _guard = with_mock_store();
197
198            assert_eq!(get(SVC, "provider/anthropic").unwrap(), None);
199            set(SVC, "provider/anthropic", "sk-ant-secret").unwrap();
200            assert_eq!(
201                get(SVC, "provider/anthropic").unwrap().as_deref(),
202                Some("sk-ant-secret")
203            );
204
205            // A second set replaces rather than duplicating.
206            set(SVC, "provider/anthropic", "sk-ant-rotated").unwrap();
207            assert_eq!(
208                get(SVC, "provider/anthropic").unwrap().as_deref(),
209                Some("sk-ant-rotated")
210            );
211
212            assert!(delete(SVC, "provider/anthropic").unwrap(), "it existed");
213            assert_eq!(get(SVC, "provider/anthropic").unwrap(), None, "and is gone");
214            assert!(
215                !delete(SVC, "provider/anthropic").unwrap(),
216                "deleting again reports nothing was removed"
217            );
218        }
219
220        /// Accounts do not leak into each other.
221        /// The public shims at the crate root - the path every caller actually
222        /// takes - driven against the mock store under the lock.
223        #[test]
224        fn the_public_api_delegates_to_this_implementation() {
225            let _guard = with_mock_store();
226            const SVC: &str = "dev.leviath.test.shim";
227
228            crate::keychain::probe(SVC).expect("a mock store is available");
229            assert_eq!(crate::keychain::get(SVC, "provider/none").unwrap(), None);
230            crate::keychain::set(SVC, "provider/none", "v").unwrap();
231            assert_eq!(
232                crate::keychain::get(SVC, "provider/none")
233                    .unwrap()
234                    .as_deref(),
235                Some("v")
236            );
237            assert!(crate::keychain::delete(SVC, "provider/none").unwrap());
238        }
239
240        #[test]
241        fn accounts_are_independent() {
242            let _guard = with_mock_store();
243            set(SVC, "provider/openai", "one").unwrap();
244            set(SVC, "mcp/github", "two").unwrap();
245            assert_eq!(get(SVC, "provider/openai").unwrap().as_deref(), Some("one"));
246            assert_eq!(get(SVC, "mcp/github").unwrap().as_deref(), Some("two"));
247        }
248
249        /// A locked or absent store is an error, not a silent `None` - the
250        /// distinction the module docs turn on.
251        #[test]
252        fn a_missing_entry_is_none_but_a_broken_store_is_an_error() {
253            assert_eq!(interpret_get(Ok("s".into())).unwrap().as_deref(), Some("s"));
254            assert_eq!(interpret_get(Err(keyring::Error::NoEntry)).unwrap(), None);
255            assert!(interpret_get(Err(keyring::Error::NoDefaultStore)).is_err());
256
257            assert!(interpret_delete(Ok(())).unwrap());
258            assert!(!interpret_delete(Err(keyring::Error::NoEntry)).unwrap());
259            assert!(interpret_delete(Err(keyring::Error::NoDefaultStore)).is_err());
260
261            assert!(describe(Ok(())).is_ok());
262            assert!(describe(Err(keyring::Error::NoDefaultStore)).is_err());
263        }
264
265        /// Every failure must name the way out. A user whose keychain is
266        /// unavailable needs to know the file backend is one config line away,
267        /// not just that a platform call failed.
268        #[test]
269        fn failures_name_the_file_fallback() {
270            let msg = unavailable(keyring::Error::NoDefaultStore);
271            assert!(msg.contains("OS credential store unavailable"), "{msg}");
272            assert!(msg.contains("credential_store = \"file\""), "{msg}");
273        }
274
275        /// With no store installed at all, every operation reports rather than
276        /// panicking - the headless-Linux case.
277        #[test]
278        fn every_operation_reports_when_there_is_no_store() {
279            let _guard = STORE.lock().expect("store lock");
280            keyring_core::unset_default_store();
281
282            assert!(get(SVC, "provider/anthropic").is_err());
283            assert!(set(SVC, "provider/anthropic", "x").is_err());
284            assert!(delete(SVC, "provider/anthropic").is_err());
285        }
286
287        /// The real platform probe. Either outcome is legitimate - a developer
288        /// machine has a credential store and a CI runner does not - so what is
289        /// asserted is that it answers rather than panicking or prompting. It
290        /// contributes no branch of its own, which is why one call covers it on
291        /// every platform.
292        #[test]
293        fn the_platform_probe_answers_on_any_platform() {
294            let _guard = STORE.lock().expect("store lock");
295            // Clear the default store so the probe takes its *other* branch --
296            // the one that actually asks the platform. Constructing an `Entry`
297            // creates a handle, not a credential, so this reads and writes
298            // nothing in a developer's real keychain.
299            keyring_core::unset_default_store();
300            let _ = probe(SVC);
301            // The probe may have installed the real platform store; put the mock
302            // back before releasing the lock, so nothing that runs next can
303            // reach the OS.
304            keyring_core::set_default_store(keyring_core::mock::Store::new().expect("mock store"));
305        }
306
307        /// The short-circuit itself: with a store already installed, the probe
308        /// must accept it rather than replacing it with the platform store.
309        #[test]
310        fn the_probe_keeps_a_store_that_is_already_installed() {
311            let _guard = with_mock_store();
312            probe(SVC).expect("an installed store is a usable store");
313            // Still the mock: a real platform store would have replaced it, and
314            // every later operation would hit the OS.
315            set(SVC, "provider/probe-check", "v").unwrap();
316            assert_eq!(
317                get(SVC, "provider/probe-check").unwrap().as_deref(),
318                Some("v")
319            );
320        }
321    }
322}
323
324/// The stub implementation, when the `keychain` feature is off.
325///
326/// Every operation reports plainly that this build has no credential store,
327/// rather than pretending an empty one. A caller that asked for the keychain
328/// backend on a build without it has a configuration problem, and silently
329/// answering "no secrets found" would look identical to a wiped keychain.
330#[cfg(not(feature = "keychain"))]
331mod imp {
332    const UNSUPPORTED: &str = "this build of Leviath has no OS credential store support \
333         (the `keychain` feature is disabled). Set `[security] credential_store = \"file\"` \
334         in ~/.leviath/config.toml to keep secrets in Leviath's own 0600 files instead.";
335
336    pub(super) fn probe(_service: &str) -> Result<(), String> {
337        Err(UNSUPPORTED.to_string())
338    }
339
340    pub(super) fn get(_service: &str, _account: &str) -> Result<Option<String>, String> {
341        Err(UNSUPPORTED.to_string())
342    }
343
344    pub(super) fn set(_service: &str, _account: &str, _secret: &str) -> Result<(), String> {
345        Err(UNSUPPORTED.to_string())
346    }
347
348    pub(super) fn delete(_service: &str, _account: &str) -> Result<bool, String> {
349        Err(UNSUPPORTED.to_string())
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    /// `is_supported` reports what was actually compiled in - a build with the
358    /// feature off must say so rather than claiming a store it cannot reach.
359    ///
360    /// The other public shims are exercised from `imp`'s tests, which hold the
361    /// process-wide store lock. Calling them from here would race those tests,
362    /// and on a machine that *has* a keychain an unguarded call reaches it.
363    #[test]
364    fn support_matches_the_feature() {
365        assert_eq!(is_supported(), cfg!(feature = "keychain"));
366    }
367}