use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
pub const MAX_FINDINGS: usize = 512;
pub const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
const PRUNE_INTERVAL: usize = 32;
static STORE_COUNT: AtomicUsize = AtomicUsize::new(0);
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FindingRecord {
pub finding_id: String,
pub captured_at: String,
pub churn_layer: String,
pub churn_class: String,
pub divergence_offset: u64,
pub churn_byte_len: u64,
pub churn_block_index: u64,
pub block: String,
}
pub fn findings_dir() -> PathBuf {
crate::config::openlatch_dir()
.join("boundary")
.join("findings")
}
pub fn store(record: &FindingRecord) {
if tokio::runtime::Handle::try_current().is_ok() {
let record = record.clone();
tokio::task::spawn_blocking(move || write_finding(&record));
} else {
write_finding(record);
}
}
fn write_finding(record: &FindingRecord) {
let dir = findings_dir();
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let path = dir.join(format!("{}.json", sanitize(&record.finding_id)));
if let Ok(json) = serde_json::to_string(record) {
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
if STORE_COUNT
.fetch_add(1, Ordering::Relaxed)
.is_multiple_of(PRUNE_INTERVAL)
{
prune(&dir);
}
}
pub fn load(finding_id: &str) -> Option<FindingRecord> {
let dir = findings_dir();
let path = dir.join(format!("{}.json", sanitize(finding_id)));
let bytes = std::fs::read(&path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn prune(dir: &std::path::Path) {
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
let now = SystemTime::now();
let mut entries: Vec<(SystemTime, PathBuf)> = Vec::new();
for e in read.flatten() {
let path = e.path();
if path.extension().and_then(|x| x.to_str()) != Some("json") {
continue;
}
let modified = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
if now
.duration_since(modified)
.map(|age| age > MAX_AGE)
.unwrap_or(false)
{
let _ = std::fs::remove_file(&path);
continue;
}
entries.push((modified, path));
}
if entries.len() > MAX_FINDINGS {
entries.sort_by_key(|(t, _)| *t);
let excess = entries.len() - MAX_FINDINGS;
for (_, path) in entries.into_iter().take(excess) {
let _ = std::fs::remove_file(&path);
}
}
}
fn sanitize(id: &str) -> String {
id.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(128)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(id: &str) -> FindingRecord {
FindingRecord {
finding_id: id.to_string(),
captured_at: "2026-07-23T00:00:00Z".to_string(),
churn_layer: "messages".to_string(),
churn_class: "timestamp".to_string(),
divergence_offset: 10,
churn_byte_len: 20,
churn_block_index: 1,
block: "the local block content".to_string(),
}
}
#[test]
fn sanitize_strips_path_traversal() {
assert_eq!(sanitize("../../etc/passwd"), "etcpasswd");
assert_eq!(sanitize("fnd_abc-123"), "fnd_abc-123");
}
#[test]
fn store_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("OPENLATCH_DIR", tmp.path());
let r = rec("fnd_roundtrip");
store(&r);
let got = load("fnd_roundtrip").expect("finding must load");
assert_eq!(got.block, r.block);
assert_eq!(got.churn_class, "timestamp");
assert!(load("fnd_absent").is_none());
std::env::remove_var("OPENLATCH_DIR");
}
}