Skip to main content

smtp_test_tool/
keystore.rs

1//! OS-native credential storage abstraction.
2//!
3//! Provides a [`Keystore`] trait with three operations - `save`, `load`,
4//! `forget` - backed by:
5//!
6//! * [`OsKeystore`] when the `keychain` cargo feature is on: dispatches
7//!   to the `keyring` crate, which uses Windows Credential Manager on
8//!   Windows, the macOS Keychain on macOS, and the Secret Service API
9//!   (`gnome-keyring`, KWallet, etc.) on Linux.
10//! * [`NullKeystore`] when the feature is off: `load` returns `None`,
11//!   `forget` is a no-op, `save` returns an explicit error so callers
12//!   surface "rebuild with --features keychain" rather than silently
13//!   succeed.
14//!
15//! Tests use a small in-memory `MockKeystore` (see the `#[cfg(test)]`
16//! block) so they pass on every OS without touching a real keychain -
17//! crucial for headless CI Linux runners where there is no Secret
18//! Service daemon.
19//!
20//! The service name shared by every entry is [`SERVICE`].  The account
21//! key is always the user's email address; if a user works with several
22//! accounts they get one keychain entry per account, with no
23//! cross-contamination.
24//!
25//! This module is the **one** exception to AGENTS.md rule #8: credentials
26//! may live in an OS keychain (which is real at-rest encryption, gated
27//! by the OS login/keychain prompt), but NEVER in our own config file.
28
29use anyhow::Result;
30
31/// Service identifier used for every entry this crate creates.  Anything
32/// stored under this name in the OS keychain came from `smtp-test-tool`.
33pub const SERVICE: &str = "smtp-test-tool";
34
35/// Abstract OS-native secret store.  Implementations MUST be safe to
36/// share across threads (the GUI calls them from both the UI thread and
37/// background test threads).
38pub trait Keystore: Send + Sync {
39    /// Persist `secret` under (SERVICE, `user`).  Overwrites any existing
40    /// value.  Returns an error only on real backend failure (e.g. no
41    /// Secret Service daemon on Linux).
42    fn save(&self, user: &str, secret: &str) -> Result<()>;
43
44    /// Look up the secret for (SERVICE, `user`).  `Ok(None)` means
45    /// "looked, none stored" - which is the expected case on first use
46    /// and must NOT be reported as an error.
47    fn load(&self, user: &str) -> Result<Option<String>>;
48
49    /// Delete the entry for (SERVICE, `user`).  Idempotent: deleting an
50    /// entry that does not exist returns `Ok(())`.
51    fn forget(&self, user: &str) -> Result<()>;
52}
53
54// ============================================================================
55// Real OS-backed implementation
56// ============================================================================
57#[cfg(feature = "keychain")]
58mod os_impl {
59    use super::{Keystore, SERVICE};
60    use anyhow::{Context, Result};
61
62    /// Routes calls through the `keyring` crate to the OS-native store.
63    #[derive(Default, Debug, Clone, Copy)]
64    pub struct OsKeystore;
65
66    impl Keystore for OsKeystore {
67        fn save(&self, user: &str, secret: &str) -> Result<()> {
68            let entry =
69                keyring::Entry::new(SERVICE, user).context("opening OS keychain entry for save")?;
70            entry
71                .set_password(secret)
72                .context("writing secret to OS keychain")
73        }
74
75        fn load(&self, user: &str) -> Result<Option<String>> {
76            let entry =
77                keyring::Entry::new(SERVICE, user).context("opening OS keychain entry for load")?;
78            match entry.get_password() {
79                Ok(s) => Ok(Some(s)),
80                // 'NoEntry' is the documented "not found" signal; treat
81                // it as a None return rather than an error so callers
82                // can do the natural `if let Some(p) = ks.load(u)?`.
83                Err(keyring::Error::NoEntry) => Ok(None),
84                Err(e) => Err(e).context("reading secret from OS keychain"),
85            }
86        }
87
88        fn forget(&self, user: &str) -> Result<()> {
89            let entry = keyring::Entry::new(SERVICE, user)
90                .context("opening OS keychain entry for forget")?;
91            match entry.delete_credential() {
92                Ok(()) => Ok(()),
93                Err(keyring::Error::NoEntry) => Ok(()),
94                Err(e) => Err(e).context("deleting OS keychain entry"),
95            }
96        }
97    }
98}
99
100#[cfg(feature = "keychain")]
101pub use os_impl::OsKeystore;
102
103// ============================================================================
104// Feature-off no-op implementation
105// ============================================================================
106/// Returned by [`default_keystore`] when the crate was built without the
107/// `keychain` feature.  Lets the rest of the codebase call `load()`
108/// without `#[cfg]` gates - it just always says "nothing stored".
109#[derive(Default, Debug, Clone, Copy)]
110pub struct NullKeystore;
111
112impl Keystore for NullKeystore {
113    fn save(&self, _user: &str, _secret: &str) -> Result<()> {
114        anyhow::bail!("keychain support is not compiled in - rebuild with `--features keychain`")
115    }
116    fn load(&self, _user: &str) -> Result<Option<String>> {
117        Ok(None)
118    }
119    fn forget(&self, _user: &str) -> Result<()> {
120        Ok(())
121    }
122}
123
124// ============================================================================
125// Factory
126// ============================================================================
127/// Return the keystore appropriate for this build.
128pub fn default_keystore() -> Box<dyn Keystore> {
129    #[cfg(feature = "keychain")]
130    {
131        Box::new(OsKeystore)
132    }
133    #[cfg(not(feature = "keychain"))]
134    {
135        Box::new(NullKeystore)
136    }
137}
138
139// ============================================================================
140// Tests
141// ============================================================================
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::collections::HashMap;
146    use std::sync::Mutex;
147
148    /// In-memory mock used by unit tests.  We deliberately do NOT rely
149    /// on the real OS keychain in tests because Linux CI runners do not
150    /// have a Secret Service daemon and we want the suite to pass on
151    /// every platform.
152    #[derive(Default)]
153    struct MockKeystore {
154        map: Mutex<HashMap<String, String>>,
155    }
156
157    impl Keystore for MockKeystore {
158        fn save(&self, user: &str, secret: &str) -> Result<()> {
159            self.map
160                .lock()
161                .expect("mock keystore mutex poisoned")
162                .insert(user.into(), secret.into());
163            Ok(())
164        }
165        fn load(&self, user: &str) -> Result<Option<String>> {
166            Ok(self
167                .map
168                .lock()
169                .expect("mock keystore mutex poisoned")
170                .get(user)
171                .cloned())
172        }
173        fn forget(&self, user: &str) -> Result<()> {
174            self.map
175                .lock()
176                .expect("mock keystore mutex poisoned")
177                .remove(user);
178            Ok(())
179        }
180    }
181
182    #[test]
183    fn mock_load_returns_none_for_missing_entry() {
184        let ks = MockKeystore::default();
185        assert_eq!(ks.load("alice@example.com").unwrap(), None);
186    }
187
188    #[test]
189    fn mock_save_then_load_round_trips() {
190        let ks = MockKeystore::default();
191        ks.save("alice@example.com", "s3cret").unwrap();
192        assert_eq!(
193            ks.load("alice@example.com").unwrap().as_deref(),
194            Some("s3cret")
195        );
196    }
197
198    #[test]
199    fn mock_forget_is_idempotent() {
200        let ks = MockKeystore::default();
201        ks.forget("never-was-here").unwrap();
202        ks.save("user@example.com", "x").unwrap();
203        ks.forget("user@example.com").unwrap();
204        ks.forget("user@example.com").unwrap();
205        assert_eq!(ks.load("user@example.com").unwrap(), None);
206    }
207
208    #[test]
209    fn mock_overwrites_existing_secret() {
210        let ks = MockKeystore::default();
211        ks.save("u", "a").unwrap();
212        ks.save("u", "b").unwrap();
213        assert_eq!(ks.load("u").unwrap().as_deref(), Some("b"));
214    }
215
216    #[test]
217    fn null_keystore_load_is_none() {
218        let ks = NullKeystore;
219        assert_eq!(ks.load("any").unwrap(), None);
220    }
221
222    #[test]
223    fn null_keystore_save_errors_clearly() {
224        let ks = NullKeystore;
225        let err = ks.save("u", "p").unwrap_err();
226        let s = err.to_string();
227        assert!(
228            s.contains("--features keychain"),
229            "error must hint at the cargo feature, got: {s}"
230        );
231    }
232}