Skip to main content

dig_keystore/backend/
memory.rs

1//! In-memory backend.
2//!
3//! Originally feature-gated behind `testing`. As of v0.1.2 it is compiled
4//! unconditionally because production adapters in other crates (notably
5//! `dig-l1-wallet`'s `encryption.rs`) use it as a scratch backend to reuse
6//! the full keystore file format without touching the filesystem. The
7//! `testing` module still re-exports it for discoverability in dependent
8//! crates' dev-dependencies.
9//!
10//! Stores blobs in a `parking_lot::Mutex<HashMap>`. Legitimate production
11//! uses: encrypt-to-bytes / decrypt-from-bytes helpers; unit tests; doc
12//! examples.
13
14use std::collections::hash_map::Entry;
15use std::collections::HashMap;
16
17use parking_lot::Mutex;
18
19use crate::backend::{BackendKey, Exclusivity, KeychainBackend};
20use crate::error::{KeystoreError, Result};
21
22/// A keychain backend that lives entirely in process memory.
23///
24/// Legitimate uses:
25/// - **Scratch backend** for bytes-in / bytes-out adapters (e.g.
26///   `dig-l1-wallet::keystore::encryption::encrypt_secret_key`).
27/// - **Tests and doc examples** where touching the filesystem is overhead.
28///
29/// Do **not** use this as the storage medium for a long-lived keystore —
30/// process exit drops all state.
31#[derive(Default)]
32pub struct MemoryBackend {
33    inner: Mutex<HashMap<BackendKey, Vec<u8>>>,
34}
35
36impl MemoryBackend {
37    /// Construct an empty backend.
38    pub fn new() -> Self {
39        Self::default()
40    }
41}
42
43impl KeychainBackend for MemoryBackend {
44    fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
45        self.inner.lock().get(key).cloned().ok_or_else(|| {
46            KeystoreError::from(std::io::Error::new(
47                std::io::ErrorKind::NotFound,
48                format!("key not found: {key}"),
49            ))
50        })
51    }
52
53    fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
54        self.inner.lock().insert(key.clone(), data.to_vec());
55        Ok(())
56    }
57
58    /// Establish `key` only if vacant, decided under the map's own lock.
59    ///
60    /// The `Entry` API is what makes this exclusive rather than best-effort:
61    /// the vacancy check and the insert happen in one critical section, so two
62    /// threads cannot both observe absence and both write.
63    fn write_new(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
64        match self.inner.lock().entry(key.clone()) {
65            Entry::Occupied(_) => Err(KeystoreError::AlreadyExists(key.as_str().to_string())),
66            Entry::Vacant(slot) => {
67                slot.insert(data.to_vec());
68                Ok(())
69            }
70        }
71    }
72
73    fn write_new_exclusivity(&self) -> Exclusivity {
74        Exclusivity::Atomic
75    }
76
77    fn delete(&self, key: &BackendKey) -> Result<()> {
78        self.inner.lock().remove(key);
79        Ok(())
80    }
81
82    fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
83        Ok(self
84            .inner
85            .lock()
86            .keys()
87            .filter(|k| k.as_str().starts_with(prefix))
88            .cloned()
89            .collect())
90    }
91
92    fn exists(&self, key: &BackendKey) -> Result<bool> {
93        Ok(self.inner.lock().contains_key(key))
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    /// **Proves:** `MemoryBackend::write_new` establishes a vacant key, refuses
102    /// an occupied one with `AlreadyExists`, and leaves the occupant's bytes
103    /// untouched — while `write` beside it still replaces.
104    ///
105    /// **Why it matters:** `MemoryBackend` is the scratch backend production
106    /// adapters reach for, and it claims `Exclusivity::Atomic`. A `write_new`
107    /// that replaced would break the claim silently.
108    ///
109    /// **Catches:** an `insert`-based implementation (which replaces), and an
110    /// inverted occupied/vacant branch.
111    #[test]
112    fn write_new_establishes_once_and_then_refuses() {
113        let be = MemoryBackend::new();
114        let key = BackendKey::new("coupled");
115
116        be.write_new(&key, b"established").unwrap();
117        let err = be.write_new(&key, b"usurper").unwrap_err();
118
119        assert!(
120            matches!(err, KeystoreError::AlreadyExists(ref k) if k == "coupled"),
121            "the collision must be adoptable: {err:?}"
122        );
123        assert_eq!(be.read(&key).unwrap(), b"established");
124
125        be.write(&key, b"replaced").unwrap();
126        assert_eq!(be.read(&key).unwrap(), b"replaced");
127    }
128
129    /// **Proves:** exactly one of many concurrent `write_new` calls wins.
130    ///
131    /// **Why it matters:** this is the mechanism behind the `Atomic` claim.
132    /// The sequential test above is satisfied identically by a check-then-write,
133    /// so on its own it pins a coincidence rather than the property a consumer
134    /// with coupled records actually relies on.
135    ///
136    /// **Catches:** a `write_new` that takes the lock twice — once to check and
137    /// once to insert — instead of deciding inside one critical section.
138    #[test]
139    fn only_one_concurrent_write_new_can_win() {
140        use std::sync::{Arc, Barrier};
141
142        const RACERS: usize = 16;
143        let be = Arc::new(MemoryBackend::new());
144        let key = BackendKey::new("contended");
145        let gate = Arc::new(Barrier::new(RACERS));
146
147        let winners = std::thread::scope(|scope| {
148            let handles: Vec<_> = (0..RACERS)
149                .map(|i| {
150                    let (be, gate, key) = (Arc::clone(&be), Arc::clone(&gate), key.clone());
151                    scope.spawn(move || {
152                        gate.wait();
153                        be.write_new(&key, &[i as u8; 8]).is_ok()
154                    })
155                })
156                .collect();
157            handles
158                .into_iter()
159                .map(|h| h.join().unwrap())
160                .filter(|won| *won)
161                .count()
162        });
163
164        assert_eq!(winners, 1, "exactly one racer may establish; {winners} did");
165    }
166
167    /// **Proves:** `MemoryBackend` claims atomic exclusivity.
168    ///
169    /// **Why it matters:** the claim is what a consumer reads before relying on
170    /// `write_new`; the test above is what makes the claim true. Both are
171    /// asserted so the claim cannot outlive the mechanism.
172    #[test]
173    fn memory_backend_claims_exclusive_creation() {
174        assert_eq!(
175            MemoryBackend::new().write_new_exclusivity(),
176            Exclusivity::Atomic
177        );
178    }
179
180    /// **Proves:** `MemoryBackend` satisfies the [`KeychainBackend`]
181    /// contract end-to-end — write then read recovers the blob; `exists`
182    /// returns `true` for written keys and `false` for deleted ones.
183    ///
184    /// **Why it matters:** Dependent crates (`apps/validator`,
185    /// `dig-l1-wallet`) build their tests on `MemoryBackend`. If the
186    /// in-memory backend drifted from the `FileBackend` semantics (e.g.,
187    /// `exists` stayed `true` after delete, or `read` returned stale bytes
188    /// after overwrite), those tests would pass in CI and fail in
189    /// production.
190    ///
191    /// **Catches:** a regression in `delete` that leaks the key in the
192    /// internal `HashMap`, or an `exists` override that short-circuits
193    /// without consulting the map.
194    #[test]
195    fn roundtrip() {
196        let be = MemoryBackend::new();
197        let k = BackendKey::new("x");
198        be.write(&k, b"data").unwrap();
199        assert_eq!(be.read(&k).unwrap(), b"data");
200        assert!(be.exists(&k).unwrap());
201        be.delete(&k).unwrap();
202        assert!(!be.exists(&k).unwrap());
203    }
204
205    /// **Proves:** reading a key that was never written returns a
206    /// [`KeystoreError::Backend`] whose inner `io::Error` is
207    /// [`ErrorKind::NotFound`].
208    ///
209    /// **Why it matters:** The exact error *kind* is load-bearing — the default
210    /// [`KeychainBackend::exists`] and `Keystore::create`'s overwrite guard both
211    /// branch on `NotFound` specifically. If `MemoryBackend::read` reported a
212    /// missing key as some other error kind, callers that wrap it (e.g.
213    /// `dig-l1-wallet`'s scratch-backend decrypt path) would treat "absent" as a
214    /// hard failure.
215    ///
216    /// **Catches:** a regression that returns a generic/`Other` error, or that
217    /// returns `Ok(empty)` for a missing key.
218    #[test]
219    fn read_missing_key_is_not_found() {
220        let be = MemoryBackend::new();
221        let err = be.read(&BackendKey::new("absent")).unwrap_err();
222        match err {
223            KeystoreError::Backend(io) => {
224                assert_eq!(io.kind(), std::io::ErrorKind::NotFound);
225            }
226            other => panic!("expected Backend(NotFound), got {other:?}"),
227        }
228    }
229
230    /// **Proves:** `write` to an existing key overwrites in place — a later
231    /// `read` sees the new bytes, never a concatenation or the stale value.
232    ///
233    /// **Why it matters:** Password rotation and KDF rotation re-`write` the
234    /// same backend key with fresh ciphertext. If `MemoryBackend` appended or
235    /// kept the old value, an `unlock` after rotation would decrypt stale
236    /// ciphertext with the new key and fail.
237    ///
238    /// **Catches:** a `write` that uses `entry().or_insert` (ignoring updates)
239    /// or otherwise fails to replace the prior blob.
240    #[test]
241    fn write_overwrites_in_place() {
242        let be = MemoryBackend::new();
243        let k = BackendKey::new("k");
244        be.write(&k, b"first").unwrap();
245        be.write(&k, b"second").unwrap();
246        assert_eq!(be.read(&k).unwrap(), b"second");
247    }
248
249    /// **Proves:** `list` returns exactly the keys whose name starts with the
250    /// given prefix, and an empty prefix lists everything.
251    ///
252    /// **Why it matters:** Callers enumerate keystores by prefix (e.g. listing
253    /// all `validator/` keys). A prefix filter that matched substrings anywhere,
254    /// or ignored the prefix entirely, would surface unrelated keys to the
255    /// operator.
256    ///
257    /// **Catches:** using `contains` instead of `starts_with`; returning all
258    /// keys regardless of prefix.
259    #[test]
260    fn list_filters_by_prefix() {
261        let be = MemoryBackend::new();
262        be.write(&BackendKey::new("validator/a"), b"1").unwrap();
263        be.write(&BackendKey::new("validator/b"), b"2").unwrap();
264        be.write(&BackendKey::new("wallet/c"), b"3").unwrap();
265
266        let mut matched: Vec<String> = be
267            .list("validator/")
268            .unwrap()
269            .into_iter()
270            .map(|k| k.as_str().to_string())
271            .collect();
272        matched.sort();
273        assert_eq!(matched, vec!["validator/a", "validator/b"]);
274
275        // An empty prefix matches every key.
276        assert_eq!(be.list("").unwrap().len(), 3);
277        // A non-matching prefix yields nothing.
278        assert!(be.list("none/").unwrap().is_empty());
279    }
280
281    /// **Proves:** `MemoryBackend::default()` produces an empty backend
282    /// equivalent to `new()`.
283    ///
284    /// **Why it matters:** Production adapters construct the scratch backend via
285    /// `MemoryBackend::default()` (it derives `Default`). An accidental
286    /// non-empty or mis-initialised `Default` would leak state between
287    /// independent encrypt/decrypt operations.
288    ///
289    /// **Catches:** a hand-written `Default` that pre-populates the map.
290    #[test]
291    fn default_is_empty() {
292        let be = MemoryBackend::default();
293        assert!(be.list("").unwrap().is_empty());
294        assert!(!be.exists(&BackendKey::new("anything")).unwrap());
295    }
296}