Skip to main content

rtb_credentials/
store.rs

1//! The [`CredentialStore`] trait and its built-in implementations.
2
3use std::collections::HashMap;
4use std::sync::{Arc, OnceLock, RwLock};
5
6use async_trait::async_trait;
7use secrecy::{ExposeSecret, SecretString};
8
9use crate::error::CredentialError;
10
11/// Backend-agnostic contract for credential storage.
12///
13/// Every method is `async` because some real backends (particularly
14/// the platform keyring on Windows and macOS) perform blocking system
15/// calls; wrapping in `spawn_blocking` keeps the trait usable in any
16/// async context.
17#[async_trait]
18pub trait CredentialStore: Send + Sync + 'static {
19    /// Retrieve a secret by `service`/`account`. Returns
20    /// [`CredentialError::NotFound`] when the store does not carry
21    /// it.
22    async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError>;
23
24    /// Store (or overwrite) a secret at `service`/`account`.
25    /// Returns [`CredentialError::ReadOnly`] on stores that do not
26    /// support mutation.
27    async fn set(
28        &self,
29        service: &str,
30        account: &str,
31        secret: SecretString,
32    ) -> Result<(), CredentialError>;
33
34    /// Remove a secret. Missing entries are not an error. Returns
35    /// [`CredentialError::ReadOnly`] on stores that do not support
36    /// mutation.
37    async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError>;
38}
39
40// =====================================================================
41// MemoryStore — HashMap-backed, test-friendly.
42// =====================================================================
43
44/// In-memory store. Ideal for tests and for downstream crates that
45/// need a `dyn CredentialStore` without touching the OS keychain.
46#[derive(Default)]
47pub struct MemoryStore {
48    inner: RwLock<HashMap<(String, String), SecretString>>,
49}
50
51impl std::fmt::Debug for MemoryStore {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("MemoryStore").finish_non_exhaustive()
54    }
55}
56
57impl MemoryStore {
58    /// Create a fresh empty store.
59    #[must_use]
60    pub fn new() -> Self {
61        Self::default()
62    }
63}
64
65#[async_trait]
66impl CredentialStore for MemoryStore {
67    async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError> {
68        let map = self.inner.read().map_err(|_| poisoned())?;
69        map.get(&(service.to_string(), account.to_string()))
70            .cloned()
71            .ok_or_else(|| CredentialError::NotFound { name: format!("{service}/{account}") })
72    }
73
74    async fn set(
75        &self,
76        service: &str,
77        account: &str,
78        secret: SecretString,
79    ) -> Result<(), CredentialError> {
80        {
81            let mut map = self.inner.write().map_err(|_| poisoned())?;
82            map.insert((service.to_string(), account.to_string()), secret);
83        }
84        Ok(())
85    }
86
87    async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
88        {
89            let mut map = self.inner.write().map_err(|_| poisoned())?;
90            map.remove(&(service.to_string(), account.to_string()));
91        }
92        Ok(())
93    }
94}
95
96fn poisoned() -> CredentialError {
97    CredentialError::Keychain("in-memory lock poisoned".to_string())
98}
99
100// =====================================================================
101// EnvStore — read-through of process env.
102// =====================================================================
103
104/// Reads secrets straight from process environment variables.
105///
106/// The `service` argument is ignored; `account` is interpreted as the
107/// env-var name. `set` / `delete` are deliberately unsupported:
108/// mutating process env from library code is `unsafe` in Rust 2024
109/// and cross-thread-unsound on every platform.
110#[derive(Debug, Default)]
111pub struct EnvStore;
112
113impl EnvStore {
114    /// Construct a new env-backed store.
115    #[must_use]
116    pub const fn new() -> Self {
117        Self
118    }
119}
120
121#[async_trait]
122impl CredentialStore for EnvStore {
123    async fn get(&self, _service: &str, account: &str) -> Result<SecretString, CredentialError> {
124        std::env::var(account)
125            .map(SecretString::from)
126            .map_err(|_| CredentialError::NotFound { name: account.to_string() })
127    }
128
129    async fn set(&self, _: &str, _: &str, _: SecretString) -> Result<(), CredentialError> {
130        Err(CredentialError::ReadOnly)
131    }
132
133    async fn delete(&self, _: &str, _: &str) -> Result<(), CredentialError> {
134        Err(CredentialError::ReadOnly)
135    }
136}
137
138// =====================================================================
139// LiteralStore — a single fixed secret.
140// =====================================================================
141
142/// Stores a single fixed secret and ignores `service`/`account` on
143/// `get`. Useful when a tool is hard-wired to a single credential
144/// (e.g. test harnesses).
145pub struct LiteralStore {
146    secret: SecretString,
147}
148
149impl std::fmt::Debug for LiteralStore {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("LiteralStore").finish_non_exhaustive()
152    }
153}
154
155impl LiteralStore {
156    /// Wrap a literal secret.
157    #[must_use]
158    pub const fn new(secret: SecretString) -> Self {
159        Self { secret }
160    }
161}
162
163#[async_trait]
164impl CredentialStore for LiteralStore {
165    async fn get(&self, _: &str, _: &str) -> Result<SecretString, CredentialError> {
166        // `SecretString` is `Clone`; cloning produces a new
167        // zeroize-on-drop container without bouncing through a
168        // bare `String` intermediate.
169        Ok(self.secret.clone())
170    }
171
172    async fn set(&self, _: &str, _: &str, _: SecretString) -> Result<(), CredentialError> {
173        Err(CredentialError::ReadOnly)
174    }
175
176    async fn delete(&self, _: &str, _: &str) -> Result<(), CredentialError> {
177        Err(CredentialError::ReadOnly)
178    }
179}
180
181// =====================================================================
182// KeyringStore — platform-native via `keyring-core`.
183// =====================================================================
184
185/// OS-keychain-backed store: macOS Keychain, Windows Credential Manager,
186/// or the freedesktop Secret Service on Linux and the BSDs.
187///
188/// Blocking keyring calls are wrapped in
189/// `tokio::task::spawn_blocking` to keep the async trait honest on
190/// any runtime.
191#[derive(Debug, Default)]
192pub struct KeyringStore;
193
194impl KeyringStore {
195    /// Create a new keyring-backed store. No handles are held until
196    /// the first call.
197    #[must_use]
198    pub const fn new() -> Self {
199        Self
200    }
201}
202
203#[async_trait]
204impl CredentialStore for KeyringStore {
205    async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError> {
206        let service = service.to_string();
207        let account = account.to_string();
208        tokio::task::spawn_blocking(move || -> Result<SecretString, CredentialError> {
209            match platform_entry(&service, &account)?.get_password() {
210                Ok(pw) => Ok(SecretString::from(pw)),
211                Err(keyring_core::Error::NoEntry) => {
212                    Err(CredentialError::NotFound { name: format!("{service}/{account}") })
213                }
214                Err(e) => Err(CredentialError::Keychain(e.to_string())),
215            }
216        })
217        .await
218        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
219    }
220
221    async fn set(
222        &self,
223        service: &str,
224        account: &str,
225        secret: SecretString,
226    ) -> Result<(), CredentialError> {
227        let service = service.to_string();
228        let account = account.to_string();
229        tokio::task::spawn_blocking(move || -> Result<(), CredentialError> {
230            platform_entry(&service, &account)?
231                .set_password(secret.expose_secret())
232                .map_err(|e| CredentialError::Keychain(e.to_string()))
233        })
234        .await
235        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
236    }
237
238    async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
239        let service = service.to_string();
240        let account = account.to_string();
241        tokio::task::spawn_blocking(move || -> Result<(), CredentialError> {
242            match platform_entry(&service, &account)?.delete_credential() {
243                Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
244                Err(e) => Err(CredentialError::Keychain(e.to_string())),
245            }
246        })
247        .await
248        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
249    }
250}
251
252/// Builds the entry from a store this crate owns rather than through
253/// `keyring_core::set_default_store`, so a host application's own choice
254/// of default store is left alone.
255fn platform_entry(service: &str, account: &str) -> Result<keyring_core::Entry, CredentialError> {
256    platform_store()?
257        .build(service, account, None)
258        .map_err(|e| CredentialError::Keychain(e.to_string()))
259}
260
261static PLATFORM_STORE: OnceLock<Arc<keyring_core::CredentialStore>> = OnceLock::new();
262
263/// A failed connection is not cached: a session bus or keyring daemon that
264/// starts later in the process is picked up on the next call.
265fn platform_store() -> Result<&'static Arc<keyring_core::CredentialStore>, CredentialError> {
266    if let Some(store) = PLATFORM_STORE.get() {
267        return Ok(store);
268    }
269    let store = new_platform_store().map_err(|e| CredentialError::Keychain(e.to_string()))?;
270    Ok(PLATFORM_STORE.get_or_init(|| store))
271}
272
273#[cfg(target_os = "macos")]
274fn new_platform_store() -> keyring_core::Result<Arc<keyring_core::CredentialStore>> {
275    Ok(apple_native_keyring_store::keychain::Store::new()?)
276}
277
278#[cfg(target_os = "windows")]
279fn new_platform_store() -> keyring_core::Result<Arc<keyring_core::CredentialStore>> {
280    Ok(windows_native_keyring_store::Store::new()?)
281}
282
283#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
284fn new_platform_store() -> keyring_core::Result<Arc<keyring_core::CredentialStore>> {
285    Ok(dbus_secret_service_keyring_store::Store::new()?)
286}
287
288#[cfg(not(any(
289    target_os = "macos",
290    target_os = "windows",
291    all(unix, not(any(target_os = "ios", target_os = "android")))
292)))]
293fn new_platform_store() -> keyring_core::Result<Arc<keyring_core::CredentialStore>> {
294    Err(keyring_core::Error::NoDefaultStore)
295}