Skip to main content

confium_store/backends/
filesystem.rs

1//! Filesystem backend.
2//!
3//! Persists key material as opaque byte blobs in a compartmentalised
4//! directory tree rooted at `<root>` (configured via the
5//! [`Options`](crate::backend::Options) key `"root"`, default
6//! `~/.local/share/confium/store/`).
7//!
8//! ```text
9//! <root>/
10//!   <module_id>/
11//!     <app_id>/
12//!       private/
13//!         <key_id>           # raw key bytes (opaque to Confium)
14//!       public/
15//!         <identity>         # raw key bytes
16//!         <identity>.sig     # detached identity signature
17//! ```
18//!
19//! Key handles (`*mut c_void`) are treated as opaque byte containers. On
20//! `put_*`, the backend dereferences the caller's `*mut Box<Vec<u8>>` and
21//! writes the inner bytes. On `get_*`/`enumerate`, it reads bytes from
22//! disk and returns a freshly `Box::into_raw`-ed `Box<Vec<u8>>`. This
23//! keeps the opaque-pointer contract from
24//! [`crate::backend::StoreInstance`] intact while giving the filesystem
25//! backend concrete bytes to persist. When the `keyfmt` interface (TODO
26//! #11) lands, the translation between its `FFIKey` and these byte blobs
27//! will move into a codec layer; the directory layout is stable.
28
29use std::ffi::c_void;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use snafu::ResultExt;
34
35use crate::backend::{Compartment, Options, StoreBackend, StoreInstance};
36use crate::error::{InvalidPathSnafu, IoSnafu, Result, ValueNotFoundSnafu};
37use crate::register_backend;
38
39/// Options key naming the store root directory.
40pub const OPT_ROOT: &str = "root";
41
42/// Default store root if `OPT_ROOT` is absent from [`Options`].
43const DEFAULT_ROOT: &str = "~/.local/share/confium/store/";
44
45/// Extension appended to an identity's public-key file to store its
46/// detached signature. Listed in [`forbidden_chars`] so identities cannot
47/// smuggle a `.sig` suffix that would collide with the signature file.
48const SIG_EXT: &str = "sig";
49
50/// Characters that must never appear in a caller-supplied path component.
51const fn forbidden_char(c: char) -> bool {
52    matches!(c, '/' | '\\' | '\0')
53}
54
55/// Replace characters that are illegal in filenames on the host platform
56/// (notably `:` on Windows, which `email:alice@example.com` contains).
57/// Returns a string that's safe to use as a path leaf on this OS. Only
58/// the on-disk filename is rewritten; the identity in the API stays as
59/// the caller supplied it.
60#[cfg(target_os = "windows")]
61fn sanitize_for_filename(s: &str) -> String {
62    s.chars()
63        .map(|c| match c {
64            ':' | '<' | '>' | '"' | '|' | '?' | '*' | '/' | '\\' => '_',
65            other => other,
66        })
67        .collect()
68}
69
70#[cfg(not(target_os = "windows"))]
71fn sanitize_for_filename(s: &str) -> String {
72    s.chars()
73        .map(|c| match c {
74            '/' | '\\' | '\0' => '_',
75            other => other,
76        })
77        .collect()
78}
79
80// --- path sanitisation ---------------------------------------------------
81
82/// Reject path components that could escape the store root or otherwise
83/// corrupt the on-disk layout. Accepts any single path component that is
84/// non-empty, contains no path separators, no NUL, and is not `.` or
85/// `..`. This keeps the backend open/closed: a future backend that wants
86/// a richer identity grammar relaxes its own validator, not this one.
87fn validate_component(component: &str) -> Result<()> {
88    if component.is_empty()
89        || component == "."
90        || component == ".."
91        || component.contains(forbidden_char)
92    {
93        return Err(InvalidPathSnafu {
94            component: component.to_string(),
95        }
96        .build());
97    }
98    Ok(())
99}
100
101/// Build `<root>/<module>/<app>/<sub>/<leaf>`, validating every
102/// caller-supplied segment.
103fn join_path(root: &Path, module: &str, app: &str, sub: &str, leaf: &str) -> Result<PathBuf> {
104    validate_component(module)?;
105    validate_component(app)?;
106    validate_component(sub)?;
107    validate_component(leaf)?;
108    Ok(root.join(module).join(app).join(sub).join(leaf))
109}
110
111// --- key handle <-> bytes codec -----------------------------------------
112
113/// Treat the opaque `key` handle as `*mut Box<Vec<u8>>` and borrow the
114/// inner bytes for writing.
115///
116/// # Safety
117///
118/// The caller must honour the [`StoreInstance`] contract: `key` is either
119/// null or a valid, non-aliased pointer to a `Box<Vec<u8>>` produced by
120/// the Engine's keyfmt codec (or by [`encode_key`] on a prior read).
121///
122/// [`StoreInstance`]: crate::backend::StoreInstance
123unsafe fn key_bytes(key: *mut c_void) -> Result<&'static [u8]> {
124    if key.is_null() {
125        return Ok(&[]);
126    }
127    // SAFETY: caller guarantees `key` points to a `Box<Vec<u8>>`.
128    let boxed: &Vec<u8> = unsafe { &*(key as *mut Box<Vec<u8>>) };
129    Ok(boxed.as_slice())
130}
131
132/// Wrap `bytes` in a `Box<Vec<u8>>` and return it as an opaque
133/// `*mut c_void`. Ownership of the allocation transfers to the caller,
134/// matching the `StoreInstance` contract for `get_*`.
135fn encode_key(bytes: Vec<u8>) -> *mut c_void {
136    Box::into_raw(Box::new(Box::new(bytes))) as *mut c_void
137}
138
139/// Reclaim a `*mut c_void` produced by [`encode_key`]. Used only in tests
140/// to avoid leaking the handles we hand to `put_*`.
141#[cfg(test)]
142unsafe fn reclaim_key(key: *mut c_void) {
143    if key.is_null() {
144        return;
145    }
146    // SAFETY: `key` was produced by `encode_key` in this test process.
147    unsafe {
148        drop(Box::from_raw(key as *mut Box<Vec<u8>>));
149    }
150}
151
152// --- atomic write --------------------------------------------------------
153
154/// Write `bytes` to `path` atomically: stage into a sibling temp file,
155/// then rename over the target. Creates parent directories as needed.
156/// The temp file shares the target's directory so the rename is
157/// guaranteed to be on the same filesystem (atomic on POSIX).
158fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
159    if let Some(parent) = path.parent() {
160        fs::create_dir_all(parent).context(IoSnafu {})?;
161    }
162    let dir = path.parent().unwrap_or_else(|| Path::new("."));
163    let tmp = dir.join(format!(
164        ".{}.tmp",
165        path.file_name()
166            .and_then(|s| s.to_str())
167            .unwrap_or("confium")
168    ));
169    fs::write(&tmp, bytes).context(IoSnafu {})?;
170    fs::rename(&tmp, path).context(IoSnafu)?;
171    Ok(())
172}
173
174// --- backend -------------------------------------------------------------
175
176/// Factory for the filesystem backend. Stateless; all mutable state lives
177/// in [`FilesystemInstance`].
178pub struct FilesystemBackend;
179
180impl StoreBackend for FilesystemBackend {
181    fn name(&self) -> &'static str {
182        "filesystem"
183    }
184
185    fn open(&self, opts: &Options) -> Result<Box<dyn StoreInstance>> {
186        let raw = opts
187            .get(OPT_ROOT)
188            .map(String::as_str)
189            .unwrap_or(DEFAULT_ROOT);
190        let root = expand_tilde(raw);
191        fs::create_dir_all(&root).context(IoSnafu {})?;
192        Ok(Box::new(FilesystemInstance { root }))
193    }
194}
195
196register_backend!(FilesystemBackend);
197
198/// Expand a leading `~` to the user's home directory. Falls back to the
199/// literal path if the home directory cannot be resolved — the subsequent
200/// `create_dir_all` will then report the real error.
201fn expand_tilde(raw: &str) -> PathBuf {
202    if let Some(rest) = raw.strip_prefix("~/") {
203        if let Some(home) = std::env::var_os("HOME") {
204            return PathBuf::from(home).join(rest);
205        }
206    } else if raw == "~" {
207        if let Some(home) = std::env::var_os("HOME") {
208            return PathBuf::from(home);
209        }
210    }
211    PathBuf::from(raw)
212}
213
214pub struct FilesystemInstance {
215    root: PathBuf,
216}
217
218impl FilesystemInstance {
219    fn private_path(&self, module: &str, app: &str, key_id: &str) -> Result<PathBuf> {
220        join_path(&self.root, module, app, "private", key_id)
221    }
222
223    fn public_key_path(&self, module: &str, app: &str, identity: &str) -> Result<PathBuf> {
224        validate_component(module)?;
225        validate_component(app)?;
226        let identity = sanitize_for_filename(identity);
227        validate_component(&identity)?;
228        Ok(self
229            .root
230            .join(module)
231            .join(app)
232            .join("public")
233            .join(identity))
234    }
235
236    fn public_sig_path(&self, module: &str, app: &str, identity: &str) -> Result<PathBuf> {
237        // Build `<identity>.sig` from the identity. We must not use
238        // `PathBuf::set_extension` here: identities legitimately contain
239        // dots (e.g. `email:alice@example.com`) and `set_extension` would
240        // replace the trailing `.com` rather than append. Instead we form
241        // the leaf as a single validated component — `validate_component`
242        // rejects separators/NUL so the concatenated `<identity>.sig`
243        // cannot escape the `public/` directory even if `identity` were
244        // hostile.
245        validate_component(module)?;
246        validate_component(app)?;
247        let identity = sanitize_for_filename(identity);
248        let leaf = format!("{identity}.{SIG_EXT}");
249        validate_component(&leaf)?;
250        Ok(self.root.join(module).join(app).join("public").join(leaf))
251    }
252
253    /// Directory whose immediate children are the entries for one
254    /// compartment. Validates `module`/`app` so a caller cannot probe
255    /// outside the root.
256    fn compartment_dir(
257        &self,
258        module: &str,
259        app: &str,
260        compartment: Compartment,
261    ) -> Result<PathBuf> {
262        let sub = match compartment {
263            Compartment::Private => "private",
264            Compartment::Public => "public",
265        };
266        validate_component(module)?;
267        validate_component(app)?;
268        Ok(self.root.join(module).join(app).join(sub))
269    }
270}
271
272impl StoreInstance for FilesystemInstance {
273    fn put_secret(
274        &mut self,
275        module: &str,
276        app: &str,
277        key_id: &str,
278        key: *mut c_void,
279    ) -> Result<()> {
280        let path = self.private_path(module, app, key_id)?;
281        // SAFETY: the caller honours the StoreInstance contract; `key` is
282        // a valid `*mut Box<Vec<u8>>` or null.
283        let bytes = unsafe { key_bytes(key) }?;
284        atomic_write(&path, bytes)
285    }
286
287    fn get_secret(&self, module: &str, app: &str, key_id: &str) -> Result<*mut c_void> {
288        read_or_not_found(&self.private_path(module, app, key_id)?).map(encode_key)
289    }
290
291    fn put_public(
292        &mut self,
293        module: &str,
294        app: &str,
295        identity: &str,
296        key: *mut c_void,
297        sig: &[u8],
298    ) -> Result<()> {
299        let key_path = self.public_key_path(module, app, identity)?;
300        let sig_path = self.public_sig_path(module, app, identity)?;
301        // SAFETY: caller honours StoreInstance contract.
302        let bytes = unsafe { key_bytes(key) }?;
303        atomic_write(&key_path, bytes)?;
304        atomic_write(&sig_path, sig)
305    }
306
307    fn get_public(
308        &self,
309        module: &str,
310        app: &str,
311        identity: &str,
312    ) -> Result<(*mut c_void, Vec<u8>)> {
313        let key_path = self.public_key_path(module, app, identity)?;
314        let sig_path = self.public_sig_path(module, app, identity)?;
315        let key_bytes = read_or_not_found(&key_path)?;
316        let sig = read_or_not_found(&sig_path)?;
317        Ok((encode_key(key_bytes), sig))
318    }
319
320    fn enumerate(
321        &self,
322        module: &str,
323        app: &str,
324        compartment: Compartment,
325    ) -> Result<Vec<(*mut c_void, String)>> {
326        let dir = self.compartment_dir(module, app, compartment)?;
327        let read = match fs::read_dir(&dir) {
328            Ok(rd) => rd,
329            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
330                return Ok(Vec::new());
331            }
332            Err(e) => return Err(e).context(IoSnafu {}),
333        };
334
335        let mut entries: Vec<(PathBuf, String)> = Vec::new();
336        for entry in read {
337            let entry = entry.context(IoSnafu {})?;
338            let path = entry.path();
339            let Some(name) = path
340                .file_name()
341                .and_then(|s| s.to_str())
342                .map(str::to_string)
343            else {
344                continue;
345            };
346            match compartment {
347                Compartment::Private => {
348                    entries.push((path, name));
349                }
350                Compartment::Public => {
351                    // Each public entry is stored as `<identity>` plus a
352                    // sibling `<identity>.sig`. Yield the identity once,
353                    // keyed on the key file (the one without the `.sig`
354                    // extension).
355                    if path.extension().and_then(|s| s.to_str()) == Some(SIG_EXT) {
356                        continue;
357                    }
358                    entries.push((path, name));
359                }
360            }
361        }
362
363        entries.sort_by(|a, b| a.1.cmp(&b.1));
364
365        let mut out = Vec::with_capacity(entries.len());
366        for (path, index) in entries {
367            let bytes = fs::read(&path).context(IoSnafu {})?;
368            out.push((encode_key(bytes), index));
369        }
370        Ok(out)
371    }
372}
373
374/// Read a file, mapping `NotFound` to [`Error::ValueNotFound`] and every
375/// other I/O error to [`Error::Io`].
376///
377/// [`Error::ValueNotFound`]: crate::error::Error::ValueNotFound
378/// [`Error::Io`]: crate::error::Error::Io
379fn read_or_not_found(path: &Path) -> Result<Vec<u8>> {
380    match fs::read(path) {
381        Ok(bytes) => Ok(bytes),
382        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(ValueNotFoundSnafu.build()),
383        Err(e) => Err(e).context(IoSnafu {}),
384    }
385}
386
387// SAFETY: the backend stores only a `PathBuf` root; no per-thread state.
388// Key handles are opaque `*mut c_void` tokens that the backend never
389// dereferences outside the brief `unsafe` blocks above, each of which
390// borrows a caller-owned `Box<Vec<u8>>` for the duration of a single call.
391unsafe impl Send for FilesystemInstance {}
392unsafe impl Sync for FilesystemInstance {}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::backend::{Options, StoreBackend, StoreInstance};
398    use std::collections::HashMap;
399    use tempfile::TempDir;
400
401    /// Open a filesystem backend rooted at a fresh temp dir.
402    fn open() -> (TempDir, Box<dyn StoreInstance>) {
403        let dir = TempDir::new().expect("tempdir");
404        let mut opts: Options = HashMap::new();
405        opts.insert(
406            OPT_ROOT.to_string(),
407            dir.path().to_str().expect("utf8 tmpdir").to_string(),
408        );
409        let ks = FilesystemBackend
410            .open(&opts)
411            .expect("filesystem backend opens");
412        (dir, ks)
413    }
414
415    /// Wrap `bytes` in the opaque handle shape the codec expects.
416    fn key_handle(bytes: &[u8]) -> *mut c_void {
417        encode_key(bytes.to_vec())
418    }
419
420    #[test]
421    fn put_get_secret_round_trip() {
422        let (_dir, mut ks) = open();
423        let secret = b"\x01\x02\x03\x04 secret key bytes";
424        let handle = key_handle(secret);
425        ks.put_secret("mod", "app", "key-1", handle)
426            .expect("put_secret");
427        unsafe { reclaim_key(handle) };
428
429        let got = ks.get_secret("mod", "app", "key-1").expect("get_secret");
430        let bytes = unsafe { key_bytes(got) }.expect("decode");
431        assert_eq!(bytes, secret);
432        unsafe { reclaim_key(got) };
433    }
434
435    #[test]
436    fn put_get_public_round_trip() {
437        let (_dir, mut ks) = open();
438        let pubkey = b"PUBKEY-BYTES";
439        let sig = vec![0xDE, 0xAD, 0xBE, 0xEF];
440        let handle = key_handle(pubkey);
441        ks.put_public("mod", "app", "email:alice@example.com", handle, &sig)
442            .expect("put_public");
443        unsafe { reclaim_key(handle) };
444
445        let (got_key, got_sig) = ks
446            .get_public("mod", "app", "email:alice@example.com")
447            .expect("get_public");
448        let bytes = unsafe { key_bytes(got_key) }.expect("decode");
449        assert_eq!(bytes, pubkey);
450        assert_eq!(got_sig, sig);
451        unsafe { reclaim_key(got_key) };
452    }
453
454    #[test]
455    fn get_secret_missing_returns_value_not_found() {
456        let (_dir, ks) = open();
457        let err = ks.get_secret("mod", "app", "missing").unwrap_err();
458        assert!(matches!(err, crate::error::Error::ValueNotFound));
459    }
460
461    #[test]
462    fn get_public_missing_returns_value_not_found() {
463        let (_dir, ks) = open();
464        let err = ks
465            .get_public("mod", "app", "email:nobody@example.com")
466            .unwrap_err();
467        assert!(matches!(err, crate::error::Error::ValueNotFound));
468    }
469
470    #[test]
471    fn public_files_distinct_for_dotted_identity() {
472        // Identities may contain dots (e.g. email addresses). The public
473        // key file and the detached signature file must be siblings
474        // distinguished by an appended `.sig`, not by `set_extension`
475        // (which would overwrite the trailing `.com`).
476        let (dir, mut ks) = open();
477        let identity = "email:alice@example.com";
478        let h = key_handle(b"pk-bytes");
479        ks.put_public("mod", "app", identity, h, b"sig-bytes")
480            .expect("put_public");
481        unsafe { reclaim_key(h) };
482
483        // The on-disk filename is sanitized for the host platform
484        // (Windows reserves `:` etc.), so the leaf differs from the
485        // caller-supplied identity on Windows.
486        let leaf = sanitize_for_filename(identity);
487        let key_path = dir
488            .path()
489            .join("mod")
490            .join("app")
491            .join("public")
492            .join(&leaf);
493        let sig_path = dir
494            .path()
495            .join("mod")
496            .join("app")
497            .join("public")
498            .join(format!("{leaf}.sig"));
499        assert!(key_path.exists(), "key file at leaf identity");
500        assert!(sig_path.exists(), "sig file at leaf identity + .sig");
501        assert_ne!(key_path, sig_path, "key and sig paths must differ");
502        assert_eq!(std::fs::read(&key_path).expect("read key"), b"pk-bytes");
503        assert_eq!(std::fs::read(&sig_path).expect("read sig"), b"sig-bytes");
504
505        let (got_key, got_sig) = ks.get_public("mod", "app", identity).expect("get_public");
506        let bytes = unsafe { key_bytes(got_key) }.expect("decode");
507        assert_eq!(bytes, b"pk-bytes");
508        assert_eq!(got_sig, b"sig-bytes");
509        unsafe { reclaim_key(got_key) };
510    }
511
512    #[test]
513    fn enumerate_private_lists_key_ids() {
514        let (_dir, mut ks) = open();
515        for (kid, bytes) in [
516            ("key-a", b"a".as_slice()),
517            ("key-b", b"bb".as_slice()),
518            ("key-c", b"ccc".as_slice()),
519        ] {
520            let h = key_handle(bytes);
521            ks.put_secret("mod", "app", kid, h).expect("put_secret");
522            unsafe { reclaim_key(h) };
523        }
524
525        let entries = ks
526            .enumerate("mod", "app", Compartment::Private)
527            .expect("enumerate private");
528        let ids: Vec<String> = entries.iter().map(|(_, id)| id.clone()).collect();
529        assert_eq!(ids, vec!["key-a", "key-b", "key-c"]);
530        for (key, _) in &entries {
531            unsafe { reclaim_key(*key) };
532        }
533    }
534
535    #[test]
536    fn enumerate_public_lists_identities_not_sigs() {
537        let (_dir, mut ks) = open();
538        for (id, bytes) in [
539            ("email:alice@example.com", b"pk-a"),
540            ("email:bob@example.com", b"pk-b"),
541        ] {
542            let h = key_handle(bytes);
543            ks.put_public("mod", "app", id, h, &[0u8])
544                .expect("put_public");
545            unsafe { reclaim_key(h) };
546        }
547
548        let entries = ks
549            .enumerate("mod", "app", Compartment::Public)
550            .expect("enumerate public");
551        let ids: Vec<String> = entries.iter().map(|(_, id)| id.clone()).collect();
552        // The on-disk filename is sanitized for the host platform; the
553        // enumerate API returns the sanitized leaf name (Windows
554        // replaces `:` with `_`).
555        let expected: Vec<String> = ["email:alice@example.com", "email:bob@example.com"]
556            .iter()
557            .map(|s| sanitize_for_filename(s))
558            .collect();
559        assert_eq!(ids, expected);
560        for (key, _) in &entries {
561            unsafe { reclaim_key(*key) };
562        }
563    }
564
565    #[test]
566    fn enumerate_missing_scope_returns_empty() {
567        let (_dir, ks) = open();
568        let entries = ks
569            .enumerate("nope", "nope", Compartment::Private)
570            .expect("enumerate should not error on absent scope");
571        assert!(entries.is_empty());
572    }
573
574    #[test]
575    fn put_secret_overwrites() {
576        let (_dir, mut ks) = open();
577        let h1 = key_handle(b"old");
578        ks.put_secret("mod", "app", "key-1", h1).expect("put old");
579        unsafe { reclaim_key(h1) };
580        let h2 = key_handle(b"new");
581        ks.put_secret("mod", "app", "key-1", h2).expect("put new");
582        unsafe { reclaim_key(h2) };
583
584        let got = ks.get_secret("mod", "app", "key-1").expect("get");
585        let bytes = unsafe { key_bytes(got) }.expect("decode");
586        assert_eq!(bytes, b"new");
587        unsafe { reclaim_key(got) };
588    }
589
590    #[test]
591    fn path_traversal_module_rejected() {
592        let (_dir, mut ks) = open();
593        let h = key_handle(b"x");
594        let err = ks.put_secret("..", "app", "key", h).unwrap_err();
595        assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
596        unsafe { reclaim_key(h) };
597    }
598
599    #[test]
600    fn path_traversal_app_rejected() {
601        let (_dir, mut ks) = open();
602        let h = key_handle(b"x");
603        let err = ks.put_secret("mod", "../../etc", "key", h).unwrap_err();
604        assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
605        unsafe { reclaim_key(h) };
606    }
607
608    #[test]
609    fn path_traversal_key_id_rejected() {
610        let (_dir, mut ks) = open();
611        let h = key_handle(b"x");
612        let err = ks.put_secret("mod", "app", "../escape", h).unwrap_err();
613        assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
614        unsafe { reclaim_key(h) };
615    }
616
617    #[test]
618    fn path_traversal_absolute_rejected() {
619        let (_dir, mut ks) = open();
620        let h = key_handle(b"x");
621        // Absolute-looking component — contains a separator, so rejected.
622        let err = ks.put_secret("/etc", "app", "key", h).unwrap_err();
623        assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
624        unsafe { reclaim_key(h) };
625    }
626
627    #[test]
628    fn nul_in_component_rejected() {
629        let (_dir, mut ks) = open();
630        let h = key_handle(b"x");
631        let err = ks.put_secret("mo\0d", "app", "key", h).unwrap_err();
632        assert!(matches!(err, crate::error::Error::InvalidPath { .. }));
633        unsafe { reclaim_key(h) };
634    }
635
636    #[test]
637    fn backend_is_registered() {
638        let backend = crate::backend::find("filesystem").expect("filesystem backend registered");
639        assert_eq!(backend.name(), "filesystem");
640    }
641
642    #[test]
643    fn open_creates_root_if_missing() {
644        let dir = TempDir::new().expect("tempdir");
645        let nested = dir.path().join("a/b/c/store");
646        let mut opts: Options = HashMap::new();
647        opts.insert(
648            OPT_ROOT.to_string(),
649            nested.to_str().expect("utf8").to_string(),
650        );
651        let _ks = FilesystemBackend.open(&opts).expect("open");
652        assert!(nested.exists(), "open should create the root directory");
653    }
654
655    #[test]
656    fn on_disk_layout_matches_spec() {
657        let (dir, mut ks) = open();
658        let h = key_handle(b"secret");
659        ks.put_secret("mod", "app", "key-1", h).expect("put_secret");
660        unsafe { reclaim_key(h) };
661
662        // Verify the exact path: <root>/<module>/<app>/private/<key_id>.
663        let expected = dir
664            .path()
665            .join("mod")
666            .join("app")
667            .join("private")
668            .join("key-1");
669        assert!(expected.exists(), "private key file at spec path");
670        let on_disk = std::fs::read(&expected).expect("read");
671        assert_eq!(on_disk, b"secret");
672    }
673}