Skip to main content

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}