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}