Skip to main content

slopguard_core/
cache.rs

1use std::fmt;
2use std::fs::{self, OpenOptions};
3use std::io::{self, Write};
4#[cfg(unix)]
5use std::os::unix::fs::OpenOptionsExt;
6use std::path::{Path, PathBuf};
7
8use getrandom::fill;
9use hmac::{Hmac, Mac};
10use log::debug;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14
15use crate::cross_file::FileSymbols;
16use crate::finding::Finding;
17use crate::resolution::FileImports;
18use crate::rule::Rule;
19
20const RULES_HASH_FILE: &str = "rules.hash";
21/// Extension of every cache entry file.
22const CACHE_ENTRY_EXT: &str = "bin";
23const CACHE_GITIGNORE: &str = "*\n";
24
25/// Version of the on-disk entry format. Mixed into the rules hash so a bump
26/// drops every stale entry instead of trying to deserialize it into the new
27/// shape.
28const CACHE_SCHEMA_VERSION: &str = "v6";
29
30/// Name of the signing key file, kept in the user cache directory.
31const KEY_FILE: &str = "cache.key";
32/// Length of the signing key, in bytes.
33const KEY_LEN: usize = 32;
34/// Length of the HMAC-SHA256 tag prepended to every signed entry.
35const MAC_LEN: usize = 32;
36
37type HmacSha256 = Hmac<Sha256>;
38
39/// What one scanned file leaves in the cache: its findings, the symbols it
40/// contributes to the cross-file index, and the imports it contributes to the
41/// resolution pass, so a cache hit feeds both project-wide passes without
42/// re-parsing the file.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct CacheEntry {
45    pub findings: Vec<Finding>,
46    pub symbols: FileSymbols,
47    #[serde(default)]
48    pub imports: FileImports,
49}
50
51/// Hex-encoded SHA256 of `data`. Shared by every cache key in the workspace.
52pub fn sha256_hex(data: &[u8]) -> String {
53    let mut hasher = Sha256::new();
54    hasher.update(data);
55    format!("{:x}", hasher.finalize())
56}
57
58/// Create `dir` (and parents) if missing and drop a `.gitignore` that ignores
59/// the entire cache directory. Idempotent.
60///
61/// # Errors
62///
63/// Returns an [`io::Error`] if the directory or `.gitignore` cannot be written.
64pub fn ensure_gitignored_dir(dir: &Path) -> Result<(), io::Error> {
65    if !dir.exists() {
66        fs::create_dir_all(dir)?;
67        fs::write(dir.join(".gitignore"), CACHE_GITIGNORE)?;
68    }
69    Ok(())
70}
71
72#[derive(Debug, Error)]
73pub enum CacheError {
74    #[error("cache I/O error: {0}")]
75    Io(#[from] io::Error),
76    #[error("cache key file {} is not a regular file of 32 bytes", .0.display())]
77    InvalidKey(PathBuf),
78}
79
80/// Root of every slopguard cache for the current user (`$XDG_CACHE_HOME/slopguard`
81/// or the platform equivalent). It lives outside any scanned repository, so a
82/// repository cannot ship cache entries of its own.
83pub fn user_cache_dir() -> PathBuf {
84    dirs::cache_dir()
85        .unwrap_or_else(|| PathBuf::from(".cache"))
86        .join("slopguard")
87}
88
89/// Default scan cache directory for the project rooted at `project_root`: a
90/// subdirectory of [`user_cache_dir`] named after a hash of the canonical root,
91/// so two projects never share (nor prune) each other's entries.
92pub fn project_cache_dir(project_root: &Path) -> PathBuf {
93    let root = project_root
94        .canonicalize()
95        .unwrap_or_else(|_| project_root.to_path_buf());
96    let digest = sha256_hex(root.as_os_str().as_encoded_bytes());
97    user_cache_dir().join("projects").join(&digest[..16])
98}
99
100/// Secret key signing every cache entry with HMAC-SHA256, so an entry written
101/// without the key (a committed or planted file) is never trusted.
102#[derive(Clone)]
103pub struct CacheKey([u8; KEY_LEN]);
104
105impl fmt::Debug for CacheKey {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str("CacheKey(..)")
108    }
109}
110
111/// State of the key file on disk.
112enum KeyFile {
113    Valid(CacheKey),
114    Missing,
115    /// A regular file whose length is not [`KEY_LEN`].
116    WrongLength,
117}
118
119impl CacheKey {
120    /// Wrap raw key bytes.
121    pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
122        Self(bytes)
123    }
124
125    /// Load the key stored in `<dir>/cache.key`, creating it (random bytes,
126    /// mode 0600 on unix) when missing. A key file of the wrong length is
127    /// replaced, which invalidates every entry signed with the old one.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`CacheError::InvalidKey`] when the key path is not a regular
132    /// file (a symlink is never followed), and [`CacheError::Io`] when the key
133    /// cannot be read, generated or written.
134    pub fn load_or_create(dir: &Path) -> Result<Self, CacheError> {
135        let path = dir.join(KEY_FILE);
136        match read_key_file(&path)? {
137            KeyFile::Valid(key) => return Ok(key),
138            KeyFile::Missing => {}
139            KeyFile::WrongLength => {
140                debug!(
141                    "cache key {} has a wrong length, regenerating",
142                    path.display()
143                );
144                fs::remove_file(&path)?;
145            }
146        }
147        let mut bytes = [0u8; KEY_LEN];
148        fill(&mut bytes).map_err(|err| io::Error::other(err.to_string()))?;
149        fs::create_dir_all(dir)?;
150        match create_key_file(&path, &bytes) {
151            Ok(()) => Ok(Self(bytes)),
152            // A concurrent run created the key first: use theirs. A file still
153            // being written is neither trusted nor removed.
154            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => match read_key_file(&path)? {
155                KeyFile::Valid(key) => Ok(key),
156                KeyFile::Missing | KeyFile::WrongLength => Err(CacheError::InvalidKey(path)),
157            },
158            Err(err) => Err(err.into()),
159        }
160    }
161
162    /// [`CacheKey::load_or_create`] in [`user_cache_dir`].
163    ///
164    /// # Errors
165    ///
166    /// Same as [`CacheKey::load_or_create`].
167    pub fn load_or_create_default() -> Result<Self, CacheError> {
168        Self::load_or_create(&user_cache_dir())
169    }
170
171    fn mac(&self, name: &str, payload: &[u8]) -> Result<HmacSha256, CacheError> {
172        let mut mac =
173            HmacSha256::new_from_slice(&self.0).map_err(|err| io::Error::other(err.to_string()))?;
174        mac.update(name.as_bytes());
175        mac.update(b"\0");
176        mac.update(payload);
177        Ok(mac)
178    }
179
180    /// Sign `payload` for the entry called `name`: the 32-byte HMAC-SHA256 tag
181    /// followed by the payload. Binding the name stops a validly signed entry
182    /// from being moved onto another key.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`CacheError::Io`] if the HMAC cannot be initialized.
187    pub fn seal(&self, name: &str, payload: &[u8]) -> Result<Vec<u8>, CacheError> {
188        let tag = self.mac(name, payload)?.finalize().into_bytes();
189        let mut out = Vec::with_capacity(MAC_LEN + payload.len());
190        out.extend_from_slice(&tag);
191        out.extend_from_slice(payload);
192        Ok(out)
193    }
194
195    /// Verify data produced by [`CacheKey::seal`] for the entry called `name`
196    /// and return its payload. A missing, truncated or invalid tag (compared in
197    /// constant time) yields `None`, so the caller recomputes the entry.
198    pub fn open<'a>(&self, name: &str, data: &'a [u8]) -> Option<&'a [u8]> {
199        if data.len() >= MAC_LEN {
200            let (tag, payload) = data.split_at(MAC_LEN);
201            let valid = self
202                .mac(name, payload)
203                .is_ok_and(|mac| mac.verify_slice(tag).is_ok());
204            if valid {
205                return Some(payload);
206            }
207        }
208        debug!("cache entry {name}: missing or invalid HMAC, ignoring");
209        None
210    }
211}
212
213/// Read the key file at `path` without following a symlink.
214fn read_key_file(path: &Path) -> Result<KeyFile, CacheError> {
215    let meta = match fs::symlink_metadata(path) {
216        Ok(meta) => meta,
217        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(KeyFile::Missing),
218        Err(err) => return Err(err.into()),
219    };
220    if !meta.file_type().is_file() {
221        return Err(CacheError::InvalidKey(path.to_path_buf()));
222    }
223    let data = fs::read(path)?;
224    Ok(match <[u8; KEY_LEN]>::try_from(data.as_slice()) {
225        Ok(bytes) => KeyFile::Valid(CacheKey(bytes)),
226        Err(_) => KeyFile::WrongLength,
227    })
228}
229
230/// Create the key file, failing if it already exists. Owner-only on unix.
231fn create_key_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
232    let mut options = OpenOptions::new();
233    options.write(true).create_new(true);
234    #[cfg(unix)]
235    options.mode(0o600);
236    let mut file = options.open(path)?;
237    file.write_all(bytes)?;
238    file.sync_all()
239}
240
241/// Compute a stable hash of the active rules by sorting them by id and hashing
242/// each rule's serialized body along with the scanner's own version.
243///
244/// The body is mixed in so editing a rule (custom or builtin) invalidates the
245/// cache even when its id is unchanged. `CARGO_PKG_VERSION` is mixed in because
246/// findings also depend on the scanner's built-in logic (metric computation,
247/// test-path detection, cross-file passes): a new binary must drop stale
248/// entries even when the rules are byte-for-byte identical.
249pub fn rules_hash(rules: &[Rule]) -> String {
250    let mut sorted: Vec<&Rule> = rules.iter().collect();
251    sorted.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
252
253    let mut hasher = Sha256::new();
254    hasher.update(CACHE_SCHEMA_VERSION.as_bytes());
255    hasher.update(b"\0");
256    hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
257    hasher.update(b"\0");
258    for rule in &sorted {
259        hasher.update(rule.id.as_str().as_bytes());
260        hasher.update(b"\0");
261        if let Ok(body) = serde_json::to_vec(rule) {
262            hasher.update(&body);
263        }
264        hasher.update(b"\0");
265    }
266    format!("{:x}", hasher.finalize())
267}
268
269/// Compute the SHA256 hash of a file's content.
270// Domain name over the generic sha256_hex, used across the scanner.
271// slopguard-disable-next-line no-trivial-function
272pub fn file_content_hash(content: &[u8]) -> String {
273    sha256_hex(content)
274}
275
276/// A file-based cache store for scan findings. Every entry is signed with the
277/// store's [`CacheKey`]; an unsigned or tampered entry reads as a miss.
278pub struct CacheStore {
279    dir: PathBuf,
280    /// Prepended to every entry filename so that scans run with different
281    /// rulesets never collide on the same content-hashed entry. Empty when the
282    /// store is not scoped to a ruleset.
283    key_prefix: String,
284    key: CacheKey,
285}
286
287impl CacheStore {
288    /// Create a cache store that writes directly to `dir`, signing its entries
289    /// with `key`.
290    pub fn with_dir(dir: PathBuf, key: CacheKey) -> Self {
291        Self {
292            dir,
293            key_prefix: String::new(),
294            key,
295        }
296    }
297
298    /// Scope this store's entries to a ruleset. Entries written by a scan with
299    /// a different active ruleset use a different prefix, so a concurrent scan
300    /// with fewer or disabled rules cannot serve its (correctly empty) result
301    /// to a scan that expects the full ruleset to fire.
302    pub fn scoped_to_rules(mut self, rules_hash: &str) -> Self {
303        self.key_prefix = format!("{rules_hash}-");
304        self
305    }
306
307    pub fn dir(&self) -> &Path {
308        &self.dir
309    }
310
311    fn ensure_dir(&self) -> Result<(), CacheError> {
312        ensure_gitignored_dir(&self.dir)?;
313        Ok(())
314    }
315
316    fn stored_rules_hash(&self) -> Option<String> {
317        fs::read_to_string(self.dir.join(RULES_HASH_FILE)).ok()
318    }
319
320    fn write_rules_hash(&self, hash: &str) -> Result<(), CacheError> {
321        self.ensure_dir()?;
322        fs::write(self.dir.join(RULES_HASH_FILE), hash)?;
323        Ok(())
324    }
325
326    /// Check if the rules have changed since the last scan. If they have,
327    /// invalidate the entire cache.
328    pub fn check_rules_changed(&self, current_hash: &str) -> Result<bool, CacheError> {
329        match self.stored_rules_hash() {
330            Some(stored) if stored == current_hash => Ok(false),
331            _ => {
332                self.invalidate_all()?;
333                self.write_rules_hash(current_hash)?;
334                Ok(true)
335            }
336        }
337    }
338
339    /// Remove every cache entry, sparing anything slopguard did not write.
340    ///
341    /// The directory may be user-supplied (`--cache-dir`, env var, config), so
342    /// a directory without the `rules.hash` manifest is not treated as a cache
343    /// and is left untouched. Inside a cache, only regular files named like an
344    /// entry are removed: symlinks, sub-directories and foreign files survive.
345    fn invalidate_all(&self) -> Result<(), CacheError> {
346        if !self.dir.join(RULES_HASH_FILE).is_file() {
347            return Ok(());
348        }
349        for entry in fs::read_dir(&self.dir)? {
350            let entry = entry?;
351            // `file_type` does not follow symlinks.
352            if !entry.file_type()?.is_file() {
353                continue;
354            }
355            if is_cache_entry_name(&entry.file_name().to_string_lossy()) {
356                fs::remove_file(entry.path())?;
357            }
358        }
359        Ok(())
360    }
361
362    fn entry_name(&self, file_hash: &str) -> String {
363        format!("{}{file_hash}.{CACHE_ENTRY_EXT}", self.key_prefix)
364    }
365
366    fn entry_path(&self, file_hash: &str) -> PathBuf {
367        self.dir.join(self.entry_name(file_hash))
368    }
369
370    /// Look up a file's cached scan result by its content hash. An entry whose
371    /// signature does not verify is ignored.
372    pub fn get(&self, file_hash: &str) -> Option<CacheEntry> {
373        let name = self.entry_name(file_hash);
374        let data = fs::read(self.entry_path(file_hash)).ok()?;
375        let payload = self.key.open(&name, &data)?;
376        match rmp_serde::from_slice(payload) {
377            Ok(entry) => Some(entry),
378            Err(err) => {
379                debug!("cache entry {name}: undecodable ({err}), ignoring");
380                None
381            }
382        }
383    }
384
385    /// Store a file's scan result, identified by its content hash and signed
386    /// with the store's key.
387    pub fn put(&self, file_hash: &str, entry: &CacheEntry) -> Result<(), CacheError> {
388        self.ensure_dir()?;
389        let name = self.entry_name(file_hash);
390        let data = rmp_serde::to_vec_named(entry).map_err(io::Error::other)?;
391        fs::write(self.entry_path(file_hash), self.key.seal(&name, &data)?)?;
392        Ok(())
393    }
394
395    /// Remove cache entries whose hash no longer appears in the set of
396    /// current file hashes.
397    pub fn cleanup(&self, current_hashes: &[String]) -> Result<usize, CacheError> {
398        if !self.dir.exists() {
399            return Ok(0);
400        }
401        let mut removed = 0;
402        for entry in fs::read_dir(&self.dir)? {
403            let entry = entry?;
404            let name = entry.file_name();
405            let name_str = name.to_string_lossy();
406            // Like `invalidate_all`, never touch what slopguard did not write.
407            if !entry.file_type()?.is_file() || !is_cache_entry_name(&name_str) {
408                continue;
409            }
410            // Only prune entries written for the active ruleset. Entries from a
411            // different ruleset carry a different prefix and are left alone.
412            let Some(hash) = name_str
413                .strip_prefix(&self.key_prefix)
414                .and_then(strip_entry_ext)
415            else {
416                continue;
417            };
418            if !current_hashes.iter().any(|h| h == hash) {
419                fs::remove_file(entry.path())?;
420                removed += 1;
421            }
422        }
423        Ok(removed)
424    }
425}
426
427/// `name` without its `.bin` extension, or `None` when it has another one.
428fn strip_entry_ext(name: &str) -> Option<&str> {
429    name.strip_suffix(CACHE_ENTRY_EXT)?.strip_suffix('.')
430}
431
432/// Whether `name` is shaped like a cache entry: `{rules_hash}-{file_hash}.bin`
433/// or `{file_hash}.bin`, with a non-empty stem of hex digits and dashes.
434fn is_cache_entry_name(name: &str) -> bool {
435    strip_entry_ext(name).is_some_and(|stem| {
436        !stem.is_empty() && stem.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
437    })
438}
439
440#[cfg(test)]
441mod tests {
442    #[cfg(unix)]
443    use std::os::unix::fs::symlink;
444    use std::path::PathBuf;
445    use std::slice;
446
447    use tempfile::tempdir;
448
449    use super::*;
450    use crate::cross_file::{TestHelper, TraitDecl, TraitImpl, UnassertedTest};
451    use crate::resolution::ImportRef;
452    use crate::rule::{parse_rule, RuleId, Severity};
453
454    fn test_key() -> CacheKey {
455        CacheKey::from_bytes([7; 32])
456    }
457
458    fn test_rule() -> Rule {
459        parse_rule(
460            r#"
461id: test-unwrap
462language: rust
463severity: error
464category: correctness
465message: ".unwrap() forbidden"
466rule:
467  kind: call_expression
468  regex: '\.unwrap\(\)\s*$'
469"#,
470        )
471        .unwrap()
472    }
473
474    fn sample_finding() -> Finding {
475        Finding {
476            rule_id: RuleId::from("test-unwrap"),
477            severity: Severity::Error,
478            category: crate::rule::Category::Correctness,
479            message: ".unwrap() forbidden".to_string(),
480            note: None,
481            fix: None,
482            file: PathBuf::from("src/main.rs"),
483            line: 2,
484            column: 5,
485            end_line: 2,
486            end_column: 20,
487            matched_text: "foo().unwrap()".to_string(),
488            confidence: None,
489            escalated: false,
490        }
491    }
492
493    #[test]
494    fn cache_miss() {
495        let dir = tempdir().unwrap();
496        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
497        let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
498
499        assert!(store.get(&hash).is_none());
500    }
501
502    fn entry_with(findings: Vec<Finding>) -> CacheEntry {
503        CacheEntry {
504            findings,
505            ..Default::default()
506        }
507    }
508
509    #[test]
510    fn cache_hit() {
511        let dir = tempdir().unwrap();
512        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
513        let content = b"fn main() { foo().unwrap(); }";
514        let hash = file_content_hash(content);
515
516        store
517            .put(&hash, &entry_with(vec![sample_finding()]))
518            .unwrap();
519        let cached = store.get(&hash).unwrap().findings;
520
521        assert_eq!(cached.len(), 1);
522        assert_eq!(cached[0].rule_id, RuleId::from("test-unwrap"));
523        assert_eq!(cached[0].line, 2);
524        assert_eq!(cached[0].matched_text, "foo().unwrap()");
525    }
526
527    #[test]
528    fn cache_entry_round_trips_symbols() {
529        let dir = tempdir().unwrap();
530        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
531        let hash = file_content_hash(b"pub trait Repository {}");
532        let entry = CacheEntry {
533            findings: Vec::new(),
534            imports: FileImports::default(),
535            symbols: FileSymbols {
536                traits: vec![TraitDecl {
537                    name: "Repository".to_string(),
538                    line: 1,
539                    column: 1,
540                    end_line: 1,
541                    end_column: 21,
542                    in_cfg_test: false,
543                    header: "pub trait Repository".to_string(),
544                }],
545                impls: vec![TraitImpl {
546                    trait_name: "Repository".to_string(),
547                    blanket: false,
548                    self_type: "PostgresRepository".to_string(),
549                }],
550                error_messages: Vec::new(),
551                types: vec!["PostgresRepository".to_string()],
552                helpers: vec![TestHelper {
553                    name: "check".to_string(),
554                    asserts: true,
555                    calls: vec!["parse".to_string()],
556                    in_cfg_test: true,
557                }],
558                unasserted_tests: vec![UnassertedTest {
559                    line: 3,
560                    column: 1,
561                    end_line: 5,
562                    end_column: 2,
563                    text: "fn t() {\n    check();\n}".to_string(),
564                    calls: vec!["check".to_string()],
565                }],
566            },
567        };
568
569        store.put(&hash, &entry).unwrap();
570        let cached = store.get(&hash).unwrap();
571
572        assert_eq!(cached.symbols.traits.len(), 1);
573        assert_eq!(cached.symbols.traits[0].name, "Repository");
574        assert_eq!(cached.symbols.traits[0].header, "pub trait Repository");
575        assert_eq!(cached.symbols.impls.len(), 1);
576        assert_eq!(cached.symbols.impls[0].trait_name, "Repository");
577        assert!(!cached.symbols.impls[0].blanket);
578        assert_eq!(cached.symbols.impls[0].self_type, "PostgresRepository");
579        assert_eq!(cached.symbols.types, vec!["PostgresRepository".to_string()]);
580        assert_eq!(cached.symbols.helpers.len(), 1);
581        assert_eq!(cached.symbols.helpers[0].name, "check");
582        assert!(cached.symbols.helpers[0].asserts);
583        assert_eq!(cached.symbols.helpers[0].calls, vec!["parse".to_string()]);
584        assert!(cached.symbols.helpers[0].in_cfg_test);
585        assert_eq!(cached.symbols.unasserted_tests.len(), 1);
586        assert_eq!(cached.symbols.unasserted_tests[0].line, 3);
587        assert_eq!(
588            cached.symbols.unasserted_tests[0].text,
589            "fn t() {\n    check();\n}"
590        );
591        assert_eq!(
592            cached.symbols.unasserted_tests[0].calls,
593            vec!["check".to_string()]
594        );
595    }
596
597    #[test]
598    fn cache_entry_round_trips_imports() {
599        let dir = tempdir().unwrap();
600        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
601        let hash = file_content_hash(b"use made_up_crate::Thing;");
602        let entry = CacheEntry {
603            imports: FileImports {
604                imports: vec![ImportRef {
605                    specifier: "made_up_crate".to_string(),
606                    line: 1,
607                    column: 1,
608                    end_line: 1,
609                    end_column: 26,
610                    matched_text: "use made_up_crate::Thing;".to_string(),
611                }],
612                local_names: vec!["foo".to_string()],
613            },
614            ..Default::default()
615        };
616
617        store.put(&hash, &entry).unwrap();
618        let cached = store.get(&hash).unwrap();
619
620        assert_eq!(cached.imports.imports.len(), 1);
621        assert_eq!(cached.imports.imports[0].specifier, "made_up_crate");
622        assert_eq!(cached.imports.local_names, vec!["foo".to_string()]);
623    }
624
625    #[test]
626    fn invalidation_file_change() {
627        let dir = tempdir().unwrap();
628        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
629
630        let content_v1 = b"fn main() { foo().unwrap(); }";
631        let content_v2 = b"fn main() { foo()?; }";
632        let hash_v1 = file_content_hash(content_v1);
633        let hash_v2 = file_content_hash(content_v2);
634
635        store
636            .put(&hash_v1, &entry_with(vec![sample_finding()]))
637            .unwrap();
638
639        assert!(store.get(&hash_v1).is_some());
640        assert!(store.get(&hash_v2).is_none());
641    }
642
643    #[test]
644    fn invalidation_rule_change() {
645        let dir = tempdir().unwrap();
646        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
647
648        let rules_v1 = [test_rule()];
649        let hash_v1 = rules_hash(&rules_v1);
650
651        let content_hash = file_content_hash(b"fn main() {}");
652        store.put(&content_hash, &entry_with(Vec::new())).unwrap();
653        store.write_rules_hash(&hash_v1).unwrap();
654
655        assert!(store.get(&content_hash).is_some());
656
657        let mut rule_v2 = test_rule();
658        rule_v2.id = RuleId::from("different-rule");
659        let rules_v2 = [rule_v2];
660        let hash_v2 = rules_hash(&rules_v2);
661        assert_ne!(hash_v1, hash_v2);
662
663        store.check_rules_changed(&hash_v2).unwrap();
664
665        assert!(
666            store.get(&content_hash).is_none(),
667            "cache entries should be invalidated when rules change"
668        );
669    }
670
671    #[test]
672    fn invalidate_all_spares_foreign_files() {
673        let dir = tempdir().unwrap();
674        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
675        let hash_v1 = rules_hash(&[test_rule()]);
676        let scoped =
677            CacheStore::with_dir(dir.path().to_path_buf(), test_key()).scoped_to_rules(&hash_v1);
678
679        let content_hash = file_content_hash(b"fn main() {}");
680        scoped.put(&content_hash, &entry_with(Vec::new())).unwrap();
681        store.write_rules_hash(&hash_v1).unwrap();
682        let entry = scoped.entry_path(&content_hash);
683        assert!(entry.is_file());
684
685        let foreign = ["notes.txt", "data.bin", "keep.rs", "Cargo.toml"];
686        for name in foreign {
687            fs::write(dir.path().join(name), b"user data").unwrap();
688        }
689        fs::create_dir(dir.path().join("subdir")).unwrap();
690
691        store.check_rules_changed("new-rules-hash").unwrap();
692
693        assert!(!entry.exists(), "the real cache entry must be invalidated");
694        for name in foreign {
695            assert!(dir.path().join(name).is_file(), "{name} must survive");
696        }
697        assert!(dir.path().join("subdir").is_dir());
698        assert_eq!(
699            fs::read_to_string(dir.path().join(RULES_HASH_FILE)).unwrap(),
700            "new-rules-hash"
701        );
702    }
703
704    #[test]
705    fn invalidate_all_does_nothing_without_manifest() {
706        let dir = tempdir().unwrap();
707        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
708        let lookalike = dir.path().join("abc123-def456.bin");
709        fs::write(&lookalike, b"not ours").unwrap();
710
711        assert!(store.check_rules_changed("rules-hash").unwrap());
712
713        assert!(
714            lookalike.is_file(),
715            "a directory without manifest is not a cache"
716        );
717        assert_eq!(
718            fs::read_to_string(dir.path().join(RULES_HASH_FILE)).unwrap(),
719            "rules-hash"
720        );
721    }
722
723    #[cfg(unix)]
724    #[test]
725    fn invalidate_all_does_not_follow_symlinks() {
726        let dir = tempdir().unwrap();
727        let outside = tempdir().unwrap();
728        let target = outside.path().join("precious.txt");
729        fs::write(&target, b"precious").unwrap();
730
731        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
732        store.write_rules_hash("old-hash").unwrap();
733        let link = dir.path().join("abc123-def456.bin");
734        symlink(&target, &link).unwrap();
735
736        store.check_rules_changed("new-hash").unwrap();
737
738        assert!(target.is_file(), "the symlink target must survive");
739        assert!(link.symlink_metadata().is_ok(), "the symlink is skipped");
740    }
741
742    #[test]
743    fn cleanup_spares_foreign_files() {
744        let dir = tempdir().unwrap();
745        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
746        let hash = file_content_hash(b"file a");
747        store.put(&hash, &entry_with(Vec::new())).unwrap();
748        fs::write(dir.path().join("notes.txt"), b"user data").unwrap();
749        fs::create_dir(dir.path().join("subdir")).unwrap();
750
751        let removed = store.cleanup(&[]).unwrap();
752
753        assert_eq!(removed, 1, "only the stale entry is pruned");
754        assert!(dir.path().join("notes.txt").is_file());
755        assert!(dir.path().join("subdir").is_dir());
756    }
757
758    #[test]
759    fn cache_entry_name_shape() {
760        assert!(is_cache_entry_name("abc123.bin"));
761        assert!(is_cache_entry_name("abc123-def456.bin"));
762        assert!(!is_cache_entry_name(".bin"));
763        assert!(!is_cache_entry_name("data.bin"));
764        assert!(!is_cache_entry_name("abc123.txt"));
765        assert!(!is_cache_entry_name("abc123bin"));
766        assert!(!is_cache_entry_name(RULES_HASH_FILE));
767        assert!(!is_cache_entry_name(".gitignore"));
768    }
769
770    #[test]
771    fn cleanup_orphan_entries() {
772        let dir = tempdir().unwrap();
773        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
774
775        let hash_a = file_content_hash(b"file a");
776        let hash_b = file_content_hash(b"file b");
777        store.put(&hash_a, &entry_with(Vec::new())).unwrap();
778        store
779            .put(&hash_b, &entry_with(vec![sample_finding()]))
780            .unwrap();
781
782        let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
783
784        assert_eq!(removed, 1);
785        assert!(store.get(&hash_a).is_some());
786        assert!(store.get(&hash_b).is_none());
787    }
788
789    #[test]
790    fn corrupted_cache_entry() {
791        let dir = tempdir().unwrap();
792        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
793
794        store.ensure_dir().unwrap();
795        let hash = file_content_hash(b"some content");
796        fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
797
798        assert!(
799            store.get(&hash).is_none(),
800            "corrupted entry should return None, not panic"
801        );
802    }
803
804    #[test]
805    fn signed_undecodable_cache_entry_is_ignored() {
806        let dir = tempdir().unwrap();
807        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
808        store.ensure_dir().unwrap();
809        let hash = file_content_hash(b"some content");
810        let name = store.entry_name(&hash);
811        let sealed = test_key().seal(&name, b"not msgpack").unwrap();
812        fs::write(store.entry_path(&hash), sealed).unwrap();
813
814        assert!(store.get(&hash).is_none());
815    }
816
817    #[test]
818    fn forged_cache_entry_without_hmac_is_ignored() {
819        let dir = tempdir().unwrap();
820        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
821        store.ensure_dir().unwrap();
822        let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
823        // What a repository could ship: a well-formed entry claiming the file
824        // has no findings, but without a signature.
825        let forged = rmp_serde::to_vec_named(&entry_with(Vec::new())).unwrap();
826        fs::write(store.entry_path(&hash), forged).unwrap();
827
828        assert!(store.get(&hash).is_none());
829    }
830
831    #[test]
832    fn truncated_cache_entry_is_ignored() {
833        let dir = tempdir().unwrap();
834        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
835        store.ensure_dir().unwrap();
836        let hash = file_content_hash(b"some content");
837        fs::write(store.entry_path(&hash), [0u8; MAC_LEN - 1]).unwrap();
838
839        assert!(store.get(&hash).is_none());
840    }
841
842    #[test]
843    fn tampered_cache_entry_is_ignored() {
844        let dir = tempdir().unwrap();
845        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
846        let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
847        store
848            .put(&hash, &entry_with(vec![sample_finding()]))
849            .unwrap();
850        let path = store.entry_path(&hash);
851        let mut data = fs::read(&path).unwrap();
852        let last = data.len() - 1;
853        data[last] ^= 0x01;
854        fs::write(&path, data).unwrap();
855
856        assert!(store.get(&hash).is_none());
857    }
858
859    #[test]
860    fn cache_entry_signed_with_other_key_is_ignored() {
861        let dir = tempdir().unwrap();
862        let other = CacheStore::with_dir(dir.path().to_path_buf(), CacheKey::from_bytes([9; 32]));
863        let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
864        other.put(&hash, &entry_with(Vec::new())).unwrap();
865        assert!(other.get(&hash).is_some());
866
867        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
868        assert!(store.get(&hash).is_none());
869    }
870
871    #[test]
872    fn cache_entry_moved_to_other_name_is_ignored() {
873        let dir = tempdir().unwrap();
874        let store = CacheStore::with_dir(dir.path().to_path_buf(), test_key());
875        let clean = file_content_hash(b"fn main() {}");
876        let dirty = file_content_hash(b"fn main() { foo().unwrap(); }");
877        store.put(&clean, &entry_with(Vec::new())).unwrap();
878        fs::copy(store.entry_path(&clean), store.entry_path(&dirty)).unwrap();
879
880        assert!(store.get(&clean).is_some());
881        assert!(
882            store.get(&dirty).is_none(),
883            "a signed entry is bound to its own name"
884        );
885    }
886
887    #[cfg(unix)]
888    #[test]
889    fn cache_key_file_is_created_with_0600_and_reused() {
890        use std::os::unix::fs::PermissionsExt;
891
892        let dir = tempdir().unwrap();
893        let key_dir = dir.path().join("slopguard");
894        let first = CacheKey::load_or_create(&key_dir).unwrap();
895        let second = CacheKey::load_or_create(&key_dir).unwrap();
896
897        assert_eq!(first.0, second.0);
898        let key_path = key_dir.join(KEY_FILE);
899        assert_eq!(fs::read(&key_path).unwrap(), first.0.to_vec());
900        let mode = fs::metadata(&key_path).unwrap().permissions().mode();
901        assert_eq!(mode & 0o777, 0o600);
902    }
903
904    #[test]
905    fn cache_key_file_wrong_length_is_regenerated() {
906        let dir = tempdir().unwrap();
907        let key_path = dir.path().join(KEY_FILE);
908        fs::write(&key_path, b"short").unwrap();
909
910        let key = CacheKey::load_or_create(dir.path()).unwrap();
911
912        let stored = fs::read(&key_path).unwrap();
913        assert_eq!(stored.len(), KEY_LEN);
914        assert_eq!(stored, key.0.to_vec());
915    }
916
917    #[test]
918    fn cache_key_directory_in_place_of_file_is_rejected() {
919        let dir = tempdir().unwrap();
920        fs::create_dir(dir.path().join(KEY_FILE)).unwrap();
921
922        let err = CacheKey::load_or_create(dir.path()).unwrap_err();
923
924        assert!(matches!(err, CacheError::InvalidKey(_)), "got: {err:?}");
925    }
926
927    #[cfg(unix)]
928    #[test]
929    fn cache_key_symlink_is_not_followed() {
930        let dir = tempdir().unwrap();
931        let outside = tempdir().unwrap();
932        let target = outside.path().join("key");
933        fs::write(&target, [3u8; KEY_LEN]).unwrap();
934        symlink(&target, dir.path().join(KEY_FILE)).unwrap();
935
936        let err = CacheKey::load_or_create(dir.path()).unwrap_err();
937
938        assert!(matches!(err, CacheError::InvalidKey(_)), "got: {err:?}");
939    }
940
941    #[test]
942    fn cache_key_debug_hides_bytes() {
943        assert_eq!(format!("{:?}", test_key()), "CacheKey(..)");
944    }
945
946    #[test]
947    fn project_cache_dir_is_outside_project_and_stable() {
948        let project = tempdir().unwrap();
949        let other = tempdir().unwrap();
950
951        let dir = project_cache_dir(project.path());
952
953        assert!(dir.starts_with(user_cache_dir()));
954        assert!(!dir.starts_with(project.path()));
955        assert_eq!(dir, project_cache_dir(project.path()));
956        assert_ne!(dir, project_cache_dir(other.path()));
957    }
958}