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";
21const CACHE_ENTRY_EXT: &str = "bin";
23const CACHE_GITIGNORE: &str = "*\n";
24
25const CACHE_SCHEMA_VERSION: &str = "v6";
29
30const KEY_FILE: &str = "cache.key";
32const KEY_LEN: usize = 32;
34const MAC_LEN: usize = 32;
36
37type HmacSha256 = Hmac<Sha256>;
38
39#[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
51pub fn sha256_hex(data: &[u8]) -> String {
53 let mut hasher = Sha256::new();
54 hasher.update(data);
55 format!("{:x}", hasher.finalize())
56}
57
58pub 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
80pub fn user_cache_dir() -> PathBuf {
84 dirs::cache_dir()
85 .unwrap_or_else(|| PathBuf::from(".cache"))
86 .join("slopguard")
87}
88
89pub 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#[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
111enum KeyFile {
113 Valid(CacheKey),
114 Missing,
115 WrongLength,
117}
118
119impl CacheKey {
120 pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
122 Self(bytes)
123 }
124
125 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 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 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 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 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
213fn 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
230fn 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
241pub 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
269pub fn file_content_hash(content: &[u8]) -> String {
273 sha256_hex(content)
274}
275
276pub struct CacheStore {
279 dir: PathBuf,
280 key_prefix: String,
284 key: CacheKey,
285}
286
287impl CacheStore {
288 pub fn with_dir(dir: PathBuf, key: CacheKey) -> Self {
291 Self {
292 dir,
293 key_prefix: String::new(),
294 key,
295 }
296 }
297
298 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 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 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 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 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 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 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 if !entry.file_type()?.is_file() || !is_cache_entry_name(&name_str) {
408 continue;
409 }
410 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
427fn strip_entry_ext(name: &str) -> Option<&str> {
429 name.strip_suffix(CACHE_ENTRY_EXT)?.strip_suffix('.')
430}
431
432fn 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 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}