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