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}