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}