Skip to main content

keyhog_core/
state_file.rs

1//! Bounded reads for on-disk KeyHog state artifacts (calibration cache,
2//! merkle index, etc.).
3
4use fs2::FileExt;
5use std::ffi::OsString;
6use std::fs::{File, OpenOptions};
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10/// Exclusive advisory lock held across a state file's read/merge/write cycle.
11///
12/// The sibling `<filename>.lock` file is stable; the operating-system lock is
13/// released automatically when this value is dropped, including after a panic
14/// or process exit. Keeping one implementation here prevents state caches from
15/// independently reintroducing lost-update races.
16pub struct StateFileWriteLock {
17    file: File,
18}
19
20impl StateFileWriteLock {
21    /// Acquire the canonical sibling lock for `state_path`.
22    pub fn acquire(state_path: &Path) -> std::io::Result<Self> {
23        let lock_path = state_file_lock_path(state_path)?;
24        let parent = lock_path.parent().ok_or_else(|| {
25            std::io::Error::new(
26                std::io::ErrorKind::InvalidInput,
27                "state-file lock path has no parent directory",
28            )
29        })?;
30        std::fs::create_dir_all(parent)?;
31        let mut options = OpenOptions::new();
32        options.create(true).read(true).write(true);
33        #[cfg(unix)]
34        {
35            use std::os::unix::fs::OpenOptionsExt;
36            options.mode(0o600);
37        }
38        let file = options.open(lock_path)?;
39        file.lock_exclusive()?;
40        Ok(Self { file })
41    }
42}
43
44impl Drop for StateFileWriteLock {
45    fn drop(&mut self) {
46        if let Err(error) = FileExt::unlock(&self.file) {
47            tracing::warn!(%error, "failed to unlock KeyHog state file; closing the lock file will release the OS lock");
48        }
49    }
50}
51
52/// Canonical sibling lock filename for a KeyHog state artifact.
53pub fn state_file_lock_path(state_path: &Path) -> std::io::Result<PathBuf> {
54    let Some(base_name) = state_path.file_name() else {
55        return Err(std::io::Error::new(
56            std::io::ErrorKind::InvalidInput,
57            format!("state path '{}' has no file name", state_path.display()),
58        ));
59    };
60    let mut file_name = OsString::from(base_name);
61    file_name.push(".lock");
62    Ok(state_path.with_file_name(file_name))
63}
64
65/// Maximum on-disk calibration cache (`calibration.json`) size.
66///
67/// The artifact holds one `{alpha, beta}` pair per detector id, control-plane
68/// data, not scan input. Multi-megabyte calibration files are corrupt or hostile.
69pub(crate) const CALIBRATION_CACHE_FILE_BYTES: u64 = 16 * 1024 * 1024;
70
71/// Maximum size of a user-authored config file read wholesale into memory
72/// suppression rules (`.keyhogignore`/rule-filter TOML) and allowlists. These are
73/// hand-authored control-plane data; a multi-megabyte one is corrupt or a
74/// resource-exhaustion vector, so the wholesale read is bounded like the caches.
75pub(crate) const RULE_CONFIG_FILE_BYTES: u64 = 16 * 1024 * 1024;
76
77/// Maximum on-disk merkle index cache file size.
78///
79/// The JSON index stores `(path, chunk_offset, mtime, size, hash)` rows. Large
80/// monorepo caches can reach hundreds of MB; this bound still refuses
81/// multi-gigabyte hostile files in the state directory.
82pub(crate) const MERKLE_INDEX_CACHE_FILE_BYTES: u64 = 512 * 1024 * 1024;
83
84/// Read a state artifact through a metadata pre-check and a TOCTOU-safe cap.
85pub(crate) fn read_capped(path: &Path, cap: u64, kind: &str) -> std::io::Result<Vec<u8>> {
86    let file = std::fs::File::open(path)?;
87    let len = file.metadata()?.len();
88    if len > cap {
89        return Err(std::io::Error::new(
90            std::io::ErrorKind::InvalidData,
91            format!(
92                "{kind} {} exceeds {cap} byte cap; delete the cache file and rerun",
93                path.display()
94            ),
95        ));
96    }
97
98    let mut data = Vec::with_capacity(len as usize);
99    file.take(cap.saturating_add(1)).read_to_end(&mut data)?;
100    if data.len() as u64 > cap {
101        return Err(std::io::Error::new(
102            std::io::ErrorKind::InvalidData,
103            format!(
104                "{kind} {} grew past {cap} byte cap while reading; retry after the file is stable",
105                path.display()
106            ),
107        ));
108    }
109    Ok(data)
110}
111
112/// Atomically replace `path` with `bytes` via a same-directory temp file.
113///
114/// Single owner for the create-dir / prefixed-tempfile / fsync / rename dance
115/// that the calibration cache and the merkle index both persist through. The
116/// `prefix` is the temp-file name prefix so each caller's stale-tmp sweep can
117/// still recognize its own orphans by name. A parentless or empty path resolves
118/// to the current directory so a bare `calibration.json` filename saves cleanly
119/// instead of failing `create_dir_all("")`.
120pub(crate) fn write_atomically(path: &Path, prefix: &str, bytes: &[u8]) -> std::io::Result<()> {
121    let parent = match path.parent().filter(|p| !p.as_os_str().is_empty()) {
122        Some(parent) => parent,
123        None => Path::new("."),
124    };
125    std::fs::create_dir_all(parent)?;
126    let mut tmp = tempfile::Builder::new()
127        .prefix(prefix)
128        .tempfile_in(parent)?;
129    std::io::Write::write_all(&mut tmp, bytes)?;
130    tmp.as_file().sync_all()?;
131    tmp.persist(path).map_err(|e| e.error)?;
132    Ok(())
133}
134
135/// Best-effort sweep of stale temp files left beside `cache_path` by a
136/// SIGKILL'd process (`tempfile`'s Drop cleans up on panic but not on signal).
137///
138/// Single owner for the sweep both the calibration cache and the merkle index
139/// perform. Deliberately conservative: only files whose name starts with one of
140/// the keyhog-owned `prefixes` AND older than `cutoff_secs` are removed, so a
141/// peer process's in-flight save or an unrelated file is never touched. Returns
142/// the number of files removed; callers own their summary logging.
143pub(crate) fn sweep_stale_tmp_siblings(
144    cache_path: &Path,
145    prefixes: &[&str],
146    cutoff_secs: u64,
147) -> usize {
148    let Some(parent) = cache_path.parent() else {
149        return 0;
150    };
151    let Ok(entries) = std::fs::read_dir(parent) else {
152        return 0;
153    };
154    let now = std::time::SystemTime::now();
155    let mut swept = 0usize;
156    for entry in entries {
157        let entry = match entry {
158            Ok(entry) => entry,
159            // Best-effort maintenance: a failed dir-entry read drops no scan
160            // coverage, so skip the entry rather than aborting the sweep.
161            Err(error) => {
162                tracing::warn!(dir = %parent.display(), %error, "skip unreadable tmp dir entry during stale-state sweep");
163                continue;
164            }
165        };
166        let name = entry.file_name();
167        let Some(name_str) = name.to_str() else {
168            continue;
169        };
170        if !prefixes.iter().any(|p| name_str.starts_with(p)) {
171            continue;
172        }
173        let path = entry.path();
174        if path == cache_path {
175            continue;
176        }
177        let Ok(meta) = path.metadata() else {
178            continue;
179        };
180        let Ok(modified) = meta.modified() else {
181            continue;
182        };
183        // A future mtime (clock skew) means "don't delete this one yet".
184        let Ok(age) = now.duration_since(modified) else {
185            continue;
186        };
187        if age.as_secs() < cutoff_secs {
188            continue;
189        }
190        if std::fs::remove_file(&path).is_ok() {
191            swept += 1;
192        }
193    }
194    swept
195}