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