1use core::fmt;
15use std::collections::BTreeMap;
16use std::io::Write as _;
17use std::path::Path;
18#[cfg(any(unix, windows))]
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25pub const KV_VERSION: u32 = 1;
31
32pub const MAX_KEY_BYTES: usize = 128;
34
35pub const MAX_VALUE_BYTES: usize = 4096;
40
41#[derive(Debug, Default, Serialize, Deserialize)]
46struct KvFile {
47 version: u32,
48 entries: BTreeMap<String, String>,
49}
50
51#[non_exhaustive]
61#[derive(Debug)]
62pub enum KvError {
63 Io(std::io::Error),
65 Decode(serde_json::Error),
70 InvalidKey(String),
73 ValueTooLong {
75 key: String,
77 len: usize,
79 },
80 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
127fn 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#[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
163struct KvLock {
169 #[cfg(unix)]
172 _flock: nix::fcntl::Flock<std::fs::File>,
173 #[cfg(windows)]
178 _handle: std::fs::File,
179}
180
181impl KvLock {
182 #[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 #[cfg(windows)]
217 fn acquire(path: &Path) -> std::io::Result<Self> {
218 use std::os::windows::fs::OpenOptionsExt as _;
219
220 const ERROR_SHARING_VIOLATION: i32 = 32;
225
226 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
251fn 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
269fn 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 tmp.persist(path).map_err(|err| KvError::Io(err.error))?;
284
285 crate::atomic_file::sync_dir(parent)?;
288 Ok(())
289}
290
291pub fn all(path: &Path) -> Result<BTreeMap<String, String>, KvError> {
301 let _lock = KvLock::acquire(path)?;
304 Ok(read_file(path)?.entries)
305}
306
307pub fn get(path: &Path, key: &str) -> Result<Option<String>, KvError> {
315 check_key(key)?;
316 Ok(all(path)?.remove(key))
317}
318
319pub 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
346pub 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
365pub 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 #[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 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 let raw = std::fs::read_to_string(&path).unwrap();
492 assert!(raw.contains(r#""a":"1""#), "{raw}");
493 }
494
495 #[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 #[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}