dig_keystore/backend/mod.rs
1//! Storage backend abstraction.
2//!
3//! A `KeychainBackend` is any byte-blob KV store. The shipped `FileBackend`
4//! persists to the local filesystem with atomic (tmp + rename) writes. Planned
5//! future backends: `OsKeyringBackend` (macOS Keychain / Windows Credential
6//! Store / Secret Service), and hardware-signer backends (`LedgerBackend`,
7//! `YubiHsmBackend`) that proxy `sign` to an external device.
8
9use crate::error::Result;
10
11#[cfg(feature = "file-backend")]
12mod file;
13mod memory;
14#[cfg(feature = "os-keychain")]
15mod os_keychain;
16
17#[cfg(feature = "file-backend")]
18pub use file::FileBackend;
19/// In-memory backend — always available. Originally feature-gated, now
20/// unconditional because production adapters (e.g., `dig-l1-wallet`'s
21/// encrypt/decrypt-bytes helpers) wrap it in scratch backends to reuse the
22/// full keystore format without touching the filesystem.
23pub use memory::MemoryBackend;
24#[cfg(feature = "os-keychain")]
25pub use os_keychain::OsKeychainBackend;
26
27/// An opaque key identifying a single encrypted blob within a backend.
28///
29/// For `FileBackend`, the key maps to `<root>/<key>.dks`; for an OS-keyring
30/// backend it maps to a service / account pair; for a hardware signer it maps
31/// to a slot identifier.
32#[derive(Clone, Debug, PartialEq, Eq, Hash)]
33pub struct BackendKey(pub String);
34
35impl BackendKey {
36 /// Construct from any string-like value.
37 pub fn new(name: impl Into<String>) -> Self {
38 Self(name.into())
39 }
40
41 /// Borrow as `&str`.
42 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45}
46
47impl<T: Into<String>> From<T> for BackendKey {
48 fn from(v: T) -> Self {
49 Self(v.into())
50 }
51}
52
53impl std::fmt::Display for BackendKey {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.write_str(&self.0)
56 }
57}
58
59/// Storage backend trait. Implementations must be `Send + Sync + 'static` so
60/// they can be held behind `Arc<dyn KeychainBackend>`.
61pub trait KeychainBackend: Send + Sync + 'static {
62 /// Read the full contents of the blob at `key`.
63 ///
64 /// Returns a backend I/O error if the blob does not exist.
65 fn read(&self, key: &BackendKey) -> Result<Vec<u8>>;
66
67 /// Write `data` to `key`. Implementations should be atomic — a reader
68 /// seeing the key after this call must see either the old bytes or the
69 /// new bytes in full, never a torn mix.
70 fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()>;
71
72 /// Remove the blob at `key`. Implementations should best-effort overwrite
73 /// the storage before removing so residual disk sectors do not retain the
74 /// ciphertext.
75 fn delete(&self, key: &BackendKey) -> Result<()>;
76
77 /// List keys that start with `prefix`. Order is unspecified.
78 fn list(&self, prefix: &str) -> Result<Vec<BackendKey>>;
79
80 /// Whether a blob exists at `key`. Default impl delegates to `read`;
81 /// backends with cheaper existence checks should override.
82 fn exists(&self, key: &BackendKey) -> Result<bool> {
83 match self.read(key) {
84 Ok(_) => Ok(true),
85 Err(crate::error::KeystoreError::Backend(e))
86 if e.kind() == std::io::ErrorKind::NotFound =>
87 {
88 Ok(false)
89 }
90 Err(e) => Err(e),
91 }
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use crate::error::KeystoreError;
99 use std::io;
100
101 /// A minimal backend that does **not** override `exists`, so it exercises
102 /// the [`KeychainBackend::exists`] *default* implementation. The shipped
103 /// backends (`FileBackend`, `MemoryBackend`) both override `exists` with a
104 /// cheaper check, leaving the default's three branches (present →
105 /// `Ok(true)`, `NotFound` → `Ok(false)`, other error → propagate) otherwise
106 /// unexercised. This stub is the only way to test that contract.
107 #[derive(Default)]
108 struct ProbeBackend {
109 /// When set, every `read` returns this io error kind instead of data.
110 fail_kind: Option<io::ErrorKind>,
111 present: bool,
112 }
113
114 impl KeychainBackend for ProbeBackend {
115 fn read(&self, _key: &BackendKey) -> Result<Vec<u8>> {
116 if let Some(kind) = self.fail_kind {
117 return Err(KeystoreError::from(io::Error::new(kind, "probe")));
118 }
119 if self.present {
120 Ok(vec![1, 2, 3])
121 } else {
122 Err(KeystoreError::from(io::Error::new(
123 io::ErrorKind::NotFound,
124 "absent",
125 )))
126 }
127 }
128 fn write(&self, _key: &BackendKey, _data: &[u8]) -> Result<()> {
129 Ok(())
130 }
131 fn delete(&self, _key: &BackendKey) -> Result<()> {
132 Ok(())
133 }
134 fn list(&self, _prefix: &str) -> Result<Vec<BackendKey>> {
135 Ok(vec![])
136 }
137 // Deliberately NO `exists` override.
138 }
139
140 /// **Proves:** `BackendKey` round-trips through every constructor + accessor
141 /// — `new`, the blanket `From<T: Into<String>>`, `as_str`, and the
142 /// `Display` impl all agree on the same underlying string.
143 ///
144 /// **Why it matters:** `BackendKey` is the address every backend keys off
145 /// (`FileBackend` maps it to `<root>/<key>.dks`). A `Display`/`as_str`
146 /// disagreement, or a `From` that mangled the input, would route reads and
147 /// writes to different paths — a silent data-loss bug.
148 ///
149 /// **Catches:** an `as_str` that returns a transformed copy; a `Display`
150 /// impl that adds quotes/prefixes; a `From` that drops or alters the value.
151 #[test]
152 fn backend_key_constructors_and_accessors_agree() {
153 let from_new = BackendKey::new("validator");
154 let from_into: BackendKey = "validator".into();
155 let from_string: BackendKey = BackendKey::from(String::from("validator"));
156
157 assert_eq!(from_new.as_str(), "validator");
158 assert_eq!(from_new, from_into);
159 assert_eq!(from_new, from_string);
160 assert_eq!(format!("{from_new}"), "validator");
161 // Eq / Hash derive sanity: distinct values are not equal.
162 assert_ne!(from_new, BackendKey::new("other"));
163 }
164
165 /// **Proves:** the default [`KeychainBackend::exists`] returns `Ok(true)`
166 /// when the blob reads back successfully.
167 ///
168 /// **Why it matters:** This is the happy-path branch of the default impl
169 /// that backends inherit unless they override it. `Keystore::create` calls
170 /// `exists` to refuse overwrites; if the default returned `false` for a
171 /// present key, `create` would clobber existing keys.
172 ///
173 /// **Catches:** an inverted truth value in the `Ok(_) => Ok(true)` arm.
174 #[test]
175 fn default_exists_true_when_present() {
176 let be = ProbeBackend {
177 present: true,
178 ..Default::default()
179 };
180 assert!(be.exists(&BackendKey::new("k")).unwrap());
181 }
182
183 /// **Proves:** the default `exists` maps a `NotFound` read error to
184 /// `Ok(false)` rather than propagating it.
185 ///
186 /// **Why it matters:** A missing key is the normal "not yet created" state,
187 /// not an error. `Keystore::create` relies on `exists(..) == Ok(false)` to
188 /// proceed with a first-time write. If `NotFound` propagated as `Err`,
189 /// creating any new keystore would fail outright.
190 ///
191 /// **Catches:** removing the `NotFound` guard so absence surfaces as an
192 /// error.
193 #[test]
194 fn default_exists_false_when_not_found() {
195 let be = ProbeBackend::default(); // present=false → NotFound
196 assert!(!be.exists(&BackendKey::new("k")).unwrap());
197 }
198
199 /// **Proves:** the default `exists` propagates non-`NotFound` I/O errors
200 /// (e.g. a permission error) instead of swallowing them as `false`.
201 ///
202 /// **Why it matters:** Treating a `PermissionDenied` as "does not exist"
203 /// would let `create` attempt to overwrite a file it merely cannot read —
204 /// masking a real environment problem behind a confusing later failure.
205 /// The default must distinguish "absent" from "inaccessible".
206 ///
207 /// **Catches:** a too-broad error arm that maps every error to `Ok(false)`.
208 #[test]
209 fn default_exists_propagates_other_errors() {
210 let be = ProbeBackend {
211 fail_kind: Some(io::ErrorKind::PermissionDenied),
212 ..Default::default()
213 };
214 let err = be.exists(&BackendKey::new("k")).unwrap_err();
215 assert!(matches!(err, KeystoreError::Backend(_)));
216 }
217}