1use fs2::FileExt;
9use serde::{Serialize, de::DeserializeOwned};
10use std::io::{Read, Write};
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13use std::path::PathBuf;
14use std::sync::atomic::{AtomicU64, Ordering};
15use tempfile::NamedTempFile;
16use tracing::{error, warn};
17
18const DISK_CACHE_DEGRADED_THRESHOLD: u64 = 100;
20
21pub struct DiskCache {
27 base: PathBuf,
28 disabled: bool,
29 write_failures: AtomicU64,
31 total_write_failures: AtomicU64,
33 entry_count: AtomicU64,
35 total_size_bytes: AtomicU64,
37}
38
39impl DiskCache {
40 pub fn drain_write_failures(&self) -> u64 {
43 self.write_failures.swap(0, Ordering::Relaxed)
44 }
45
46 pub fn is_degraded(&self) -> bool {
49 self.total_write_failures.load(Ordering::Relaxed) >= DISK_CACHE_DEGRADED_THRESHOLD
50 }
51
52 #[must_use]
55 pub fn cache_stats(&self) -> (u64, u64) {
56 (
57 self.entry_count.load(Ordering::Relaxed),
58 self.total_size_bytes.load(Ordering::Relaxed),
59 )
60 }
61}
62
63impl DiskCache {
64 pub fn new(base: PathBuf, disabled: bool) -> Self {
67 if disabled {
68 return Self {
69 base,
70 disabled: true,
71 write_failures: AtomicU64::new(0),
72 total_write_failures: AtomicU64::new(0),
73 entry_count: AtomicU64::new(0),
74 total_size_bytes: AtomicU64::new(0),
75 };
76 }
77 if let Err(e) = std::fs::create_dir_all(&base) {
78 warn!(path = %base.display(), error = %e, "disk cache disabled: failed to create cache directory");
79 return Self {
80 base,
81 disabled: true,
82 write_failures: AtomicU64::new(0),
83 total_write_failures: AtomicU64::new(0),
84 entry_count: AtomicU64::new(0),
85 total_size_bytes: AtomicU64::new(0),
86 };
87 }
88 #[cfg(unix)]
89 if let Err(e) = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)) {
90 warn!(path = %base.display(), error = %e, "disk cache: failed to set directory permissions to 0700");
91 }
92 #[cfg(not(unix))]
93 let _ = &base; Self {
95 base,
96 disabled: false,
97 write_failures: AtomicU64::new(0),
98 total_write_failures: AtomicU64::new(0),
99 entry_count: AtomicU64::new(0),
100 total_size_bytes: AtomicU64::new(0),
101 }
102 }
103
104 pub fn entry_path(&self, tool: &str, key: &blake3::Hash) -> PathBuf {
105 let hex = format!("{}", key);
106 self.base
107 .join(tool)
108 .join(&hex[..2])
109 .join(format!("{}.json.snap", hex))
110 }
111
112 pub fn get<T: DeserializeOwned>(&self, tool: &str, key: &blake3::Hash) -> Option<T> {
115 if self.disabled {
116 return None;
117 }
118 let path = self.entry_path(tool, key);
119 let dir = path.parent()?;
120
121 let _lock = lock_shard_shared(dir)?;
123
124 let compressed = std::fs::read(&path).ok()?;
125 let mut decompressed_data = Vec::new();
126 snap::read::FrameDecoder::new(&compressed[..])
127 .read_to_end(&mut decompressed_data)
128 .ok()?;
129 serde_json::from_slice(&decompressed_data).ok()
130 }
131
132 fn serialize_entry<T: Serialize>(value: &T) -> Option<Vec<u8>> {
134 let json = serde_json::to_vec(value).ok()?;
135 let mut compressed = Vec::new();
136 snap::write::FrameEncoder::new(&mut compressed)
137 .write_all(&json)
138 .ok()?;
139 Some(compressed)
140 }
141
142 fn write_entry_atomically(
146 dir: &std::path::Path,
147 path: &std::path::Path,
148 compressed: &[u8],
149 ) -> Result<(), std::io::Error> {
150 use std::io::Write;
151 let _lock = lock_shard_exclusive(dir)?;
153 let mut tmp = NamedTempFile::new_in(dir)?;
154 tmp.write_all(compressed)?;
155 tmp.persist(path).map(|_| ()).map_err(|e| e.error)
156 }
157
158 pub fn put<T: Serialize>(&self, tool: &str, key: &blake3::Hash, value: &T) {
160 if self.disabled {
161 return;
162 }
163 let path = self.entry_path(tool, key);
164 let dir = match path.parent() {
165 Some(d) => d.to_path_buf(),
166 None => return,
167 };
168 if let Err(e) = std::fs::create_dir_all(&dir) {
169 warn!(tool, error = %e, "disk cache: failed to create cache directory");
170 self.record_write_failure();
171 return;
172 }
173 let compressed = match Self::serialize_entry(value) {
174 Some(c) => c,
175 None => return,
176 };
177 let compressed_size = compressed.len() as u64;
178 if Self::write_entry_atomically(&dir, &path, &compressed)
179 .ok()
180 .is_none()
181 {
182 self.record_write_failure();
183 return;
184 }
185 self.entry_count.fetch_add(1, Ordering::Relaxed);
187 self.total_size_bytes
188 .fetch_add(compressed_size, Ordering::Relaxed);
189 }
190
191 fn record_write_failure(&self) {
195 self.write_failures.fetch_add(1, Ordering::Relaxed);
196 let total = self.total_write_failures.fetch_add(1, Ordering::Relaxed) + 1;
197 if total == DISK_CACHE_DEGRADED_THRESHOLD {
198 error!(
199 path = %self.base.display(),
200 total,
201 threshold = DISK_CACHE_DEGRADED_THRESHOLD,
202 "disk cache is degraded: consecutive write failures have reached the alert threshold; \
203 check disk space and permissions at the cache directory"
204 );
205 }
206 }
207
208 pub fn evict_stale(&self, retention_days: u64) {
210 if self.disabled {
211 return;
212 }
213 let cutoff = std::time::SystemTime::now()
214 .checked_sub(std::time::Duration::from_secs(retention_days * 86_400))
215 .unwrap_or(std::time::UNIX_EPOCH);
216 if let Ok((evicted_count, evicted_bytes)) = evict_dir_recursive(&self.base, cutoff) {
217 self.entry_count.fetch_sub(evicted_count, Ordering::Relaxed);
218 self.total_size_bytes
219 .fetch_sub(evicted_bytes, Ordering::Relaxed);
220 }
221 }
222}
223
224fn evict_dir_recursive(
225 dir: &std::path::Path,
226 cutoff: std::time::SystemTime,
227) -> std::io::Result<(u64, u64)> {
228 let mut evicted_count = 0u64;
229 let mut evicted_bytes = 0u64;
230 for entry in std::fs::read_dir(dir)? {
231 let entry = entry?;
232 let meta = entry.metadata()?;
233 let path = entry.path();
234 if meta.is_dir() {
235 if let Ok((count, bytes)) = evict_dir_recursive(&path, cutoff) {
236 evicted_count += count;
237 evicted_bytes += bytes;
238 }
239 } else if meta.is_file()
240 && let Ok(mtime) = meta.modified()
241 && mtime < cutoff
242 {
243 let file_size = meta.len();
244 if let Err(e) = std::fs::remove_file(&path) {
245 warn!(path = %path.display(), error = %e, "disk cache: failed to evict stale cache file");
246 } else {
247 evicted_count += 1;
248 evicted_bytes += file_size;
249 }
250 }
251 }
252 Ok((evicted_count, evicted_bytes))
253}
254
255fn lock_shard_shared(shard_dir: &std::path::Path) -> Option<ShardLockGuard> {
260 let lock_path = shard_dir.join(".lock");
261 let file = std::fs::OpenOptions::new()
262 .create(true)
263 .write(true)
264 .truncate(false)
265 .open(&lock_path)
266 .ok()?;
267 match file.lock_shared() {
268 Ok(()) => Some(ShardLockGuard(file)),
269 Err(e) => {
270 warn!(
271 error = %e, lock_path = %lock_path.display(),
272 "disk cache: failed to acquire shared lock on shard; proceeding without lock"
273 );
274 None
275 }
276 }
277}
278
279fn lock_shard_exclusive(shard_dir: &std::path::Path) -> Result<ShardLockGuard, std::io::Error> {
284 let lock_path = shard_dir.join(".lock");
285 let file = std::fs::OpenOptions::new()
286 .create(true)
287 .write(true)
288 .truncate(false)
289 .open(&lock_path)?;
290 file.lock_exclusive()?;
291 Ok(ShardLockGuard(file))
292}
293
294struct ShardLockGuard(
297 #[expect(dead_code)]
300 std::fs::File,
301);
302
303#[cfg(test)]
304mod disk_cache_tests {
305 use super::*;
306 use std::io::Read;
307 use tempfile::TempDir;
308
309 #[test]
310 fn test_disk_cache_roundtrip() {
311 let dir = TempDir::new().unwrap();
312 let cache = DiskCache::new(dir.path().to_path_buf(), false);
313 let key = blake3::hash(b"test-key");
314 let value = serde_json::json!({"result": "success", "count": 42});
315 cache.put("analyze_file", &key, &value);
316 let retrieved: Option<serde_json::Value> = cache.get("analyze_file", &key);
317 assert_eq!(retrieved, Some(value));
318 }
319
320 #[test]
321 fn test_disk_cache_permissions() {
322 #[cfg(unix)]
323 {
324 use std::os::unix::fs::PermissionsExt;
325 let dir = TempDir::new().unwrap();
326 let cache_dir = dir.path().join("analysis-cache");
327 let _cache = DiskCache::new(cache_dir.clone(), false);
328 let meta = std::fs::metadata(&cache_dir).unwrap();
329 let mode = meta.permissions().mode() & 0o777;
330 assert_eq!(mode, 0o700, "cache dir must be mode 0700");
331 }
332 }
333
334 #[test]
335 fn test_disk_cache_corrupt_entry_returns_none() {
336 let dir = TempDir::new().unwrap();
337 let cache = DiskCache::new(dir.path().to_path_buf(), false);
338 let key = blake3::hash(b"corrupt-key");
339 let path = cache.entry_path("analyze_file", &key);
340 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
341 std::fs::write(&path, b"not valid snappy data").unwrap();
342 let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
343 assert!(result.is_none(), "corrupt entry must return None");
344 }
345
346 #[test]
347 fn test_disk_cache_disabled_on_dir_creation_failure() {
348 let dir = TempDir::new().unwrap();
349 let blocked = dir.path().join("blocked");
352 std::fs::write(&blocked, b"").unwrap();
353 let cache = DiskCache::new(blocked, false);
354 let key = blake3::hash(b"should-not-exist");
356 cache.put("analyze_file", &key, &serde_json::json!({"x": 1}));
357 let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
358 assert!(
359 result.is_none(),
360 "cache must be disabled after dir creation failure"
361 );
362 assert!(
363 cache.disabled,
364 "disabled flag must be true after dir creation failure"
365 );
366 }
367
368 #[test]
369 fn test_concurrent_get_put_same_shard() {
370 let dir = TempDir::new().unwrap();
373 let cache = std::sync::Arc::new(DiskCache::new(dir.path().to_path_buf(), false));
374 let key = blake3::hash(b"concurrent-test-key");
375 let value = serde_json::json!({"result": "from put thread", "n": 42});
376
377 cache.put("analyze_file", &key, &value);
379
380 let cache_get = cache.clone();
381 let cache_put = cache.clone();
382 let key_put = key;
383 let key_get = key;
384 let value_put = serde_json::json!({"result": "from put thread", "n": 100});
385
386 std::thread::scope(|scope| {
387 scope.spawn(|| {
388 cache_put.put("analyze_file", &key_put, &value_put);
390 });
391 scope.spawn(|| {
392 let _: Option<serde_json::Value> = cache_get.get("analyze_file", &key_get);
394 });
395 });
396
397 let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
399 assert!(
400 result.is_some(),
401 "entry must still be retrievable after concurrent access"
402 );
403 }
404
405 #[test]
406 fn test_concurrent_puts_same_shard() {
407 let dir = TempDir::new().unwrap();
410 let cache = std::sync::Arc::new(DiskCache::new(dir.path().to_path_buf(), false));
411 let key = blake3::hash(b"concurrent-put-key");
412 let value_a = serde_json::json!({"writer": "A", "data": "hello from A"});
413 let value_b = serde_json::json!({"writer": "B", "data": "hello from B"});
414
415 let cache_a = cache.clone();
416 let cache_b = cache.clone();
417 let key_a = key;
418 let key_b = key;
419
420 std::thread::scope(|scope| {
421 scope.spawn(|| {
422 cache_a.put("analyze_file", &key_a, &value_a);
423 });
424 scope.spawn(|| {
425 cache_b.put("analyze_file", &key_b, &value_b);
426 });
427 });
428
429 let result: Option<serde_json::Value> = cache.get("analyze_file", &key);
431 assert!(
432 result.is_some(),
433 "entry must be retrievable after concurrent puts"
434 );
435 let v = result.unwrap();
437 let writer = v.get("writer").and_then(|w| w.as_str());
438 assert!(
439 writer == Some("A") || writer == Some("B"),
440 "entry must contain data from one of the concurrent writers, got {writer:?}"
441 );
442 }
443}