1use 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
24pub const KV_VERSION: u32 = 1;
30
31pub const MAX_KEY_BYTES: usize = 128;
33
34pub const MAX_VALUE_BYTES: usize = 4096;
39
40#[derive(Debug, Default, Serialize, Deserialize)]
45struct KvFile {
46 version: u32,
47 entries: BTreeMap<String, String>,
48}
49
50#[non_exhaustive]
60#[derive(Debug)]
61pub enum KvError {
62 Io(std::io::Error),
64 Decode(serde_json::Error),
69 InvalidKey(String),
72 ValueTooLong {
74 key: String,
76 len: usize,
78 },
79 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
126fn 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
145fn 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
163fn 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 tmp.persist(path).map_err(|err| KvError::Io(err.error))?;
178
179 crate::atomic_file::sync_dir(parent)?;
182 Ok(())
183}
184
185pub fn all(path: &Path) -> Result<BTreeMap<String, String>, KvError> {
195 let _lock = FileLock::acquire(path)?;
198 Ok(read_file(path)?.entries)
199}
200
201pub fn get(path: &Path, key: &str) -> Result<Option<String>, KvError> {
209 check_key(key)?;
210 Ok(all(path)?.remove(key))
211}
212
213pub 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
240pub 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
259pub 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 #[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 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 let raw = std::fs::read_to_string(&path).unwrap();
386 assert!(raw.contains(r#""a":"1""#), "{raw}");
387 }
388
389 #[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 #[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}