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, the keystore root directory (on creation) and every written file
25//! are restricted to mode `0700` / `0600` — reachable only by the owning user
26//! — and that restriction is **verified after the fact**, not merely
27//! requested. A path that is still group- or other-accessible fails the write
28//! with [`KeystoreError::InsecurePermissions`] rather than succeeding quietly,
29//! because a `chmod` on a filesystem without POSIX modes reports success and
30//! changes nothing. See [`is_owner_only`].
31//!
32//! **On Windows there is no equivalent floor.** Standard NTFS ACL inheritance
33//! applies and this crate does not narrow it, so a blob inherits whatever its
34//! parent directory grants. Restricting it would mean an explicit owner-only
35//! DACL, which requires Win32 FFI, and this package pins `unsafe_code =
36//! "forbid"` as a spec property (`SPEC.md` §12/§13.2, conformance C-15) — so
37//! that enforcement cannot live here. It belongs beside the platform hardware
38//! providers in a separate workspace member (dig_ecosystem #1693). Until then,
39//! operators on a shared user account should not rely on this crate for access
40//! control on Windows.
41//!
42//! Either way this is defence in depth. The blob is already sealed with
43//! AES-256-GCM under an Argon2id-hardened key (`SPEC.md` §3–§5); permissions
44//! decide who may *attempt* an offline attack on it, not whether one succeeds.
45//!
46//! # Secure delete
47//!
48//! On modern SSDs, a single-pass overwrite cannot guarantee the sectors are
49//! unrecoverable — the SSD's flash translation layer may have copied them
50//! elsewhere. This crate does a single zero pass as a best-effort. For
51//! high-value keys on untrusted hardware, use full-disk encryption (LUKS,
52//! BitLocker) which zero-keys the entire volume on wipe.
53//!
54//! # References
55//!
56//! - [POSIX `rename(2)`](https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html)
57//! - [Windows `MoveFileExW`](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw)
58//! - [DJB on secure-delete on SSDs](https://cr.yp.to/bib/2009/coker.pdf)
59
60use std::fs;
61use std::io::{self, Read, Write};
62use std::path::{Path, PathBuf};
63
64use crate::backend::{BackendKey, Exclusivity, KeychainBackend};
65use crate::error::{KeystoreError, Result};
66
67/// File extension for keystore blobs. Stands for "DIG KeyStore".
68const EXT: &str = "dks";
69
70/// Every group and other permission bit — the set that must be clear on any
71/// path holding sealed key material.
72const GROUP_AND_OTHER_BITS: u32 = 0o077;
73
74/// Whether `mode` grants access to nobody but the owner.
75///
76/// This is the property the backend actually promises. It is deliberately
77/// phrased over the *observed* bits rather than over "did `chmod` return
78/// `Ok`", because the two are not the same thing: on a filesystem with no
79/// POSIX mode support a `chmod` succeeds and changes nothing, so a successful
80/// call is no evidence at all that the file is protected.
81///
82/// Only the low nine permission bits are considered; file-type and setuid
83/// bits carried in the same word are irrelevant to who may read the blob.
84///
85/// Compiled on every platform even though only Unix calls it, so that its
86/// behaviour is testable on any build host. A `#[cfg(unix)]` predicate is
87/// unfalsifiable on a Windows developer machine, which is where most of this
88/// crate's consumers are written.
89#[cfg_attr(not(unix), allow(dead_code))]
90fn is_owner_only(mode: u32) -> bool {
91    mode & GROUP_AND_OTHER_BITS == 0
92}
93
94/// Request owner-only permissions on `path`, then verify they took effect.
95///
96/// `requested` is the mode to ask for (`0o700` for the root directory,
97/// `0o600` for a blob). The request's own error is intentionally ignored: it
98/// is the verification below, not the call's return value, that decides
99/// whether the path is safe to hold key material.
100///
101/// On non-Unix hosts this is a no-op — see the module docs for what does and
102/// does not protect a blob on Windows.
103#[allow(unused_variables)]
104fn enforce_owner_only(path: &Path, requested: u32) -> Result<()> {
105    #[cfg(unix)]
106    {
107        use std::os::unix::fs::PermissionsExt;
108
109        let _ = fs::set_permissions(path, fs::Permissions::from_mode(requested));
110
111        let mode = fs::metadata(path)?.permissions().mode() & 0o777;
112        if !is_owner_only(mode) {
113            return Err(KeystoreError::InsecurePermissions {
114                path: path.display().to_string(),
115                mode,
116            });
117        }
118    }
119    Ok(())
120}
121
122/// Create `path` for writing, born owner-only where the platform allows it.
123///
124/// `File::create` opens with `0666 & ~umask`, so on a default umask the tmp
125/// blob exists at `0644` for the window between the open and the narrowing
126/// `chmod`. Requesting the mode in the `open(2)` call itself removes that
127/// window: the file never exists under a permissive mode at all. `create_new`
128/// additionally refuses to follow a symlink planted on the tmp path.
129///
130/// One window is *not* closed by this, and is not closable from user space: a
131/// process holding a directory fd opened before the root was tightened can
132/// still `openat` inside it, and read permission granted at open time survives
133/// any later `chmod`. That is why the root is brought to a verified `0700`
134/// before any tmp file is created, rather than relying on the blob mode alone.
135fn create_owner_only(path: &Path) -> Result<fs::File> {
136    let mut opts = fs::OpenOptions::new();
137    opts.write(true).create_new(true);
138    #[cfg(unix)]
139    {
140        use std::os::unix::fs::OpenOptionsExt;
141        opts.mode(0o600);
142    }
143    Ok(opts.open(path)?)
144}
145
146/// Filesystem-backed keychain.
147///
148/// Thread-safe — `KeychainBackend` is `Send + Sync`, and all operations use
149/// OS-level atomic primitives (rename, unlink). Multiple `FileBackend`
150/// instances pointing at the same root directory coexist without mutual
151/// serialization; the tmp-file names include a random suffix so concurrent
152/// writes to the same `BackendKey` do not step on each other's tmp files.
153///
154/// # Example
155///
156/// ```no_run
157/// use std::sync::Arc;
158/// use dig_keystore::{
159///     backend::{FileBackend, BackendKey, KeychainBackend},
160/// };
161///
162/// let backend: Arc<dyn KeychainBackend> = Arc::new(
163///     FileBackend::new("/var/lib/dig/keys")
164/// );
165/// backend.write(&BackendKey::new("v1"), b"...").unwrap();
166/// # drop(backend);
167/// ```
168pub struct FileBackend {
169    /// Directory that contains all `<key>.dks` files owned by this backend.
170    root: PathBuf,
171}
172
173impl FileBackend {
174    /// Create a new file backend rooted at `root`.
175    ///
176    /// The directory is **not** created immediately — it is lazily created on
177    /// the first `write` call (with mode `0700` on Unix). This lets callers
178    /// construct a `FileBackend` in tests without side effects; no files are
179    /// written until the first `write`.
180    ///
181    /// # Example
182    ///
183    /// ```
184    /// use dig_keystore::backend::FileBackend;
185    /// let be = FileBackend::new("/var/lib/dig/keys");
186    /// let _ = be;  // directory not created yet
187    /// ```
188    pub fn new(root: impl Into<PathBuf>) -> Self {
189        Self { root: root.into() }
190    }
191
192    /// The root directory this backend writes to.
193    pub fn root(&self) -> &Path {
194        &self.root
195    }
196
197    /// Build the full path for a `BackendKey`.
198    fn path_for(&self, key: &BackendKey) -> PathBuf {
199        let mut p = self.root.clone();
200        p.push(format!("{}.{}", key.as_str(), EXT));
201        p
202    }
203
204    /// Create the root directory if it does not already exist, and hold it to
205    /// the owner-only floor whether or not this call created it.
206    ///
207    /// Called from `write` to support the "lazy directory creation" behaviour.
208    /// On Unix the directory is restricted to mode `0700` — so only the owning
209    /// user can list or enter it — and that is verified, not assumed.
210    ///
211    /// **The check runs on every write, not only on the creation path.** An
212    /// earlier shape returned early when the root already existed, which left
213    /// the floor unreachable for exactly the roots that need it most: one
214    /// created by a version that requested `0700` without checking the result,
215    /// on a filesystem where that request does nothing, stays unverified
216    /// forever.
217    ///
218    /// The exposure that closes is the root's **write** bits rather than its
219    /// read bits — blobs carry their own verified `0600`, so a permissive root
220    /// does not expose sealed bytes, but group- or world-writable grants
221    /// `unlink` and `create` inside it. That is blob substitution (rolling a
222    /// victim back to an older sealed seed) and deletion, on the directory
223    /// holding an account master seed.
224    ///
225    /// **A permissive mode is repaired; a symlinked root is refused.** The two
226    /// resolve in opposite directions because they are different kinds of
227    /// claim. A mode is a property of the intended directory: the backend can
228    /// correct it in one syscall and then *verify* that it did, so refusing
229    /// instead would turn any drift — a restore from backup, a `chmod` by the
230    /// user — into a permanent brick on master-seed writes, and hand anyone who
231    /// can merely widen the mode a denial primitive over a condition the crate
232    /// can fix. A symlink is a claim about *which directory the keystore is*,
233    /// and no syscall makes an attacker-chosen directory into the intended one;
234    /// since `set_permissions` and `metadata` both follow links, "repairing" it
235    /// would mean chmodding that directory to `0700` and sealing the seed
236    /// inside it. Fail closed where the invariant cannot be established, repair
237    /// where it can be established and confirmed.
238    fn ensure_root(&self) -> Result<()> {
239        // `symlink_metadata` inspects the root itself; `exists()` and
240        // `metadata()` both follow links, and so do `set_permissions` and the
241        // verification read below. Following a link here would mean chmodding
242        // and then seeding a directory chosen by whoever planted it.
243        match fs::symlink_metadata(&self.root) {
244            Ok(meta) if meta.file_type().is_symlink() => Err(KeystoreError::UnsafeRoot {
245                path: self.root.display().to_string(),
246                reason: "it is a symbolic link; pass the resolved target if that is intended",
247            }),
248            Ok(meta) if !meta.is_dir() => Err(KeystoreError::UnsafeRoot {
249                path: self.root.display().to_string(),
250                reason: "it exists and is not a directory",
251            }),
252            Ok(_) => enforce_owner_only(&self.root, 0o700),
253            Err(e) if e.kind() == io::ErrorKind::NotFound => {
254                fs::create_dir_all(&self.root)?;
255                enforce_owner_only(&self.root, 0o700)
256            }
257            Err(e) => Err(KeystoreError::from(e)),
258        }
259    }
260}
261
262impl KeychainBackend for FileBackend {
263    /// Read the entire file at `<root>/<key>.dks`.
264    ///
265    /// Returns `KeystoreError::Backend` wrapping an `io::Error` with
266    /// `ErrorKind::NotFound` if the file does not exist.
267    fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
268        let path = self.path_for(key);
269        let mut f = fs::File::open(&path)?;
270        let mut buf = Vec::new();
271        f.read_to_end(&mut buf)?;
272        Ok(buf)
273    }
274
275    /// Atomically write `data` to `<root>/<key>.dks`.
276    ///
277    /// Steps:
278    /// 1. Ensure `root` exists, is a directory rather than a symlink, and is
279    ///    verified owner-only.
280    /// 2. Create sibling `<key>.dks.tmp.<random>` file with mode `0600`
281    ///    requested in the `open(2)` call on Unix, then verify the mode that
282    ///    actually took effect before any bytes are written —
283    ///    so a root that cannot hold key material safely yields
284    ///    [`KeystoreError::InsecurePermissions`] and an empty, removed tmp
285    ///    file rather than an exposed blob.
286    /// 3. Write `data`, `fsync` the file handle.
287    /// 4. `rename` the tmp file onto the final name.
288    /// 5. On Unix, `fsync` the containing directory so the rename is durable.
289    /// 6. On error in step 4, best-effort unlink the tmp file.
290    ///
291    /// The random suffix in step 2 is **not** cryptographic — it exists only
292    /// to disambiguate two concurrent writes to the same key from the same
293    /// process. Uses a hash of `(nanoseconds_since_epoch, pid)`.
294    fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
295        self.ensure_root()?;
296        let final_path = self.path_for(key);
297        let mut tmp_path = final_path.clone();
298        let rand_suffix: u64 = fastrand_suffix();
299        tmp_path.set_extension(format!("{EXT}.tmp.{rand_suffix:016x}"));
300
301        // Stage the bytes into the tmp file. Written as a closure so the file
302        // handle is dropped by leaving scope — and so EVERY failure in here,
303        // not just a rename failure, gets the same cleanup below. An earlier
304        // shape orphaned the tmp file whenever `write_all` or `sync_all`
305        // failed.
306        let staged = (|| -> Result<()> {
307            let mut f = create_owner_only(&tmp_path)?;
308            // Restrict the file BEFORE any ciphertext reaches it. A keystore
309            // that cannot protect its own blobs must write nothing at all,
310            // rather than report success over a world-readable seed.
311            enforce_owner_only(&tmp_path, 0o600)?;
312            f.write_all(data)?;
313            // fsync the file so the bytes hit durable storage before rename.
314            // Without this, a crash between write() and rename() would leave
315            // a zero-length tmp file and no keystore data at all.
316            f.sync_all()?;
317            Ok(())
318        })();
319
320        if let Err(e) = staged {
321            // The handle is already closed, so this also succeeds on Windows,
322            // where an open file cannot be unlinked.
323            let _ = fs::remove_file(&tmp_path);
324            return Err(e);
325        }
326
327        // Atomic rename. On POSIX this is truly atomic within a filesystem;
328        // on Windows it's "effectively atomic" via MoveFileExW.
329        fs::rename(&tmp_path, &final_path).map_err(|e| {
330            // Best-effort cleanup of the tmp file on rename failure.
331            let _ = fs::remove_file(&tmp_path);
332            KeystoreError::from(e)
333        })?;
334
335        // fsync the containing directory on Unix so the rename is durable
336        // across a crash. No-op on Windows (directory fsync isn't a concept).
337        #[cfg(unix)]
338        {
339            if let Ok(dir) = fs::File::open(&self.root) {
340                let _ = dir.sync_all();
341            }
342        }
343
344        Ok(())
345    }
346
347    /// Best-effort secure delete, then unlink.
348    ///
349    /// Steps:
350    /// 1. No-op if file does not exist (idempotent).
351    /// 2. Open the file for writing; overwrite with zeros in 4 KiB chunks.
352    /// 3. `fsync` the overwritten file so zeros hit storage.
353    /// 4. `unlink` the file.
354    ///
355    /// Step 2 is best-effort. On SSDs with flash translation layer or on
356    /// copy-on-write filesystems (btrfs, ZFS), the zero pass may not reach
357    /// the sectors that held the ciphertext. Use full-disk encryption for
358    /// stronger guarantees.
359    fn delete(&self, key: &BackendKey) -> Result<()> {
360        let path = self.path_for(key);
361        if !path.exists() {
362            return Ok(());
363        }
364
365        if let Ok(metadata) = fs::metadata(&path) {
366            let len = metadata.len();
367            if let Ok(mut f) = fs::OpenOptions::new().write(true).open(&path) {
368                let zeros = vec![0u8; 4096];
369                let mut remaining = len as usize;
370                while remaining > 0 {
371                    let n = remaining.min(zeros.len());
372                    if f.write_all(&zeros[..n]).is_err() {
373                        break;
374                    }
375                    remaining -= n;
376                }
377                let _ = f.sync_all();
378            }
379        }
380
381        fs::remove_file(&path)?;
382        Ok(())
383    }
384
385    /// Enumerate keys whose names start with `prefix`.
386    ///
387    /// Scans the root directory; skips any file that:
388    /// - does not end in `.dks`
389    /// - has a non-UTF-8 name
390    /// - does not start with `prefix`
391    ///
392    /// Returns an empty vec if the root directory does not exist.
393    fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
394        if !self.root.exists() {
395            return Ok(Vec::new());
396        }
397        let mut out = Vec::new();
398        for entry in fs::read_dir(&self.root)? {
399            let entry = entry?;
400            let name = entry.file_name();
401            let name = match name.to_str() {
402                Some(s) => s,
403                None => continue,
404            };
405            let Some(stem) = name.strip_suffix(&format!(".{EXT}")) else {
406                continue;
407            };
408            if stem.starts_with(prefix) {
409                out.push(BackendKey::new(stem.to_string()));
410            }
411        }
412        Ok(out)
413    }
414
415    /// Stat the path without opening it, preserving the trait's three-valued
416    /// contract: present, confidently absent, or **could not determine**.
417    ///
418    /// Uses `symlink_metadata` rather than `Path::exists()` or `try_exists()`.
419    /// `Path::exists()` maps every error to `false`, which turns an
420    /// inspection failure into a confident negative — and the caller uses that
421    /// answer to decide whether to mint over a `write` that replaces.
422    ///
423    /// `symlink_metadata` is also the stricter of the two honest options: it
424    /// does not follow links, so a **dangling symlink** at the key path counts
425    /// as present. Something occupies that name; refusing to write over it is
426    /// the fail-closed reading, whereas `try_exists()` would report `false` and
427    /// invite exactly the overwrite this method exists to prevent.
428    fn exists(&self, key: &BackendKey) -> Result<bool> {
429        match fs::symlink_metadata(self.path_for(key)) {
430            Ok(_) => Ok(true),
431            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
432            Err(e) => Err(e.into()),
433        }
434    }
435
436    /// Establish `<root>/<key>.dks` **only if it does not already exist**.
437    ///
438    /// Exclusivity comes from the OS: the file is opened with `create_new`, so
439    /// exactly one racer creates it and every other gets
440    /// [`KeystoreError::AlreadyExists`] — a distinguishable error the loser can
441    /// adopt on, rather than a generic I/O failure it can only give up on.
442    ///
443    /// # Why this does not use tmp + rename
444    ///
445    /// `rename` always replaces, so it cannot express "only if absent"; the two
446    /// guarantees are not simultaneously available without a hard link, which
447    /// not every filesystem supports. Exclusivity is the one that matters here,
448    /// and the cost is bounded: a crash mid-write leaves a **short file**, which
449    /// the format's magic, length and CRC all detect on the next read
450    /// (`SPEC.md` §3.2), and which is repaired by deleting it and retrying. The
451    /// state this method exists to prevent — a coupled pair that settled
452    /// mismatched — is neither detectable nor repairable. A best-effort unlink
453    /// removes the partial file on the way out of any failure.
454    fn write_new(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
455        self.ensure_root()?;
456        let path = self.path_for(key);
457
458        // `create_owner_only` is the same exclusive, born-owner-only open the
459        // tmp-file path uses: `create_new(true)` plus the requested mode, so
460        // the file never exists under a permissive mode and a symlink planted
461        // on the path is refused rather than followed.
462        let f = match create_owner_only(&path) {
463            Ok(f) => f,
464            Err(KeystoreError::Backend(e)) if e.kind() == io::ErrorKind::AlreadyExists => {
465                return Err(KeystoreError::AlreadyExists(key.as_str().to_string()))
466            }
467            Err(e) => return Err(e),
468        };
469
470        // Stage the bytes with the handle owned by a closure, so it is closed
471        // by leaving scope. An explicit `drop(f)` would say the same thing on
472        // every native target and trip `clippy::drop_non_drop` on wasm32, where
473        // `std::fs::File` is a stub that does not implement `Drop` — and the
474        // close is load-bearing, because Windows cannot unlink an open file.
475        // Same shape as `write`, for the same reason.
476        let staged = (|mut f: fs::File| -> Result<()> {
477            // The mode is verified, not merely requested: a `chmod` on a
478            // filesystem without POSIX modes reports success and changes
479            // nothing, so the file is removed below rather than filled with key
480            // material it cannot protect. Same floor and reasoning as `write`.
481            enforce_owner_only(&path, 0o600)?;
482            f.write_all(data)?;
483            f.sync_all()?;
484            Ok(())
485        })(f);
486
487        if let Err(e) = staged {
488            // Only reachable once THIS call created the file — an
489            // `AlreadyExists` returned above, so a losing racer never reaches
490            // here and can never unlink the winner's blob.
491            let _ = fs::remove_file(&path);
492            return Err(e);
493        }
494
495        // fsync the containing directory on Unix so the creation is durable
496        // across a crash, matching `write`. Not a concept on Windows.
497        #[cfg(unix)]
498        {
499            if let Ok(dir) = fs::File::open(&self.root) {
500                let _ = dir.sync_all();
501            }
502        }
503
504        Ok(())
505    }
506
507    /// `create_new(true)` is an atomic create-if-absent at the OS level, so two
508    /// concurrent calls cannot both succeed.
509    fn write_new_exclusivity(&self) -> Exclusivity {
510        Exclusivity::Atomic
511    }
512}
513
514/// Quick, non-cryptographic random suffix for tmp filenames.
515///
516/// We do NOT use this for anything security-sensitive — it only disambiguates
517/// concurrent tmp files. Uses `(nanoseconds_since_epoch * golden_ratio_prime) + pid`
518/// for a spread uniform enough to avoid collisions across processes on the same host.
519///
520/// If two tmp files happen to collide, the loser will fail the final
521/// `fs::rename` with `AlreadyExists` (on Windows) or succeed but overwrite
522/// the other tmp (on Unix); either way the actual final `.dks` file is
523/// unaffected.
524fn fastrand_suffix() -> u64 {
525    use std::time::{SystemTime, UNIX_EPOCH};
526    let ns = SystemTime::now()
527        .duration_since(UNIX_EPOCH)
528        .map(|d| d.as_nanos() as u64)
529        .unwrap_or(0);
530    let pid = std::process::id() as u64;
531    // 0x9E37_79B9_7F4A_7C15 = 2^64 / golden ratio — gives uniform spread.
532    ns.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(pid)
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::error::KeystoreError;
539    use tempfile::TempDir;
540    /// A key whose name carries an interior NUL byte. Every OS path API
541    /// rejects it with `InvalidInput` — never `NotFound` — so it is a
542    /// deterministic, cross-platform way to make a `stat` **fail to answer**
543    /// rather than answer "absent".
544    ///
545    /// It is the vehicle, not the property. The property is that an
546    /// undeterminable read refuses; the realistic vehicles (an unreadable
547    /// parent directory, a failing mount, an I/O fault) are not portably
548    /// constructible in a unit test, and one of them is covered by
549    /// `exists_refuses_when_the_parent_cannot_be_read` below.
550    fn undeterminable_key() -> BackendKey {
551        let mut name = String::from("un");
552        name.push('\u{0}');
553        name.push_str("determinable");
554        BackendKey::new(name)
555    }
556
557    /// **Proves:** `FileBackend::exists` returns `Err` — not `Ok(false)` —
558    /// when the filesystem could not answer whether the blob is there.
559    ///
560    /// **Why it matters:** `exists` is a two-valued answer to a three-valued
561    /// question, and its one production caller (`Keystore::create_with_rng`,
562    /// `src/custody/keystore.rs`) uses it to decide whether to MINT.
563    /// `FileBackend::write` is replace-semantics tmp+rename, so a spurious
564    /// `false` does not merely mint a duplicate beside the original — it
565    /// **overwrites the original**. Once the blob is hardware-wrapped
566    /// (`SPEC.md` §17) that overwrite is unrecoverable, and
567    /// `HardwareUnwrapFailed` structurally cannot name its own cause
568    /// (§17.5b), so the loss is silent as well as permanent.
569    ///
570    /// **Catches:** exactly the implementation this replaced —
571    /// `Ok(self.path_for(key).exists())`. `Path::exists()` maps *every* error
572    /// to `false`, so it returns `Ok(false)` for this fixture and this
573    /// assertion fails. Also catches any future "cheap override" that maps a
574    /// non-`NotFound` error to absent.
575    #[test]
576    fn exists_refuses_rather_than_reporting_absent_when_it_cannot_tell() {
577        let dir = TempDir::new().unwrap();
578        let be = FileBackend::new(dir.path().to_path_buf());
579
580        // Control: the same backend answers a *determinable* absence honestly,
581        // so the test cannot pass by refusing everything.
582        assert!(
583            !be.exists(&BackendKey::new("genuinely-absent")).unwrap(),
584            "a determinable absence must still be reported as absent"
585        );
586
587        let err = be
588            .exists(&undeterminable_key())
589            .expect_err("an unanswerable stat must not be reported as absent");
590        assert!(
591            matches!(err, KeystoreError::Backend(_)),
592            "the refusal must carry the underlying I/O cause"
593        );
594    }
595
596    /// **Proves:** the refusal reaches the mint decision — a write is not
597    /// attempted, and nothing is half-created, when the read could not answer.
598    ///
599    /// **Why it matters:** this is the *placement* half. The assertion above
600    /// pins the backend's contract; a guard added in `Keystore::create`
601    /// instead would leave every other present and future `exists` caller
602    /// minting on a false absence. Both the seam and its effect are observed,
603    /// so the fix cannot be relocated without a test noticing.
604    ///
605    /// **Catches:** an `exists` override that answers `Ok(false)` here, which
606    /// would let the write proceed.
607    #[test]
608    fn an_unanswerable_read_does_not_reach_a_write() {
609        let dir = TempDir::new().unwrap();
610        let be = FileBackend::new(dir.path().to_path_buf());
611        let key = undeterminable_key();
612
613        assert!(be.exists(&key).is_err());
614        // And the write itself refuses too, rather than half-creating anything.
615        assert!(be.write(&key, b"payload").is_err());
616        assert!(
617            be.list("").unwrap().is_empty(),
618            "a refused write must leave no residue"
619        );
620    }
621
622    /// **Proves:** a parent directory the process cannot read makes `exists`
623    /// refuse, rather than report absent.
624    ///
625    /// **Why it matters:** this is the *realistic* vehicle for the same
626    /// property — a keystore root whose permissions changed under a running
627    /// service. The NUL-key test above proves the branch; this one proves the
628    /// branch is reached by a situation that actually happens.
629    ///
630    /// **Unix only.** Windows has no equivalent portable construction: mode
631    /// bits do not apply, and denying access needs an explicit DACL, which
632    /// needs Win32 FFI this crate cannot contain (`unsafe_code = "forbid"`,
633    /// §13.2 C-15). On Windows this test is not compiled in and the property
634    /// rests on the test above. It is exercised by the Linux and macOS CI legs;
635    /// a Windows developer cannot run it locally.
636    #[cfg(unix)]
637    #[test]
638    fn exists_refuses_when_the_parent_cannot_be_read() {
639        use std::os::unix::fs::PermissionsExt;
640
641        let dir = TempDir::new().unwrap();
642        let root = dir.path().join("locked");
643        fs::create_dir(&root).unwrap();
644        let be = FileBackend::new(root.clone());
645        let key = BackendKey::new("sealed");
646        be.write(&key, b"payload").unwrap();
647
648        fs::set_permissions(&root, fs::Permissions::from_mode(0o000)).unwrap();
649        let answer = be.exists(&key);
650        // Restore before asserting so a failure cannot leave an unremovable dir.
651        fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap();
652
653        // Root defeats permission bits entirely, so the fixture cannot make the
654        // stat fail there. Rather than skip — which would print `ok` while
655        // asserting nothing, and go unfalsifiable on any root CI runner — each
656        // environment asserts the outcome it can actually exhibit. Neither
657        // branch is vacuous, and neither is `Ok(false)`, which is the answer
658        // this method must never give.
659        if running_as_root() {
660            assert!(
661                answer.unwrap(),
662                "root can read the directory, so the blob must be reported present"
663            );
664        } else {
665            assert!(
666                answer.is_err(),
667                "an unreadable parent must refuse, not report the blob absent"
668            );
669        }
670    }
671
672    /// Whether this process can read a `0o000` directory — i.e. is effectively
673    /// root. Probed by observation rather than `libc::geteuid`, because the
674    /// crate forbids `unsafe` and the observable is the thing we actually care
675    /// about.
676    #[cfg(unix)]
677    fn running_as_root() -> bool {
678        use std::os::unix::fs::PermissionsExt;
679        let probe = TempDir::new().unwrap();
680        let d = probe.path().join("probe");
681        fs::create_dir(&d).unwrap();
682        fs::set_permissions(&d, fs::Permissions::from_mode(0o000)).unwrap();
683        let readable = fs::read_dir(&d).is_ok();
684        fs::set_permissions(&d, fs::Permissions::from_mode(0o700)).unwrap();
685        readable
686    }
687
688    /// **Proves:** `write_new` refuses an existing key with a distinguishable
689    /// `AlreadyExists`, and leaves the stored bytes untouched.
690    ///
691    /// **Why it matters:** a consumer storing two COUPLED records — a wrapped
692    /// blob and the device key that opens it — needs to say "I am
693    /// *establishing* this, not updating it" (dig-keystore#16). With only
694    /// replace-semantics `write`, two concurrent starts settle key `D_B`
695    /// beside blob `B_A`, which never self-heals. `create_new` +
696    /// adopt-on-`AlreadyExists` makes that state unreachable rather than
697    /// unlikely.
698    ///
699    /// **Catches:** a `write_new` implemented as `exists()` then `write()`,
700    /// which would replace the bytes; and one that reports the collision as a
701    /// generic I/O error, which a caller cannot adopt on.
702    #[test]
703    fn write_new_refuses_an_existing_key_without_touching_it() {
704        let dir = TempDir::new().unwrap();
705        let be = FileBackend::new(dir.path().to_path_buf());
706        let key = BackendKey::new("coupled");
707
708        be.write_new(&key, b"established").unwrap();
709        let err = be
710            .write_new(&key, b"usurper")
711            .expect_err("write_new must refuse an established key");
712
713        assert!(
714            matches!(err, KeystoreError::AlreadyExists(ref k) if k == "coupled"),
715            "the collision must be adoptable, not a generic I/O error: {err:?}"
716        );
717        assert_eq!(
718            be.read(&key).unwrap(),
719            b"established",
720            "a refused write_new must not replace the established bytes"
721        );
722    }
723
724    /// **Proves:** `write_new` on an absent key stores bytes `read` recovers,
725    /// and the `write` beside it still replaces.
726    ///
727    /// **Why it matters:** the control for the test above. A `write_new` that
728    /// refused unconditionally would satisfy the refusal assertion perfectly
729    /// while being useless, and no other test would notice.
730    ///
731    /// **Catches:** a `write_new` that never writes, writes to a different
732    /// path than `write`/`read` use, or that accidentally makes `write`
733    /// exclusive too.
734    #[test]
735    fn write_new_establishes_an_absent_key() {
736        let dir = TempDir::new().unwrap();
737        let be = FileBackend::new(dir.path().to_path_buf());
738        let key = BackendKey::new("fresh");
739
740        be.write_new(&key, b"payload").unwrap();
741        assert_eq!(be.read(&key).unwrap(), b"payload");
742        be.write(&key, b"replaced").unwrap();
743        assert_eq!(be.read(&key).unwrap(), b"replaced");
744    }
745
746    /// **Proves:** `FileBackend` claims exclusive `write_new`.
747    ///
748    /// **Why it matters:** [`Exclusivity`] is what a consumer reads to decide
749    /// whether `write_new` can be *relied on* to make a coupled mismatch
750    /// unreachable. A backend that overstates it hands back the exact race the
751    /// method exists to remove.
752    ///
753    /// **Catches:** a `FileBackend` that keeps the `Atomic` claim after being
754    /// reimplemented as a check-then-write.
755    #[test]
756    fn file_backend_claims_exclusive_creation() {
757        let dir = TempDir::new().unwrap();
758        let be = FileBackend::new(dir.path().to_path_buf());
759        assert_eq!(be.write_new_exclusivity(), Exclusivity::Atomic);
760    }
761
762    /// **Proves:** under contention, exactly ONE `write_new` establishes the key
763    /// and every other racer gets `AlreadyExists` — the mechanism behind the
764    /// [`Exclusivity::Atomic`] claim, not merely the claim.
765    ///
766    /// **Why it matters:** `write_new_refuses_an_existing_key_without_touching_it`
767    /// above is satisfied identically by a check-then-write, so on its own it
768    /// pins a coincidence. The whole value of `write_new` to a consumer with
769    /// coupled records is that two concurrent *starts* cannot both establish;
770    /// that is a property of concurrency and nothing sequential can observe it.
771    ///
772    /// **Catches:** a `write_new` reimplemented as `exists()` then `write()`.
773    /// Every thread would pass the vacancy check inside the barrier window and
774    /// several would report success.
775    ///
776    /// **One-directional, deliberately.** A correct implementation can *never*
777    /// produce two winners, so this test cannot fail spuriously. A broken one
778    /// is caught probabilistically — the barrier maximises the overlap, but a
779    /// single unlucky scheduling could still serialise the threads. It is a
780    /// sound proof of the negative and a strong-but-not-certain detector of the
781    /// positive, which is the right way round.
782    #[test]
783    fn only_one_concurrent_write_new_can_win() {
784        use std::sync::{Arc, Barrier};
785
786        const RACERS: usize = 16;
787
788        let dir = TempDir::new().unwrap();
789        // Create the root up front so the race is over the blob, not over
790        // `ensure_root`, which would serialise the threads before they reach
791        // the interesting call.
792        let be = Arc::new(FileBackend::new(dir.path().to_path_buf()));
793        be.write(&BackendKey::new("warmup"), b"x").unwrap();
794
795        let key = BackendKey::new("contended");
796        let gate = Arc::new(Barrier::new(RACERS));
797
798        let winners: usize = std::thread::scope(|scope| {
799            let handles: Vec<_> = (0..RACERS)
800                .map(|i| {
801                    let (be, gate, key) = (Arc::clone(&be), Arc::clone(&gate), key.clone());
802                    scope.spawn(move || {
803                        // Each racer writes a distinguishable payload, so the
804                        // survivor identifies which one won.
805                        let payload = [i as u8; 8];
806                        gate.wait();
807                        be.write_new(&key, &payload).is_ok()
808                    })
809                })
810                .collect();
811            // `join` consumes the handle, so map-then-filter rather than
812            // `filter(|h| h.join()..)`. `unwrap` is deliberate: a panicking
813            // racer must fail this test, not be counted as a loser.
814            handles
815                .into_iter()
816                .map(|h| h.join().unwrap())
817                .filter(|won| *won)
818                .count()
819        });
820
821        assert_eq!(
822            winners, 1,
823            "exactly one racer may establish a key; {winners} did"
824        );
825        // The stored bytes are one racer's payload in full — never a blend of
826        // two, which is what a torn concurrent write would leave.
827        let stored = be.read(&key).unwrap();
828        assert_eq!(stored.len(), 8);
829        assert!(
830            stored.iter().all(|b| *b == stored[0]),
831            "the survivor's payload must be intact, not a mix of two writers"
832        );
833    }
834
835    /// **Proves:** `FileBackend::write` followed by `FileBackend::read`
836    /// recovers the same bytes.
837    ///
838    /// **Why it matters:** The basic "file actually persists" check. This
839    /// exercises the full tmp-file + rename path including directory
840    /// creation, mode setting, `fsync`, and `rename`.
841    ///
842    /// **Catches:** a regression where `write` skips the rename step (file
843    /// left in `<name>.tmp.XXX` form) or `read` opens the wrong path.
844    #[test]
845    fn write_then_read_roundtrip() {
846        let dir = TempDir::new().unwrap();
847        let be = FileBackend::new(dir.path().to_path_buf());
848        let key = BackendKey::new("test");
849        be.write(&key, b"hello").unwrap();
850        let out = be.read(&key).unwrap();
851        assert_eq!(out, b"hello");
852    }
853
854    /// **Proves:** two sequential `write` calls to the same key leave no
855    /// `.tmp.` residue in the directory — meaning the tmp-then-rename
856    /// dance successfully cleaned up intermediate files.
857    ///
858    /// **Why it matters:** If tmp files accumulated, `list` would return
859    /// them to callers, disk space would leak, and operators would have to
860    /// manually clean up. The second `write` also asserts that the newer
861    /// content (`"second"`) overwrote the older (`"first"`) — atomicity's
862    /// visible behaviour.
863    ///
864    /// **Catches:** a regression where the rename fails silently and the
865    /// tmp file is not deleted; a regression where the final file is not
866    /// actually renamed on top of the previous one.
867    #[test]
868    fn write_is_atomic_on_rename_failure() {
869        let dir = TempDir::new().unwrap();
870        let be = FileBackend::new(dir.path().to_path_buf());
871        let key = BackendKey::new("atomic");
872        be.write(&key, b"first").unwrap();
873        be.write(&key, b"second").unwrap();
874        assert_eq!(be.read(&key).unwrap(), b"second");
875        // No .tmp files should linger.
876        let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
877        for e in entries {
878            let name = e.unwrap().file_name();
879            let s = name.to_string_lossy().into_owned();
880            assert!(!s.contains(".tmp."), "leftover tmp file: {s}");
881        }
882    }
883
884    /// **Proves:** after `delete`, the file is gone and `exists` returns `false`.
885    ///
886    /// **Why it matters:** Confirms the delete path actually unlinks the
887    /// file. This is the final action in `Keystore::delete`; a regression
888    /// here would leave keystore files behind after an operator thought
889    /// they had wiped them.
890    ///
891    /// **Catches:** a regression where `delete` only overwrites (secure
892    /// wipe) without unlinking; where `exists` checks a stale cache; or
893    /// where `delete` silently errors on the unlink step.
894    #[test]
895    fn delete_removes_file() {
896        let dir = TempDir::new().unwrap();
897        let be = FileBackend::new(dir.path().to_path_buf());
898        let key = BackendKey::new("delete_me");
899        be.write(&key, b"bye").unwrap();
900        assert!(be.exists(&key).unwrap());
901        be.delete(&key).unwrap();
902        assert!(!be.exists(&key).unwrap());
903    }
904
905    /// **Proves:** deleting a non-existent key is a no-op success — not an
906    /// error.
907    ///
908    /// **Why it matters:** The [`KeychainBackend`] contract requires
909    /// `delete` to be idempotent. Callers (e.g., `dig-validator keys remove`)
910    /// can call `delete` without first checking existence; a double-call
911    /// after a concurrent delete should not fail.
912    ///
913    /// **Catches:** a regression where `delete` returns `NotFound` for
914    /// missing files.
915    #[test]
916    fn delete_is_idempotent() {
917        let dir = TempDir::new().unwrap();
918        let be = FileBackend::new(dir.path().to_path_buf());
919        be.delete(&BackendKey::new("never_existed")).unwrap();
920    }
921
922    /// **Proves:** `list("alph")` returns exactly `["alpha", "alpha2"]`
923    /// when the directory contains `alpha.dks`, `alpha2.dks`, and `beta.dks`.
924    ///
925    /// **Why it matters:** Prefix-based listing is what enables CLI tools
926    /// like `dig-validator keys list` to enumerate all keystores of a given
927    /// operator. Strict prefix matching (not substring, not suffix) must
928    /// be pinned.
929    ///
930    /// **Catches:** `starts_with` → `contains` regression (which would
931    /// include `beta` if prefix were `"eta"`); failure to strip the `.dks`
932    /// extension.
933    #[test]
934    fn list_with_prefix() {
935        let dir = TempDir::new().unwrap();
936        let be = FileBackend::new(dir.path().to_path_buf());
937        be.write(&BackendKey::new("alpha"), b"a").unwrap();
938        be.write(&BackendKey::new("alpha2"), b"a").unwrap();
939        be.write(&BackendKey::new("beta"), b"b").unwrap();
940        let mut keys = be.list("alph").unwrap();
941        keys.sort_by_key(|k| k.0.clone());
942        assert_eq!(
943            keys,
944            vec![BackendKey::new("alpha"), BackendKey::new("alpha2")]
945        );
946    }
947
948    /// **Proves:** reading a non-existent key returns a `KeystoreError::Backend`
949    /// wrapping an `io::Error` with `ErrorKind::NotFound`.
950    ///
951    /// **Why it matters:** The default [`KeychainBackend::exists`] impl
952    /// relies on this specific error shape to distinguish "not present"
953    /// from "I/O failed." If `read` returned a generic `InvalidInput` or
954    /// similar, `exists` would misclassify missing keys.
955    ///
956    /// **Catches:** a regression where `read` eats the OS error and
957    /// returns a custom `KeystoreError` variant, breaking the default
958    /// `exists` implementation.
959    #[test]
960    fn read_nonexistent_returns_error() {
961        let dir = TempDir::new().unwrap();
962        let be = FileBackend::new(dir.path().to_path_buf());
963        let err = be.read(&BackendKey::new("missing")).unwrap_err();
964        let is_not_found = match &err {
965            KeystoreError::Backend(io) => io.kind() == std::io::ErrorKind::NotFound,
966            _ => false,
967        };
968        assert!(is_not_found);
969    }
970
971    /// **Proves:** `FileBackend::write` lazily creates the root directory
972    /// (and intermediate parents) when the first write arrives.
973    ///
974    /// **Why it matters:** Operators may point the validator at
975    /// `~/.dig/keys/` before that directory exists. Requiring them to
976    /// `mkdir -p` first is poor UX. This test pins the "lazy mkdir" on
977    /// first write behaviour so `FileBackend::new` can remain side-effect-free.
978    ///
979    /// **Catches:** a regression where `write` assumes the dir exists and
980    /// fails with `NotFound` on first call; or where `new` eagerly creates
981    /// the dir (unwanted in tests).
982    #[test]
983    fn creates_root_dir() {
984        let dir = TempDir::new().unwrap();
985        let sub = dir.path().join("nested/keys");
986        let be = FileBackend::new(sub.clone());
987        assert!(!sub.exists());
988        be.write(&BackendKey::new("k"), b"x").unwrap();
989        assert!(sub.exists());
990    }
991
992    /// `is_owner_only` accepts exactly those modes that grant nobody but the
993    /// owner any access.
994    ///
995    /// **Why it matters:** this predicate is the whole of the permission
996    /// guarantee. Everything else in `enforce_owner_only` is plumbing around
997    /// its answer, so a predicate that is merely *nearly* right silently
998    /// downgrades the at-rest floor for dig-app's account seed and dig-node's
999    /// seed store, which are this backend's production callers.
1000    ///
1001    /// **Catches:** each of the plausible near-miss implementations. `0o400`
1002    /// and `0o000` rule out an equality test against `0o600`; `0o640` rules
1003    /// out a predicate that only inspects the *other* triad (and any
1004    /// `mode & 0o077 != 0o077` inversion, which would read group-readable as
1005    /// safe); `0o604` rules out one that only inspects the *group* triad.
1006    #[test]
1007    fn owner_only_predicate_rejects_every_non_owner_bit() {
1008        // No access for group or other, at varying owner permissions.
1009        for mode in [0o000, 0o400, 0o600, 0o700] {
1010            assert!(
1011                is_owner_only(mode),
1012                "{mode:04o} grants nobody but the owner"
1013            );
1014        }
1015
1016        // A single group or other bit is enough to fail, in either triad.
1017        for mode in [0o640, 0o604, 0o644, 0o060, 0o006, 0o660, 0o777] {
1018            assert!(!is_owner_only(mode), "{mode:04o} reaches beyond the owner");
1019        }
1020    }
1021
1022    /// A written blob, and the root that holds it, really are owner-only on
1023    /// disk — not merely requested to be.
1024    ///
1025    /// **Why it matters:** `SPEC.md` §10.3 / conformance C-14 state mode
1026    /// `0700` for the root and `0600` for blobs as a normative property. It
1027    /// was previously requested with the result discarded, so nothing
1028    /// observed whether it held.
1029    ///
1030    /// **Catches:** a regression that drops the `enforce_owner_only` call
1031    /// from either `ensure_root` or `write`, or that reorders the blob's
1032    /// restriction after `write_all` so ciphertext lands at the umask default
1033    /// first.
1034    ///
1035    /// Unix-only because Windows has no POSIX mode. That makes it
1036    /// unfalsifiable on a Windows build host, which is why the predicate above
1037    /// is tested separately and unconditionally.
1038    #[cfg(unix)]
1039    #[test]
1040    fn written_blob_and_root_are_owner_only_on_disk() {
1041        use std::os::unix::fs::PermissionsExt;
1042
1043        let dir = TempDir::new().unwrap();
1044        let root = dir.path().join("keys");
1045        let be = FileBackend::new(root.clone());
1046        be.write(&BackendKey::new("seed"), b"sealed").unwrap();
1047
1048        let root_mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777;
1049        assert_eq!(root_mode, 0o700, "root dir mode");
1050
1051        let blob_mode = fs::metadata(root.join("seed.dks"))
1052            .unwrap()
1053            .permissions()
1054            .mode()
1055            & 0o777;
1056        assert_eq!(blob_mode, 0o600, "blob mode");
1057    }
1058
1059    /// An **already-existing** permissive root is brought back to `0700` on the
1060    /// next write, not left alone.
1061    ///
1062    /// **Why it matters:** the floor is worthless if it only applies to roots
1063    /// this version created. A root created by 0.8.x — which requested `0700`
1064    /// and discarded the result — is precisely the one at risk, and it exists
1065    /// before any 0.9.0 write reaches it. The exposure is the root's *write*
1066    /// bits: blobs carry their own verified `0600`, but a group- or
1067    /// world-writable root grants `unlink` and `create`, which is blob
1068    /// substitution (rolling a victim back to an older sealed seed) and
1069    /// deletion, on the directory holding an account master seed.
1070    ///
1071    /// **Catches:** the `if self.root.exists() { return Ok(()); }` early
1072    /// return. Under it this test sees `0o755` and fails, because
1073    /// `enforce_owner_only` never runs on the existing-root path.
1074    /// `written_blob_and_root_are_owner_only_on_disk` above cannot catch it:
1075    /// its root is a fresh non-existent path, so it only ever exercises the
1076    /// creation branch.
1077    ///
1078    /// **Why this asserts tightening rather than `InsecurePermissions`:** on a
1079    /// root the process owns, `chmod` succeeds, so the permissive mode is
1080    /// repaired and there is nothing to refuse. Erroring instead would fail a
1081    /// host the crate can simply fix. `InsecurePermissions` stays reserved for
1082    /// the unrepairable case — a filesystem where the `chmod` does nothing, a
1083    /// foreign-owned root where it returns `EPERM`, an immutable attribute.
1084    /// The *production call sites* therefore cannot reach the refusal on a
1085    /// mode-honouring filesystem the process owns; the refusal itself is not
1086    /// unreachable, and
1087    /// `enforce_owner_only_refuses_a_mode_it_could_not_bring_to_the_floor`
1088    /// drives it directly.
1089    #[cfg(unix)]
1090    #[test]
1091    fn existing_permissive_root_is_tightened_on_write() {
1092        use std::os::unix::fs::PermissionsExt;
1093
1094        let dir = TempDir::new().unwrap();
1095        let root = dir.path().join("keys");
1096        fs::create_dir_all(&root).unwrap();
1097        fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
1098        assert_eq!(
1099            fs::metadata(&root).unwrap().permissions().mode() & 0o777,
1100            0o755,
1101            "fixture must start group/other-accessible, or it proves nothing"
1102        );
1103
1104        let be = FileBackend::new(root.clone());
1105        be.write(&BackendKey::new("seed"), b"sealed").unwrap();
1106
1107        assert_eq!(
1108            fs::metadata(&root).unwrap().permissions().mode() & 0o777,
1109            0o700,
1110            "an existing root must be brought to the floor, not skipped"
1111        );
1112    }
1113
1114    /// The verify half of chmod-then-verify actually refuses.
1115    ///
1116    /// **Property:** when the mode observed after the request is *not*
1117    /// owner-only, `enforce_owner_only` returns `InsecurePermissions` carrying
1118    /// the bits it saw — it does not return `Ok` on the strength of the
1119    /// `chmod` having succeeded.
1120    ///
1121    /// **Why this is the load-bearing assertion of the whole change:** the
1122    /// thesis of 0.9.0 is "verify the outcome, do not trust the request". The
1123    /// request's own `Result` is discarded on purpose in `enforce_owner_only`;
1124    /// the refusal below is the entire reason that is safe. Without this test
1125    /// the fail-closed block can be deleted with a green suite, returning the
1126    /// crate to the 0.8.x shape — `set_permissions` called and its result
1127    /// thrown away with nothing observing the bits.
1128    ///
1129    /// **Fixture design.** The refusal cannot be provoked through `write`,
1130    /// whose call sites always request an owner-only mode on a path the
1131    /// process owns, so a `write`-level fixture would need a mode-ignoring
1132    /// mount or a second uid — neither available in a test, which is what
1133    /// previously left this branch untested. The requested mode is a
1134    /// *parameter*, so asking for a permissive one drives the same verified
1135    /// read the production path performs, hermetically: no root, no second
1136    /// uid, no exotic mount. `0o755` is used rather than `0o777` because it
1137    /// leaves the owner triad at its production value, so the assertion is
1138    /// about the group and other bits and nothing else.
1139    ///
1140    /// **Catches:** deletion of the fail-closed block in `enforce_owner_only`,
1141    /// and any narrowing of `is_owner_only` reached through it. Asserting the
1142    /// observed `mode` — not merely that the call erred — also rules out a
1143    /// refusal that reports the mode it *asked* for instead of the one on
1144    /// disk, which would make the diagnostic useless on exactly the mount
1145    /// classes it exists to diagnose.
1146    #[cfg(unix)]
1147    #[test]
1148    fn enforce_owner_only_refuses_a_mode_it_could_not_bring_to_the_floor() {
1149        use std::os::unix::fs::PermissionsExt;
1150
1151        let dir = TempDir::new().unwrap();
1152        let root = dir.path().join("permissive");
1153        fs::create_dir_all(&root).unwrap();
1154
1155        let err = enforce_owner_only(&root, 0o755)
1156            .expect_err("a mode granting group and other access must be refused, not accepted");
1157
1158        match err {
1159            KeystoreError::InsecurePermissions { path, mode } => {
1160                assert_eq!(mode, 0o755, "the reported mode must be the one on disk");
1161                assert_eq!(path, root.display().to_string(), "reported path");
1162            }
1163            other => panic!("expected InsecurePermissions, got {other:?}"),
1164        }
1165
1166        // The refusal describes the state it found, so the mode really is the
1167        // permissive one — the fixture is not silently owner-only already.
1168        assert_eq!(
1169            fs::metadata(&root).unwrap().permissions().mode() & 0o777,
1170            0o755,
1171            "fixture must remain group/other-accessible, or it proves nothing"
1172        );
1173    }
1174
1175    /// A symlinked root is refused, not followed.
1176    ///
1177    /// **Property:** `write` on a root that is a symbolic link returns
1178    /// `UnsafeRoot` and touches neither the target's mode nor its contents.
1179    ///
1180    /// **Why refuse here when a permissive mode is repaired:** a mode is a
1181    /// property of the intended directory that the backend can correct and
1182    /// then verify. A symlink is a claim about *which* directory the keystore
1183    /// is, and no syscall makes an attacker-chosen directory into the intended
1184    /// one. Both `set_permissions` and `metadata` follow links, so the
1185    /// alternative is chmodding a directory of someone else's choosing to
1186    /// `0700` and sealing an account master seed inside it.
1187    ///
1188    /// **Catches:** reverting `symlink_metadata` to `exists()`/`metadata()`.
1189    ///
1190    /// **The side effects are asserted before the error, deliberately.** Under
1191    /// that revert the write returns `Ok`, so an `expect_err` placed first
1192    /// panics and the two assertions that name the actual damage never run —
1193    /// the proof would fire on "no error" rather than on the primitive. Ordered
1194    /// this way, the failure a reverting change sees is the chmod reaching
1195    /// through the link, which is what is new in this diff. The error
1196    /// assertion still has to be there: a write that failed for some later,
1197    /// unrelated reason would leave the victim equally untouched.
1198    #[cfg(unix)]
1199    #[test]
1200    fn symlinked_root_is_refused_and_its_target_is_untouched() {
1201        use std::os::unix::fs::PermissionsExt;
1202
1203        let dir = TempDir::new().unwrap();
1204        let victim = dir.path().join("victim");
1205        fs::create_dir_all(&victim).unwrap();
1206        fs::set_permissions(&victim, fs::Permissions::from_mode(0o755)).unwrap();
1207
1208        let root = dir.path().join("keys");
1209        std::os::unix::fs::symlink(&victim, &root).unwrap();
1210
1211        let result = FileBackend::new(root.clone()).write(&BackendKey::new("seed"), b"sealed");
1212
1213        assert_eq!(
1214            fs::metadata(&victim).unwrap().permissions().mode() & 0o777,
1215            0o755,
1216            "the link's target must not be chmodded through the link"
1217        );
1218        assert!(
1219            !victim.join("seed.dks").exists(),
1220            "the sealed blob must not land in the link's target"
1221        );
1222        assert!(
1223            matches!(result, Err(KeystoreError::UnsafeRoot { .. })),
1224            "a symlinked root must be refused, got {result:?}"
1225        );
1226    }
1227}