use fs2::FileExt;
use std::ffi::OsString;
use std::fs::{File, OpenOptions};
use std::io::Read;
use std::path::{Path, PathBuf};
pub struct StateFileWriteLock {
file: File,
}
impl StateFileWriteLock {
pub fn acquire(state_path: &Path) -> std::io::Result<Self> {
let lock_path = state_file_lock_path(state_path)?;
let parent = lock_path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"state-file lock path has no parent directory",
)
})?;
std::fs::create_dir_all(parent)?;
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(lock_path)?;
file.lock_exclusive()?;
Ok(Self { file })
}
}
impl Drop for StateFileWriteLock {
fn drop(&mut self) {
if let Err(error) = FileExt::unlock(&self.file) {
tracing::warn!(%error, "failed to unlock KeyHog state file; closing the lock file will release the OS lock");
}
}
}
pub fn state_file_lock_path(state_path: &Path) -> std::io::Result<PathBuf> {
let Some(base_name) = state_path.file_name() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("state path '{}' has no file name", state_path.display()),
));
};
let mut file_name = OsString::from(base_name);
file_name.push(".lock");
Ok(state_path.with_file_name(file_name))
}
pub(crate) const CALIBRATION_CACHE_FILE_BYTES: u64 = 16 * 1024 * 1024;
pub(crate) const RULE_CONFIG_FILE_BYTES: u64 = 16 * 1024 * 1024;
pub(crate) const MERKLE_INDEX_CACHE_FILE_BYTES: u64 = 512 * 1024 * 1024;
pub(crate) fn read_capped(path: &Path, cap: u64, kind: &str) -> std::io::Result<Vec<u8>> {
let file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
if len > cap {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{kind} {} exceeds {cap} byte cap; delete the cache file and rerun",
path.display()
),
));
}
let mut data = Vec::with_capacity(len as usize);
file.take(cap.saturating_add(1)).read_to_end(&mut data)?;
if data.len() as u64 > cap {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{kind} {} grew past {cap} byte cap while reading; retry after the file is stable",
path.display()
),
));
}
Ok(data)
}
pub(crate) fn write_atomically(path: &Path, prefix: &str, bytes: &[u8]) -> std::io::Result<()> {
let parent = match path.parent().filter(|p| !p.as_os_str().is_empty()) {
Some(parent) => parent,
None => Path::new("."),
};
std::fs::create_dir_all(parent)?;
let mut tmp = tempfile::Builder::new()
.prefix(prefix)
.tempfile_in(parent)?;
std::io::Write::write_all(&mut tmp, bytes)?;
tmp.as_file().sync_all()?;
tmp.persist(path).map_err(|e| e.error)?;
Ok(())
}
pub(crate) fn sweep_stale_tmp_siblings(
cache_path: &Path,
prefixes: &[&str],
cutoff_secs: u64,
) -> usize {
let Some(parent) = cache_path.parent() else {
return 0;
};
let Ok(entries) = std::fs::read_dir(parent) else {
return 0;
};
let now = std::time::SystemTime::now();
let mut swept = 0usize;
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(dir = %parent.display(), %error, "skip unreadable tmp dir entry during stale-state sweep");
continue;
}
};
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if !prefixes.iter().any(|p| name_str.starts_with(p)) {
continue;
}
let path = entry.path();
if path == cache_path {
continue;
}
let Ok(meta) = path.metadata() else {
continue;
};
let Ok(modified) = meta.modified() else {
continue;
};
let Ok(age) = now.duration_since(modified) else {
continue;
};
if age.as_secs() < cutoff_secs {
continue;
}
if std::fs::remove_file(&path).is_ok() {
swept += 1;
}
}
swept
}