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}