1use std::{
2 collections::HashMap,
3 fs::{self, File},
4 io::Read,
5 path::{Path, PathBuf},
6};
7
8#[cfg(test)]
9use color_eyre::eyre::ensure;
10use color_eyre::Result;
11use sha2::{Digest, Sha256};
12use tempfile::TempDir;
13use xxhash_rust::xxh3::Xxh3;
14use yara::Rules;
15
16use crate::{exts::RuleExt, scanner::RuleScore};
17
18#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
19struct ContentMatch {
20 score: RuleScore,
21 filetypes: Vec<String>,
22}
23
24pub(crate) struct FileHashes {
25 pub fast: u128,
26 pub sha256: String,
27}
28
29struct CachedFile {
30 path: PathBuf,
31 matches: Vec<ContentMatch>,
32}
33
34pub struct ScanCache<'a> {
37 rules: &'a Rules,
38 reuse: Option<&'a crate::reuse_cache::ReuseCache>,
39 pub stats: crate::reuse_cache::CacheStats,
40 directory: TempDir,
41 files: HashMap<(u128, u64), CachedFile>,
42 max_entries: usize,
43 remaining_bytes: u64,
44 pub scanned_files: usize,
45 pub reused_files: usize,
46}
47
48impl<'a> ScanCache<'a> {
49 pub fn new(rules: &'a Rules, max_entries: usize, max_bytes: u64) -> Result<Self> {
50 Ok(Self {
51 rules,
52 reuse: None,
53 stats: crate::reuse_cache::CacheStats::new("yara", crate::reuse_cache::CacheMode::Off),
54 directory: tempfile::tempdir()?,
55 files: HashMap::new(),
56 max_entries,
57 remaining_bytes: max_bytes,
58 scanned_files: 0,
59 reused_files: 0,
60 })
61 }
62
63 pub fn set_reuse(&mut self, reuse: Option<&'a crate::reuse_cache::ReuseCache>) {
64 self.reuse = reuse;
65 self.stats.mode = reuse.map_or(crate::reuse_cache::CacheMode::Off, |cache| cache.mode);
66 }
67
68 #[cfg(test)]
69 pub fn scan(&mut self, path: &Path, max_scan_size: u64) -> Result<Vec<RuleScore>> {
70 let size = path.metadata()?.len();
71 ensure!(
72 size <= max_scan_size,
73 "file {} is {size} bytes, exceeding the {max_scan_size}-byte scan limit",
74 path.display()
75 );
76 self.scan_hashed(path, size, &hash_file(path)?)
77 }
78
79 pub(crate) fn prefetch(&mut self, files: &[(PathBuf, u64, FileHashes)]) {
80 if let Some(reuse) = self.reuse.filter(|cache| cache.uses_database()) {
81 let keys = files
82 .iter()
83 .filter(|(_, size, hash)| !self.files.contains_key(&(hash.fast, *size)))
84 .map(|(_, _, hash)| {
85 (
86 format!("sha256:{}", hash.sha256),
87 crate::durable_cache::Key {
88 file_digest: hash.sha256.clone(),
89 language: String::new(),
90 },
91 )
92 })
93 .collect::<Vec<_>>();
94 reuse.prefetch(&keys, &mut self.stats);
95 }
96 }
97
98 pub(crate) fn scan_hashed(
99 &mut self,
100 path: &Path,
101 size: u64,
102 hashes: &FileHashes,
103 ) -> Result<Vec<RuleScore>> {
104 let identity = (hashes.fast, size);
105 if let Some(cached) = self.files.get(&identity) {
106 if files_equal(path, &cached.path, size)? {
108 self.reused_files += 1;
109 return Ok(matches_for_path(&cached.matches, path));
110 }
111 }
112
113 let key = if self
114 .reuse
115 .is_some_and(crate::reuse_cache::ReuseCache::uses_database)
116 {
117 format!("sha256:{}", hashes.sha256)
118 } else {
119 format!("{:032x}:{}", identity.0, size)
120 };
121 let mut cached = self
122 .reuse
123 .and_then(|cache| cache.lookup::<Vec<ContentMatch>>(&key, path, &mut self.stats));
124 if let Some(matches) = &mut cached {
125 canonicalize(matches);
126 }
127 let reuse_enabled = cached.is_some()
128 && self
129 .reuse
130 .is_some_and(crate::reuse_cache::ReuseCache::should_reuse);
131 let matches = if let Some(matches) = cached.as_ref().filter(|_| reuse_enabled) {
132 self.stats.reused_files += 1;
133 self.stats.reused_bytes += size;
134 matches.clone()
135 } else {
136 let started = std::time::Instant::now();
137 self.stats.engine_files += 1;
138 self.stats.engine_bytes += size;
139 let scanned = self.rules.scan_file(path, 10);
140 self.stats.engine_us += started.elapsed().as_micros();
141 let mut matches: Vec<_> = scanned?
142 .into_iter()
143 .map(|rule| ContentMatch {
144 filetypes: rule
145 .get_filetypes()
146 .into_iter()
147 .map(str::to_owned)
148 .collect(),
149 score: RuleScore::from(rule),
150 })
151 .collect();
152 canonicalize(&mut matches);
153 self.scanned_files += 1;
154 if let Some(previous) = cached {
155 self.stats.validated_files += 1;
156 if previous != matches {
157 self.stats.mismatched_files += 1;
158 if let Some(cache) = self.reuse {
159 cache.quarantine(&key, &mut self.stats);
160 }
161 report_mismatch(path, &hashes.sha256, &previous, &matches);
162 if self.stats.reused_files > 0 {
163 color_eyre::eyre::bail!("Cache validation failed; cached results for this job must be discarded");
164 }
165 }
166 }
167 if let Some(cache) = self.reuse {
168 cache.insert(key, path, &matches, &mut self.stats);
169 }
170 matches
171 };
172 let result = matches_for_path(&matches, path);
173 if self.files.len() < self.max_entries
174 && size <= self.remaining_bytes
175 && !self.files.contains_key(&identity)
176 {
177 let destination = self.directory.path().join(self.files.len().to_string());
178 match fs::copy(path, &destination) {
179 Ok(_) => {
180 self.remaining_bytes -= size;
181 self.files.insert(
182 identity,
183 CachedFile {
184 path: destination,
185 matches,
186 },
187 );
188 }
189 Err(error) => {
190 self.max_entries = 0;
193 tracing::warn!(
194 event = "content_scan_cache_write_failed",
195 %error,
196 "Disabling new cache entries; continuing with successful YARA results"
197 );
198 }
199 }
200 }
201 Ok(result)
202 }
203}
204
205fn report_mismatch(path: &Path, digest: &str, previous: &[ContentMatch], matches: &[ContentMatch]) {
206 tracing::error!(
207 event = "scan_reuse_mismatch",
208 file_sha256 = %digest,
209 file_path = %path.display(),
210 cached_count = previous.len(),
211 fresh_count = matches.len(),
212 cached_matches = ?previous.iter().take(16).collect::<Vec<_>>(),
213 fresh_matches = ?matches.iter().take(16).collect::<Vec<_>>(),
214 "Cached YARA findings differ from fresh scan; quarantining this file"
215 );
216}
217
218fn canonicalize(matches: &mut [ContentMatch]) {
219 for matched in matches.iter_mut() {
220 matched.filetypes.sort_unstable();
221 matched.filetypes.dedup();
222 }
223 matches.sort_unstable_by(|a, b| {
224 (&a.score.name, a.score.score, &a.filetypes).cmp(&(
225 &b.score.name,
226 b.score.score,
227 &b.filetypes,
228 ))
229 });
230}
231
232fn matches_for_path(matches: &[ContentMatch], path: &Path) -> Vec<RuleScore> {
233 matches
234 .iter()
235 .filter(|matched| {
236 matched.filetypes.is_empty()
237 || matched
238 .filetypes
239 .iter()
240 .any(|suffix| path.to_string_lossy().ends_with(suffix))
241 })
242 .map(|matched| matched.score.clone())
243 .collect()
244}
245
246pub(crate) fn hash_file(path: &Path) -> Result<FileHashes> {
247 let mut file = File::open(path)?;
248 let mut hasher = Xxh3::new();
249 let mut sha256 = Sha256::new();
250 let mut buffer = [0_u8; 8192];
251 loop {
252 let read = file.read(&mut buffer)?;
253 if read == 0 {
254 return Ok(FileHashes {
255 fast: hasher.digest128(),
256 sha256: format!("{:x}", sha256.finalize()),
257 });
258 }
259 hasher.update(&buffer[..read]);
260 sha256.update(&buffer[..read]);
261 }
262}
263
264fn files_equal(left: &Path, right: &Path, mut remaining: u64) -> Result<bool> {
265 let mut left = File::open(left)?;
266 let mut right = File::open(right)?;
267 let mut left_buffer = [0_u8; 8192];
268 let mut right_buffer = [0_u8; 8192];
269 while remaining > 0 {
270 let length = usize::try_from(remaining.min(8192))?;
271 left.read_exact(&mut left_buffer[..length])?;
272 right.read_exact(&mut right_buffer[..length])?;
273 if left_buffer[..length] != right_buffer[..length] {
274 return Ok(false);
275 }
276 remaining -= u64::try_from(length)?;
277 }
278 Ok(true)
279}
280
281#[cfg(test)]
282mod tests {
283 use super::{hash_file, ScanCache};
284 use std::fs;
285 use tempfile::tempdir;
286 use yara::{Compiler, Rules};
287
288 fn rules() -> Rules {
289 Compiler::new()
290 .unwrap()
291 .add_rules_str(
292 r#"rule python {
293 meta: filetype = ".py .pyi" weight = 5
294 strings: $a = "danger"
295 condition: $a
296 }
297 rule compound_suffix {
298 meta: filetype = "special.txt" weight = 3
299 strings: $a = "danger"
300 condition: $a
301 }"#,
302 )
303 .unwrap()
304 .compile_rules()
305 .unwrap()
306 }
307
308 #[test]
309 fn cache_validation_ignores_order_but_preserves_findings_and_filters() {
310 use super::{canonicalize, ContentMatch};
311 use crate::{
312 reuse_cache::{CacheMode, CacheStats, ReuseCache},
313 scanner::RuleScore,
314 };
315 let rules = rules();
316 let dir = tempdir().unwrap();
317 let path = dir.path().join("danger.py");
318 fs::write(&path, b"danger").unwrap();
319 let old = vec![
320 ContentMatch {
321 score: RuleScore {
322 name: "python".into(),
323 score: 5,
324 },
325 filetypes: vec![".pyi".into(), ".py".into()],
326 },
327 ContentMatch {
328 score: RuleScore {
329 name: "compound_suffix".into(),
330 score: 3,
331 },
332 filetypes: vec!["special.txt".into()],
333 },
334 ];
335 let shared = ReuseCache::new(CacheMode::Observe, 10, 4096);
336 let key = format!("{:032x}:6", hash_file(&path).unwrap().fast);
337 shared.insert(
338 key,
339 &path,
340 &old,
341 &mut CacheStats::new("yara", CacheMode::Observe),
342 );
343 let mut scan = ScanCache::new(&rules, 10, 4096).unwrap();
344 scan.set_reuse(Some(&shared));
345 assert_eq!(scan.scan(&path, 1024).unwrap().len(), 1);
346 assert_eq!(scan.stats.validated_files, 1);
347 assert_eq!(scan.stats.mismatched_files, 0);
348 let mut canonical = old;
349 canonicalize(&mut canonical);
350 let mut changed = canonical.clone();
351 changed[0].score.score += 1;
352 canonicalize(&mut changed);
353 assert_ne!(canonical, changed);
354 changed = canonical.clone();
355 changed[0].filetypes.push(".sh".into());
356 canonicalize(&mut changed);
357 assert_ne!(canonical, changed);
358 }
359
360 #[test]
361 fn a_sample_mismatch_preserves_fresh_results_and_quarantines_only_that_key() {
362 use crate::reuse_cache::{CacheMode, CacheStats, ReuseCache};
363 let rules = rules();
364 let dir = tempdir().unwrap();
365 let path = dir.path().join("danger.py");
366 fs::write(&path, b"danger").unwrap();
367 let shared = ReuseCache::new(CacheMode::Reuse, 10, 1024);
368 let mut setup = CacheStats::new("yara", CacheMode::Reuse);
369 shared.insert(
370 format!("{:032x}:6", hash_file(&path).unwrap().fast),
371 &path,
372 &Vec::<super::ContentMatch>::new(),
373 &mut setup,
374 );
375 for _ in 0..99 {
376 assert!(shared.should_reuse());
377 }
378 let mut cache = ScanCache::new(&rules, 10, 1024).unwrap();
379 cache.set_reuse(Some(&shared));
380 assert_eq!(cache.scan(&path, 1024).unwrap().len(), 1);
381 assert_eq!(cache.stats.mismatched_files, 1);
382 assert!(!shared.is_disabled());
383 let key = format!("{:032x}:6", hash_file(&path).unwrap().fast);
384 assert!(shared
385 .lookup::<Vec<super::ContentMatch>>(&key, &path, &mut setup)
386 .is_none());
387 shared.insert(
388 key.clone(),
389 &path,
390 &Vec::<super::ContentMatch>::new(),
391 &mut setup,
392 );
393 assert!(shared
394 .lookup::<Vec<super::ContentMatch>>(&key, &path, &mut setup)
395 .is_none());
396 shared.insert("unrelated".into(), &path, &vec![7_u8], &mut setup);
397 assert_eq!(
398 shared.lookup::<Vec<u8>>("unrelated", &path, &mut setup),
399 Some(vec![7])
400 );
401 }
402
403 #[test]
404 fn cross_job_reuse_observation_and_rules_reset_preserve_results() {
405 use crate::reuse_cache::{CacheMode, ReuseCache};
406 let rules = rules();
407 let dir = tempdir().unwrap();
408 let path = dir.path().join("first.py");
409 fs::write(&path, b"danger").unwrap();
410 for mode in [CacheMode::Off, CacheMode::Observe, CacheMode::Reuse] {
411 let mut shared = ReuseCache::new(mode, 10, 1024);
412 for iteration in 0..2 {
413 let mut job = ScanCache::new(&rules, 10, 1024).unwrap();
414 job.set_reuse(Some(&shared));
415 assert_eq!(job.scan(&path, 1024).unwrap()[0].name, "python");
416 assert_eq!(
417 job.stats.reused_files,
418 u64::from(iteration == 1 && mode == CacheMode::Reuse)
419 );
420 assert_eq!(
421 job.stats.validated_files,
422 u64::from(iteration == 1 && mode == CacheMode::Observe)
423 );
424 assert_eq!(job.stats.mismatched_files, 0);
425 }
426 shared.clear();
427 let changed = Compiler::new()
428 .unwrap()
429 .add_rules_str("rule changed { condition: true }")
430 .unwrap()
431 .compile_rules()
432 .unwrap();
433 let mut job = ScanCache::new(&changed, 10, 1024).unwrap();
434 job.set_reuse(Some(&shared));
435 assert_eq!(job.scan(&path, 1024).unwrap()[0].name, "changed");
436 assert_eq!(job.stats.reused_files, 0);
437 }
438 }
439
440 #[test]
441 fn reuse_filters_each_original_path_including_compound_suffixes() {
442 let rules = rules();
443 let directory = tempdir().unwrap();
444 let mut cache = ScanCache::new(&rules, 10, 1024).unwrap();
445 for (name, expected) in [
446 ("first.txt", None),
447 ("second.py", Some("python")),
448 ("third.pyi", Some("python")),
449 ("special.txt", Some("compound_suffix")),
450 ] {
451 let path = directory.path().join(name);
452 fs::write(&path, b"danger").unwrap();
453 let matches = cache.scan(&path, 1024).unwrap();
454 assert_eq!(
455 matches.first().map(|matched| matched.name.as_str()),
456 expected
457 );
458 }
459 assert_eq!((cache.scanned_files, cache.reused_files), (1, 3));
460 }
461
462 #[test]
463 fn cache_limits_fall_back_to_scanning_and_keep_existing_hits() {
464 let rules = rules();
465 let directory = tempdir().unwrap();
466 for (entries, bytes) in [(1, 1024), (10, 6)] {
467 let mut cache = ScanCache::new(&rules, entries, bytes).unwrap();
468 let original = directory.path().join("original.py");
469 let other = directory.path().join("other.py");
470 fs::write(&original, b"danger").unwrap();
471 fs::write(&other, b"danger too").unwrap();
472 assert_eq!(cache.scan(&original, 1024).unwrap().len(), 1);
473 assert_eq!(cache.scan(&other, 1024).unwrap().len(), 1);
474 assert_eq!(cache.scan(&other, 1024).unwrap().len(), 1);
475 assert_eq!(cache.scan(&original, 1024).unwrap().len(), 1);
476 assert_eq!((cache.scanned_files, cache.reused_files), (3, 1));
477 assert_eq!(cache.files.len(), 1);
478 }
479 }
480
481 #[test]
482 fn cache_write_failure_preserves_successful_results_and_stops_new_writes() {
483 let rules = rules();
484 let directory = tempdir().unwrap();
485 let path = directory.path().join("module.py");
486 fs::write(&path, b"danger").unwrap();
487 let mut cache = ScanCache::new(&rules, 10, 1024).unwrap();
488 fs::remove_dir(cache.directory.path()).unwrap();
489 assert_eq!(cache.scan(&path, 1024).unwrap().len(), 1);
490 assert_eq!(cache.scan(&path, 1024).unwrap().len(), 1);
491 assert_eq!((cache.scanned_files, cache.reused_files), (2, 0));
492 assert_eq!(cache.max_entries, 0);
493 assert!(cache.files.is_empty());
494 }
495
496 #[test]
497 fn hash_collision_does_not_reuse_a_clean_result() {
498 let rules = rules();
499 let directory = tempdir().unwrap();
500 let clean = directory.path().join("clean.py");
501 let malicious = directory.path().join("malicious.py");
502 fs::write(&clean, b"benign").unwrap();
503 fs::write(&malicious, b"danger").unwrap();
504 let mut cache = ScanCache::new(&rules, 10, 1024).unwrap();
505 assert!(cache.scan(&clean, 1024).unwrap().is_empty());
506 let cached = cache
507 .files
508 .remove(&(hash_file(&clean).unwrap().fast, 6))
509 .unwrap();
510 cache
511 .files
512 .insert((hash_file(&malicious).unwrap().fast, 6), cached);
513 assert_eq!(cache.scan(&malicious, 1024).unwrap().len(), 1);
514 assert_eq!((cache.scanned_files, cache.reused_files), (2, 0));
515 }
516
517 #[test]
518 fn clean_empty_files_reuse_but_size_checks_still_apply() {
519 let rules = rules();
520 let directory = tempdir().unwrap();
521 let path = directory.path().join("empty.py");
522 fs::write(&path, b"").unwrap();
523 let mut cache = ScanCache::new(&rules, 10, 1024).unwrap();
524 assert!(cache.scan(&path, 0).unwrap().is_empty());
525 assert!(cache.scan(&path, 0).unwrap().is_empty());
526 assert_eq!((cache.scanned_files, cache.reused_files), (1, 1));
527 fs::write(&path, b"danger").unwrap();
528 cache.scan(&path, 6).unwrap();
529 assert!(cache.scan(&path, 5).is_err());
530 assert_eq!((cache.scanned_files, cache.reused_files), (2, 1));
531 }
532}