Skip to main content

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}