kernel/persistence.rs
1//! On-disk JSON store substrate: atomic writes and corruption quarantine.
2//!
3//! Every persisted store in the kernel routes through these helpers so the two
4//! invariants hold everywhere: writes are atomic for readers (a crash mid-write
5//! cannot leave a half-written file), and an unreadable file is quarantined
6//! rather than silently reset or skipped.
7
8use std::fs::{self, File};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15
16/// Errors raised by the JSON-on-disk store helpers.
17#[derive(Debug, thiserror::Error)]
18pub enum StoreError {
19 /// A filesystem operation failed.
20 #[error("store io error: {0}")]
21 Io(#[from] io::Error),
22
23 /// A value could not be serialized to JSON.
24 #[error("store encode error: {0}")]
25 Encode(#[source] serde_json::Error),
26
27 /// The file existed but could not be decoded. The corruption signal is never
28 /// lost: `quarantined` is `Some(path)` when the file was successfully moved
29 /// aside, or `None` when quarantine itself failed (and the file was left in
30 /// place). Either way the caller learns the store was corrupt.
31 #[error("corrupt store at {path}: {source}")]
32 Corrupt {
33 /// The original path the corrupt file occupied.
34 path: PathBuf,
35 /// Where the corrupt file was moved, or `None` if quarantine failed.
36 quarantined: Option<PathBuf>,
37 /// The decode error that triggered quarantine.
38 #[source]
39 source: serde_json::Error,
40 },
41}
42
43static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
44
45/// A suffix unique across processes and threads, used to name temp and
46/// quarantine siblings so they never collide — the PID separates processes, the
47/// atomic counter separates threads within a process, and the timestamp orders
48/// them roughly in time.
49fn unique_suffix() -> String {
50 let unique = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
51 format!(
52 "{}-{}-{unique}",
53 std::process::id(),
54 crate::time::now_millis()
55 )
56}
57
58fn directory_of(path: &Path) -> PathBuf {
59 match path.parent() {
60 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
61 _ => PathBuf::from("."),
62 }
63}
64
65fn file_name_of(path: &Path) -> &str {
66 path.file_name()
67 .and_then(|name| name.to_str())
68 .unwrap_or("store")
69}
70
71/// Serialize `value` as pretty-printed JSON and write it atomically to `path`.
72pub fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), StoreError> {
73 let bytes = serde_json::to_vec_pretty(value).map_err(StoreError::Encode)?;
74 write_atomic(path, &bytes)?;
75 Ok(())
76}
77
78/// Read and decode JSON from `path`.
79///
80/// Returns `Ok(None)` when the file does not exist. When the file exists but
81/// cannot be decoded, it is quarantined and [`StoreError::Corrupt`] is returned —
82/// the unreadable bytes are never silently discarded or overwritten. If the
83/// quarantine move itself fails, the error still reports the corruption (with
84/// `quarantined: None`) so the signal is never lost.
85pub fn read_json<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, StoreError> {
86 let bytes = match fs::read(path) {
87 Ok(bytes) => bytes,
88 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
89 Err(err) => return Err(StoreError::Io(err)),
90 };
91 match serde_json::from_slice::<T>(&bytes) {
92 Ok(value) => Ok(Some(value)),
93 Err(source) => Err(StoreError::Corrupt {
94 path: path.to_path_buf(),
95 quarantined: quarantine(path).ok(),
96 source,
97 }),
98 }
99}
100
101/// Write `bytes` to `path` atomically.
102///
103/// The low-level primitive beneath [`write_json_atomic`]; it returns a bare
104/// [`io::Result`] because no encoding is involved. Creates any missing parent
105/// directories, writes a sibling temp file, flushes it, then renames it over the
106/// destination. The rename is atomic on the same filesystem, so readers observe
107/// either the old file or the fully written new one — never a partial write. The
108/// temp file is removed on every failure path, and the parent directory is
109/// flushed after a successful rename for durability (best-effort).
110pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
111 let directory = directory_of(path);
112 fs::create_dir_all(&directory)?;
113 let temp = temp_sibling(path);
114 if let Err(err) = write_temp_then_rename(&temp, path, bytes) {
115 let _ = fs::remove_file(&temp);
116 return Err(err);
117 }
118 let _ = File::open(&directory).and_then(|dir| dir.sync_all());
119 Ok(())
120}
121
122fn write_temp_then_rename(temp: &Path, path: &Path, bytes: &[u8]) -> io::Result<()> {
123 {
124 let mut file = File::create(temp)?;
125 file.write_all(bytes)?;
126 file.sync_all()?;
127 }
128 fs::rename(temp, path)
129}
130
131/// Rename a corrupt file to a sibling `<name>.corrupt-<pid>-<epoch-millis>-<n>`
132/// and return the new path. The low-level primitive beneath [`read_json`]; it
133/// returns a bare [`io::Result`] and leaves the original path free for a fresh
134/// default to take.
135pub fn quarantine(path: &Path) -> io::Result<PathBuf> {
136 let target = directory_of(path).join(format!(
137 "{}.corrupt-{}",
138 file_name_of(path),
139 unique_suffix()
140 ));
141 fs::rename(path, &target)?;
142 Ok(target)
143}
144
145fn temp_sibling(path: &Path) -> PathBuf {
146 directory_of(path).join(format!(".{}.tmp-{}", file_name_of(path), unique_suffix()))
147}