Skip to main content

shep_core/
kv.rs

1//! `kv.json`: the shepherd's key/value store (spec §5).
2//!
3//! A flat map of short strings under `$SHEP_HOME`, for ad-hoc operator notes
4//! and dog runtime tweaks. Explicitly **not** the primary config path — a
5//! Flockfile is what configures a sheep and `shep.toml` is what configures the
6//! shepherd and its dogs. This is the place for the things neither of those
7//! has a field for.
8//!
9//! # Why this is a file and not an RPC
10//!
11//! Spec §5 says the store is for "ad-hoc + dog runtime tweaks", so a dog reads
12//! it — which rules out keeping it private to shep-cli, and is why it lives
13//! here, where every crate in the workspace and every `shep dog <name>` gets it
14//! for free. It does NOT follow that it has to go over the socket. A dog's
15//! `[dog.<name>]` section travels that way because the alternative on the table
16//! was the child's ENVIRONMENT, which is readable from the process table,
17//! inherited by every grandchild and captured into crash dumps (spec §8). A
18//! `0600` file inside a `0700` `$SHEP_HOME`, opened by a process running as the
19//! same user, has none of those properties, so the socket would buy nothing —
20//! while costing the thing every other config verb in this tree provides:
21//! `shep set` works with no shepherd running, exactly as `shep enable` and
22//! `shep barks` do.
23//!
24//! # Writing
25//!
26//! Every mutation is a read-modify-rename under an exclusive advisory lock on a
27//! sibling `kv.json.lock`, with the new content staged through a uniquely-named
28//! `0600` temp file, `fsync`ed and `rename`d over the original. That is the
29//! same shape `barks::append` uses, for the same reasons and after the same
30//! bug: two processes appending to `barks.jsonl` silently lost half of each
31//! other's records until an advisory lock landed there, and a shared temp name
32//! had one writer's `rename` consume the other's staging file. Do not
33//! reimplement either half here — it is a third instance of one pattern, not a
34//! third pattern.
35//!
36//! # Keys
37//!
38//! One flat string per key, matching `[A-Za-z0-9._-]`, 1 to
39//! [`MAX_KEY_BYTES`], not starting with `.`. A dot is part of a NAME, not a
40//! path: `bark.cooldown` is one key, and there is no nested object behind it.
41//! map.md inherited a dotted-path parse from pm2's own store; this project's
42//! standing decision is that pm2's formats live only in the importer, and a
43//! nesting grammar here would be a second config language — with its own
44//! quoting rules — for a store the spec itself calls not the primary config
45//! path. The narrow alphabet also means `shep get $key` never needs quoting.
46
47use core::fmt;
48use std::collections::BTreeMap;
49use std::io::Write as _;
50use std::path::Path;
51// `PathBuf` backs `lock_path` below, which both platform arms of `KvLock`
52// need — the unix one for `nix::fcntl::Flock`'s target, the windows one for
53// the `share_mode(0)` handle — so it is gated the same way `lock_path` is,
54// rather than to `cfg(unix)` alone.
55#[cfg(any(unix, windows))]
56use std::path::PathBuf;
57
58use serde::{Deserialize, Serialize};
59
60/// The on-disk format's version.
61///
62/// A store carrying a HIGHER version is refused rather than read or replaced
63/// ([`KvError::FutureVersion`]): the file is small, it is an operator's, and
64/// there is no undo for a downgrade that overwrites it. The muster roll's
65/// `SNAPSHOT_VERSION` is the precedent.
66pub const KV_VERSION: u32 = 1;
67
68/// Longest key this store accepts, in bytes.
69pub const MAX_KEY_BYTES: usize = 128;
70
71/// Longest value this store accepts, in bytes.
72///
73/// The store is read whole on every access, and a cap is what keeps it from
74/// quietly becoming a blob store — which it would, because it is the only
75/// writable thing in `$SHEP_HOME` with no schema.
76pub const MAX_VALUE_BYTES: usize = 4096;
77
78/// The file's shape: a version and a flat map.
79///
80/// `BTreeMap`, not `HashMap`, so the file is written in key order and two
81/// writes of the same content produce byte-identical files — which makes the
82/// store diffable, greppable, and safe to keep in a dotfiles repository.
83#[derive(Debug, Default, Serialize, Deserialize)]
84struct KvFile {
85    version: u32,
86    entries: BTreeMap<String, String>,
87}
88
89/// Error type returned by this module.
90///
91/// `#[non_exhaustive]`: shep-core is a published library and this enum is
92/// reachable from it, so a further failure shape — a store whose size exceeded
93/// a future cap, say — must not break an out-of-tree consumer's `match`
94/// (IR-20).
95///
96/// Wraps `io::Error`/`serde_json::Error` directly rather than stringifying
97/// them, matching [`BarkError`](crate::barks::BarkError), so callers keep the
98/// underlying diagnostic through [`core::error::Error::source`] — at the cost,
99/// documented there too, of not deriving `Clone`/`PartialEq`/`Eq` (IR-19's
100/// exception for variants wrapping `io::Error`).
101#[non_exhaustive]
102#[derive(Debug)]
103pub enum KvError {
104    /// The store could not be read, written, or replaced.
105    Io(std::io::Error),
106    /// The store's JSON could not be parsed.
107    ///
108    /// Refused rather than repaired: unlike `barks.jsonl`, which is read during
109    /// an incident and so forgives a bad line, this file is a map an operator
110    /// wrote and a partial read of it would silently drop keys that are still
111    /// on disk.
112    Decode(serde_json::Error),
113    /// A key outside the grammar; carries it verbatim so the message can quote
114    /// what was typed.
115    InvalidKey(String),
116    /// A value over [`MAX_VALUE_BYTES`].
117    ValueTooLong {
118        /// The key it was being stored under.
119        key: String,
120        /// Its length in bytes.
121        len: usize,
122    },
123    /// The store on disk is a version this build does not understand; carries
124    /// that version. Nothing was written.
125    FutureVersion(u32),
126}
127
128impl fmt::Display for KvError {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            Self::Io(err) => write!(f, "kv store I/O failed: {err}"),
132            Self::Decode(err) => write!(f, "kv store failed to parse: {err}"),
133            Self::InvalidKey(key) => write!(f, "`{key}` is not a valid kv key"),
134            Self::ValueTooLong { key, len } => write!(
135                f,
136                "value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
137            ),
138            Self::FutureVersion(version) => {
139                write!(
140                    f,
141                    "kv store is version {version}, newer than this build understands"
142                )
143            }
144        }
145    }
146}
147
148impl core::error::Error for KvError {
149    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
150        match self {
151            Self::Io(err) => Some(err),
152            Self::Decode(err) => Some(err),
153            Self::InvalidKey(_) | Self::ValueTooLong { .. } | Self::FutureVersion(_) => None,
154        }
155    }
156}
157
158impl From<std::io::Error> for KvError {
159    fn from(source: std::io::Error) -> Self {
160        Self::Io(source)
161    }
162}
163
164impl From<serde_json::Error> for KvError {
165    fn from(source: serde_json::Error) -> Self {
166        Self::Decode(source)
167    }
168}
169
170/// Checks one key against the grammar.
171///
172/// # Errors
173/// [`KvError::InvalidKey`] — empty, over [`MAX_KEY_BYTES`], starting with `.`,
174/// or containing anything outside `[A-Za-z0-9._-]`.
175fn check_key(key: &str) -> Result<(), KvError> {
176    let ok = !key.is_empty()
177        && key.len() <= MAX_KEY_BYTES
178        && !key.starts_with('.')
179        && key
180            .bytes()
181            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
182    if ok {
183        Ok(())
184    } else {
185        Err(KvError::InvalidKey(key.to_string()))
186    }
187}
188
189/// The lock file that guards `path`: its own name with `.lock` appended, so
190/// it sits in `$SHEP_HOME` next to the store and inherits that directory's
191/// `0700`.
192///
193/// `cfg(any(unix, windows))` alongside its two callers — [`KvLock::acquire`]
194/// names a real lock file on both platforms now, unix through `flock(2)` and
195/// windows through an exclusive `share_mode(0)` open.
196#[cfg(any(unix, windows))]
197fn lock_path(path: &Path) -> PathBuf {
198    let mut name = path
199        .file_name()
200        .map(std::ffi::OsStr::to_os_string)
201        .unwrap_or_default();
202    name.push(".lock");
203    path.parent().unwrap_or_else(|| Path::new(".")).join(name)
204}
205
206/// An exclusive advisory lock over one kv store, held for as long as the
207/// value lives and released when it drops (including on an early `?`, and by
208/// the kernel if the process dies holding it).
209///
210/// The same lock [`barks::RingLock`](crate::barks) documents, on this file:
211/// on a **sibling** `kv.json.lock`, never on the store itself, because the
212/// `rename` that installs new content replaces the inode a lock on the
213/// target would be held on.
214struct KvLock {
215    /// `flock(2)` is released by this handle's `Drop`. Named with a leading
216    /// underscore because it is held, never read.
217    #[cfg(unix)]
218    _flock: nix::fcntl::Flock<std::fs::File>,
219    /// The lock file, opened with `share_mode(0)` so no other handle —
220    /// same-process or not, read or write — can open it while this one is
221    /// live. Released by this handle's `Drop`, the same role `_flock` plays
222    /// on unix. Named with a leading underscore because it is held, never
223    /// read.
224    #[cfg(windows)]
225    _handle: std::fs::File,
226}
227
228impl KvLock {
229    /// Blocks until this process holds the store's lock exclusively.
230    ///
231    /// # Errors
232    /// The lock file could not be created beside `path`, or `flock` failed
233    /// for a reason other than contention (contention blocks rather than
234    /// failing).
235    #[cfg(unix)]
236    fn acquire(path: &Path) -> std::io::Result<Self> {
237        use nix::fcntl::{Flock, FlockArg};
238        use std::os::unix::fs::OpenOptionsExt as _;
239
240        let file = std::fs::OpenOptions::new()
241            .write(true)
242            .create(true)
243            .truncate(false)
244            .mode(crate::atomic_file::OWNER_ONLY_FILE_MODE)
245            .open(lock_path(path))?;
246
247        Flock::lock(file, FlockArg::LockExclusive)
248            .map(|flock| Self { _flock: flock })
249            .map_err(|(_file, errno)| std::io::Error::from(errno))
250    }
251
252    /// Blocks until this process holds the store's lock exclusively.
253    ///
254    /// `flock(2)` has no Windows equivalent, but `share_mode(0)` gives the
255    /// same exclusivity through a different door: opening the lock file with
256    /// every share flag cleared means no other handle — another process's or
257    /// this one's, read or write — can be opened on it while this handle
258    /// lives, which is mandatory (enforced by the OS on every open, not just
259    /// respected by cooperating callers) exactly as `flock` is. What it does
260    /// not give is a blocking wait: a contended open fails immediately with
261    /// `ERROR_SHARING_VIOLATION` rather than parking the thread the way
262    /// `flock`'s `LockExclusive` does, so this polls on a short sleep until
263    /// the open succeeds. Two writers in the *same* process are covered too —
264    /// Windows share-mode denial is per-file, not per-process, so a second
265    /// thread's open contends with the first thread's open handle exactly as
266    /// a second process's would.
267    ///
268    /// # Errors
269    /// The lock file could not be created beside `path`, or the open failed
270    /// for a reason other than sharing contention (contention retries rather
271    /// than failing).
272    #[cfg(windows)]
273    fn acquire(path: &Path) -> std::io::Result<Self> {
274        use std::os::windows::fs::OpenOptionsExt as _;
275
276        /// Windows' `ERROR_SHARING_VIOLATION`: another handle already holds
277        /// share access this open's `share_mode(0)` denies. Hardcoded rather
278        /// than pulled from `windows-sys` — this crate has no Windows-only
279        /// dependency today, and one well-known, stable error code does not
280        /// earn it one.
281        const ERROR_SHARING_VIOLATION: i32 = 32;
282
283        /// How long a contended retry sleeps before trying again. Short
284        /// enough that a lock held for a normal `set`/`get`'s duration (a
285        /// handful of small file operations) costs this loop only a few
286        /// iterations, long enough not to spin the CPU while it waits.
287        const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2);
288
289        let lock_path = lock_path(path);
290        loop {
291            match std::fs::OpenOptions::new()
292                .write(true)
293                .create(true)
294                .truncate(false)
295                .share_mode(0)
296                .open(&lock_path)
297            {
298                Ok(handle) => return Ok(Self { _handle: handle }),
299                Err(error) if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) => {
300                    std::thread::sleep(RETRY_INTERVAL);
301                }
302                Err(error) => return Err(error),
303            }
304        }
305    }
306}
307
308/// Reads `path` under the lock the caller already holds.
309///
310/// A missing file reads as an empty, current-version store — `shep get`
311/// against a fresh `$SHEP_HOME` is the first thing anyone runs, and an
312/// `ENOENT` in their face would be wrong. Any other `io::Error` propagates.
313fn read_file(path: &Path) -> Result<KvFile, KvError> {
314    let raw = match std::fs::read_to_string(path) {
315        Ok(raw) => raw,
316        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(KvFile::default()),
317        Err(err) => return Err(KvError::Io(err)),
318    };
319    let file: KvFile = serde_json::from_str(&raw)?;
320    if file.version > KV_VERSION {
321        return Err(KvError::FutureVersion(file.version));
322    }
323    Ok(file)
324}
325
326/// Rewrites `path` to hold exactly `file`, atomically — see this module's
327/// own doc for the staged-temp-file-then-rename shape.
328fn write_file(path: &Path, file: &KvFile) -> Result<(), KvError> {
329    let parent = path.parent().unwrap_or_else(|| Path::new("."));
330    let mut tmp = crate::atomic_file::create_staging_file(parent, "kv", ".tmp")?;
331
332    let json = serde_json::to_string_pretty(file)?;
333    tmp.write_all(json.as_bytes())?;
334    tmp.write_all(b"\n")?;
335    tmp.as_file().sync_all()?;
336
337    // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
338    // inside the error and its `Drop` removes the staging file, so a failed
339    // replace does not leave one behind.
340    tmp.persist(path).map_err(|err| KvError::Io(err.error))?;
341
342    // The `sync_all` above made the CONTENTS durable; this makes the rename
343    // that published them durable. See `shep_core::atomic_file`.
344    crate::atomic_file::sync_dir(parent)?;
345    Ok(())
346}
347
348/// Every key/value pair in the store, in key order.
349///
350/// # Errors
351///
352/// - [`KvError::Io`] — the store could not be opened or read. A store that is
353///   simply absent is not an error: it reads as empty.
354/// - [`KvError::Decode`] — the file is not the JSON this module writes.
355/// - [`KvError::FutureVersion`] — the file's `version` is newer than
356///   [`KV_VERSION`]. Nothing is read and nothing is written.
357pub fn all(path: &Path) -> Result<BTreeMap<String, String>, KvError> {
358    // Taking the lock here too costs one extra `open` and removes the
359    // question of whether a lock-free reader could observe a half-`rename`d
360    // file entirely — harmless in practice, since the rename is atomic and
361    // the worst case is a whole old file, but not worth reasoning about
362    // twice. Do not "optimize" this away without re-deriving that.
363    let _lock = KvLock::acquire(path)?;
364    Ok(read_file(path)?.entries)
365}
366
367/// One key's value, or `None` if it is not in the store.
368///
369/// # Errors
370///
371/// [`KvError::InvalidKey`] for a key outside the grammar (refused before the
372/// file is opened, so a malformed key never creates one), plus `Io`, `Decode`
373/// and `FutureVersion` exactly as [`all`] returns them.
374pub fn get(path: &Path, key: &str) -> Result<Option<String>, KvError> {
375    check_key(key)?;
376    Ok(all(path)?.remove(key))
377}
378
379/// Stores `value` under `key`, replacing any previous value.
380///
381/// # Errors
382///
383/// - [`KvError::InvalidKey`] — the key is outside the grammar.
384/// - [`KvError::ValueTooLong`] — the value exceeds [`MAX_VALUE_BYTES`].
385/// - [`KvError::FutureVersion`] — the store on disk is newer than this build
386///   understands. **Nothing is written**; a downgrade that overwrote an
387///   operator's store has no undo.
388/// - [`KvError::Decode`] — the existing file could not be parsed. Refused
389///   rather than replaced, for the same reason.
390/// - [`KvError::Io`] — the lock, the temp file, the `fsync` or the `rename`
391///   failed. Either the whole write landed or none of it did.
392pub fn set(path: &Path, key: &str, value: &str) -> Result<(), KvError> {
393    check_key(key)?;
394    if value.len() > MAX_VALUE_BYTES {
395        return Err(KvError::ValueTooLong {
396            key: key.to_string(),
397            len: value.len(),
398        });
399    }
400
401    let _lock = KvLock::acquire(path)?;
402    let mut file = read_file(path)?;
403    file.version = KV_VERSION;
404    file.entries.insert(key.to_string(), value.to_string());
405    write_file(path, &file)
406}
407
408/// Removes `key`, returning whether it was there.
409///
410/// # Errors
411///
412/// The same set [`set`] returns, minus [`KvError::ValueTooLong`]: `InvalidKey`,
413/// `FutureVersion`, `Decode`, `Io`.
414pub fn unset(path: &Path, key: &str) -> Result<bool, KvError> {
415    check_key(key)?;
416
417    let _lock = KvLock::acquire(path)?;
418    let mut file = read_file(path)?;
419    let was_present = file.entries.remove(key).is_some();
420    if was_present {
421        file.version = KV_VERSION;
422        write_file(path, &file)?;
423    }
424    Ok(was_present)
425}
426
427/// Empties the store, returning how many keys were removed.
428///
429/// # Errors
430///
431/// [`KvError::FutureVersion`], [`KvError::Decode`] and [`KvError::Io`]. A
432/// store that does not exist clears to `0` rather than failing — `shep unset
433/// --all` on a fresh machine is a success that removed nothing.
434pub fn clear(path: &Path) -> Result<u32, KvError> {
435    let _lock = KvLock::acquire(path)?;
436    let file = read_file(path)?;
437    let count = u32::try_from(file.entries.len()).unwrap_or(u32::MAX);
438    if count > 0 {
439        write_file(
440            path,
441            &KvFile {
442                version: KV_VERSION,
443                entries: BTreeMap::new(),
444            },
445        )?;
446    }
447    Ok(count)
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    /// fails if a set value cannot be read back, or if the file is not created
455    /// on first write. Everything else here is a refusal or a race; this is the
456    /// one case that says the store stores.
457    #[test]
458    fn a_value_survives_a_write_and_a_read() {
459        let dir = tempfile::tempdir().unwrap();
460        let path = dir.path().join("kv.json");
461        set(&path, "bark.cooldown", "30s").unwrap();
462        assert_eq!(
463            get(&path, "bark.cooldown").unwrap(),
464            Some("30s".to_string())
465        );
466    }
467
468    /// fails if a missing store is an error rather than an empty one. `shep get`
469    /// against a fresh `$SHEP_HOME` is the first thing anyone runs, and an
470    /// `ENOENT` in their face would be wrong: the store has no keys, which is
471    /// a fact, not a failure.
472    #[test]
473    fn a_store_that_does_not_exist_reads_as_empty() {
474        let dir = tempfile::tempdir().unwrap();
475        let path = dir.path().join("kv.json");
476        assert!(all(&path).unwrap().is_empty());
477        assert_eq!(get(&path, "anything").unwrap(), None);
478    }
479
480    /// fails if `unset` stops distinguishing a key it removed from one that was
481    /// never there. `shep unset typo` has to be able to say so rather than
482    /// exiting 0 on a no-op the operator will read as success.
483    #[test]
484    fn unset_reports_whether_the_key_was_there() {
485        let dir = tempfile::tempdir().unwrap();
486        let path = dir.path().join("kv.json");
487        set(&path, "a", "1").unwrap();
488        assert!(unset(&path, "a").unwrap());
489        assert!(!unset(&path, "a").unwrap());
490    }
491
492    /// fails if `clear` misreports how much it removed, or leaves anything.
493    #[test]
494    fn clear_empties_the_store_and_counts_what_it_took() {
495        let dir = tempfile::tempdir().unwrap();
496        let path = dir.path().join("kv.json");
497        set(&path, "a", "1").unwrap();
498        set(&path, "b", "2").unwrap();
499        assert_eq!(clear(&path).unwrap(), 2);
500        assert!(all(&path).unwrap().is_empty());
501        assert_eq!(clear(&path).unwrap(), 0);
502    }
503
504    /// fails if the key grammar widens. Each rejection here is deliberate: a
505    /// key goes onto a shell command line (`shep get $k`) and into a JSON
506    /// object, so whitespace, control characters and an empty name all have to
507    /// be refused at the door rather than quoted around forever.
508    #[test]
509    fn the_key_grammar_refuses_what_it_says_it_refuses() {
510        let dir = tempfile::tempdir().unwrap();
511        let path = dir.path().join("kv.json");
512        for bad in [
513            "", " ", "a b", "a\nb", "a/b", "a:b", ".hidden", "a\"b", "$HOME",
514        ] {
515            assert!(
516                matches!(set(&path, bad, "1"), Err(KvError::InvalidKey(_))),
517                "`{bad}` was accepted as a key"
518            );
519        }
520        for good in ["a", "bark.cooldown", "metrics_port", "a-b", "A1.b-c_d"] {
521            assert!(set(&path, good, "1").is_ok(), "`{good}` was refused");
522        }
523    }
524
525    /// fails if a key that merely CONTAINS a dot is treated as a path into a
526    /// nested object. `bark.cooldown` is one key whose name has a dot in it —
527    /// the store is flat, and the dot is a naming convention, not a grammar.
528    #[test]
529    fn a_dotted_key_is_one_flat_key_and_not_a_path() {
530        let dir = tempfile::tempdir().unwrap();
531        let path = dir.path().join("kv.json");
532        set(&path, "bark.cooldown", "30s").unwrap();
533        set(&path, "bark.sink", "discord").unwrap();
534        let stored = all(&path).unwrap();
535        assert_eq!(stored.len(), 2);
536        assert!(stored.contains_key("bark.cooldown"));
537        assert_eq!(get(&path, "bark").unwrap(), None);
538        // And on disk, not just in the map: a nested writer would produce
539        // `{"bark":{"cooldown":…}}` and this is what notices.
540        let raw = std::fs::read_to_string(&path).unwrap();
541        assert!(raw.contains(r#""bark.cooldown""#), "{raw}");
542    }
543
544    /// fails if an oversized value is stored. The store is `$SHEP_HOME`'s
545    /// smallest file and is read whole on every access; a cap keeps it from
546    /// quietly becoming a blob store.
547    #[test]
548    fn an_oversized_value_is_refused_by_name_and_length() {
549        let dir = tempfile::tempdir().unwrap();
550        let path = dir.path().join("kv.json");
551        let big = "x".repeat(MAX_VALUE_BYTES + 1);
552        let err = set(&path, "a", &big).unwrap_err();
553        let KvError::ValueTooLong { key, len } = err else {
554            panic!("expected ValueTooLong, got {err:?}");
555        };
556        assert_eq!(key, "a");
557        assert_eq!(len, MAX_VALUE_BYTES + 1);
558    }
559
560    /// fails if a store written by a future shep is silently overwritten. This
561    /// file is small but it is an operator's, and clobbering it on a downgrade
562    /// would be an unrecoverable loss for no gain.
563    #[test]
564    fn a_store_from_a_future_shep_is_refused_rather_than_replaced() {
565        let dir = tempfile::tempdir().unwrap();
566        let path = dir.path().join("kv.json");
567        std::fs::write(&path, r#"{"version":99,"entries":{"a":"1"}}"#).unwrap();
568        assert!(matches!(all(&path), Err(KvError::FutureVersion(99))));
569        assert!(matches!(
570            set(&path, "b", "2"),
571            Err(KvError::FutureVersion(99))
572        ));
573        // Untouched, which is the half that matters.
574        let raw = std::fs::read_to_string(&path).unwrap();
575        assert!(raw.contains(r#""a":"1""#), "{raw}");
576    }
577
578    /// fails if the file is created group- or world-readable. `$SHEP_HOME` is
579    /// already `0700`, so this is belt-and-braces — and it is the mode a `tar`,
580    /// a `cp -p` or a backup carries out of that directory with the file, where
581    /// no directory mode follows it. Same argument `barks.jsonl` records.
582    #[cfg(unix)]
583    #[test]
584    fn the_store_is_owner_only() {
585        use std::os::unix::fs::PermissionsExt as _;
586        let dir = tempfile::tempdir().unwrap();
587        let path = dir.path().join("kv.json");
588        set(&path, "a", "1").unwrap();
589        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
590        assert_eq!(mode, 0o600, "{mode:o}");
591    }
592
593    /// fails if two concurrent writers lose each other's keys. This is not a
594    /// theoretical race: `barks.jsonl` lost half of 400 records to exactly this
595    /// shape before it grew the same advisory lock, and the store has the same
596    /// two-writer future (an operator's `shep set` and a dog's own).
597    ///
598    /// Bounded (IR-46): the join is under a timeout, so a lock that deadlocks
599    /// fails this test instead of hanging the suite.
600    #[test]
601    fn two_concurrent_writers_lose_nothing() {
602        let dir = tempfile::tempdir().unwrap();
603        let path = dir.path().join("kv.json");
604        const PER_WRITER: usize = 100;
605
606        let (done_tx, done_rx) = std::sync::mpsc::channel();
607        for writer in 0..2 {
608            let path = path.clone();
609            let done_tx = done_tx.clone();
610            std::thread::spawn(move || {
611                for n in 0..PER_WRITER {
612                    set(&path, &format!("w{writer}.k{n}"), "v").unwrap();
613                }
614                done_tx.send(()).unwrap();
615            });
616        }
617        drop(done_tx);
618        for _ in 0..2 {
619            done_rx
620                .recv_timeout(std::time::Duration::from_secs(60))
621                .expect("a writer did not finish within 60s");
622        }
623
624        assert_eq!(all(&path).unwrap().len(), PER_WRITER * 2);
625    }
626}