dig_keystore/backend/file.rs
1//! Filesystem backend.
2//!
3//! # What this does
4//!
5//! Stores each [`BackendKey`] as a `<root>/<key>.dks` file (`.dks` = "DIG
6//! keystore"). Writes are atomic (tmp file + rename). Deletes best-effort
7//! overwrite the file with zeros before unlinking.
8//!
9//! # Atomicity
10//!
11//! On **POSIX**: `rename(2)` is atomic within a filesystem. We write to
12//! `<key>.dks.tmp.<random>`, `fsync` the file handle, then `rename` onto the
13//! final name. If the process crashes between the open and the rename, the
14//! tmp file is orphaned but the original `<key>.dks` (if any) is intact.
15//!
16//! On **Windows**: Rust's `std::fs::rename` wraps `MoveFileExW` with the
17//! `MOVEFILE_REPLACE_EXISTING` flag, which is atomic enough for our purposes
18//! (Windows does not provide a fully-atomic rename-across-replace on all
19//! filesystems but the behaviour is "either old or new contents — never a
20//! torn write").
21//!
22//! # Permissions
23//!
24//! On Unix, both the keystore root directory (on creation) and every written
25//! file are set to mode `0700` / `0600` respectively — readable only by the
26//! owning user. On Windows, standard NTFS ACL inheritance applies; operators
27//! running under a shared user account should not rely on this crate for
28//! access control.
29//!
30//! # Secure delete
31//!
32//! On modern SSDs, a single-pass overwrite cannot guarantee the sectors are
33//! unrecoverable — the SSD's flash translation layer may have copied them
34//! elsewhere. This crate does a single zero pass as a best-effort. For
35//! high-value keys on untrusted hardware, use full-disk encryption (LUKS,
36//! BitLocker) which zero-keys the entire volume on wipe.
37//!
38//! # References
39//!
40//! - [POSIX `rename(2)`](https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html)
41//! - [Windows `MoveFileExW`](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw)
42//! - [DJB on secure-delete on SSDs](https://cr.yp.to/bib/2009/coker.pdf)
43
44use std::fs;
45use std::io::{Read, Write};
46use std::path::{Path, PathBuf};
47
48use crate::backend::{BackendKey, KeychainBackend};
49use crate::error::{KeystoreError, Result};
50
51/// File extension for keystore blobs. Stands for "DIG KeyStore".
52const EXT: &str = "dks";
53
54/// Filesystem-backed keychain.
55///
56/// Thread-safe — `KeychainBackend` is `Send + Sync`, and all operations use
57/// OS-level atomic primitives (rename, unlink). Multiple `FileBackend`
58/// instances pointing at the same root directory coexist without mutual
59/// serialization; the tmp-file names include a random suffix so concurrent
60/// writes to the same `BackendKey` do not step on each other's tmp files.
61///
62/// # Example
63///
64/// ```no_run
65/// use std::sync::Arc;
66/// use dig_keystore::{
67/// backend::{FileBackend, BackendKey, KeychainBackend},
68/// };
69///
70/// let backend: Arc<dyn KeychainBackend> = Arc::new(
71/// FileBackend::new("/var/lib/dig/keys")
72/// );
73/// backend.write(&BackendKey::new("v1"), b"...").unwrap();
74/// # drop(backend);
75/// ```
76pub struct FileBackend {
77 /// Directory that contains all `<key>.dks` files owned by this backend.
78 root: PathBuf,
79}
80
81impl FileBackend {
82 /// Create a new file backend rooted at `root`.
83 ///
84 /// The directory is **not** created immediately — it is lazily created on
85 /// the first `write` call (with mode `0700` on Unix). This lets callers
86 /// construct a `FileBackend` in tests without side effects; no files are
87 /// written until the first `write`.
88 ///
89 /// # Example
90 ///
91 /// ```
92 /// use dig_keystore::backend::FileBackend;
93 /// let be = FileBackend::new("/var/lib/dig/keys");
94 /// let _ = be; // directory not created yet
95 /// ```
96 pub fn new(root: impl Into<PathBuf>) -> Self {
97 Self { root: root.into() }
98 }
99
100 /// The root directory this backend writes to.
101 pub fn root(&self) -> &Path {
102 &self.root
103 }
104
105 /// Build the full path for a `BackendKey`.
106 fn path_for(&self, key: &BackendKey) -> PathBuf {
107 let mut p = self.root.clone();
108 p.push(format!("{}.{}", key.as_str(), EXT));
109 p
110 }
111
112 /// Create the root directory if it does not already exist.
113 ///
114 /// Called from `write` to support the "lazy directory creation" behaviour.
115 /// On Unix, sets the directory to mode `0700` so only the owning user can
116 /// list / enter it.
117 fn ensure_root(&self) -> Result<()> {
118 if self.root.exists() {
119 return Ok(());
120 }
121 fs::create_dir_all(&self.root)?;
122 #[cfg(unix)]
123 {
124 use std::os::unix::fs::PermissionsExt;
125 let _ = fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700));
126 }
127 Ok(())
128 }
129}
130
131impl KeychainBackend for FileBackend {
132 /// Read the entire file at `<root>/<key>.dks`.
133 ///
134 /// Returns `KeystoreError::Backend` wrapping an `io::Error` with
135 /// `ErrorKind::NotFound` if the file does not exist.
136 fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
137 let path = self.path_for(key);
138 let mut f = fs::File::open(&path)?;
139 let mut buf = Vec::new();
140 f.read_to_end(&mut buf)?;
141 Ok(buf)
142 }
143
144 /// Atomically write `data` to `<root>/<key>.dks`.
145 ///
146 /// Steps:
147 /// 1. Ensure `root` exists.
148 /// 2. Create sibling `<key>.dks.tmp.<random>` file, mode `0600` on Unix.
149 /// 3. Write `data`, `fsync` the file handle.
150 /// 4. `rename` the tmp file onto the final name.
151 /// 5. On Unix, `fsync` the containing directory so the rename is durable.
152 /// 6. On error in step 4, best-effort unlink the tmp file.
153 ///
154 /// The random suffix in step 2 is **not** cryptographic — it exists only
155 /// to disambiguate two concurrent writes to the same key from the same
156 /// process. Uses a hash of `(nanoseconds_since_epoch, pid)`.
157 fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
158 self.ensure_root()?;
159 let final_path = self.path_for(key);
160 let mut tmp_path = final_path.clone();
161 let rand_suffix: u64 = fastrand_suffix();
162 tmp_path.set_extension(format!("{EXT}.tmp.{rand_suffix:016x}"));
163
164 {
165 let mut f = fs::File::create(&tmp_path)?;
166 #[cfg(unix)]
167 {
168 use std::os::unix::fs::PermissionsExt;
169 let _ = f.set_permissions(fs::Permissions::from_mode(0o600));
170 }
171 f.write_all(data)?;
172 // fsync the file so the bytes hit durable storage before rename.
173 // Without this, a crash between write() and rename() would leave
174 // a zero-length tmp file and no keystore data at all.
175 f.sync_all()?;
176 }
177
178 // Atomic rename. On POSIX this is truly atomic within a filesystem;
179 // on Windows it's "effectively atomic" via MoveFileExW.
180 fs::rename(&tmp_path, &final_path).map_err(|e| {
181 // Best-effort cleanup of the tmp file on rename failure.
182 let _ = fs::remove_file(&tmp_path);
183 KeystoreError::from(e)
184 })?;
185
186 // fsync the containing directory on Unix so the rename is durable
187 // across a crash. No-op on Windows (directory fsync isn't a concept).
188 #[cfg(unix)]
189 {
190 if let Ok(dir) = fs::File::open(&self.root) {
191 let _ = dir.sync_all();
192 }
193 }
194
195 Ok(())
196 }
197
198 /// Best-effort secure delete, then unlink.
199 ///
200 /// Steps:
201 /// 1. No-op if file does not exist (idempotent).
202 /// 2. Open the file for writing; overwrite with zeros in 4 KiB chunks.
203 /// 3. `fsync` the overwritten file so zeros hit storage.
204 /// 4. `unlink` the file.
205 ///
206 /// Step 2 is best-effort. On SSDs with flash translation layer or on
207 /// copy-on-write filesystems (btrfs, ZFS), the zero pass may not reach
208 /// the sectors that held the ciphertext. Use full-disk encryption for
209 /// stronger guarantees.
210 fn delete(&self, key: &BackendKey) -> Result<()> {
211 let path = self.path_for(key);
212 if !path.exists() {
213 return Ok(());
214 }
215
216 if let Ok(metadata) = fs::metadata(&path) {
217 let len = metadata.len();
218 if let Ok(mut f) = fs::OpenOptions::new().write(true).open(&path) {
219 let zeros = vec![0u8; 4096];
220 let mut remaining = len as usize;
221 while remaining > 0 {
222 let n = remaining.min(zeros.len());
223 if f.write_all(&zeros[..n]).is_err() {
224 break;
225 }
226 remaining -= n;
227 }
228 let _ = f.sync_all();
229 }
230 }
231
232 fs::remove_file(&path)?;
233 Ok(())
234 }
235
236 /// Enumerate keys whose names start with `prefix`.
237 ///
238 /// Scans the root directory; skips any file that:
239 /// - does not end in `.dks`
240 /// - has a non-UTF-8 name
241 /// - does not start with `prefix`
242 ///
243 /// Returns an empty vec if the root directory does not exist.
244 fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
245 if !self.root.exists() {
246 return Ok(Vec::new());
247 }
248 let mut out = Vec::new();
249 for entry in fs::read_dir(&self.root)? {
250 let entry = entry?;
251 let name = entry.file_name();
252 let name = match name.to_str() {
253 Some(s) => s,
254 None => continue,
255 };
256 let Some(stem) = name.strip_suffix(&format!(".{EXT}")) else {
257 continue;
258 };
259 if stem.starts_with(prefix) {
260 out.push(BackendKey::new(stem.to_string()));
261 }
262 }
263 Ok(out)
264 }
265
266 /// Cheap override — `Path::exists` stats without opening the file.
267 fn exists(&self, key: &BackendKey) -> Result<bool> {
268 Ok(self.path_for(key).exists())
269 }
270}
271
272/// Quick, non-cryptographic random suffix for tmp filenames.
273///
274/// We do NOT use this for anything security-sensitive — it only disambiguates
275/// concurrent tmp files. Uses `(nanoseconds_since_epoch * golden_ratio_prime) + pid`
276/// for a spread uniform enough to avoid collisions across processes on the same host.
277///
278/// If two tmp files happen to collide, the loser will fail the final
279/// `fs::rename` with `AlreadyExists` (on Windows) or succeed but overwrite
280/// the other tmp (on Unix); either way the actual final `.dks` file is
281/// unaffected.
282fn fastrand_suffix() -> u64 {
283 use std::time::{SystemTime, UNIX_EPOCH};
284 let ns = SystemTime::now()
285 .duration_since(UNIX_EPOCH)
286 .map(|d| d.as_nanos() as u64)
287 .unwrap_or(0);
288 let pid = std::process::id() as u64;
289 // 0x9E37_79B9_7F4A_7C15 = 2^64 / golden ratio — gives uniform spread.
290 ns.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(pid)
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use tempfile::TempDir;
297
298 /// **Proves:** `FileBackend::write` followed by `FileBackend::read`
299 /// recovers the same bytes.
300 ///
301 /// **Why it matters:** The basic "file actually persists" check. This
302 /// exercises the full tmp-file + rename path including directory
303 /// creation, mode setting, `fsync`, and `rename`.
304 ///
305 /// **Catches:** a regression where `write` skips the rename step (file
306 /// left in `<name>.tmp.XXX` form) or `read` opens the wrong path.
307 #[test]
308 fn write_then_read_roundtrip() {
309 let dir = TempDir::new().unwrap();
310 let be = FileBackend::new(dir.path().to_path_buf());
311 let key = BackendKey::new("test");
312 be.write(&key, b"hello").unwrap();
313 let out = be.read(&key).unwrap();
314 assert_eq!(out, b"hello");
315 }
316
317 /// **Proves:** two sequential `write` calls to the same key leave no
318 /// `.tmp.` residue in the directory — meaning the tmp-then-rename
319 /// dance successfully cleaned up intermediate files.
320 ///
321 /// **Why it matters:** If tmp files accumulated, `list` would return
322 /// them to callers, disk space would leak, and operators would have to
323 /// manually clean up. The second `write` also asserts that the newer
324 /// content (`"second"`) overwrote the older (`"first"`) — atomicity's
325 /// visible behaviour.
326 ///
327 /// **Catches:** a regression where the rename fails silently and the
328 /// tmp file is not deleted; a regression where the final file is not
329 /// actually renamed on top of the previous one.
330 #[test]
331 fn write_is_atomic_on_rename_failure() {
332 let dir = TempDir::new().unwrap();
333 let be = FileBackend::new(dir.path().to_path_buf());
334 let key = BackendKey::new("atomic");
335 be.write(&key, b"first").unwrap();
336 be.write(&key, b"second").unwrap();
337 assert_eq!(be.read(&key).unwrap(), b"second");
338 // No .tmp files should linger.
339 let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
340 for e in entries {
341 let name = e.unwrap().file_name();
342 let s = name.to_string_lossy().into_owned();
343 assert!(!s.contains(".tmp."), "leftover tmp file: {s}");
344 }
345 }
346
347 /// **Proves:** after `delete`, the file is gone and `exists` returns `false`.
348 ///
349 /// **Why it matters:** Confirms the delete path actually unlinks the
350 /// file. This is the final action in `Keystore::delete`; a regression
351 /// here would leave keystore files behind after an operator thought
352 /// they had wiped them.
353 ///
354 /// **Catches:** a regression where `delete` only overwrites (secure
355 /// wipe) without unlinking; where `exists` checks a stale cache; or
356 /// where `delete` silently errors on the unlink step.
357 #[test]
358 fn delete_removes_file() {
359 let dir = TempDir::new().unwrap();
360 let be = FileBackend::new(dir.path().to_path_buf());
361 let key = BackendKey::new("delete_me");
362 be.write(&key, b"bye").unwrap();
363 assert!(be.exists(&key).unwrap());
364 be.delete(&key).unwrap();
365 assert!(!be.exists(&key).unwrap());
366 }
367
368 /// **Proves:** deleting a non-existent key is a no-op success — not an
369 /// error.
370 ///
371 /// **Why it matters:** The [`KeychainBackend`] contract requires
372 /// `delete` to be idempotent. Callers (e.g., `dig-validator keys remove`)
373 /// can call `delete` without first checking existence; a double-call
374 /// after a concurrent delete should not fail.
375 ///
376 /// **Catches:** a regression where `delete` returns `NotFound` for
377 /// missing files.
378 #[test]
379 fn delete_is_idempotent() {
380 let dir = TempDir::new().unwrap();
381 let be = FileBackend::new(dir.path().to_path_buf());
382 be.delete(&BackendKey::new("never_existed")).unwrap();
383 }
384
385 /// **Proves:** `list("alph")` returns exactly `["alpha", "alpha2"]`
386 /// when the directory contains `alpha.dks`, `alpha2.dks`, and `beta.dks`.
387 ///
388 /// **Why it matters:** Prefix-based listing is what enables CLI tools
389 /// like `dig-validator keys list` to enumerate all keystores of a given
390 /// operator. Strict prefix matching (not substring, not suffix) must
391 /// be pinned.
392 ///
393 /// **Catches:** `starts_with` → `contains` regression (which would
394 /// include `beta` if prefix were `"eta"`); failure to strip the `.dks`
395 /// extension.
396 #[test]
397 fn list_with_prefix() {
398 let dir = TempDir::new().unwrap();
399 let be = FileBackend::new(dir.path().to_path_buf());
400 be.write(&BackendKey::new("alpha"), b"a").unwrap();
401 be.write(&BackendKey::new("alpha2"), b"a").unwrap();
402 be.write(&BackendKey::new("beta"), b"b").unwrap();
403 let mut keys = be.list("alph").unwrap();
404 keys.sort_by_key(|k| k.0.clone());
405 assert_eq!(
406 keys,
407 vec![BackendKey::new("alpha"), BackendKey::new("alpha2")]
408 );
409 }
410
411 /// **Proves:** reading a non-existent key returns a `KeystoreError::Backend`
412 /// wrapping an `io::Error` with `ErrorKind::NotFound`.
413 ///
414 /// **Why it matters:** The default [`KeychainBackend::exists`] impl
415 /// relies on this specific error shape to distinguish "not present"
416 /// from "I/O failed." If `read` returned a generic `InvalidInput` or
417 /// similar, `exists` would misclassify missing keys.
418 ///
419 /// **Catches:** a regression where `read` eats the OS error and
420 /// returns a custom `KeystoreError` variant, breaking the default
421 /// `exists` implementation.
422 #[test]
423 fn read_nonexistent_returns_error() {
424 let dir = TempDir::new().unwrap();
425 let be = FileBackend::new(dir.path().to_path_buf());
426 let err = be.read(&BackendKey::new("missing")).unwrap_err();
427 let is_not_found = match &err {
428 KeystoreError::Backend(io) => io.kind() == std::io::ErrorKind::NotFound,
429 _ => false,
430 };
431 assert!(is_not_found);
432 }
433
434 /// **Proves:** `FileBackend::write` lazily creates the root directory
435 /// (and intermediate parents) when the first write arrives.
436 ///
437 /// **Why it matters:** Operators may point the validator at
438 /// `~/.dig/keys/` before that directory exists. Requiring them to
439 /// `mkdir -p` first is poor UX. This test pins the "lazy mkdir" on
440 /// first write behaviour so `FileBackend::new` can remain side-effect-free.
441 ///
442 /// **Catches:** a regression where `write` assumes the dir exists and
443 /// fails with `NotFound` on first call; or where `new` eagerly creates
444 /// the dir (unwanted in tests).
445 #[test]
446 fn creates_root_dir() {
447 let dir = TempDir::new().unwrap();
448 let sub = dir.path().join("nested/keys");
449 let be = FileBackend::new(sub.clone());
450 assert!(!sub.exists());
451 be.write(&BackendKey::new("k"), b"x").unwrap();
452 assert!(sub.exists());
453 }
454}