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