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. Not the primary config path: a Flockfile
5//! configures a sheep, `shep.toml` the shepherd and its dogs. A file rather
6//! than an RPC, so `shep set`/`get`/`unset` work with no shepherd running.
7//! Every mutation is a read-modify-rename under an exclusive lock on a
8//! sibling `kv.json.lock`, staged through a temp file: the same shape
9//! `barks::append` uses, so do not reimplement it here.
10//!
11//! Keys match `[A-Za-z0-9._-]`, 1 to [`MAX_KEY_BYTES`], not starting with
12//! `.`; a dot is part of a key's name, not a path.
13
14use core::fmt;
15use std::collections::BTreeMap;
16use std::io::Write as _;
17use std::path::Path;
18// `PathBuf` backs `lock_path` below, gated the same way for both platform
19// arms of `KvLock`.
20#[cfg(any(unix, windows))]
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25/// The on-disk format's version.
26///
27/// A store carrying a higher version is refused rather than read or
28/// replaced ([`KvError::FutureVersion`]): there is no undo for a downgrade
29/// that overwrites an operator's store.
30pub const KV_VERSION: u32 = 1;
31
32/// Longest key this store accepts, in bytes.
33pub const MAX_KEY_BYTES: usize = 128;
34
35/// Longest value this store accepts, in bytes.
36///
37/// The store is read whole on every access; a cap keeps it from becoming an
38/// unbounded blob store.
39pub const MAX_VALUE_BYTES: usize = 4096;
40
41/// The file's shape: a version and a flat map.
42///
43/// `BTreeMap`, not `HashMap`, so the file writes in key order: two writes of
44/// the same content produce byte-identical files.
45#[derive(Debug, Default, Serialize, Deserialize)]
46struct KvFile {
47    version: u32,
48    entries: BTreeMap<String, String>,
49}
50
51/// Error type returned by this module.
52///
53/// `#[non_exhaustive]`: shep-core is published, so a new failure variant
54/// must not break an out-of-tree `match`.
55///
56/// Wraps `io::Error`/`serde_json::Error` directly rather than stringifying
57/// them, matching [`BarkError`](crate::barks::BarkError), so callers keep
58/// the underlying diagnostic through [`core::error::Error::source`]; this
59/// type does not derive `Clone`/`PartialEq`/`Eq` as a result.
60#[non_exhaustive]
61#[derive(Debug)]
62pub enum KvError {
63    /// The store could not be read, written, or replaced.
64    Io(std::io::Error),
65    /// The store's JSON could not be parsed.
66    ///
67    /// Refused rather than repaired: a partial read would silently drop keys
68    /// still on disk.
69    Decode(serde_json::Error),
70    /// A key outside the grammar; carries it verbatim so the message can quote
71    /// what was typed.
72    InvalidKey(String),
73    /// A value over [`MAX_VALUE_BYTES`].
74    ValueTooLong {
75        /// The key it was being stored under.
76        key: String,
77        /// Its length in bytes.
78        len: usize,
79    },
80    /// The store on disk is a version this build does not understand; carries
81    /// that version. Nothing was written.
82    FutureVersion(u32),
83}
84
85impl fmt::Display for KvError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::Io(err) => write!(f, "kv store I/O failed: {err}"),
89            Self::Decode(err) => write!(f, "kv store failed to parse: {err}"),
90            Self::InvalidKey(key) => write!(f, "`{key}` is not a valid kv key"),
91            Self::ValueTooLong { key, len } => write!(
92                f,
93                "value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
94            ),
95            Self::FutureVersion(version) => {
96                write!(
97                    f,
98                    "kv store is version {version}, newer than this build understands"
99                )
100            }
101        }
102    }
103}
104
105impl core::error::Error for KvError {
106    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
107        match self {
108            Self::Io(err) => Some(err),
109            Self::Decode(err) => Some(err),
110            Self::InvalidKey(_) | Self::ValueTooLong { .. } | Self::FutureVersion(_) => None,
111        }
112    }
113}
114
115impl From<std::io::Error> for KvError {
116    fn from(source: std::io::Error) -> Self {
117        Self::Io(source)
118    }
119}
120
121impl From<serde_json::Error> for KvError {
122    fn from(source: serde_json::Error) -> Self {
123        Self::Decode(source)
124    }
125}
126
127/// Checks one key against the grammar.
128///
129/// # Errors
130/// [`KvError::InvalidKey`]: empty, over [`MAX_KEY_BYTES`], starting with `.`,
131/// or containing anything outside `[A-Za-z0-9._-]`.
132fn check_key(key: &str) -> Result<(), KvError> {
133    let ok = !key.is_empty()
134        && key.len() <= MAX_KEY_BYTES
135        && !key.starts_with('.')
136        && key
137            .bytes()
138            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
139    if ok {
140        Ok(())
141    } else {
142        Err(KvError::InvalidKey(key.to_string()))
143    }
144}
145
146/// The lock file that guards `path`: its own name with `.lock` appended, so
147/// it sits in `$SHEP_HOME` next to the store and inherits that directory's
148/// `0700`.
149///
150/// `cfg(any(unix, windows))` alongside its two callers: [`KvLock::acquire`]
151/// names a real lock file on both platforms now, unix through `flock(2)` and
152/// windows through an exclusive `share_mode(0)` open.
153#[cfg(any(unix, windows))]
154fn lock_path(path: &Path) -> PathBuf {
155    let mut name = path
156        .file_name()
157        .map(std::ffi::OsStr::to_os_string)
158        .unwrap_or_default();
159    name.push(".lock");
160    path.parent().unwrap_or_else(|| Path::new(".")).join(name)
161}
162
163/// An exclusive advisory lock over one kv store, released when it drops,
164/// including by the kernel if the process dies holding it.
165///
166/// On a sibling `kv.json.lock`, never on the store itself: `rename`
167/// replaces the store's inode, which would orphan a lock held on it.
168struct KvLock {
169    /// `flock(2)` is released by this handle's `Drop`. Named with a leading
170    /// underscore because it is held, never read.
171    #[cfg(unix)]
172    _flock: nix::fcntl::Flock<std::fs::File>,
173    /// The lock file, opened with `share_mode(0)` so no other handle can
174    /// open it while this one is live; released by `Drop`, the same role
175    /// `_flock` plays on unix. Named with a leading underscore because it
176    /// is held, never read.
177    #[cfg(windows)]
178    _handle: std::fs::File,
179}
180
181impl KvLock {
182    /// Blocks until this process holds the store's lock exclusively.
183    ///
184    /// # Errors
185    /// The lock file could not be created beside `path`, or `flock` failed
186    /// for a reason other than contention (contention blocks rather than
187    /// failing).
188    #[cfg(unix)]
189    fn acquire(path: &Path) -> std::io::Result<Self> {
190        use nix::fcntl::{Flock, FlockArg};
191        use std::os::unix::fs::OpenOptionsExt as _;
192
193        let file = std::fs::OpenOptions::new()
194            .write(true)
195            .create(true)
196            .truncate(false)
197            .mode(crate::atomic_file::OWNER_ONLY_FILE_MODE)
198            .open(lock_path(path))?;
199
200        Flock::lock(file, FlockArg::LockExclusive)
201            .map(|flock| Self { _flock: flock })
202            .map_err(|(_file, errno)| std::io::Error::from(errno))
203    }
204
205    /// Blocks until this process holds the store's lock exclusively.
206    ///
207    /// `share_mode(0)` denies every other open, in this process or another,
208    /// giving the same exclusivity as unix `flock`. A contended open fails
209    /// immediately with `ERROR_SHARING_VIOLATION` rather than blocking, so
210    /// this polls on a short sleep until it succeeds.
211    ///
212    /// # Errors
213    /// The lock file could not be created beside `path`, or the open failed
214    /// for a reason other than sharing contention (contention retries rather
215    /// than failing).
216    #[cfg(windows)]
217    fn acquire(path: &Path) -> std::io::Result<Self> {
218        use std::os::windows::fs::OpenOptionsExt as _;
219
220        /// Windows' `ERROR_SHARING_VIOLATION`: another handle already holds
221        /// share access this open's `share_mode(0)` denies. Hardcoded rather
222        /// than pulled from `windows-sys`, since this crate has no other
223        /// Windows-only dependency.
224        const ERROR_SHARING_VIOLATION: i32 = 32;
225
226        /// How long a contended retry sleeps before trying again. Short
227        /// enough that a lock held for a normal `set`/`get`'s duration (a
228        /// handful of small file operations) costs this loop only a few
229        /// iterations, long enough not to spin the CPU while it waits.
230        const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2);
231
232        let lock_path = lock_path(path);
233        loop {
234            match std::fs::OpenOptions::new()
235                .write(true)
236                .create(true)
237                .truncate(false)
238                .share_mode(0)
239                .open(&lock_path)
240            {
241                Ok(handle) => return Ok(Self { _handle: handle }),
242                Err(error) if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) => {
243                    std::thread::sleep(RETRY_INTERVAL);
244                }
245                Err(error) => return Err(error),
246            }
247        }
248    }
249}
250
251/// Reads `path` under the lock the caller already holds.
252///
253/// A missing file reads as an empty, current-version store: `shep get`
254/// against a fresh `$SHEP_HOME` should not fail with `ENOENT`. Any other
255/// `io::Error` propagates.
256fn read_file(path: &Path) -> Result<KvFile, KvError> {
257    let raw = match std::fs::read_to_string(path) {
258        Ok(raw) => raw,
259        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(KvFile::default()),
260        Err(err) => return Err(KvError::Io(err)),
261    };
262    let file: KvFile = serde_json::from_str(&raw)?;
263    if file.version > KV_VERSION {
264        return Err(KvError::FutureVersion(file.version));
265    }
266    Ok(file)
267}
268
269/// Rewrites `path` to hold exactly `file`, atomically: staged through a
270/// temp file, then renamed over the original.
271fn write_file(path: &Path, file: &KvFile) -> Result<(), KvError> {
272    let parent = path.parent().unwrap_or_else(|| Path::new("."));
273    let mut tmp = crate::atomic_file::create_staging_file(parent, "kv", ".tmp")?;
274
275    let json = serde_json::to_string_pretty(file)?;
276    tmp.write_all(json.as_bytes())?;
277    tmp.write_all(b"\n")?;
278    tmp.as_file().sync_all()?;
279
280    // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
281    // inside the error and its `Drop` removes the staging file, so a failed
282    // replace does not leave one behind.
283    tmp.persist(path).map_err(|err| KvError::Io(err.error))?;
284
285    // `sync_all` above made the contents durable; this makes the rename
286    // that published them durable too.
287    crate::atomic_file::sync_dir(parent)?;
288    Ok(())
289}
290
291/// Every key/value pair in the store, in key order.
292///
293/// # Errors
294///
295/// - [`KvError::Io`]: the store could not be opened or read. A store that is
296///   simply absent is not an error: it reads as empty.
297/// - [`KvError::Decode`]: the file is not the JSON this module writes.
298/// - [`KvError::FutureVersion`]: the file's `version` is newer than
299///   [`KV_VERSION`]. Nothing is read and nothing is written.
300pub fn all(path: &Path) -> Result<BTreeMap<String, String>, KvError> {
301    // Taking the lock here too costs one extra `open`, but it orders this
302    // read against `set`/`unset`'s read-modify-rename instead of racing it.
303    let _lock = KvLock::acquire(path)?;
304    Ok(read_file(path)?.entries)
305}
306
307/// One key's value, or `None` if it is not in the store.
308///
309/// # Errors
310///
311/// [`KvError::InvalidKey`] for a key outside the grammar (refused before the
312/// file is opened, so a malformed key never creates one), plus `Io`, `Decode`
313/// and `FutureVersion` exactly as [`all`] returns them.
314pub fn get(path: &Path, key: &str) -> Result<Option<String>, KvError> {
315    check_key(key)?;
316    Ok(all(path)?.remove(key))
317}
318
319/// Stores `value` under `key`, replacing any previous value.
320///
321/// # Errors
322///
323/// - [`KvError::InvalidKey`]: the key is outside the grammar.
324/// - [`KvError::ValueTooLong`]: the value exceeds [`MAX_VALUE_BYTES`].
325/// - [`KvError::FutureVersion`]: the store on disk is newer than this
326///   build understands. Nothing is written.
327/// - [`KvError::Decode`]: the existing file could not be parsed.
328/// - [`KvError::Io`]: the lock, the temp file, the `fsync` or the
329///   `rename` failed.
330pub fn set(path: &Path, key: &str, value: &str) -> Result<(), KvError> {
331    check_key(key)?;
332    if value.len() > MAX_VALUE_BYTES {
333        return Err(KvError::ValueTooLong {
334            key: key.to_string(),
335            len: value.len(),
336        });
337    }
338
339    let _lock = KvLock::acquire(path)?;
340    let mut file = read_file(path)?;
341    file.version = KV_VERSION;
342    file.entries.insert(key.to_string(), value.to_string());
343    write_file(path, &file)
344}
345
346/// Removes `key`, returning whether it was there.
347///
348/// # Errors
349///
350/// The same set [`set`] returns, minus [`KvError::ValueTooLong`]: `InvalidKey`,
351/// `FutureVersion`, `Decode`, `Io`.
352pub fn unset(path: &Path, key: &str) -> Result<bool, KvError> {
353    check_key(key)?;
354
355    let _lock = KvLock::acquire(path)?;
356    let mut file = read_file(path)?;
357    let was_present = file.entries.remove(key).is_some();
358    if was_present {
359        file.version = KV_VERSION;
360        write_file(path, &file)?;
361    }
362    Ok(was_present)
363}
364
365/// Empties the store, returning how many keys were removed.
366///
367/// # Errors
368///
369/// [`KvError::FutureVersion`], [`KvError::Decode`] and [`KvError::Io`]. A
370/// store that does not exist clears to `0` rather than failing: `shep unset
371/// --all` on a fresh machine is a success that removed nothing.
372pub fn clear(path: &Path) -> Result<u32, KvError> {
373    let _lock = KvLock::acquire(path)?;
374    let file = read_file(path)?;
375    let count = u32::try_from(file.entries.len()).unwrap_or(u32::MAX);
376    if count > 0 {
377        write_file(
378            path,
379            &KvFile {
380                version: KV_VERSION,
381                entries: BTreeMap::new(),
382            },
383        )?;
384    }
385    Ok(count)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn a_value_survives_a_write_and_a_read() {
394        let dir = tempfile::tempdir().unwrap();
395        let path = dir.path().join("kv.json");
396        set(&path, "bark.cooldown", "30s").unwrap();
397        assert_eq!(
398            get(&path, "bark.cooldown").unwrap(),
399            Some("30s".to_string())
400        );
401    }
402
403    #[test]
404    fn a_store_that_does_not_exist_reads_as_empty() {
405        let dir = tempfile::tempdir().unwrap();
406        let path = dir.path().join("kv.json");
407        assert!(all(&path).unwrap().is_empty());
408        assert_eq!(get(&path, "anything").unwrap(), None);
409    }
410
411    #[test]
412    fn unset_reports_whether_the_key_was_there() {
413        let dir = tempfile::tempdir().unwrap();
414        let path = dir.path().join("kv.json");
415        set(&path, "a", "1").unwrap();
416        assert!(unset(&path, "a").unwrap());
417        assert!(!unset(&path, "a").unwrap());
418    }
419
420    #[test]
421    fn clear_empties_the_store_and_counts_what_it_took() {
422        let dir = tempfile::tempdir().unwrap();
423        let path = dir.path().join("kv.json");
424        set(&path, "a", "1").unwrap();
425        set(&path, "b", "2").unwrap();
426        assert_eq!(clear(&path).unwrap(), 2);
427        assert!(all(&path).unwrap().is_empty());
428        assert_eq!(clear(&path).unwrap(), 0);
429    }
430
431    /// A key goes onto a shell command line (`shep get $k`) and into a JSON
432    /// object, so whitespace, control characters and an empty name are
433    /// refused.
434    #[test]
435    fn the_key_grammar_refuses_what_it_says_it_refuses() {
436        let dir = tempfile::tempdir().unwrap();
437        let path = dir.path().join("kv.json");
438        for bad in [
439            "", " ", "a b", "a\nb", "a/b", "a:b", ".hidden", "a\"b", "$HOME",
440        ] {
441            assert!(
442                matches!(set(&path, bad, "1"), Err(KvError::InvalidKey(_))),
443                "`{bad}` was accepted as a key"
444            );
445        }
446        for good in ["a", "bark.cooldown", "metrics_port", "a-b", "A1.b-c_d"] {
447            assert!(set(&path, good, "1").is_ok(), "`{good}` was refused");
448        }
449    }
450
451    #[test]
452    fn a_dotted_key_is_one_flat_key_and_not_a_path() {
453        let dir = tempfile::tempdir().unwrap();
454        let path = dir.path().join("kv.json");
455        set(&path, "bark.cooldown", "30s").unwrap();
456        set(&path, "bark.sink", "discord").unwrap();
457        let stored = all(&path).unwrap();
458        assert_eq!(stored.len(), 2);
459        assert!(stored.contains_key("bark.cooldown"));
460        assert_eq!(get(&path, "bark").unwrap(), None);
461        // And on disk, not just in the map: a nested writer would produce
462        // `{"bark":{"cooldown":…}}` and this is what notices.
463        let raw = std::fs::read_to_string(&path).unwrap();
464        assert!(raw.contains(r#""bark.cooldown""#), "{raw}");
465    }
466
467    #[test]
468    fn an_oversized_value_is_refused_by_name_and_length() {
469        let dir = tempfile::tempdir().unwrap();
470        let path = dir.path().join("kv.json");
471        let big = "x".repeat(MAX_VALUE_BYTES + 1);
472        let err = set(&path, "a", &big).unwrap_err();
473        let KvError::ValueTooLong { key, len } = err else {
474            panic!("expected ValueTooLong, got {err:?}");
475        };
476        assert_eq!(key, "a");
477        assert_eq!(len, MAX_VALUE_BYTES + 1);
478    }
479
480    #[test]
481    fn a_store_from_a_future_shep_is_refused_rather_than_replaced() {
482        let dir = tempfile::tempdir().unwrap();
483        let path = dir.path().join("kv.json");
484        std::fs::write(&path, r#"{"version":99,"entries":{"a":"1"}}"#).unwrap();
485        assert!(matches!(all(&path), Err(KvError::FutureVersion(99))));
486        assert!(matches!(
487            set(&path, "b", "2"),
488            Err(KvError::FutureVersion(99))
489        ));
490        // Untouched, which is the half that matters.
491        let raw = std::fs::read_to_string(&path).unwrap();
492        assert!(raw.contains(r#""a":"1""#), "{raw}");
493    }
494
495    /// `$SHEP_HOME` is already `0700`; this guards the mode a `tar`, a
496    /// `cp -p` or a backup carries out with the file, where no directory
497    /// mode follows.
498    #[cfg(unix)]
499    #[test]
500    fn the_store_is_owner_only() {
501        use std::os::unix::fs::PermissionsExt as _;
502        let dir = tempfile::tempdir().unwrap();
503        let path = dir.path().join("kv.json");
504        set(&path, "a", "1").unwrap();
505        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
506        assert_eq!(mode, 0o600, "{mode:o}");
507    }
508
509    /// Bounded: each join is under a timeout, so a lock that deadlocks fails
510    /// this test instead of hanging the suite.
511    #[test]
512    fn two_concurrent_writers_lose_nothing() {
513        let dir = tempfile::tempdir().unwrap();
514        let path = dir.path().join("kv.json");
515        const PER_WRITER: usize = 100;
516
517        let (done_tx, done_rx) = std::sync::mpsc::channel();
518        for writer in 0..2 {
519            let path = path.clone();
520            let done_tx = done_tx.clone();
521            std::thread::spawn(move || {
522                for n in 0..PER_WRITER {
523                    set(&path, &format!("w{writer}.k{n}"), "v").unwrap();
524                }
525                done_tx.send(()).unwrap();
526            });
527        }
528        drop(done_tx);
529        for _ in 0..2 {
530            done_rx
531                .recv_timeout(std::time::Duration::from_secs(60))
532                .expect("a writer did not finish within 60s");
533        }
534
535        assert_eq!(all(&path).unwrap().len(), PER_WRITER * 2);
536    }
537}