Skip to main content

slopguard_core/
cache.rs

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";
15/// Extension of every cache entry file.
16const CACHE_ENTRY_EXT: &str = "bin";
17const CACHE_GITIGNORE: &str = "*\n";
18
19/// Version of the on-disk entry format. Mixed into the rules hash so a bump
20/// drops every stale entry instead of trying to deserialize it into the new
21/// shape.
22const CACHE_SCHEMA_VERSION: &str = "v5";
23
24/// What one scanned file leaves in the cache: its findings, the symbols it
25/// contributes to the cross-file index, and the imports it contributes to the
26/// resolution pass, so a cache hit feeds both project-wide passes without
27/// re-parsing the file.
28#[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
36/// Hex-encoded SHA256 of `data`. Shared by every cache key in the workspace.
37pub fn sha256_hex(data: &[u8]) -> String {
38    let mut hasher = Sha256::new();
39    hasher.update(data);
40    format!("{:x}", hasher.finalize())
41}
42
43/// Create `dir` (and parents) if missing and drop a `.gitignore` that ignores
44/// the entire cache directory. Idempotent.
45///
46/// # Errors
47///
48/// Returns an [`io::Error`] if the directory or `.gitignore` cannot be written.
49pub 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
63/// Compute a stable hash of the active rules by sorting them by id and hashing
64/// each rule's serialized body along with the scanner's own version.
65///
66/// The body is mixed in so editing a rule (custom or builtin) invalidates the
67/// cache even when its id is unchanged. `CARGO_PKG_VERSION` is mixed in because
68/// findings also depend on the scanner's built-in logic (metric computation,
69/// test-path detection, cross-file passes): a new binary must drop stale
70/// entries even when the rules are byte-for-byte identical.
71pub 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
91/// Compute the SHA256 hash of a file's content.
92// Domain name over the generic sha256_hex, used across the scanner.
93// slopguard-disable-next-line no-trivial-function
94pub fn file_content_hash(content: &[u8]) -> String {
95    sha256_hex(content)
96}
97
98/// A file-based cache store for scan findings.
99pub struct CacheStore {
100    dir: PathBuf,
101    /// Prepended to every entry filename so that scans run with different
102    /// rulesets never collide on the same content-hashed entry. Empty when the
103    /// store is not scoped to a ruleset.
104    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    /// Create a cache store that writes directly to the given directory
116    /// instead of appending `.slopguard-cache`.
117    pub fn with_dir(dir: PathBuf) -> Self {
118        Self {
119            dir,
120            key_prefix: String::new(),
121        }
122    }
123
124    /// Scope this store's entries to a ruleset. Entries written by a scan with
125    /// a different active ruleset use a different prefix, so a concurrent scan
126    /// with fewer or disabled rules cannot serve its (correctly empty) result
127    /// to a scan that expects the full ruleset to fire.
128    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    /// Check if the rules have changed since the last scan. If they have,
153    /// invalidate the entire cache.
154    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    /// Remove every cache entry, sparing anything slopguard did not write.
166    ///
167    /// The directory may be user-supplied (`--cache-dir`, env var, config), so
168    /// a directory without the `rules.hash` manifest is not treated as a cache
169    /// and is left untouched. Inside a cache, only regular files named like an
170    /// entry are removed: symlinks, sub-directories and foreign files survive.
171    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            // `file_type` does not follow symlinks.
178            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    /// Look up a file's cached scan result by its content hash.
194    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    /// Store a file's scan result, identified by its content hash.
201    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    /// Remove cache entries whose hash no longer appears in the set of
209    /// current file hashes.
210    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            // Like `invalidate_all`, never touch what slopguard did not write.
220            if !entry.file_type()?.is_file() || !is_cache_entry_name(&name_str) {
221                continue;
222            }
223            // Only prune entries written for the active ruleset. Entries from a
224            // different ruleset carry a different prefix and are left alone.
225            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
240/// `name` without its `.bin` extension, or `None` when it has another one.
241fn strip_entry_ext(name: &str) -> Option<&str> {
242    name.strip_suffix(CACHE_ENTRY_EXT)?.strip_suffix('.')
243}
244
245/// Whether `name` is shaped like a cache entry: `{rules_hash}-{file_hash}.bin`
246/// or `{file_hash}.bin`, with a non-empty stem of hex digits and dashes.
247fn 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}