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