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 a [`crate::file_lock`] on
8//! a sibling `kv.json.lock`, staged through a temp file.
9//!
10//! Keys match `[A-Za-z0-9._-]`, 1 to [`MAX_KEY_BYTES`], not starting with
11//! `.`; a dot is part of a key's name, not a path.
12
13// Every store writes through this same shape. Take `file_lock` and
14// `atomic_file` rather than open-coding another copy here.
15use core::fmt;
16use std::collections::BTreeMap;
17use std::path::Path;
18
19use serde::{Deserialize, Serialize};
20
21use crate::file_lock::FileLock;
22
23/// The on-disk format's version.
24///
25/// A store carrying a higher version is refused rather than read or
26/// replaced ([`KvError::FutureVersion`]): there is no undo for a downgrade
27/// that overwrites an operator's store.
28pub const KV_VERSION: u32 = 1;
29
30/// Longest key this store accepts, in bytes.
31pub const MAX_KEY_BYTES: usize = 128;
32
33/// Longest value this store accepts, in bytes.
34///
35/// The store is read whole on every access; a cap keeps it from becoming an
36/// unbounded blob store.
37pub const MAX_VALUE_BYTES: usize = 4096;
38
39/// The file's shape: a version and a flat map.
40///
41/// `BTreeMap`, not `HashMap`, so the file writes in key order: two writes of
42/// the same content produce byte-identical files.
43#[derive(Debug, Default, Serialize, Deserialize)]
44struct KvFile {
45    version: u32,
46    entries: BTreeMap<String, String>,
47}
48
49/// Error type returned by this module.
50///
51/// `#[non_exhaustive]`: shep-core is published, so a new failure variant
52/// must not break an out-of-tree `match`.
53///
54/// Wraps `io::Error`/`serde_json::Error` directly rather than stringifying
55/// them, matching [`BarkError`](crate::barks::BarkError), so callers keep
56/// the underlying diagnostic through [`core::error::Error::source`]; this
57/// type does not derive `Clone`/`PartialEq`/`Eq` as a result.
58#[non_exhaustive]
59#[derive(Debug)]
60pub enum KvError {
61    /// The store could not be read, written, or replaced.
62    Io(std::io::Error),
63    /// The store's JSON could not be parsed.
64    ///
65    /// Refused rather than repaired: a partial read would silently drop keys
66    /// still on disk.
67    Decode(serde_json::Error),
68    /// A key outside the grammar; carries it verbatim so the message can quote
69    /// what was typed.
70    InvalidKey(String),
71    /// A value over [`MAX_VALUE_BYTES`].
72    ValueTooLong {
73        /// The key it was being stored under.
74        key: String,
75        /// Its length in bytes.
76        len: usize,
77    },
78    /// The store on disk is a version this build does not understand; carries
79    /// that version. Nothing was written.
80    FutureVersion(u32),
81}
82
83impl fmt::Display for KvError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::Io(err) => write!(f, "kv store I/O failed: {err}"),
87            Self::Decode(err) => write!(f, "kv store failed to parse: {err}"),
88            Self::InvalidKey(key) => write!(f, "`{key}` is not a valid kv key"),
89            Self::ValueTooLong { key, len } => write!(
90                f,
91                "value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
92            ),
93            Self::FutureVersion(version) => {
94                write!(
95                    f,
96                    "kv store is version {version}, newer than this build understands"
97                )
98            }
99        }
100    }
101}
102
103impl core::error::Error for KvError {
104    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
105        match self {
106            Self::Io(err) => Some(err),
107            Self::Decode(err) => Some(err),
108            Self::InvalidKey(_) | Self::ValueTooLong { .. } | Self::FutureVersion(_) => None,
109        }
110    }
111}
112
113impl From<std::io::Error> for KvError {
114    fn from(source: std::io::Error) -> Self {
115        Self::Io(source)
116    }
117}
118
119impl From<serde_json::Error> for KvError {
120    fn from(source: serde_json::Error) -> Self {
121        Self::Decode(source)
122    }
123}
124
125/// Checks one key against the grammar.
126///
127/// # Errors
128/// [`KvError::InvalidKey`]: empty, over [`MAX_KEY_BYTES`], starting with `.`,
129/// or containing anything outside `[A-Za-z0-9._-]`.
130fn check_key(key: &str) -> Result<(), KvError> {
131    let ok = !key.is_empty()
132        && key.len() <= MAX_KEY_BYTES
133        && !key.starts_with('.')
134        && key
135            .bytes()
136            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
137    if ok {
138        Ok(())
139    } else {
140        Err(KvError::InvalidKey(key.to_string()))
141    }
142}
143
144/// Reads `path` under the lock the caller already holds.
145///
146/// A missing file reads as an empty, current-version store: `shep get`
147/// against a fresh `$SHEP_HOME` should not fail with `ENOENT`. Any other
148/// `io::Error` propagates.
149fn read_file(path: &Path) -> Result<KvFile, KvError> {
150    let raw = match std::fs::read_to_string(path) {
151        Ok(raw) => raw,
152        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(KvFile::default()),
153        Err(err) => return Err(KvError::Io(err)),
154    };
155    let file: KvFile = serde_json::from_str(&raw)?;
156    if file.version > KV_VERSION {
157        return Err(KvError::FutureVersion(file.version));
158    }
159    Ok(file)
160}
161
162/// Rewrites `path` to hold exactly `file`.
163fn write_file(path: &Path, file: &KvFile) -> Result<(), KvError> {
164    crate::atomic_file::write_json(path, "kv", file).map_err(KvError::Io)
165}
166
167/// Every key/value pair in the store, in key order.
168///
169/// # Errors
170///
171/// - [`KvError::Io`]: the store could not be opened or read. A store that is
172///   simply absent is not an error: it reads as empty.
173/// - [`KvError::Decode`]: the file is not the JSON this module writes.
174/// - [`KvError::FutureVersion`]: the file's `version` is newer than
175///   [`KV_VERSION`]. Nothing is read and nothing is written.
176pub fn all(path: &Path) -> Result<BTreeMap<String, String>, KvError> {
177    // Taking the lock here too costs one extra `open`, but it orders this
178    // read against `set`/`unset`'s read-modify-rename instead of racing it.
179    let _lock = FileLock::acquire(path)?;
180    Ok(read_file(path)?.entries)
181}
182
183/// One key's value, or `None` if it is not in the store.
184///
185/// # Errors
186///
187/// [`KvError::InvalidKey`] for a key outside the grammar (refused before the
188/// file is opened, so a malformed key never creates one), plus `Io`, `Decode`
189/// and `FutureVersion` exactly as [`all`] returns them.
190pub fn get(path: &Path, key: &str) -> Result<Option<String>, KvError> {
191    check_key(key)?;
192    Ok(all(path)?.remove(key))
193}
194
195/// Stores `value` under `key`, replacing any previous value.
196///
197/// # Errors
198///
199/// - [`KvError::InvalidKey`]: the key is outside the grammar.
200/// - [`KvError::ValueTooLong`]: the value exceeds [`MAX_VALUE_BYTES`].
201/// - [`KvError::FutureVersion`]: the store on disk is newer than this
202///   build understands. Nothing is written.
203/// - [`KvError::Decode`]: the existing file could not be parsed.
204/// - [`KvError::Io`]: the lock, the temp file, the `fsync` or the
205///   `rename` failed.
206pub fn set(path: &Path, key: &str, value: &str) -> Result<(), KvError> {
207    check_key(key)?;
208    if value.len() > MAX_VALUE_BYTES {
209        return Err(KvError::ValueTooLong {
210            key: key.to_string(),
211            len: value.len(),
212        });
213    }
214
215    let _lock = FileLock::acquire(path)?;
216    let mut file = read_file(path)?;
217    file.version = KV_VERSION;
218    file.entries.insert(key.to_string(), value.to_string());
219    write_file(path, &file)
220}
221
222/// Removes `key`, returning whether it was there.
223///
224/// # Errors
225///
226/// The same set [`set`] returns, minus [`KvError::ValueTooLong`]: `InvalidKey`,
227/// `FutureVersion`, `Decode`, `Io`.
228pub fn unset(path: &Path, key: &str) -> Result<bool, KvError> {
229    check_key(key)?;
230
231    let _lock = FileLock::acquire(path)?;
232    let mut file = read_file(path)?;
233    let was_present = file.entries.remove(key).is_some();
234    if was_present {
235        file.version = KV_VERSION;
236        write_file(path, &file)?;
237    }
238    Ok(was_present)
239}
240
241/// Empties the store, returning how many keys were removed.
242///
243/// # Errors
244///
245/// [`KvError::FutureVersion`], [`KvError::Decode`] and [`KvError::Io`]. A
246/// store that does not exist clears to `0` rather than failing: `shep unset
247/// --all` on a fresh machine is a success that removed nothing.
248pub fn clear(path: &Path) -> Result<u32, KvError> {
249    let _lock = FileLock::acquire(path)?;
250    let file = read_file(path)?;
251    let count = u32::try_from(file.entries.len()).unwrap_or(u32::MAX);
252    if count > 0 {
253        write_file(
254            path,
255            &KvFile {
256                version: KV_VERSION,
257                entries: BTreeMap::new(),
258            },
259        )?;
260    }
261    Ok(count)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn a_value_survives_a_write_and_a_read() {
270        let dir = tempfile::tempdir().unwrap();
271        let path = dir.path().join("kv.json");
272        set(&path, "bark.cooldown", "30s").unwrap();
273        assert_eq!(
274            get(&path, "bark.cooldown").unwrap(),
275            Some("30s".to_string())
276        );
277    }
278
279    #[test]
280    fn a_store_that_does_not_exist_reads_as_empty() {
281        let dir = tempfile::tempdir().unwrap();
282        let path = dir.path().join("kv.json");
283        assert!(all(&path).unwrap().is_empty());
284        assert_eq!(get(&path, "anything").unwrap(), None);
285    }
286
287    #[test]
288    fn unset_reports_whether_the_key_was_there() {
289        let dir = tempfile::tempdir().unwrap();
290        let path = dir.path().join("kv.json");
291        set(&path, "a", "1").unwrap();
292        assert!(unset(&path, "a").unwrap());
293        assert!(!unset(&path, "a").unwrap());
294    }
295
296    #[test]
297    fn clear_empties_the_store_and_counts_what_it_took() {
298        let dir = tempfile::tempdir().unwrap();
299        let path = dir.path().join("kv.json");
300        set(&path, "a", "1").unwrap();
301        set(&path, "b", "2").unwrap();
302        assert_eq!(clear(&path).unwrap(), 2);
303        assert!(all(&path).unwrap().is_empty());
304        assert_eq!(clear(&path).unwrap(), 0);
305    }
306
307    /// A key goes onto a shell command line (`shep get $k`) and into a JSON
308    /// object, so whitespace, control characters and an empty name are
309    /// refused.
310    #[test]
311    fn the_key_grammar_refuses_what_it_says_it_refuses() {
312        let dir = tempfile::tempdir().unwrap();
313        let path = dir.path().join("kv.json");
314        for bad in [
315            "", " ", "a b", "a\nb", "a/b", "a:b", ".hidden", "a\"b", "$HOME",
316        ] {
317            assert!(
318                matches!(set(&path, bad, "1"), Err(KvError::InvalidKey(_))),
319                "`{bad}` was accepted as a key"
320            );
321        }
322        for good in ["a", "bark.cooldown", "metrics_port", "a-b", "A1.b-c_d"] {
323            assert!(set(&path, good, "1").is_ok(), "`{good}` was refused");
324        }
325    }
326
327    #[test]
328    fn a_dotted_key_is_one_flat_key_and_not_a_path() {
329        let dir = tempfile::tempdir().unwrap();
330        let path = dir.path().join("kv.json");
331        set(&path, "bark.cooldown", "30s").unwrap();
332        set(&path, "bark.sink", "discord").unwrap();
333        let stored = all(&path).unwrap();
334        assert_eq!(stored.len(), 2);
335        assert!(stored.contains_key("bark.cooldown"));
336        assert_eq!(get(&path, "bark").unwrap(), None);
337        // And on disk, not just in the map: a nested writer would produce
338        // `{"bark":{"cooldown":…}}` and this is what notices.
339        let raw = std::fs::read_to_string(&path).unwrap();
340        assert!(raw.contains(r#""bark.cooldown""#), "{raw}");
341    }
342
343    #[test]
344    fn an_oversized_value_is_refused_by_name_and_length() {
345        let dir = tempfile::tempdir().unwrap();
346        let path = dir.path().join("kv.json");
347        let big = "x".repeat(MAX_VALUE_BYTES + 1);
348        let err = set(&path, "a", &big).unwrap_err();
349        let KvError::ValueTooLong { key, len } = err else {
350            panic!("expected ValueTooLong, got {err:?}");
351        };
352        assert_eq!(key, "a");
353        assert_eq!(len, MAX_VALUE_BYTES + 1);
354    }
355
356    #[test]
357    fn a_store_from_a_future_shep_is_refused_rather_than_replaced() {
358        let dir = tempfile::tempdir().unwrap();
359        let path = dir.path().join("kv.json");
360        std::fs::write(&path, r#"{"version":99,"entries":{"a":"1"}}"#).unwrap();
361        assert!(matches!(all(&path), Err(KvError::FutureVersion(99))));
362        assert!(matches!(
363            set(&path, "b", "2"),
364            Err(KvError::FutureVersion(99))
365        ));
366        // Untouched, which is the half that matters.
367        let raw = std::fs::read_to_string(&path).unwrap();
368        assert!(raw.contains(r#""a":"1""#), "{raw}");
369    }
370
371    /// `$SHEP_HOME` is already `0700`; this guards the mode a `tar`, a
372    /// `cp -p` or a backup carries out with the file, where no directory
373    /// mode follows.
374    #[cfg(unix)]
375    #[test]
376    fn the_store_is_owner_only() {
377        use std::os::unix::fs::PermissionsExt as _;
378        let dir = tempfile::tempdir().unwrap();
379        let path = dir.path().join("kv.json");
380        set(&path, "a", "1").unwrap();
381        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
382        assert_eq!(mode, 0o600, "{mode:o}");
383    }
384
385    /// Bounded: each join is under a timeout, so a lock that deadlocks fails
386    /// this test instead of hanging the suite.
387    #[test]
388    fn two_concurrent_writers_lose_nothing() {
389        let dir = tempfile::tempdir().unwrap();
390        let path = dir.path().join("kv.json");
391        const PER_WRITER: usize = 100;
392
393        let (done_tx, done_rx) = std::sync::mpsc::channel();
394        for writer in 0..2 {
395            let path = path.clone();
396            let done_tx = done_tx.clone();
397            std::thread::spawn(move || {
398                for n in 0..PER_WRITER {
399                    set(&path, &format!("w{writer}.k{n}"), "v").unwrap();
400                }
401                done_tx.send(()).unwrap();
402            });
403        }
404        drop(done_tx);
405        for _ in 0..2 {
406            done_rx
407                .recv_timeout(std::time::Duration::from_secs(60))
408                .expect("a writer did not finish within 60s");
409        }
410
411        assert_eq!(all(&path).unwrap().len(), PER_WRITER * 2);
412    }
413}