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";
15const CACHE_GITIGNORE: &str = "*\n";
16
17/// Version of the on-disk entry format. Mixed into the rules hash so a bump
18/// drops every stale entry instead of trying to deserialize it into the new
19/// shape.
20const CACHE_SCHEMA_VERSION: &str = "v5";
21
22/// What one scanned file leaves in the cache: its findings, the symbols it
23/// contributes to the cross-file index, and the imports it contributes to the
24/// resolution pass, so a cache hit feeds both project-wide passes without
25/// re-parsing the file.
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct CacheEntry {
28    pub findings: Vec<Finding>,
29    pub symbols: FileSymbols,
30    #[serde(default)]
31    pub imports: FileImports,
32}
33
34/// Hex-encoded SHA256 of `data`. Shared by every cache key in the workspace.
35pub fn sha256_hex(data: &[u8]) -> String {
36    let mut hasher = Sha256::new();
37    hasher.update(data);
38    format!("{:x}", hasher.finalize())
39}
40
41/// Create `dir` (and parents) if missing and drop a `.gitignore` that ignores
42/// the entire cache directory. Idempotent.
43///
44/// # Errors
45///
46/// Returns an [`io::Error`] if the directory or `.gitignore` cannot be written.
47pub fn ensure_gitignored_dir(dir: &Path) -> Result<(), io::Error> {
48    if !dir.exists() {
49        fs::create_dir_all(dir)?;
50        fs::write(dir.join(".gitignore"), CACHE_GITIGNORE)?;
51    }
52    Ok(())
53}
54
55#[derive(Debug, Error)]
56pub enum CacheError {
57    #[error("cache I/O error: {0}")]
58    Io(#[from] io::Error),
59}
60
61/// Compute a stable hash of the active rules by sorting them by id and hashing
62/// each rule's serialized body along with the scanner's own version.
63///
64/// The body is mixed in so editing a rule (custom or builtin) invalidates the
65/// cache even when its id is unchanged. `CARGO_PKG_VERSION` is mixed in because
66/// findings also depend on the scanner's built-in logic (metric computation,
67/// test-path detection, cross-file passes): a new binary must drop stale
68/// entries even when the rules are byte-for-byte identical.
69pub fn rules_hash(rules: &[Rule]) -> String {
70    let mut sorted: Vec<&Rule> = rules.iter().collect();
71    sorted.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
72
73    let mut hasher = Sha256::new();
74    hasher.update(CACHE_SCHEMA_VERSION.as_bytes());
75    hasher.update(b"\0");
76    hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
77    hasher.update(b"\0");
78    for rule in &sorted {
79        hasher.update(rule.id.as_str().as_bytes());
80        hasher.update(b"\0");
81        if let Ok(body) = serde_json::to_vec(rule) {
82            hasher.update(&body);
83        }
84        hasher.update(b"\0");
85    }
86    format!("{:x}", hasher.finalize())
87}
88
89/// Compute the SHA256 hash of a file's content.
90// Domain name over the generic sha256_hex, used across the scanner.
91// slopguard-disable-next-line no-trivial-function
92pub fn file_content_hash(content: &[u8]) -> String {
93    sha256_hex(content)
94}
95
96/// A file-based cache store for scan findings.
97pub struct CacheStore {
98    dir: PathBuf,
99    /// Prepended to every entry filename so that scans run with different
100    /// rulesets never collide on the same content-hashed entry. Empty when the
101    /// store is not scoped to a ruleset.
102    key_prefix: String,
103}
104
105impl CacheStore {
106    pub fn new(project_root: &Path) -> Self {
107        Self {
108            dir: project_root.join(".slopguard-cache"),
109            key_prefix: String::new(),
110        }
111    }
112
113    /// Create a cache store that writes directly to the given directory
114    /// instead of appending `.slopguard-cache`.
115    pub fn with_dir(dir: PathBuf) -> Self {
116        Self {
117            dir,
118            key_prefix: String::new(),
119        }
120    }
121
122    /// Scope this store's entries to a ruleset. Entries written by a scan with
123    /// a different active ruleset use a different prefix, so a concurrent scan
124    /// with fewer or disabled rules cannot serve its (correctly empty) result
125    /// to a scan that expects the full ruleset to fire.
126    pub fn scoped_to_rules(mut self, rules_hash: &str) -> Self {
127        self.key_prefix = format!("{rules_hash}-");
128        self
129    }
130
131    pub fn dir(&self) -> &Path {
132        &self.dir
133    }
134
135    fn ensure_dir(&self) -> Result<(), CacheError> {
136        ensure_gitignored_dir(&self.dir)?;
137        Ok(())
138    }
139
140    fn stored_rules_hash(&self) -> Option<String> {
141        fs::read_to_string(self.dir.join(RULES_HASH_FILE)).ok()
142    }
143
144    fn write_rules_hash(&self, hash: &str) -> Result<(), CacheError> {
145        self.ensure_dir()?;
146        fs::write(self.dir.join(RULES_HASH_FILE), hash)?;
147        Ok(())
148    }
149
150    /// Check if the rules have changed since the last scan. If they have,
151    /// invalidate the entire cache.
152    pub fn check_rules_changed(&self, current_hash: &str) -> Result<bool, CacheError> {
153        match self.stored_rules_hash() {
154            Some(stored) if stored == current_hash => Ok(false),
155            _ => {
156                self.invalidate_all()?;
157                self.write_rules_hash(current_hash)?;
158                Ok(true)
159            }
160        }
161    }
162
163    fn invalidate_all(&self) -> Result<(), CacheError> {
164        if self.dir.exists() {
165            for entry in fs::read_dir(&self.dir)? {
166                let entry = entry?;
167                let name = entry.file_name();
168                let name_str = name.to_string_lossy();
169                if name_str != ".gitignore" && name_str != RULES_HASH_FILE {
170                    fs::remove_file(entry.path())?;
171                }
172            }
173        }
174        Ok(())
175    }
176
177    fn entry_path(&self, file_hash: &str) -> PathBuf {
178        self.dir.join(format!("{}{file_hash}.bin", self.key_prefix))
179    }
180
181    /// Look up a file's cached scan result by its content hash.
182    pub fn get(&self, file_hash: &str) -> Option<CacheEntry> {
183        let path = self.entry_path(file_hash);
184        let data = fs::read(&path).ok()?;
185        rmp_serde::from_slice(&data).ok()
186    }
187
188    /// Store a file's scan result, identified by its content hash.
189    pub fn put(&self, file_hash: &str, entry: &CacheEntry) -> Result<(), CacheError> {
190        self.ensure_dir()?;
191        let data = rmp_serde::to_vec_named(entry).map_err(io::Error::other)?;
192        fs::write(self.entry_path(file_hash), data)?;
193        Ok(())
194    }
195
196    /// Remove cache entries whose hash no longer appears in the set of
197    /// current file hashes.
198    pub fn cleanup(&self, current_hashes: &[String]) -> Result<usize, CacheError> {
199        if !self.dir.exists() {
200            return Ok(0);
201        }
202        let mut removed = 0;
203        for entry in fs::read_dir(&self.dir)? {
204            let entry = entry?;
205            let name = entry.file_name();
206            let name_str = name.to_string_lossy();
207            if name_str == ".gitignore" || name_str == RULES_HASH_FILE {
208                continue;
209            }
210            // Only prune entries written for the active ruleset. Entries from a
211            // different ruleset carry a different prefix and are left alone.
212            let Some(rest) = name_str.strip_prefix(&self.key_prefix) else {
213                continue;
214            };
215            let hash = rest.trim_end_matches(".bin");
216            if !current_hashes.iter().any(|h| h == hash) {
217                fs::remove_file(entry.path())?;
218                removed += 1;
219            }
220        }
221        Ok(removed)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use std::path::PathBuf;
228    use std::slice;
229
230    use tempfile::tempdir;
231
232    use super::*;
233    use crate::cross_file::{TestHelper, TraitDecl, TraitImpl, UnassertedTest};
234    use crate::resolution::ImportRef;
235    use crate::rule::{parse_rule, RuleId, Severity};
236
237    fn test_rule() -> Rule {
238        parse_rule(
239            r#"
240id: test-unwrap
241language: rust
242severity: error
243category: correctness
244message: ".unwrap() forbidden"
245rule:
246  kind: call_expression
247  regex: '\.unwrap\(\)\s*$'
248"#,
249        )
250        .unwrap()
251    }
252
253    fn sample_finding() -> Finding {
254        Finding {
255            rule_id: RuleId::from("test-unwrap"),
256            severity: Severity::Error,
257            category: crate::rule::Category::Correctness,
258            message: ".unwrap() forbidden".to_string(),
259            note: None,
260            fix: None,
261            file: PathBuf::from("src/main.rs"),
262            line: 2,
263            column: 5,
264            end_line: 2,
265            end_column: 20,
266            matched_text: "foo().unwrap()".to_string(),
267            confidence: None,
268            escalated: false,
269        }
270    }
271
272    #[test]
273    fn cache_miss() {
274        let dir = tempdir().unwrap();
275        let store = CacheStore::new(dir.path());
276        let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
277
278        assert!(store.get(&hash).is_none());
279    }
280
281    fn entry_with(findings: Vec<Finding>) -> CacheEntry {
282        CacheEntry {
283            findings,
284            ..Default::default()
285        }
286    }
287
288    #[test]
289    fn cache_hit() {
290        let dir = tempdir().unwrap();
291        let store = CacheStore::new(dir.path());
292        let content = b"fn main() { foo().unwrap(); }";
293        let hash = file_content_hash(content);
294
295        store
296            .put(&hash, &entry_with(vec![sample_finding()]))
297            .unwrap();
298        let cached = store.get(&hash).unwrap().findings;
299
300        assert_eq!(cached.len(), 1);
301        assert_eq!(cached[0].rule_id, RuleId::from("test-unwrap"));
302        assert_eq!(cached[0].line, 2);
303        assert_eq!(cached[0].matched_text, "foo().unwrap()");
304    }
305
306    #[test]
307    fn cache_entry_round_trips_symbols() {
308        let dir = tempdir().unwrap();
309        let store = CacheStore::new(dir.path());
310        let hash = file_content_hash(b"pub trait Repository {}");
311        let entry = CacheEntry {
312            findings: Vec::new(),
313            imports: FileImports::default(),
314            symbols: FileSymbols {
315                traits: vec![TraitDecl {
316                    name: "Repository".to_string(),
317                    line: 1,
318                    column: 1,
319                    end_line: 1,
320                    end_column: 21,
321                    in_cfg_test: false,
322                    header: "pub trait Repository".to_string(),
323                }],
324                impls: vec![TraitImpl {
325                    trait_name: "Repository".to_string(),
326                    blanket: false,
327                    self_type: "PostgresRepository".to_string(),
328                }],
329                error_messages: Vec::new(),
330                types: vec!["PostgresRepository".to_string()],
331                helpers: vec![TestHelper {
332                    name: "check".to_string(),
333                    asserts: true,
334                    calls: vec!["parse".to_string()],
335                    in_cfg_test: true,
336                }],
337                unasserted_tests: vec![UnassertedTest {
338                    line: 3,
339                    column: 1,
340                    end_line: 5,
341                    end_column: 2,
342                    text: "fn t() {\n    check();\n}".to_string(),
343                    calls: vec!["check".to_string()],
344                }],
345            },
346        };
347
348        store.put(&hash, &entry).unwrap();
349        let cached = store.get(&hash).unwrap();
350
351        assert_eq!(cached.symbols.traits.len(), 1);
352        assert_eq!(cached.symbols.traits[0].name, "Repository");
353        assert_eq!(cached.symbols.traits[0].header, "pub trait Repository");
354        assert_eq!(cached.symbols.impls.len(), 1);
355        assert_eq!(cached.symbols.impls[0].trait_name, "Repository");
356        assert!(!cached.symbols.impls[0].blanket);
357        assert_eq!(cached.symbols.impls[0].self_type, "PostgresRepository");
358        assert_eq!(cached.symbols.types, vec!["PostgresRepository".to_string()]);
359        assert_eq!(cached.symbols.helpers.len(), 1);
360        assert_eq!(cached.symbols.helpers[0].name, "check");
361        assert!(cached.symbols.helpers[0].asserts);
362        assert_eq!(cached.symbols.helpers[0].calls, vec!["parse".to_string()]);
363        assert!(cached.symbols.helpers[0].in_cfg_test);
364        assert_eq!(cached.symbols.unasserted_tests.len(), 1);
365        assert_eq!(cached.symbols.unasserted_tests[0].line, 3);
366        assert_eq!(
367            cached.symbols.unasserted_tests[0].text,
368            "fn t() {\n    check();\n}"
369        );
370        assert_eq!(
371            cached.symbols.unasserted_tests[0].calls,
372            vec!["check".to_string()]
373        );
374    }
375
376    #[test]
377    fn cache_entry_round_trips_imports() {
378        let dir = tempdir().unwrap();
379        let store = CacheStore::new(dir.path());
380        let hash = file_content_hash(b"use made_up_crate::Thing;");
381        let entry = CacheEntry {
382            imports: FileImports {
383                imports: vec![ImportRef {
384                    specifier: "made_up_crate".to_string(),
385                    line: 1,
386                    column: 1,
387                    end_line: 1,
388                    end_column: 26,
389                    matched_text: "use made_up_crate::Thing;".to_string(),
390                }],
391                local_names: vec!["foo".to_string()],
392            },
393            ..Default::default()
394        };
395
396        store.put(&hash, &entry).unwrap();
397        let cached = store.get(&hash).unwrap();
398
399        assert_eq!(cached.imports.imports.len(), 1);
400        assert_eq!(cached.imports.imports[0].specifier, "made_up_crate");
401        assert_eq!(cached.imports.local_names, vec!["foo".to_string()]);
402    }
403
404    #[test]
405    fn invalidation_file_change() {
406        let dir = tempdir().unwrap();
407        let store = CacheStore::new(dir.path());
408
409        let content_v1 = b"fn main() { foo().unwrap(); }";
410        let content_v2 = b"fn main() { foo()?; }";
411        let hash_v1 = file_content_hash(content_v1);
412        let hash_v2 = file_content_hash(content_v2);
413
414        store
415            .put(&hash_v1, &entry_with(vec![sample_finding()]))
416            .unwrap();
417
418        assert!(store.get(&hash_v1).is_some());
419        assert!(store.get(&hash_v2).is_none());
420    }
421
422    #[test]
423    fn invalidation_rule_change() {
424        let dir = tempdir().unwrap();
425        let store = CacheStore::new(dir.path());
426
427        let rules_v1 = [test_rule()];
428        let hash_v1 = rules_hash(&rules_v1);
429
430        let content_hash = file_content_hash(b"fn main() {}");
431        store.put(&content_hash, &entry_with(Vec::new())).unwrap();
432        store.write_rules_hash(&hash_v1).unwrap();
433
434        assert!(store.get(&content_hash).is_some());
435
436        let mut rule_v2 = test_rule();
437        rule_v2.id = RuleId::from("different-rule");
438        let rules_v2 = [rule_v2];
439        let hash_v2 = rules_hash(&rules_v2);
440        assert_ne!(hash_v1, hash_v2);
441
442        store.check_rules_changed(&hash_v2).unwrap();
443
444        assert!(
445            store.get(&content_hash).is_none(),
446            "cache entries should be invalidated when rules change"
447        );
448    }
449
450    #[test]
451    fn cleanup_orphan_entries() {
452        let dir = tempdir().unwrap();
453        let store = CacheStore::new(dir.path());
454
455        let hash_a = file_content_hash(b"file a");
456        let hash_b = file_content_hash(b"file b");
457        store.put(&hash_a, &entry_with(Vec::new())).unwrap();
458        store
459            .put(&hash_b, &entry_with(vec![sample_finding()]))
460            .unwrap();
461
462        let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
463
464        assert_eq!(removed, 1);
465        assert!(store.get(&hash_a).is_some());
466        assert!(store.get(&hash_b).is_none());
467    }
468
469    #[test]
470    fn corrupted_cache_entry() {
471        let dir = tempdir().unwrap();
472        let store = CacheStore::new(dir.path());
473
474        store.ensure_dir().unwrap();
475        let hash = file_content_hash(b"some content");
476        fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
477
478        assert!(
479            store.get(&hash).is_none(),
480            "corrupted entry should return None, not panic"
481        );
482    }
483}