keyhog_core/
state_file.rs1use fs2::FileExt;
5use std::ffi::OsString;
6use std::fs::{File, OpenOptions};
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10pub struct StateFileWriteLock {
17 file: File,
18}
19
20impl StateFileWriteLock {
21 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); 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
66pub 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
79pub const DEFAULT_TMP_PREFIX: &str = ".tmp.keyhog-";
81
82pub(crate) const CALIBRATION_CACHE_FILE_BYTES: u64 = 16 * 1024 * 1024;
87
88pub(crate) const RULE_CONFIG_FILE_BYTES: u64 = 16 * 1024 * 1024;
93
94pub(crate) const MERKLE_INDEX_CACHE_FILE_BYTES: u64 = 512 * 1024 * 1024;
100
101pub 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
129pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
136 write_atomically_with_prefix(path, DEFAULT_TMP_PREFIX, bytes)
137}
138
139pub 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
151pub 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
166pub 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
188pub 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 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 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}