1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
//! Centralized cache directory scanner, reconciler, and least-recently-written eviction engine.
//!
//! Enforces bounded growth across all KeyHog cache artifact kinds:
//! - Hyperscan pattern databases (`hs-*.db`)
//! - Detector parse plans (`detectors-*.json`)
//! - Compiled GPU matchers (`programs/*`)
//! - MatcherArtifact graphs (`*.khm`)
//! - Stale inter-process synchronization locks (`*.lock`)
use keyhog_core::{CacheEvictionPolicy, CacheKind};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// Detailed report of cache reconciliation and eviction actions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct EvictionReport {
/// Number of entries observed before eviction.
pub initial_count: usize,
/// Total bytes observed before eviction.
pub initial_bytes: u64,
/// Number of entries successfully evicted.
pub evicted_count: usize,
/// Total bytes freed by eviction.
pub evicted_bytes: u64,
/// Number of entries remaining after eviction.
pub retained_count: usize,
/// Total bytes remaining after eviction.
pub retained_bytes: u64,
/// Number of stale lock files removed.
pub stale_locks_removed: usize,
}
#[derive(Debug)]
struct CacheEntry {
path: PathBuf,
modified: SystemTime,
bytes: u64,
}
/// Evict artifacts of a specific `CacheKind` in `cache_dir` according to `policy`.
pub fn evict_cache_dir_with_policy(
cache_dir: &Path,
kind: CacheKind,
policy: CacheEvictionPolicy,
) -> EvictionReport {
if !cache_dir.exists() || !cache_dir.is_dir() {
return EvictionReport::default();
}
let mut report = EvictionReport::default();
if kind == CacheKind::LockFiles {
let mut entries = Vec::new();
collect_matching_entries(cache_dir, kind, &mut entries);
report.initial_count = entries.len();
report.initial_bytes = entries.iter().map(|e| e.bytes).sum();
if policy.max_lock_age_secs > 0 {
report.stale_locks_removed =
collect_stale_lock_files(cache_dir, Duration::from_secs(policy.max_lock_age_secs));
}
report.evicted_count = report.stale_locks_removed;
let mut remaining_entries = Vec::new();
collect_matching_entries(cache_dir, kind, &mut remaining_entries);
report.retained_count = remaining_entries.len();
report.retained_bytes = remaining_entries.iter().map(|e| e.bytes).sum();
report.evicted_bytes = report.initial_bytes.saturating_sub(report.retained_bytes);
return report;
}
// Discover all entries matching this CacheKind
let mut entries = Vec::new();
collect_matching_entries(cache_dir, kind, &mut entries);
report.initial_count = entries.len();
let mut current_bytes: u64 = entries.iter().map(|e| e.bytes).sum();
report.initial_bytes = current_bytes;
// Sort oldest first (least recently written by modification time)
entries.sort_by(|a, b| a.modified.cmp(&b.modified));
let total = entries.len();
let mut retained_count = 0;
for (idx, entry) in entries.into_iter().enumerate() {
let is_newest = idx + 1 == total;
let remaining_candidates = total - idx;
let files_if_retained = retained_count + remaining_candidates;
let over_count = files_if_retained > policy.max_entries;
let over_bytes = current_bytes > policy.max_bytes;
// If policy permits at least one entry, never evict the sole newest entry on byte cap
let should_evict = if is_newest && retained_count == 0 && policy.max_entries >= 1 {
false
} else {
over_count || over_bytes
};
if should_evict {
match std::fs::remove_file(&entry.path) {
Ok(()) => {
report.evicted_count += 1;
report.evicted_bytes += entry.bytes;
current_bytes = current_bytes.saturating_sub(entry.bytes);
continue;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
current_bytes = current_bytes.saturating_sub(entry.bytes);
continue;
}
// LAW10: recall-safe cache eviction failure keeps unlinked artifact in retained accounting
Err(_) => {}
}
}
retained_count += 1;
}
report.retained_count = retained_count;
report.retained_bytes = current_bytes;
report
}
/// Reconcile and evict all registered cache kinds under `cache_root` using default policies.
pub fn reconcile_all_cache_kinds(cache_root: &Path) -> Vec<(CacheKind, EvictionReport)> {
let mut results = Vec::with_capacity(CacheKind::ALL.len());
for &kind in CacheKind::ALL {
let policy = kind.default_policy();
let report = evict_cache_dir_with_policy(cache_root, kind, policy);
results.push((kind, report));
}
results
}
/// Collect and remove stale `.lock` files in `cache_dir` older than `max_age`.
///
/// Only unlinks a `.lock` file if its age exceeds `max_age` AND an exclusive non-blocking
/// advisory lock (`try_lock_exclusive`) can be acquired, preventing the removal of actively-held locks.
pub fn collect_stale_lock_files(cache_dir: &Path, max_age: Duration) -> usize {
collect_stale_locks_bounded(cache_dir, max_age, true)
}
fn collect_stale_locks_bounded(cache_dir: &Path, max_age: Duration, top_level: bool) -> usize {
let Ok(read_dir) = std::fs::read_dir(cache_dir) else {
return 0;
};
let now = SystemTime::now();
let mut removed = 0;
for entry in read_dir.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
// Refuse symlinks: never follow symlinks
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
// Only inspect immediate `programs/` subfolder when at top level
if top_level && path.file_name().and_then(|n| n.to_str()) == Some("programs") {
removed += collect_stale_locks_bounded(&path, max_age, false);
}
continue;
}
if CacheKind::classify_path(&path) == Some(CacheKind::LockFiles) {
if let Some(path_str) = path.to_str() {
if let Some(target_str) = path_str.strip_suffix(".lock") {
if Path::new(target_str).exists() {
// Companion state/cache artifact still exists; keep its coordination lock file
continue;
}
}
}
let Ok(meta) = entry.metadata() else {
continue;
};
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); // LAW10: deterministic default epoch for sort ordering only; no effect on scan findings
let age = now.duration_since(modified).unwrap_or(Duration::ZERO); // LAW10: conservative zero duration prevents premature lock collection; no effect on scan findings
if age >= max_age {
// LAW10: unopenable lock file is conservatively skipped; no effect on scan findings
if let Ok(file) = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
{
use fs2::FileExt;
if file.try_lock_exclusive().is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(m1), Ok(m2)) = (file.metadata(), std::fs::metadata(&path)) {
if m1.ino() == m2.ino() && m1.dev() == m2.dev() && m1.nlink() > 0 {
if std::fs::remove_file(&path).is_ok() {
removed += 1;
}
}
}
}
#[cfg(not(unix))]
{
if std::fs::remove_file(&path).is_ok() {
removed += 1;
}
}
let _ = file.unlock(); // LAW10: no runtime effect; unlocking unlinked descriptor cleans up kernel lock table entry
}
}
}
}
}
removed
}
fn collect_matching_entries(cache_root: &Path, kind: CacheKind, out: &mut Vec<CacheEntry>) {
collect_matching_entries_bounded(cache_root, kind, out, true);
}
/// Count cached files matching the specified kind without eviction.
pub fn count_matching_entries(cache_root: &Path, kind: CacheKind) -> usize {
let mut out = Vec::new();
collect_matching_entries(cache_root, kind, &mut out);
out.len()
}
fn collect_matching_entries_bounded(
dir: &Path,
kind: CacheKind,
out: &mut Vec<CacheEntry>,
top_level: bool,
) {
let Ok(read_dir) = std::fs::read_dir(dir) else {
return;
};
let mut protected_files = std::collections::HashSet::new();
if kind == CacheKind::GpuPrograms {
let manifest_path = dir.join(".installed_manifest.json");
if manifest_path.exists() {
if let Ok(bytes) = std::fs::read(&manifest_path) {
// LAW10: fail-closed, the unreadable arm below evicts nothing
#[derive(serde::Deserialize)]
struct Manifest {
#[serde(default)]
artifacts: Vec<Entry>,
}
#[derive(serde::Deserialize)]
struct Entry {
file_name: String,
}
if let Ok(manifest) = serde_json::from_slice::<Manifest>(&bytes) {
// LAW10: fail-closed, the malformed arm below evicts nothing
for item in manifest.artifacts {
protected_files.insert(item.file_name);
}
} else {
// Malformed manifest exists: fail closed and do not collect/evict any GpuPrograms
return;
}
} else {
// Unreadable manifest exists: fail closed and do not collect/evict any GpuPrograms
return;
}
}
}
for entry in read_dir.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
// Refuse symlinks: never follow symlinks outside cache root
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
// Only inspect immediate `programs/` subfolder when at top level
if top_level && path.file_name().and_then(|n| n.to_str()) == Some("programs") {
collect_matching_entries_bounded(&path, kind, out, false);
}
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') || protected_files.contains(name) {
continue;
}
}
if kind.matches_path(&path) {
if let Ok(meta) = entry.metadata() {
// LAW10: unreadable metadata skips entry without aborting eviction of other artifacts
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); // LAW10: deterministic default epoch for LRU sort ordering only; no effect on scan findings
let bytes = meta.len();
out.push(CacheEntry {
path,
modified,
bytes,
});
}
}
}
}