1use core::fmt;
16use std::collections::BTreeMap;
17use std::path::Path;
18
19use serde::{Deserialize, Serialize};
20
21use crate::file_lock::FileLock;
22
23pub const KV_VERSION: u32 = 1;
29
30pub const MAX_KEY_BYTES: usize = 128;
32
33pub const MAX_VALUE_BYTES: usize = 4096;
38
39#[derive(Debug, Default, Serialize, Deserialize)]
44struct KvFile {
45 version: u32,
46 entries: BTreeMap<String, String>,
47}
48
49#[non_exhaustive]
59#[derive(Debug)]
60pub enum KvError {
61 Io(std::io::Error),
63 Decode(serde_json::Error),
68 InvalidKey(String),
71 ValueTooLong {
73 key: String,
75 len: usize,
77 },
78 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
125fn 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
144fn 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
162fn write_file(path: &Path, file: &KvFile) -> Result<(), KvError> {
164 crate::atomic_file::write_json(path, "kv", file).map_err(KvError::Io)
165}
166
167pub fn all(path: &Path) -> Result<BTreeMap<String, String>, KvError> {
177 let _lock = FileLock::acquire(path)?;
180 Ok(read_file(path)?.entries)
181}
182
183pub fn get(path: &Path, key: &str) -> Result<Option<String>, KvError> {
191 check_key(key)?;
192 Ok(all(path)?.remove(key))
193}
194
195pub 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
222pub 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
241pub 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 #[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 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 let raw = std::fs::read_to_string(&path).unwrap();
368 assert!(raw.contains(r#""a":"1""#), "{raw}");
369 }
370
371 #[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 #[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}