Skip to main content

keyhog_core/
state_file.rs

1//! Bounded reads and atomic durable writes for on-disk KeyHog state artifacts
2//! (calibration cache, merkle index, compiled matcher artifacts, 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        loop {
39            let file = options.open(&lock_path)?;
40            file.lock_exclusive()?;
41            #[cfg(unix)]
42            {
43                use std::os::unix::fs::MetadataExt;
44                if let (Ok(meta1), Ok(meta2)) = (file.metadata(), std::fs::metadata(&lock_path)) {
45                    if meta1.ino() == meta2.ino() && meta1.dev() == meta2.dev() && meta1.nlink() > 0
46                    {
47                        return Ok(Self { file });
48                    }
49                    let _ = FileExt::unlock(&file); // LAW10: the retained handle is dropped on the next iteration, which releases the OS lock; no runtime effect
50                    continue;
51                }
52            }
53            return Ok(Self { file });
54        }
55    }
56}
57
58impl Drop for StateFileWriteLock {
59    fn drop(&mut self) {
60        if let Err(error) = FileExt::unlock(&self.file) {
61            tracing::warn!(%error, "failed to unlock KeyHog state file; closing the lock file will release the OS lock");
62        }
63    }
64}
65
66/// Canonical sibling lock filename for a KeyHog state artifact.
67pub fn state_file_lock_path(state_path: &Path) -> std::io::Result<PathBuf> {
68    let Some(base_name) = state_path.file_name() else {
69        return Err(std::io::Error::new(
70            std::io::ErrorKind::InvalidInput,
71            format!("state path '{}' has no file name", state_path.display()),
72        ));
73    };
74    let mut file_name = OsString::from(base_name);
75    file_name.push(".lock");
76    Ok(state_path.with_file_name(file_name))
77}
78
79/// Default temp-file name prefix for atomic state writes.
80pub const DEFAULT_TMP_PREFIX: &str = ".tmp.keyhog-";
81
82/// Maximum on-disk calibration cache (`calibration.json`) size.
83///
84/// The artifact holds one `{alpha, beta}` pair per detector id, control-plane
85/// data, not scan input. Multi-megabyte calibration files are corrupt or hostile.
86pub(crate) const CALIBRATION_CACHE_FILE_BYTES: u64 = 16 * 1024 * 1024;
87
88/// Maximum size of a user-authored config file read wholesale into memory
89/// suppression rules (`.keyhogignore`/rule-filter TOML) and allowlists. These are
90/// hand-authored control-plane data; a multi-megabyte one is corrupt or a
91/// resource-exhaustion vector, so the wholesale read is bounded like the caches.
92pub(crate) const RULE_CONFIG_FILE_BYTES: u64 = 16 * 1024 * 1024;
93
94/// Maximum on-disk merkle index cache file size.
95///
96/// The JSON index stores `(path, chunk_offset, mtime, size, hash)` rows. Large
97/// monorepo caches can reach hundreds of MB; this bound still refuses
98/// multi-gigabyte hostile files in the state directory.
99pub(crate) const MERKLE_INDEX_CACHE_FILE_BYTES: u64 = 512 * 1024 * 1024;
100
101/// Read a state artifact through a metadata pre-check and a TOCTOU-safe cap.
102pub fn read_capped(path: &Path, cap: u64, kind: &str) -> std::io::Result<Vec<u8>> {
103    let file = std::fs::File::open(path)?;
104    let len = file.metadata()?.len();
105    if len > cap {
106        return Err(std::io::Error::new(
107            std::io::ErrorKind::InvalidData,
108            format!(
109                "{kind} {} exceeds {cap} byte cap; delete the cache file and rerun",
110                path.display()
111            ),
112        ));
113    }
114
115    let mut data = Vec::with_capacity(len as usize);
116    file.take(cap.saturating_add(1)).read_to_end(&mut data)?;
117    if data.len() as u64 > cap {
118        return Err(std::io::Error::new(
119            std::io::ErrorKind::InvalidData,
120            format!(
121                "{kind} {} grew past {cap} byte cap while reading; retry after the file is stable",
122                path.display()
123            ),
124        ));
125    }
126    Ok(data)
127}
128
129/// Atomically replace `path` with `bytes` via a same-directory temp file.
130///
131/// Single owner for the create-dir / prefixed-tempfile / sync / rename dance
132/// that all KeyHog state artifacts, caches, and scanner artifacts persist through.
133/// A parentless or empty path resolves to the current directory (`.`) so a bare
134/// output filename saves cleanly instead of failing `create_dir_all("")`.
135pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
136    write_atomically_with_prefix(path, DEFAULT_TMP_PREFIX, bytes)
137}
138
139/// Atomically replace `path` with `bytes` using a custom temp-file prefix.
140pub fn write_atomically_with_prefix(
141    path: &Path,
142    prefix: &str,
143    bytes: &[u8],
144) -> std::io::Result<()> {
145    write_atomically_with_writer_and_prefix(path, prefix, |tmp| {
146        use std::io::Write as _;
147        tmp.write_all(bytes)
148    })
149}
150
151/// Atomically create or replace `path` by executing a writer closure against a
152/// same-directory temp file.
153///
154/// The temp file is synced to disk via [`std::fs::File::sync_all`] before atomic
155/// rename onto `path`. If `writer` returns an error or panics, the temporary file
156/// is automatically dropped and unlinked by [`tempfile::NamedTempFile`]'s `Drop`
157/// implementation, preventing partially-written files from corrupting state or
158/// leaking stale artifacts.
159pub fn write_atomically_with_writer<F>(path: &Path, writer: F) -> std::io::Result<()>
160where
161    F: FnOnce(&mut tempfile::NamedTempFile) -> std::io::Result<()>,
162{
163    write_atomically_with_writer_and_prefix(path, DEFAULT_TMP_PREFIX, writer)
164}
165
166/// Atomically create or replace `path` with a custom temp-file prefix via a writer closure.
167pub fn write_atomically_with_writer_and_prefix<F>(
168    path: &Path,
169    prefix: &str,
170    writer: F,
171) -> std::io::Result<()>
172where
173    F: FnOnce(&mut tempfile::NamedTempFile) -> std::io::Result<()>,
174{
175    let parent = match path.parent().filter(|p| !p.as_os_str().is_empty()) {
176        Some(parent) => parent,
177        None => Path::new("."),
178    };
179    std::fs::create_dir_all(parent)?;
180    let mut tmp = tempfile::Builder::new()
181        .prefix(prefix)
182        .tempfile_in(parent)?;
183    writer(&mut tmp)?;
184    tmp.as_file().sync_all()?;
185    tmp.persist(path).map(drop).map_err(|e| e.error)
186}
187
188/// Best-effort sweep of stale temp files left beside `cache_path` by a
189/// SIGKILL'd process (`tempfile`'s Drop cleans up on panic but not on signal).
190///
191/// Single owner for the sweep both the calibration cache and the merkle index
192/// perform. Deliberately conservative: only files whose name starts with one of
193/// the keyhog-owned `prefixes` AND older than `cutoff_secs` are removed, so a
194/// peer process's in-flight save or an unrelated file is never touched. Returns
195/// the number of files removed; callers own their summary logging.
196pub fn sweep_stale_tmp_siblings(cache_path: &Path, prefixes: &[&str], cutoff_secs: u64) -> usize {
197    let Some(parent) = cache_path.parent() else {
198        return 0;
199    };
200    let Ok(entries) = std::fs::read_dir(parent) else {
201        return 0;
202    };
203    let now = std::time::SystemTime::now();
204    let mut swept = 0usize;
205    for entry in entries {
206        let entry = match entry {
207            Ok(entry) => entry,
208            // Best-effort maintenance: a failed dir-entry read drops no scan
209            // coverage, so skip the entry rather than aborting the sweep.
210            Err(error) => {
211                tracing::warn!(dir = %parent.display(), %error, "skip unreadable tmp dir entry during stale-state sweep");
212                continue;
213            }
214        };
215        let name = entry.file_name();
216        let Some(name_str) = name.to_str() else {
217            continue;
218        };
219        if !prefixes.iter().any(|p| name_str.starts_with(p)) {
220            continue;
221        }
222        let path = entry.path();
223        if path == cache_path {
224            continue;
225        }
226        let Ok(meta) = path.metadata() else {
227            continue;
228        };
229        let Ok(modified) = meta.modified() else {
230            continue;
231        };
232        // A future mtime (clock skew) means "don't delete this one yet".
233        let Ok(age) = now.duration_since(modified) else {
234            continue;
235        };
236        if age.as_secs() < cutoff_secs {
237            continue;
238        }
239        if std::fs::remove_file(&path).is_ok() {
240            swept += 1;
241        }
242    }
243    swept
244}