1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use thiserror::Error;
8
9use crate::cross_file::FileSymbols;
10use crate::finding::Finding;
11use crate::resolution::FileImports;
12use crate::rule::Rule;
13
14const RULES_HASH_FILE: &str = "rules.hash";
15const CACHE_ENTRY_EXT: &str = "bin";
17const CACHE_GITIGNORE: &str = "*\n";
18
19const CACHE_SCHEMA_VERSION: &str = "v5";
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct CacheEntry {
30 pub findings: Vec<Finding>,
31 pub symbols: FileSymbols,
32 #[serde(default)]
33 pub imports: FileImports,
34}
35
36pub fn sha256_hex(data: &[u8]) -> String {
38 let mut hasher = Sha256::new();
39 hasher.update(data);
40 format!("{:x}", hasher.finalize())
41}
42
43pub fn ensure_gitignored_dir(dir: &Path) -> Result<(), io::Error> {
50 if !dir.exists() {
51 fs::create_dir_all(dir)?;
52 fs::write(dir.join(".gitignore"), CACHE_GITIGNORE)?;
53 }
54 Ok(())
55}
56
57#[derive(Debug, Error)]
58pub enum CacheError {
59 #[error("cache I/O error: {0}")]
60 Io(#[from] io::Error),
61}
62
63pub fn rules_hash(rules: &[Rule]) -> String {
72 let mut sorted: Vec<&Rule> = rules.iter().collect();
73 sorted.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
74
75 let mut hasher = Sha256::new();
76 hasher.update(CACHE_SCHEMA_VERSION.as_bytes());
77 hasher.update(b"\0");
78 hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
79 hasher.update(b"\0");
80 for rule in &sorted {
81 hasher.update(rule.id.as_str().as_bytes());
82 hasher.update(b"\0");
83 if let Ok(body) = serde_json::to_vec(rule) {
84 hasher.update(&body);
85 }
86 hasher.update(b"\0");
87 }
88 format!("{:x}", hasher.finalize())
89}
90
91pub fn file_content_hash(content: &[u8]) -> String {
95 sha256_hex(content)
96}
97
98pub struct CacheStore {
100 dir: PathBuf,
101 key_prefix: String,
105}
106
107impl CacheStore {
108 pub fn new(project_root: &Path) -> Self {
109 Self {
110 dir: project_root.join(".slopguard-cache"),
111 key_prefix: String::new(),
112 }
113 }
114
115 pub fn with_dir(dir: PathBuf) -> Self {
118 Self {
119 dir,
120 key_prefix: String::new(),
121 }
122 }
123
124 pub fn scoped_to_rules(mut self, rules_hash: &str) -> Self {
129 self.key_prefix = format!("{rules_hash}-");
130 self
131 }
132
133 pub fn dir(&self) -> &Path {
134 &self.dir
135 }
136
137 fn ensure_dir(&self) -> Result<(), CacheError> {
138 ensure_gitignored_dir(&self.dir)?;
139 Ok(())
140 }
141
142 fn stored_rules_hash(&self) -> Option<String> {
143 fs::read_to_string(self.dir.join(RULES_HASH_FILE)).ok()
144 }
145
146 fn write_rules_hash(&self, hash: &str) -> Result<(), CacheError> {
147 self.ensure_dir()?;
148 fs::write(self.dir.join(RULES_HASH_FILE), hash)?;
149 Ok(())
150 }
151
152 pub fn check_rules_changed(&self, current_hash: &str) -> Result<bool, CacheError> {
155 match self.stored_rules_hash() {
156 Some(stored) if stored == current_hash => Ok(false),
157 _ => {
158 self.invalidate_all()?;
159 self.write_rules_hash(current_hash)?;
160 Ok(true)
161 }
162 }
163 }
164
165 fn invalidate_all(&self) -> Result<(), CacheError> {
172 if !self.dir.join(RULES_HASH_FILE).is_file() {
173 return Ok(());
174 }
175 for entry in fs::read_dir(&self.dir)? {
176 let entry = entry?;
177 if !entry.file_type()?.is_file() {
179 continue;
180 }
181 if is_cache_entry_name(&entry.file_name().to_string_lossy()) {
182 fs::remove_file(entry.path())?;
183 }
184 }
185 Ok(())
186 }
187
188 fn entry_path(&self, file_hash: &str) -> PathBuf {
189 self.dir
190 .join(format!("{}{file_hash}.{CACHE_ENTRY_EXT}", self.key_prefix))
191 }
192
193 pub fn get(&self, file_hash: &str) -> Option<CacheEntry> {
195 let path = self.entry_path(file_hash);
196 let data = fs::read(&path).ok()?;
197 rmp_serde::from_slice(&data).ok()
198 }
199
200 pub fn put(&self, file_hash: &str, entry: &CacheEntry) -> Result<(), CacheError> {
202 self.ensure_dir()?;
203 let data = rmp_serde::to_vec_named(entry).map_err(io::Error::other)?;
204 fs::write(self.entry_path(file_hash), data)?;
205 Ok(())
206 }
207
208 pub fn cleanup(&self, current_hashes: &[String]) -> Result<usize, CacheError> {
211 if !self.dir.exists() {
212 return Ok(0);
213 }
214 let mut removed = 0;
215 for entry in fs::read_dir(&self.dir)? {
216 let entry = entry?;
217 let name = entry.file_name();
218 let name_str = name.to_string_lossy();
219 if !entry.file_type()?.is_file() || !is_cache_entry_name(&name_str) {
221 continue;
222 }
223 let Some(hash) = name_str
226 .strip_prefix(&self.key_prefix)
227 .and_then(strip_entry_ext)
228 else {
229 continue;
230 };
231 if !current_hashes.iter().any(|h| h == hash) {
232 fs::remove_file(entry.path())?;
233 removed += 1;
234 }
235 }
236 Ok(removed)
237 }
238}
239
240fn strip_entry_ext(name: &str) -> Option<&str> {
242 name.strip_suffix(CACHE_ENTRY_EXT)?.strip_suffix('.')
243}
244
245fn is_cache_entry_name(name: &str) -> bool {
248 strip_entry_ext(name).is_some_and(|stem| {
249 !stem.is_empty() && stem.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
250 })
251}
252
253#[cfg(test)]
254mod tests {
255 #[cfg(unix)]
256 use std::os::unix::fs::symlink;
257 use std::path::PathBuf;
258 use std::slice;
259
260 use tempfile::tempdir;
261
262 use super::*;
263 use crate::cross_file::{TestHelper, TraitDecl, TraitImpl, UnassertedTest};
264 use crate::resolution::ImportRef;
265 use crate::rule::{parse_rule, RuleId, Severity};
266
267 fn test_rule() -> Rule {
268 parse_rule(
269 r#"
270id: test-unwrap
271language: rust
272severity: error
273category: correctness
274message: ".unwrap() forbidden"
275rule:
276 kind: call_expression
277 regex: '\.unwrap\(\)\s*$'
278"#,
279 )
280 .unwrap()
281 }
282
283 fn sample_finding() -> Finding {
284 Finding {
285 rule_id: RuleId::from("test-unwrap"),
286 severity: Severity::Error,
287 category: crate::rule::Category::Correctness,
288 message: ".unwrap() forbidden".to_string(),
289 note: None,
290 fix: None,
291 file: PathBuf::from("src/main.rs"),
292 line: 2,
293 column: 5,
294 end_line: 2,
295 end_column: 20,
296 matched_text: "foo().unwrap()".to_string(),
297 confidence: None,
298 escalated: false,
299 }
300 }
301
302 #[test]
303 fn cache_miss() {
304 let dir = tempdir().unwrap();
305 let store = CacheStore::new(dir.path());
306 let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
307
308 assert!(store.get(&hash).is_none());
309 }
310
311 fn entry_with(findings: Vec<Finding>) -> CacheEntry {
312 CacheEntry {
313 findings,
314 ..Default::default()
315 }
316 }
317
318 #[test]
319 fn cache_hit() {
320 let dir = tempdir().unwrap();
321 let store = CacheStore::new(dir.path());
322 let content = b"fn main() { foo().unwrap(); }";
323 let hash = file_content_hash(content);
324
325 store
326 .put(&hash, &entry_with(vec![sample_finding()]))
327 .unwrap();
328 let cached = store.get(&hash).unwrap().findings;
329
330 assert_eq!(cached.len(), 1);
331 assert_eq!(cached[0].rule_id, RuleId::from("test-unwrap"));
332 assert_eq!(cached[0].line, 2);
333 assert_eq!(cached[0].matched_text, "foo().unwrap()");
334 }
335
336 #[test]
337 fn cache_entry_round_trips_symbols() {
338 let dir = tempdir().unwrap();
339 let store = CacheStore::new(dir.path());
340 let hash = file_content_hash(b"pub trait Repository {}");
341 let entry = CacheEntry {
342 findings: Vec::new(),
343 imports: FileImports::default(),
344 symbols: FileSymbols {
345 traits: vec![TraitDecl {
346 name: "Repository".to_string(),
347 line: 1,
348 column: 1,
349 end_line: 1,
350 end_column: 21,
351 in_cfg_test: false,
352 header: "pub trait Repository".to_string(),
353 }],
354 impls: vec![TraitImpl {
355 trait_name: "Repository".to_string(),
356 blanket: false,
357 self_type: "PostgresRepository".to_string(),
358 }],
359 error_messages: Vec::new(),
360 types: vec!["PostgresRepository".to_string()],
361 helpers: vec![TestHelper {
362 name: "check".to_string(),
363 asserts: true,
364 calls: vec!["parse".to_string()],
365 in_cfg_test: true,
366 }],
367 unasserted_tests: vec![UnassertedTest {
368 line: 3,
369 column: 1,
370 end_line: 5,
371 end_column: 2,
372 text: "fn t() {\n check();\n}".to_string(),
373 calls: vec!["check".to_string()],
374 }],
375 },
376 };
377
378 store.put(&hash, &entry).unwrap();
379 let cached = store.get(&hash).unwrap();
380
381 assert_eq!(cached.symbols.traits.len(), 1);
382 assert_eq!(cached.symbols.traits[0].name, "Repository");
383 assert_eq!(cached.symbols.traits[0].header, "pub trait Repository");
384 assert_eq!(cached.symbols.impls.len(), 1);
385 assert_eq!(cached.symbols.impls[0].trait_name, "Repository");
386 assert!(!cached.symbols.impls[0].blanket);
387 assert_eq!(cached.symbols.impls[0].self_type, "PostgresRepository");
388 assert_eq!(cached.symbols.types, vec!["PostgresRepository".to_string()]);
389 assert_eq!(cached.symbols.helpers.len(), 1);
390 assert_eq!(cached.symbols.helpers[0].name, "check");
391 assert!(cached.symbols.helpers[0].asserts);
392 assert_eq!(cached.symbols.helpers[0].calls, vec!["parse".to_string()]);
393 assert!(cached.symbols.helpers[0].in_cfg_test);
394 assert_eq!(cached.symbols.unasserted_tests.len(), 1);
395 assert_eq!(cached.symbols.unasserted_tests[0].line, 3);
396 assert_eq!(
397 cached.symbols.unasserted_tests[0].text,
398 "fn t() {\n check();\n}"
399 );
400 assert_eq!(
401 cached.symbols.unasserted_tests[0].calls,
402 vec!["check".to_string()]
403 );
404 }
405
406 #[test]
407 fn cache_entry_round_trips_imports() {
408 let dir = tempdir().unwrap();
409 let store = CacheStore::new(dir.path());
410 let hash = file_content_hash(b"use made_up_crate::Thing;");
411 let entry = CacheEntry {
412 imports: FileImports {
413 imports: vec![ImportRef {
414 specifier: "made_up_crate".to_string(),
415 line: 1,
416 column: 1,
417 end_line: 1,
418 end_column: 26,
419 matched_text: "use made_up_crate::Thing;".to_string(),
420 }],
421 local_names: vec!["foo".to_string()],
422 },
423 ..Default::default()
424 };
425
426 store.put(&hash, &entry).unwrap();
427 let cached = store.get(&hash).unwrap();
428
429 assert_eq!(cached.imports.imports.len(), 1);
430 assert_eq!(cached.imports.imports[0].specifier, "made_up_crate");
431 assert_eq!(cached.imports.local_names, vec!["foo".to_string()]);
432 }
433
434 #[test]
435 fn invalidation_file_change() {
436 let dir = tempdir().unwrap();
437 let store = CacheStore::new(dir.path());
438
439 let content_v1 = b"fn main() { foo().unwrap(); }";
440 let content_v2 = b"fn main() { foo()?; }";
441 let hash_v1 = file_content_hash(content_v1);
442 let hash_v2 = file_content_hash(content_v2);
443
444 store
445 .put(&hash_v1, &entry_with(vec![sample_finding()]))
446 .unwrap();
447
448 assert!(store.get(&hash_v1).is_some());
449 assert!(store.get(&hash_v2).is_none());
450 }
451
452 #[test]
453 fn invalidation_rule_change() {
454 let dir = tempdir().unwrap();
455 let store = CacheStore::new(dir.path());
456
457 let rules_v1 = [test_rule()];
458 let hash_v1 = rules_hash(&rules_v1);
459
460 let content_hash = file_content_hash(b"fn main() {}");
461 store.put(&content_hash, &entry_with(Vec::new())).unwrap();
462 store.write_rules_hash(&hash_v1).unwrap();
463
464 assert!(store.get(&content_hash).is_some());
465
466 let mut rule_v2 = test_rule();
467 rule_v2.id = RuleId::from("different-rule");
468 let rules_v2 = [rule_v2];
469 let hash_v2 = rules_hash(&rules_v2);
470 assert_ne!(hash_v1, hash_v2);
471
472 store.check_rules_changed(&hash_v2).unwrap();
473
474 assert!(
475 store.get(&content_hash).is_none(),
476 "cache entries should be invalidated when rules change"
477 );
478 }
479
480 #[test]
481 fn invalidate_all_spares_foreign_files() {
482 let dir = tempdir().unwrap();
483 let store = CacheStore::with_dir(dir.path().to_path_buf());
484 let hash_v1 = rules_hash(&[test_rule()]);
485 let scoped = CacheStore::with_dir(dir.path().to_path_buf()).scoped_to_rules(&hash_v1);
486
487 let content_hash = file_content_hash(b"fn main() {}");
488 scoped.put(&content_hash, &entry_with(Vec::new())).unwrap();
489 store.write_rules_hash(&hash_v1).unwrap();
490 let entry = scoped.entry_path(&content_hash);
491 assert!(entry.is_file());
492
493 let foreign = ["notes.txt", "data.bin", "keep.rs", "Cargo.toml"];
494 for name in foreign {
495 fs::write(dir.path().join(name), b"user data").unwrap();
496 }
497 fs::create_dir(dir.path().join("subdir")).unwrap();
498
499 store.check_rules_changed("new-rules-hash").unwrap();
500
501 assert!(!entry.exists(), "the real cache entry must be invalidated");
502 for name in foreign {
503 assert!(dir.path().join(name).is_file(), "{name} must survive");
504 }
505 assert!(dir.path().join("subdir").is_dir());
506 assert_eq!(
507 fs::read_to_string(dir.path().join(RULES_HASH_FILE)).unwrap(),
508 "new-rules-hash"
509 );
510 }
511
512 #[test]
513 fn invalidate_all_does_nothing_without_manifest() {
514 let dir = tempdir().unwrap();
515 let store = CacheStore::with_dir(dir.path().to_path_buf());
516 let lookalike = dir.path().join("abc123-def456.bin");
517 fs::write(&lookalike, b"not ours").unwrap();
518
519 assert!(store.check_rules_changed("rules-hash").unwrap());
520
521 assert!(
522 lookalike.is_file(),
523 "a directory without manifest is not a cache"
524 );
525 assert_eq!(
526 fs::read_to_string(dir.path().join(RULES_HASH_FILE)).unwrap(),
527 "rules-hash"
528 );
529 }
530
531 #[cfg(unix)]
532 #[test]
533 fn invalidate_all_does_not_follow_symlinks() {
534 let dir = tempdir().unwrap();
535 let outside = tempdir().unwrap();
536 let target = outside.path().join("precious.txt");
537 fs::write(&target, b"precious").unwrap();
538
539 let store = CacheStore::with_dir(dir.path().to_path_buf());
540 store.write_rules_hash("old-hash").unwrap();
541 let link = dir.path().join("abc123-def456.bin");
542 symlink(&target, &link).unwrap();
543
544 store.check_rules_changed("new-hash").unwrap();
545
546 assert!(target.is_file(), "the symlink target must survive");
547 assert!(link.symlink_metadata().is_ok(), "the symlink is skipped");
548 }
549
550 #[test]
551 fn cleanup_spares_foreign_files() {
552 let dir = tempdir().unwrap();
553 let store = CacheStore::with_dir(dir.path().to_path_buf());
554 let hash = file_content_hash(b"file a");
555 store.put(&hash, &entry_with(Vec::new())).unwrap();
556 fs::write(dir.path().join("notes.txt"), b"user data").unwrap();
557 fs::create_dir(dir.path().join("subdir")).unwrap();
558
559 let removed = store.cleanup(&[]).unwrap();
560
561 assert_eq!(removed, 1, "only the stale entry is pruned");
562 assert!(dir.path().join("notes.txt").is_file());
563 assert!(dir.path().join("subdir").is_dir());
564 }
565
566 #[test]
567 fn cache_entry_name_shape() {
568 assert!(is_cache_entry_name("abc123.bin"));
569 assert!(is_cache_entry_name("abc123-def456.bin"));
570 assert!(!is_cache_entry_name(".bin"));
571 assert!(!is_cache_entry_name("data.bin"));
572 assert!(!is_cache_entry_name("abc123.txt"));
573 assert!(!is_cache_entry_name("abc123bin"));
574 assert!(!is_cache_entry_name(RULES_HASH_FILE));
575 assert!(!is_cache_entry_name(".gitignore"));
576 }
577
578 #[test]
579 fn cleanup_orphan_entries() {
580 let dir = tempdir().unwrap();
581 let store = CacheStore::new(dir.path());
582
583 let hash_a = file_content_hash(b"file a");
584 let hash_b = file_content_hash(b"file b");
585 store.put(&hash_a, &entry_with(Vec::new())).unwrap();
586 store
587 .put(&hash_b, &entry_with(vec![sample_finding()]))
588 .unwrap();
589
590 let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
591
592 assert_eq!(removed, 1);
593 assert!(store.get(&hash_a).is_some());
594 assert!(store.get(&hash_b).is_none());
595 }
596
597 #[test]
598 fn corrupted_cache_entry() {
599 let dir = tempdir().unwrap();
600 let store = CacheStore::new(dir.path());
601
602 store.ensure_dir().unwrap();
603 let hash = file_content_hash(b"some content");
604 fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
605
606 assert!(
607 store.get(&hash).is_none(),
608 "corrupted entry should return None, not panic"
609 );
610 }
611}