Skip to main content

kovra_core/
store.rs

1//! Per-secret sealed-file store: the source of truth (ADR-0001 §A.1–3).
2//!
3//! Each secret is one independently AEAD-sealed record, filed as a single file
4//! named `BLAKE3(coordinate).sec` under a vault directory. This is the
5//! durability boundary: one corrupt file loses **one** secret, never the whole
6//! vault.
7//!
8//! On-disk frame: a fixed header (magic [`FRAME_MAGIC`] + little-endian
9//! [`FRAME_VERSION`]) followed by the JSON-serialized
10//! [`SealedRecord`](crate::crypto::SealedRecord). The header lets the loader
11//! reject foreign/garbage files cleanly and version the frame independently of
12//! the sealed payload.
13//!
14//! Writes are atomic (temp → `fsync` → rotate previous to `.bak` → `rename`)
15//! and files are `0600`. The loader is **tolerant**: a record that fails its
16//! frame or its AEAD tag is quarantined and skipped, never aborting the scan.
17
18use std::fs::{self, File};
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22use crate::coordinate::Coordinate;
23use crate::crypto::{KEY_LEN, SealedRecord, open};
24use crate::error::CoreError;
25use crate::record::SecretRecord;
26
27/// Frame magic: marks a file as a kovra sealed record.
28pub const FRAME_MAGIC: &[u8; 4] = b"KOVR";
29/// On-disk frame version (independent of the vault schema version).
30pub const FRAME_VERSION: u32 = 1;
31/// Extension for a sealed record file.
32pub const RECORD_EXT: &str = "sec";
33
34const HEADER_LEN: usize = 4 + 4; // magic + u32 version
35
36/// A record that could not be loaded, surfaced rather than aborting the scan.
37/// The reason is a coordinate-free description (I12) — never a value.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Quarantined {
40    /// The record id (file stem) that failed to load.
41    pub id: String,
42    /// Why it was quarantined (frame mismatch, AEAD failure, I/O error).
43    pub reason: String,
44}
45
46/// Outcome of [`load_all`]: every decryptable record plus the quarantined ones.
47#[derive(Debug, Default)]
48pub struct LoadOutcome {
49    /// Successfully opened records, keyed by record id (file stem).
50    pub records: Vec<(String, SecretRecord)>,
51    /// Records skipped because they failed to load (ADR-0001 §A.3).
52    pub quarantined: Vec<Quarantined>,
53}
54
55/// The on-disk path of a record within `dir`, given its storage id (the
56/// `<id>.sec` naming convention, owned here so callers never re-derive it).
57pub fn record_path_for_id(dir: &Path, id: &str) -> PathBuf {
58    dir.join(format!("{id}.{RECORD_EXT}"))
59}
60
61/// The on-disk path of a coordinate's record within `dir`.
62pub fn record_path(dir: &Path, coord: &Coordinate) -> Result<PathBuf, CoreError> {
63    Ok(record_path_for_id(dir, &coord.storage_id()?))
64}
65
66/// Frame a sealed record for storage: header + JSON payload.
67fn frame(sealed: &SealedRecord) -> Result<Vec<u8>, CoreError> {
68    let payload =
69        serde_json::to_vec(sealed).map_err(|e| CoreError::Serialization(e.to_string()))?;
70    let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
71    out.extend_from_slice(FRAME_MAGIC);
72    out.extend_from_slice(&FRAME_VERSION.to_le_bytes());
73    out.extend_from_slice(&payload);
74    Ok(out)
75}
76
77/// Parse a framed record back into a sealed record, validating the header.
78fn unframe(bytes: &[u8]) -> Result<SealedRecord, CoreError> {
79    if bytes.len() < HEADER_LEN || &bytes[..4] != FRAME_MAGIC {
80        return Err(CoreError::Serialization(
81            "not a kovra record frame".to_string(),
82        ));
83    }
84    let version = u32::from_le_bytes(bytes[4..8].try_into().expect("checked length"));
85    if version != FRAME_VERSION {
86        return Err(CoreError::Serialization(format!(
87            "unsupported record frame version {version}"
88        )));
89    }
90    serde_json::from_slice(&bytes[HEADER_LEN..])
91        .map_err(|e| CoreError::Serialization(e.to_string()))
92}
93
94/// Set restrictive permissions on a freshly created path: owner-only. On Unix
95/// `0600` for files / `0700` for directories. On Windows the equivalent — a
96/// **protected** DACL granting only the current user, `SYSTEM`, and
97/// `Administrators` (inheritance stripped, so `Users`/`Everyone` are removed) —
98/// mirroring how a Unix `0600` still lets root in (KOV-58, I7). No-op on other
99/// targets. The single owner of kovra's on-disk permission policy — the index
100/// reuses it for `index.redb`.
101#[cfg(unix)]
102pub(crate) fn restrict(path: &Path, mode: u32) -> Result<(), CoreError> {
103    use std::os::unix::fs::PermissionsExt;
104    fs::set_permissions(path, fs::Permissions::from_mode(mode))
105        .map_err(|e| CoreError::Io(format!("chmod {mode:o}: {e}")))
106}
107
108#[cfg(windows)]
109pub(crate) fn restrict(path: &Path, _mode: u32) -> Result<(), CoreError> {
110    // Files and dirs both become owner-only; the Unix mode is not meaningful here.
111    windows_acl::restrict_owner_only(path)
112}
113
114#[cfg(not(any(unix, windows)))]
115pub(crate) fn restrict(_path: &Path, _mode: u32) -> Result<(), CoreError> {
116    Ok(())
117}
118
119/// Windows owner-only ACLs — the analog of `chmod 0600/0700` (I7, KOV-58).
120///
121/// Builds a **protected** DACL with exactly three full-control ACEs — the current
122/// user, `SYSTEM`, and `Administrators` — and applies it with
123/// `PROTECTED_DACL_SECURITY_INFORMATION` so inherited ACEs (which on a lax parent
124/// would grant `Users`/`Everyone`) are dropped. SYSTEM/Administrators are kept on
125/// purpose: that matches Unix semantics, where a `0600` file is still readable by
126/// root. Validated on real hardware (Windows validation tracker row 3); the test
127/// below asserts the resulting DACL is protected (inheritance removed).
128#[cfg(windows)]
129mod windows_acl {
130    use std::os::windows::ffi::OsStrExt;
131    use std::path::Path;
132
133    use windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, LocalFree};
134    use windows::Win32::Security::Authorization::{
135        EXPLICIT_ACCESS_W, MULTIPLE_TRUSTEE_OPERATION, SE_FILE_OBJECT, SET_ACCESS,
136        SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W,
137    };
138    use windows::Win32::Security::{
139        ACE_FLAGS, ACL, CreateWellKnownSid, DACL_SECURITY_INFORMATION, GetTokenInformation,
140        PROTECTED_DACL_SECURITY_INFORMATION, PSID, SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER,
141        TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid,
142    };
143    use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
144    use windows::core::{PCWSTR, PWSTR};
145
146    use crate::error::CoreError;
147
148    /// `GENERIC_ALL` — full control, the access mask granted to each trustee.
149    const GENERIC_ALL: u32 = 0x1000_0000;
150
151    pub(crate) fn restrict_owner_only(path: &Path) -> Result<(), CoreError> {
152        // SAFETY: the body is a sequence of FFI calls whose pointer arguments all
153        // refer to locals that outlive the call; see the per-call notes.
154        unsafe { apply(path) }.map_err(|e| CoreError::Io(format!("set ACL on {path:?}: {e}")))
155    }
156
157    /// One full-control, non-inheritable ACE for `sid`.
158    fn explicit_access(sid: PSID) -> EXPLICIT_ACCESS_W {
159        EXPLICIT_ACCESS_W {
160            grfAccessPermissions: GENERIC_ALL,
161            grfAccessMode: SET_ACCESS,
162            grfInheritance: ACE_FLAGS(0), // NO_INHERITANCE
163            Trustee: TRUSTEE_W {
164                pMultipleTrustee: core::ptr::null_mut(),
165                MultipleTrusteeOperation: MULTIPLE_TRUSTEE_OPERATION(0),
166                TrusteeForm: TRUSTEE_IS_SID,
167                TrusteeType: TRUSTEE_IS_UNKNOWN,
168                // For TRUSTEE_IS_SID the SID pointer is carried in ptstrName.
169                ptstrName: PWSTR(sid.0 as *mut u16),
170            },
171        }
172    }
173
174    unsafe fn apply(path: &Path) -> windows::core::Result<()> {
175        // 1. Current user's SID, from this process's token.
176        let mut token = HANDLE::default();
177        unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)? };
178        let mut len = 0u32;
179        // First call sizes the buffer (it returns ERROR_INSUFFICIENT_BUFFER).
180        let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut len) };
181        let mut buf = vec![0u8; len as usize];
182        let info = unsafe {
183            GetTokenInformation(
184                token,
185                TokenUser,
186                Some(buf.as_mut_ptr().cast()),
187                len,
188                &mut len,
189            )
190        };
191        unsafe { CloseHandle(token).ok() };
192        info?;
193        // SAFETY: `buf` holds a TOKEN_USER written by the OS; valid for this scope.
194        let token_user = unsafe { &*(buf.as_ptr() as *const TOKEN_USER) };
195        let user_sid = token_user.User.Sid;
196
197        // 2. Well-known SYSTEM and Administrators SIDs (fixed-size local buffers).
198        let mut system_buf = [0u8; SECURITY_MAX_SID_SIZE as usize];
199        let mut admins_buf = [0u8; SECURITY_MAX_SID_SIZE as usize];
200        let mut n = SECURITY_MAX_SID_SIZE;
201        unsafe {
202            CreateWellKnownSid(
203                WinLocalSystemSid,
204                None,
205                Some(PSID(system_buf.as_mut_ptr().cast())),
206                &mut n,
207            )?
208        };
209        let mut n2 = SECURITY_MAX_SID_SIZE;
210        unsafe {
211            CreateWellKnownSid(
212                WinBuiltinAdministratorsSid,
213                None,
214                Some(PSID(admins_buf.as_mut_ptr().cast())),
215                &mut n2,
216            )?
217        };
218        let system_sid = PSID(system_buf.as_mut_ptr().cast());
219        let admins_sid = PSID(admins_buf.as_mut_ptr().cast());
220
221        // 3. Build the DACL: full control for {user, SYSTEM, Administrators}.
222        let entries = [
223            explicit_access(user_sid),
224            explicit_access(system_sid),
225            explicit_access(admins_sid),
226        ];
227        let mut new_acl: *mut ACL = core::ptr::null_mut();
228        let rc = unsafe { SetEntriesInAclW(Some(&entries), None, &mut new_acl) };
229        if rc.0 != 0 {
230            return Err(windows::core::Error::from_hresult(
231                windows::core::HRESULT::from_win32(rc.0),
232            ));
233        }
234
235        // 4. Apply as a PROTECTED DACL so inherited ACEs (Users/Everyone) are
236        //    stripped. Owner/group/SACL are left unchanged.
237        let wide: Vec<u16> = path
238            .as_os_str()
239            .encode_wide()
240            .chain(core::iter::once(0))
241            .collect();
242        let rc = unsafe {
243            SetNamedSecurityInfoW(
244                PCWSTR(wide.as_ptr()),
245                SE_FILE_OBJECT,
246                DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
247                None,
248                None,
249                Some(new_acl as *const ACL),
250                None,
251            )
252        };
253        // The ACL SetEntriesInAclW allocated is freed regardless of the outcome.
254        unsafe { LocalFree(Some(HLOCAL(new_acl.cast()))) };
255        if rc.0 != 0 {
256            return Err(windows::core::Error::from_hresult(
257                windows::core::HRESULT::from_win32(rc.0),
258            ));
259        }
260        Ok(())
261    }
262}
263
264/// Create a vault directory (and parents) at `0700` if missing.
265pub fn ensure_dir(dir: &Path) -> Result<(), CoreError> {
266    if !dir.exists() {
267        fs::create_dir_all(dir).map_err(|e| CoreError::Io(format!("create {dir:?}: {e}")))?;
268        restrict(dir, 0o700)?;
269    }
270    Ok(())
271}
272
273/// Write a sealed record atomically: temp file → `fsync` → rotate any existing
274/// record to `.bak` → `rename` into place. The result is `0600`.
275pub fn write_record(
276    dir: &Path,
277    coord: &Coordinate,
278    sealed: &SealedRecord,
279) -> Result<(), CoreError> {
280    ensure_dir(dir)?;
281    let path = record_path(dir, coord)?;
282    let tmp = path.with_extension(format!("{RECORD_EXT}.tmp"));
283    let bytes = frame(sealed)?;
284
285    {
286        let mut f = File::create(&tmp).map_err(|e| CoreError::Io(format!("create tmp: {e}")))?;
287        f.write_all(&bytes)
288            .map_err(|e| CoreError::Io(format!("write tmp: {e}")))?;
289        f.sync_all()
290            .map_err(|e| CoreError::Io(format!("fsync tmp: {e}")))?;
291    }
292    restrict(&tmp, 0o600)?;
293
294    if path.exists() {
295        let bak = path.with_extension(format!("{RECORD_EXT}.bak"));
296        fs::rename(&path, &bak).map_err(|e| CoreError::Io(format!("rotate .bak: {e}")))?;
297    }
298    fs::rename(&tmp, &path).map_err(|e| CoreError::Io(format!("rename into place: {e}")))?;
299    Ok(())
300}
301
302/// Read and decrypt a single record by coordinate — the O(1) point-lookup path
303/// used by resolution (it never touches the index, ADR-0001 §A.5). Returns
304/// `Ok(None)` when the record does not exist.
305pub fn read_record(
306    dir: &Path,
307    coord: &Coordinate,
308    key: &[u8; KEY_LEN],
309) -> Result<Option<SecretRecord>, CoreError> {
310    let path = record_path(dir, coord)?;
311    if !path.exists() {
312        return Ok(None);
313    }
314    let bytes = fs::read(&path).map_err(|e| CoreError::Io(format!("read record: {e}")))?;
315    let sealed = unframe(&bytes)?;
316    Ok(Some(open(&sealed, key)?))
317}
318
319/// Load every record in a vault directory, tolerantly (ADR-0001 §A.3). A file
320/// that fails its frame or its AEAD tag is quarantined and skipped; the scan
321/// never aborts, so one corrupt record cannot hide the rest.
322pub fn load_all(dir: &Path, key: &[u8; KEY_LEN]) -> Result<LoadOutcome, CoreError> {
323    let mut outcome = LoadOutcome::default();
324    if !dir.exists() {
325        return Ok(outcome);
326    }
327    let entries = fs::read_dir(dir).map_err(|e| CoreError::Io(format!("read_dir: {e}")))?;
328    for entry in entries {
329        let entry = entry.map_err(|e| CoreError::Io(format!("dir entry: {e}")))?;
330        let path = entry.path();
331        if path.extension().and_then(|e| e.to_str()) != Some(RECORD_EXT) {
332            continue; // skip .bak, .tmp, index, anything not a record
333        }
334        let id = path
335            .file_stem()
336            .and_then(|s| s.to_str())
337            .unwrap_or_default()
338            .to_string();
339
340        let opened = fs::read(&path)
341            .map_err(|e| CoreError::Io(format!("read: {e}")))
342            .and_then(|bytes| unframe(&bytes))
343            .and_then(|sealed| open(&sealed, key));
344
345        match opened {
346            Ok(record) => outcome.records.push((id, record)),
347            Err(e) => outcome.quarantined.push(Quarantined {
348                id,
349                reason: e.to_string(),
350            }),
351        }
352    }
353    Ok(outcome)
354}
355
356/// Remove a record (its `.sec` file). The `.bak` rotation, if any, is left in
357/// place. No-op if the record does not exist.
358pub fn delete_record(dir: &Path, coord: &Coordinate) -> Result<(), CoreError> {
359    let path = record_path(dir, coord)?;
360    if path.exists() {
361        fs::remove_file(&path).map_err(|e| CoreError::Io(format!("remove record: {e}")))?;
362    }
363    Ok(())
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::crypto::seal;
370    use crate::secret::SecretValue;
371    use crate::sensitivity::Sensitivity;
372
373    fn key() -> [u8; KEY_LEN] {
374        [0x11; KEY_LEN]
375    }
376
377    fn coord(s: &str) -> Coordinate {
378        s.parse().unwrap()
379    }
380
381    fn literal(value: &str) -> SecretRecord {
382        SecretRecord::Literal {
383            value: SecretValue::from(value),
384            sensitivity: Sensitivity::Medium,
385            revealable: false,
386            environment: "prod".to_string(),
387            component: "db".to_string(),
388            key: "password".to_string(),
389            description: None,
390            created: "2026-05-30T00:00:00Z".to_string(),
391            updated: "2026-05-30T00:00:00Z".to_string(),
392        }
393    }
394
395    #[test]
396    fn write_then_read_round_trips() {
397        let dir = tempfile::tempdir().unwrap();
398        let c = coord("secret:prod/db/password");
399        let sealed = seal(&literal("hunter2"), &key()).unwrap();
400        write_record(dir.path(), &c, &sealed).unwrap();
401
402        let got = read_record(dir.path(), &c, &key()).unwrap().unwrap();
403        match got {
404            SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"hunter2"),
405            other => panic!("expected literal, got {other:?}"),
406        }
407    }
408
409    #[test]
410    fn read_missing_is_none() {
411        let dir = tempfile::tempdir().unwrap();
412        let c = coord("secret:prod/db/absent");
413        assert!(read_record(dir.path(), &c, &key()).unwrap().is_none());
414    }
415
416    #[test]
417    fn record_file_has_extension_and_hashed_name() {
418        let dir = tempfile::tempdir().unwrap();
419        let c = coord("secret:prod/db/password");
420        write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
421        let path = record_path(dir.path(), &c).unwrap();
422        assert!(path.exists());
423        // filename is the blake3 hex id, never the cleartext coordinate
424        let name = path.file_name().unwrap().to_str().unwrap();
425        assert!(name.ends_with(".sec"));
426        assert!(!name.contains("password"));
427    }
428
429    #[cfg(unix)]
430    #[test]
431    fn written_record_is_0600() {
432        use std::os::unix::fs::PermissionsExt;
433        let dir = tempfile::tempdir().unwrap();
434        let c = coord("secret:prod/db/password");
435        write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
436        let mode = fs::metadata(record_path(dir.path(), &c).unwrap())
437            .unwrap()
438            .permissions()
439            .mode();
440        assert_eq!(mode & 0o777, 0o600);
441    }
442
443    // I7 (Windows analog of `0600`): `restrict` makes the file's DACL **protected**
444    // — inheritance is removed, so a lax parent can no longer leak `Users`/`Everyone`
445    // access to the secret at rest (KOV-58). We assert the SE_DACL_PROTECTED control
446    // bit; the impl only ever adds {current user, SYSTEM, Administrators}.
447    #[cfg(windows)]
448    #[test]
449    fn windows_restrict_makes_dacl_protected() {
450        use std::os::windows::ffi::OsStrExt;
451
452        use windows::Win32::Foundation::{HLOCAL, LocalFree};
453        use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
454        use windows::Win32::Security::{
455            DACL_SECURITY_INFORMATION, GetSecurityDescriptorControl, PSECURITY_DESCRIPTOR,
456            SE_DACL_PROTECTED,
457        };
458        use windows::core::PCWSTR;
459
460        let dir = tempfile::tempdir().unwrap();
461        let path = dir.path().join("secret.bin");
462        fs::write(&path, b"x").unwrap();
463        restrict(&path, 0o600).unwrap();
464
465        let wide: Vec<u16> = path
466            .as_os_str()
467            .encode_wide()
468            .chain(core::iter::once(0))
469            .collect();
470        let mut psd = PSECURITY_DESCRIPTOR::default();
471        // SAFETY: FFI; `psd` receives an owned descriptor we LocalFree below.
472        let rc = unsafe {
473            GetNamedSecurityInfoW(
474                PCWSTR(wide.as_ptr()),
475                SE_FILE_OBJECT,
476                DACL_SECURITY_INFORMATION,
477                None,
478                None,
479                None,
480                None,
481                &mut psd,
482            )
483        };
484        assert_eq!(rc.0, 0, "GetNamedSecurityInfoW failed: {}", rc.0);
485        let mut control = 0u16;
486        let mut revision = 0u32;
487        // SAFETY: FFI; `psd` is a valid descriptor until we free it.
488        unsafe { GetSecurityDescriptorControl(psd, &mut control, &mut revision).unwrap() };
489        // SAFETY: FFI; free the descriptor GetNamedSecurityInfoW allocated.
490        unsafe { LocalFree(Some(HLOCAL(psd.0.cast()))) };
491
492        assert!(
493            control & SE_DACL_PROTECTED.0 != 0,
494            "DACL must be protected (inheritance stripped); control={control:#x}"
495        );
496    }
497
498    #[test]
499    fn overwrite_rotates_previous_to_bak() {
500        let dir = tempfile::tempdir().unwrap();
501        let c = coord("secret:prod/db/password");
502        write_record(dir.path(), &c, &seal(&literal("v1"), &key()).unwrap()).unwrap();
503        write_record(dir.path(), &c, &seal(&literal("v2"), &key()).unwrap()).unwrap();
504
505        // current holds v2
506        let current = read_record(dir.path(), &c, &key()).unwrap().unwrap();
507        match current {
508            SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"v2"),
509            other => panic!("expected literal, got {other:?}"),
510        }
511        // a .bak sibling exists
512        let bak = record_path(dir.path(), &c)
513            .unwrap()
514            .with_extension(format!("{RECORD_EXT}.bak"));
515        assert!(bak.exists());
516    }
517
518    #[test]
519    fn load_all_quarantines_corrupt_and_loads_siblings() {
520        let dir = tempfile::tempdir().unwrap();
521        let good = coord("secret:prod/db/good");
522        let bad = coord("secret:prod/db/bad");
523        write_record(
524            dir.path(),
525            &good,
526            &seal(&literal("good-val"), &key()).unwrap(),
527        )
528        .unwrap();
529        write_record(
530            dir.path(),
531            &bad,
532            &seal(&literal("bad-val"), &key()).unwrap(),
533        )
534        .unwrap();
535
536        // Corrupt the ciphertext of the "bad" record (flip a byte past the frame
537        // header, so the frame parses but the AEAD tag fails).
538        let bad_path = record_path(dir.path(), &bad).unwrap();
539        let mut bytes = fs::read(&bad_path).unwrap();
540        let last = bytes.len() - 1;
541        bytes[last] ^= 0xff;
542        fs::write(&bad_path, &bytes).unwrap();
543
544        let outcome = load_all(dir.path(), &key()).unwrap();
545        assert_eq!(outcome.records.len(), 1, "the good record still loads");
546        assert_eq!(
547            outcome.quarantined.len(),
548            1,
549            "the bad record is quarantined"
550        );
551        assert_eq!(outcome.quarantined[0].id, bad.storage_id().unwrap());
552        // quarantine reason carries no value
553        assert!(!outcome.quarantined[0].reason.contains("bad-val"));
554    }
555
556    #[test]
557    fn load_all_quarantines_garbage_file() {
558        let dir = tempfile::tempdir().unwrap();
559        ensure_dir(dir.path()).unwrap();
560        fs::write(dir.path().join("deadbeef.sec"), b"not a frame").unwrap();
561        let outcome = load_all(dir.path(), &key()).unwrap();
562        assert!(outcome.records.is_empty());
563        assert_eq!(outcome.quarantined.len(), 1);
564    }
565
566    #[test]
567    fn delete_removes_record() {
568        let dir = tempfile::tempdir().unwrap();
569        let c = coord("secret:prod/db/password");
570        write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
571        delete_record(dir.path(), &c).unwrap();
572        assert!(read_record(dir.path(), &c, &key()).unwrap().is_none());
573    }
574
575    #[test]
576    fn placeholder_coordinate_is_rejected() {
577        let dir = tempfile::tempdir().unwrap();
578        let c = coord("secret:${ENV}/db/password");
579        assert!(matches!(
580            write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()),
581            Err(CoreError::NotStorable(_))
582        ));
583    }
584}