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::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 the `keyring` crate.
183// =====================================================================
184
185/// OS-keychain-backed store. Delegates to [`keyring::Entry`].
186///
187/// Blocking keyring calls are wrapped in
188/// `tokio::task::spawn_blocking` to keep the async trait honest on
189/// any runtime.
190#[derive(Debug, Default)]
191pub struct KeyringStore;
192
193impl KeyringStore {
194    /// Create a new keyring-backed store. No handles are held until
195    /// the first call.
196    #[must_use]
197    pub const fn new() -> Self {
198        Self
199    }
200}
201
202#[async_trait]
203impl CredentialStore for KeyringStore {
204    async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError> {
205        let service = service.to_string();
206        let account = account.to_string();
207        tokio::task::spawn_blocking(move || -> Result<SecretString, CredentialError> {
208            let entry = keyring::Entry::new(&service, &account)
209                .map_err(|e| CredentialError::Keychain(e.to_string()))?;
210            match entry.get_password() {
211                Ok(pw) => Ok(SecretString::from(pw)),
212                Err(keyring::Error::NoEntry) => {
213                    Err(CredentialError::NotFound { name: format!("{service}/{account}") })
214                }
215                Err(e) => Err(CredentialError::Keychain(e.to_string())),
216            }
217        })
218        .await
219        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
220    }
221
222    async fn set(
223        &self,
224        service: &str,
225        account: &str,
226        secret: SecretString,
227    ) -> Result<(), CredentialError> {
228        let service = service.to_string();
229        let account = account.to_string();
230        tokio::task::spawn_blocking(move || -> Result<(), CredentialError> {
231            let entry = keyring::Entry::new(&service, &account)
232                .map_err(|e| CredentialError::Keychain(e.to_string()))?;
233            entry
234                .set_password(secret.expose_secret())
235                .map_err(|e| CredentialError::Keychain(e.to_string()))
236        })
237        .await
238        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
239    }
240
241    async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
242        let service = service.to_string();
243        let account = account.to_string();
244        tokio::task::spawn_blocking(move || -> Result<(), CredentialError> {
245            let entry = keyring::Entry::new(&service, &account)
246                .map_err(|e| CredentialError::Keychain(e.to_string()))?;
247            match entry.delete_credential() {
248                Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
249                Err(e) => Err(CredentialError::Keychain(e.to_string())),
250            }
251        })
252        .await
253        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
254    }
255}