Skip to main content

slopguard_core/
cache.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use sha2::{Digest, Sha256};
6use thiserror::Error;
7
8use crate::finding::Finding;
9use crate::rule::Rule;
10
11const RULES_HASH_FILE: &str = "rules.hash";
12const CACHE_GITIGNORE: &str = "*\n";
13
14/// Hex-encoded SHA256 of `data`. Shared by every cache key in the workspace.
15pub fn sha256_hex(data: &[u8]) -> String {
16    let mut hasher = Sha256::new();
17    hasher.update(data);
18    format!("{:x}", hasher.finalize())
19}
20
21/// Create `dir` (and parents) if missing and drop a `.gitignore` that ignores
22/// the entire cache directory. Idempotent.
23///
24/// # Errors
25///
26/// Returns an [`io::Error`] if the directory or `.gitignore` cannot be written.
27pub fn ensure_gitignored_dir(dir: &Path) -> Result<(), io::Error> {
28    if !dir.exists() {
29        fs::create_dir_all(dir)?;
30        fs::write(dir.join(".gitignore"), CACHE_GITIGNORE)?;
31    }
32    Ok(())
33}
34
35#[derive(Debug, Error)]
36pub enum CacheError {
37    #[error("cache I/O error: {0}")]
38    Io(#[from] io::Error),
39}
40
41/// Compute a stable hash of the active rules by sorting their ids and hashing
42/// the concatenated id list together with each rule's YAML-serialized body.
43pub fn rules_hash(rules: &[Rule]) -> String {
44    let mut ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
45    ids.sort();
46
47    let mut hasher = Sha256::new();
48    for id in &ids {
49        hasher.update(id.as_bytes());
50        hasher.update(b"\0");
51    }
52    format!("{:x}", hasher.finalize())
53}
54
55/// Compute the SHA256 hash of a file's content.
56pub fn file_content_hash(content: &[u8]) -> String {
57    sha256_hex(content)
58}
59
60/// A file-based cache store for scan findings.
61pub struct CacheStore {
62    dir: PathBuf,
63    /// Prepended to every entry filename so that scans run with different
64    /// rulesets never collide on the same content-hashed entry. Empty when the
65    /// store is not scoped to a ruleset.
66    key_prefix: String,
67}
68
69impl CacheStore {
70    pub fn new(project_root: &Path) -> Self {
71        Self {
72            dir: project_root.join(".slopguard-cache"),
73            key_prefix: String::new(),
74        }
75    }
76
77    /// Create a cache store that writes directly to the given directory
78    /// instead of appending `.slopguard-cache`.
79    pub fn with_dir(dir: PathBuf) -> Self {
80        Self {
81            dir,
82            key_prefix: String::new(),
83        }
84    }
85
86    /// Scope this store's entries to a ruleset. Entries written by a scan with
87    /// a different active ruleset use a different prefix, so a concurrent scan
88    /// with fewer or disabled rules cannot serve its (correctly empty) result
89    /// to a scan that expects the full ruleset to fire.
90    pub fn scoped_to_rules(mut self, rules_hash: &str) -> Self {
91        self.key_prefix = format!("{rules_hash}-");
92        self
93    }
94
95    pub fn dir(&self) -> &Path {
96        &self.dir
97    }
98
99    fn ensure_dir(&self) -> Result<(), CacheError> {
100        ensure_gitignored_dir(&self.dir)?;
101        Ok(())
102    }
103
104    fn stored_rules_hash(&self) -> Option<String> {
105        fs::read_to_string(self.dir.join(RULES_HASH_FILE)).ok()
106    }
107
108    fn write_rules_hash(&self, hash: &str) -> Result<(), CacheError> {
109        self.ensure_dir()?;
110        fs::write(self.dir.join(RULES_HASH_FILE), hash)?;
111        Ok(())
112    }
113
114    /// Check if the rules have changed since the last scan. If they have,
115    /// invalidate the entire cache.
116    pub fn check_rules_changed(&self, current_hash: &str) -> Result<bool, CacheError> {
117        match self.stored_rules_hash() {
118            Some(stored) if stored == current_hash => Ok(false),
119            _ => {
120                self.invalidate_all()?;
121                self.write_rules_hash(current_hash)?;
122                Ok(true)
123            }
124        }
125    }
126
127    fn invalidate_all(&self) -> Result<(), CacheError> {
128        if self.dir.exists() {
129            for entry in fs::read_dir(&self.dir)? {
130                let entry = entry?;
131                let name = entry.file_name();
132                let name_str = name.to_string_lossy();
133                if name_str != ".gitignore" && name_str != RULES_HASH_FILE {
134                    fs::remove_file(entry.path())?;
135                }
136            }
137        }
138        Ok(())
139    }
140
141    fn entry_path(&self, file_hash: &str) -> PathBuf {
142        self.dir.join(format!("{}{file_hash}.bin", self.key_prefix))
143    }
144
145    /// Look up cached findings for a file by its content hash.
146    pub fn get(&self, file_hash: &str) -> Option<Vec<Finding>> {
147        let path = self.entry_path(file_hash);
148        let data = fs::read(&path).ok()?;
149        rmp_serde::from_slice(&data).ok()
150    }
151
152    /// Store findings for a file identified by its content hash.
153    pub fn put(&self, file_hash: &str, findings: &[Finding]) -> Result<(), CacheError> {
154        self.ensure_dir()?;
155        let data = rmp_serde::to_vec_named(findings).map_err(io::Error::other)?;
156        fs::write(self.entry_path(file_hash), data)?;
157        Ok(())
158    }
159
160    /// Remove cache entries whose hash no longer appears in the set of
161    /// current file hashes.
162    pub fn cleanup(&self, current_hashes: &[String]) -> Result<usize, CacheError> {
163        if !self.dir.exists() {
164            return Ok(0);
165        }
166        let mut removed = 0;
167        for entry in fs::read_dir(&self.dir)? {
168            let entry = entry?;
169            let name = entry.file_name();
170            let name_str = name.to_string_lossy();
171            if name_str == ".gitignore" || name_str == RULES_HASH_FILE {
172                continue;
173            }
174            // Only prune entries written for the active ruleset. Entries from a
175            // different ruleset carry a different prefix and are left alone.
176            let Some(rest) = name_str.strip_prefix(&self.key_prefix) else {
177                continue;
178            };
179            let hash = rest.trim_end_matches(".bin");
180            if !current_hashes.iter().any(|h| h == hash) {
181                fs::remove_file(entry.path())?;
182                removed += 1;
183            }
184        }
185        Ok(removed)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use std::path::PathBuf;
192    use std::slice;
193
194    use tempfile::tempdir;
195
196    use super::*;
197    use crate::rule::{parse_rule, RuleId, Severity};
198
199    fn test_rule() -> Rule {
200        parse_rule(
201            r#"
202id: test-unwrap
203language: rust
204severity: error
205category: correctness
206message: ".unwrap() forbidden"
207rule:
208  kind: call_expression
209  regex: '\.unwrap\(\)\s*$'
210"#,
211        )
212        .unwrap()
213    }
214
215    fn sample_finding() -> Finding {
216        Finding {
217            rule_id: RuleId::from("test-unwrap"),
218            severity: Severity::Error,
219            category: crate::rule::Category::Correctness,
220            message: ".unwrap() forbidden".to_string(),
221            note: None,
222            fix: None,
223            file: PathBuf::from("src/main.rs"),
224            line: 2,
225            column: 5,
226            end_line: 2,
227            end_column: 20,
228            matched_text: "foo().unwrap()".to_string(),
229            confidence: None,
230            escalated: false,
231        }
232    }
233
234    #[test]
235    fn cache_miss() {
236        let dir = tempdir().unwrap();
237        let store = CacheStore::new(dir.path());
238        let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
239
240        assert!(store.get(&hash).is_none());
241    }
242
243    #[test]
244    fn cache_hit() {
245        let dir = tempdir().unwrap();
246        let store = CacheStore::new(dir.path());
247        let content = b"fn main() { foo().unwrap(); }";
248        let hash = file_content_hash(content);
249        let findings = vec![sample_finding()];
250
251        store.put(&hash, &findings).unwrap();
252        let cached = store.get(&hash).unwrap();
253
254        assert_eq!(cached.len(), 1);
255        assert_eq!(cached[0].rule_id, RuleId::from("test-unwrap"));
256        assert_eq!(cached[0].line, 2);
257        assert_eq!(cached[0].matched_text, "foo().unwrap()");
258    }
259
260    #[test]
261    fn invalidation_file_change() {
262        let dir = tempdir().unwrap();
263        let store = CacheStore::new(dir.path());
264
265        let content_v1 = b"fn main() { foo().unwrap(); }";
266        let content_v2 = b"fn main() { foo()?; }";
267        let hash_v1 = file_content_hash(content_v1);
268        let hash_v2 = file_content_hash(content_v2);
269
270        store.put(&hash_v1, &[sample_finding()]).unwrap();
271
272        assert!(store.get(&hash_v1).is_some());
273        assert!(store.get(&hash_v2).is_none());
274    }
275
276    #[test]
277    fn invalidation_rule_change() {
278        let dir = tempdir().unwrap();
279        let store = CacheStore::new(dir.path());
280
281        let rules_v1 = [test_rule()];
282        let hash_v1 = rules_hash(&rules_v1);
283
284        let content_hash = file_content_hash(b"fn main() {}");
285        store.put(&content_hash, &[]).unwrap();
286        store.write_rules_hash(&hash_v1).unwrap();
287
288        assert!(store.get(&content_hash).is_some());
289
290        let mut rule_v2 = test_rule();
291        rule_v2.id = RuleId::from("different-rule");
292        let rules_v2 = [rule_v2];
293        let hash_v2 = rules_hash(&rules_v2);
294        assert_ne!(hash_v1, hash_v2);
295
296        store.check_rules_changed(&hash_v2).unwrap();
297
298        assert!(
299            store.get(&content_hash).is_none(),
300            "cache entries should be invalidated when rules change"
301        );
302    }
303
304    #[test]
305    fn cleanup_orphan_entries() {
306        let dir = tempdir().unwrap();
307        let store = CacheStore::new(dir.path());
308
309        let hash_a = file_content_hash(b"file a");
310        let hash_b = file_content_hash(b"file b");
311        store.put(&hash_a, &[]).unwrap();
312        store.put(&hash_b, &[sample_finding()]).unwrap();
313
314        let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
315
316        assert_eq!(removed, 1);
317        assert!(store.get(&hash_a).is_some());
318        assert!(store.get(&hash_b).is_none());
319    }
320
321    #[test]
322    fn corrupted_cache_entry() {
323        let dir = tempdir().unwrap();
324        let store = CacheStore::new(dir.path());
325
326        store.ensure_dir().unwrap();
327        let hash = file_content_hash(b"some content");
328        fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
329
330        assert!(
331            store.get(&hash).is_none(),
332            "corrupted entry should return None, not panic"
333        );
334    }
335}