1use std::path::PathBuf;
2use std::{collections::HashSet, path::Path};
3
4use color_eyre::{eyre::ensure, Result};
5use reqwest::{blocking::Client, Url};
6use tempfile::TempDir;
7use walkdir::WalkDir;
8use yara::Rules;
9
10use crate::{
11 client::{download_distribution, Job, SubmitJobResultsSuccess},
12 scan_cache::ScanCache,
13 utils::create_inspector_url,
14 APP_CONFIG,
15};
16
17#[derive(Debug, Hash, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
18pub struct RuleScore {
19 pub name: String,
20 pub score: i64,
21}
22
23#[derive(Debug)]
25pub struct FileScanResult {
26 pub path: PathBuf,
27 pub rules: Vec<RuleScore>,
28}
29
30impl FileScanResult {
31 fn new(path: PathBuf, rules: Vec<RuleScore>) -> Self {
32 Self { path, rules }
33 }
34
35 fn calculate_score(&self) -> i64 {
37 self.rules.iter().map(|i| i.score).sum()
38 }
39}
40
41struct Distribution {
43 dir: TempDir,
44 inspector_url: Url,
45}
46
47impl Distribution {
48 fn scan(
49 &mut self,
50 cache: &mut ScanCache<'_>,
51 max_scan_size: u64,
52 ) -> Result<DistributionScanResults> {
53 let mut results = DistributionScanResults::empty(self.inspector_url.clone());
54 let paths = WalkDir::new(self.dir.path())
55 .follow_links(false)
56 .into_iter()
57 .filter_map(|entry| match entry {
58 Ok(entry) if entry.file_type().is_file() => Some(Ok(entry.into_path())),
59 Ok(_) => None,
60 Err(error) => Some(Err(error)),
61 })
62 .collect::<std::result::Result<Vec<_>, _>>()?;
63 for chunk in paths.chunks(crate::durable_cache::BATCH_SIZE) {
64 let mut files = Vec::with_capacity(chunk.len());
65 for path in chunk {
66 let size = path.metadata()?.len();
67 ensure!(
68 size <= max_scan_size,
69 "file {} is {size} bytes, exceeding the {max_scan_size}-byte scan size limit",
70 path.display()
71 );
72 files.push((path.clone(), size, crate::scan_cache::hash_file(path)?));
73 }
74 cache.prefetch(&files);
75 for (path, size, hashes) in files {
76 let rules = cache.scan_hashed(&path, size, &hashes)?;
77 results.record(FileScanResult::new(
78 self.relative_to_archive_root(&path)?,
79 rules,
80 ));
81 }
82 }
83
84 Ok(results)
85 }
86
87 #[cfg(test)]
93 fn scan_file(
94 &self,
95 path: &Path,
96 cache: &mut ScanCache<'_>,
97 max_scan_size: u64,
98 ) -> Result<FileScanResult> {
99 let rules = cache.scan(path, max_scan_size)?;
100
101 Ok(FileScanResult::new(
102 self.relative_to_archive_root(path)?,
103 rules,
104 ))
105 }
106
107 fn relative_to_archive_root(&self, path: &Path) -> Result<PathBuf> {
109 Ok(path.strip_prefix(self.dir.path())?.to_path_buf())
110 }
111}
112
113#[derive(Debug)]
115pub struct DistributionScanResults {
116 most_malicious_file: Option<FileScanResult>,
118
119 matched_rules: HashSet<RuleScore>,
121
122 inspector_url: Url,
124}
125
126impl DistributionScanResults {
127 #[cfg(test)]
130 fn new(file_scan_results: Vec<FileScanResult>, inspector_url: Url) -> Self {
131 let mut results = Self::empty(inspector_url);
132 for file_scan_result in file_scan_results {
133 results.record(file_scan_result);
134 }
135 results
136 }
137
138 fn empty(inspector_url: Url) -> Self {
139 Self {
140 most_malicious_file: None,
141 matched_rules: HashSet::new(),
142 inspector_url,
143 }
144 }
145
146 fn record(&mut self, file_scan_result: FileScanResult) {
147 self.matched_rules
148 .extend(file_scan_result.rules.iter().cloned());
149 let should_replace = self
150 .most_malicious_file
151 .as_ref()
152 .is_none_or(|current| file_scan_result.calculate_score() >= current.calculate_score());
153 if should_replace {
154 self.most_malicious_file = Some(file_scan_result);
155 }
156 }
157
158 pub fn get_most_malicious_file(&self) -> Option<&FileScanResult> {
163 self.most_malicious_file.as_ref()
164 }
165
166 #[cfg(test)]
168 fn get_matched_rules(&self) -> HashSet<&RuleScore> {
169 self.matched_rules.iter().collect()
170 }
171
172 pub fn get_total_score(&self) -> i64 {
174 self.matched_rules.iter().map(|rule| rule.score).sum()
175 }
176
177 #[cfg(test)]
179 fn get_matched_rule_identifiers(&self) -> Vec<&str> {
180 self.matched_rules
181 .iter()
182 .map(|rule| rule.name.as_str())
183 .collect()
184 }
185
186 pub fn inspector_url(&self) -> Option<String> {
189 self.get_most_malicious_file().map(|file| {
190 format!(
191 "{}{}",
192 self.inspector_url.as_str(),
193 file.path.to_string_lossy().as_ref()
194 )
195 })
196 }
197}
198
199pub struct PackageScanResults {
200 pub name: String,
201 pub version: String,
202 pub attempt: u64,
203 pub assignment_id: String,
204 pub distribution_scan_results: Vec<DistributionScanResults>,
205 pub commit_hash: String,
206}
207
208impl PackageScanResults {
209 pub fn new(
210 name: String,
211 version: String,
212 attempt: u64,
213 assignment_id: String,
214 distribution_scan_results: Vec<DistributionScanResults>,
215 commit_hash: String,
216 ) -> Self {
217 Self {
218 name,
219 version,
220 attempt,
221 assignment_id,
222 distribution_scan_results,
223 commit_hash,
224 }
225 }
226
227 pub fn build_body(&self) -> SubmitJobResultsSuccess {
229 let highest_score_distribution = self
230 .distribution_scan_results
231 .iter()
232 .max_by_key(|distribution| distribution.get_total_score());
233
234 let score = highest_score_distribution
235 .map(DistributionScanResults::get_total_score)
236 .unwrap_or_default();
237
238 let inspector_url =
239 highest_score_distribution.and_then(DistributionScanResults::inspector_url);
240
241 let mut rules_matched = self
242 .distribution_scan_results
243 .iter()
244 .flat_map(|distribution| &distribution.matched_rules)
245 .map(|rule| rule.name.clone())
246 .collect::<HashSet<_>>()
247 .into_iter()
248 .collect::<Vec<_>>();
249 rules_matched.sort_unstable();
250
251 SubmitJobResultsSuccess {
252 name: self.name.clone(),
253 version: self.version.clone(),
254 attempt: self.attempt,
255 assignment_id: self.assignment_id.clone(),
256 score,
257 inspector_url,
258 rules_matched,
259 commit: self.commit_hash.clone(),
260 }
261 }
262}
263
264pub fn scan_all_distributions(
268 http_client: &Client,
269 rules: &Rules,
270 job: &Job,
271 reuse: Option<&crate::reuse_cache::ReuseCache>,
272 stats: &mut crate::reuse_cache::CacheStats,
273) -> Result<Vec<DistributionScanResults>> {
274 ensure!(
275 job.distributions.len() <= APP_CONFIG.max_distributions,
276 "package contains {} distributions, exceeding the {}-distribution limit",
277 job.distributions.len(),
278 APP_CONFIG.max_distributions
279 );
280 let mut distribution_scan_results = Vec::with_capacity(job.distributions.len());
281 let mut cache = ScanCache::new(
282 rules,
283 APP_CONFIG.max_archive_entries,
284 APP_CONFIG.max_expanded_size,
285 )?;
286 cache.set_reuse(reuse);
287 let result = (|| {
288 for distribution in &job.distributions {
289 let download_url: Url = distribution.parse()?;
290 let inspector_url = create_inspector_url(&job.name, &job.version, &download_url);
291
292 let dir = download_distribution(http_client, download_url.clone())?;
293
294 let mut dist = Distribution { dir, inspector_url };
295 let distribution_scan_result = dist.scan(&mut cache, APP_CONFIG.max_scan_size)?;
296 distribution_scan_results.push(distribution_scan_result);
297 }
298
299 tracing::info!(
300 event = "content_scan_cache",
301 scanned_files = cache.scanned_files,
302 reused_files = cache.reused_files,
303 "Finished package content scans"
304 );
305 ensure!(!(cache.stats.reused_files > 0 && reuse.is_some_and(crate::reuse_cache::ReuseCache::is_disabled)),
306 "Cross-package cache validation failed; reuse disabled and this job's cached results discarded");
307 Ok(distribution_scan_results)
308 })();
309 if let Some(reuse) = reuse {
310 reuse.flush(&mut cache.stats);
311 }
312 *stats = cache.stats.clone();
313 result
314}
315
316#[cfg(test)]
317mod tests {
318 use super::{DistributionScanResults, PackageScanResults};
319 use crate::{
320 client::{Job, ScanResultSerializer, SubmitJobResultsError, SubmitJobResultsSuccess},
321 scanner::{FileScanResult, RuleScore},
322 };
323 use std::io::Write;
324 use std::{collections::HashSet, path::PathBuf};
325 use tempfile::{tempdir, tempdir_in};
326 use yara::Compiler;
327
328 #[test]
329 fn test_scan_result_success_serialization() {
330 let success = SubmitJobResultsSuccess {
331 name: "test".into(),
332 version: "1.0.0".into(),
333 attempt: 2,
334 assignment_id: "4e3702e8-27a3-46e6-b51c-4779a94fa4ab".into(),
335 score: 10,
336 inspector_url: Some("inspector url".into()),
337 rules_matched: vec!["abc".into(), "def".into()],
338 commit: "commit hash".into(),
339 };
340
341 let scan_result: ScanResultSerializer = Ok(success).into();
342 let actual = serde_json::to_string(&scan_result).unwrap();
343 let expected = r#"{"name":"test","version":"1.0.0","attempt":2,"assignment_id":"4e3702e8-27a3-46e6-b51c-4779a94fa4ab","score":10,"inspector_url":"inspector url","rules_matched":["abc","def"],"commit":"commit hash"}"#;
344
345 assert_eq!(actual, expected);
346 }
347
348 #[test]
349 fn test_scan_result_error_serialization() {
350 let error = SubmitJobResultsError {
351 name: "test".into(),
352 version: "1.0.0".into(),
353 attempt: 3,
354 assignment_id: "a58c83d7-0864-48da-b3d1-e7ae59ac9572".into(),
355 reason: "Package too large".into(),
356 };
357
358 let scan_result: ScanResultSerializer = Err(error).into();
359 let actual = serde_json::to_string(&scan_result).unwrap();
360 let expected = r#"{"name":"test","version":"1.0.0","attempt":3,"assignment_id":"a58c83d7-0864-48da-b3d1-e7ae59ac9572","reason":"Package too large"}"#;
361
362 assert_eq!(actual, expected);
363 }
364
365 #[test]
366 fn test_file_score() {
367 let rules = vec![
368 RuleScore {
369 name: String::from("rule1"),
370 score: 5,
371 },
372 RuleScore {
373 name: String::from("rule2"),
374 score: 7,
375 },
376 ];
377
378 let file_scan_result = FileScanResult {
379 path: PathBuf::default(),
380 rules,
381 };
382 assert_eq!(file_scan_result.calculate_score(), 12);
383 }
384
385 #[test]
386 fn test_get_most_malicious_file() {
387 let file_scan_results = vec![
388 FileScanResult {
389 path: PathBuf::default(),
390 rules: vec![RuleScore {
391 name: String::from("rule1"),
392 score: 5,
393 }],
394 },
395 FileScanResult {
396 path: PathBuf::default(),
397 rules: vec![RuleScore {
398 name: String::from("rule2"),
399 score: 7,
400 }],
401 },
402 FileScanResult {
403 path: PathBuf::default(),
404 rules: vec![RuleScore {
405 name: String::from("rule3"),
406 score: 4,
407 }],
408 },
409 ];
410
411 let distribution_scan_results = DistributionScanResults::new(
412 file_scan_results,
413 reqwest::Url::parse("https://example.net").unwrap(),
414 );
415
416 assert_eq!(
417 distribution_scan_results
418 .get_most_malicious_file()
419 .unwrap()
420 .rules[0]
421 .name,
422 "rule2"
423 );
424 }
425
426 #[test]
427 fn distribution_results_retain_only_the_highest_scoring_file() {
428 let file_scan_results = (0..100)
429 .map(|index| FileScanResult {
430 path: PathBuf::from(format!("file-{index}")),
431 rules: Vec::new(),
432 })
433 .collect();
434
435 let results = DistributionScanResults::new(
436 file_scan_results,
437 reqwest::Url::parse("https://example.net").unwrap(),
438 );
439
440 assert!(results.matched_rules.is_empty());
441 assert_eq!(
442 results.get_most_malicious_file().unwrap().path,
443 PathBuf::from("file-99")
444 );
445 }
446
447 #[test]
448 fn package_distribution_count_is_bounded_before_downloads() {
449 let rules = Compiler::new()
450 .unwrap()
451 .add_rules_str("rule never { condition: false }")
452 .unwrap()
453 .compile_rules()
454 .unwrap();
455 let job = Job {
456 hash: String::new(),
457 name: "large-package".into(),
458 version: "1.0.0".into(),
459 distributions: vec![
460 "https://example.com/distribution.whl".into();
461 crate::APP_CONFIG.max_distributions + 1
462 ],
463 attempt: 1,
464 assignment_id: "d4d10b9b-f0ea-44dc-9d21-33c0ae9ed3c0".into(),
465 };
466
467 let error = super::scan_all_distributions(
468 &reqwest::blocking::Client::new(),
469 &rules,
470 &job,
471 None,
472 &mut crate::reuse_cache::CacheStats::new("yara", crate::reuse_cache::CacheMode::Off),
473 )
474 .unwrap_err();
475
476 assert!(error.to_string().contains("distribution limit"));
477 }
478
479 #[test]
480 fn malformed_distribution_urls_return_an_error() {
481 let rules = Compiler::new()
482 .unwrap()
483 .add_rules_str("rule never { condition: false }")
484 .unwrap()
485 .compile_rules()
486 .unwrap();
487 let job = Job {
488 hash: String::new(),
489 name: "malformed-distribution".into(),
490 version: "1.0.0".into(),
491 distributions: vec!["not a URL".into()],
492 attempt: 1,
493 assignment_id: "e6e7d9ea-8ba5-4597-b4b8-a2b07090ad2c".into(),
494 };
495
496 let result = super::scan_all_distributions(
497 &reqwest::blocking::Client::new(),
498 &rules,
499 &job,
500 None,
501 &mut crate::reuse_cache::CacheStats::new("yara", crate::reuse_cache::CacheMode::Off),
502 );
503
504 assert!(result.is_err());
505 }
506
507 #[test]
508 fn test_get_matched_rules() {
509 let file_scan_results = vec![
510 FileScanResult {
511 path: PathBuf::default(),
512 rules: vec![
513 RuleScore {
514 name: String::from("rule1"),
515 score: 5,
516 },
517 RuleScore {
518 name: String::from("rule2"),
519 score: 7,
520 },
521 ],
522 },
523 FileScanResult {
524 path: PathBuf::default(),
525 rules: vec![
526 RuleScore {
527 name: String::from("rule2"),
528 score: 7,
529 },
530 RuleScore {
531 name: String::from("rule3"),
532 score: 9,
533 },
534 ],
535 },
536 FileScanResult {
537 path: PathBuf::default(),
538 rules: vec![
539 RuleScore {
540 name: String::from("rule3"),
541 score: 9,
542 },
543 RuleScore {
544 name: String::from("rule4"),
545 score: 6,
546 },
547 ],
548 },
549 ];
550
551 let distribution_scan_results = DistributionScanResults::new(
552 file_scan_results,
553 reqwest::Url::parse("https://example.net").unwrap(),
554 );
555
556 let matched_rules: HashSet<RuleScore> = distribution_scan_results
557 .get_matched_rules()
558 .into_iter()
559 .cloned()
560 .collect();
561
562 let expected_rules = HashSet::from([
563 RuleScore {
564 name: String::from("rule1"),
565 score: 5,
566 },
567 RuleScore {
568 name: String::from("rule2"),
569 score: 7,
570 },
571 RuleScore {
572 name: String::from("rule3"),
573 score: 9,
574 },
575 RuleScore {
576 name: String::from("rule4"),
577 score: 6,
578 },
579 ]);
580
581 assert_eq!(matched_rules, expected_rules);
582 }
583
584 #[test]
585 fn test_get_matched_rule_identifiers() {
586 let file_scan_results = vec![
587 FileScanResult {
588 path: PathBuf::default(),
589 rules: vec![
590 RuleScore {
591 name: String::from("rule1"),
592 score: 5,
593 },
594 RuleScore {
595 name: String::from("rule2"),
596 score: 7,
597 },
598 ],
599 },
600 FileScanResult {
601 path: PathBuf::default(),
602 rules: vec![
603 RuleScore {
604 name: String::from("rule2"),
605 score: 7,
606 },
607 RuleScore {
608 name: String::from("rule3"),
609 score: 9,
610 },
611 ],
612 },
613 FileScanResult {
614 path: PathBuf::default(),
615 rules: vec![
616 RuleScore {
617 name: String::from("rule3"),
618 score: 9,
619 },
620 RuleScore {
621 name: String::from("rule4"),
622 score: 6,
623 },
624 ],
625 },
626 ];
627
628 let distribution_scan_results = DistributionScanResults::new(
629 file_scan_results,
630 reqwest::Url::parse("https://example.net").unwrap(),
631 );
632
633 let matched_rule_identifiers = distribution_scan_results.get_matched_rule_identifiers();
634
635 let expected_rule_identifiers = vec!["rule1", "rule2", "rule3", "rule4"];
636
637 assert_eq!(
638 HashSet::<_>::from_iter(matched_rule_identifiers),
639 HashSet::<_>::from_iter(expected_rule_identifiers)
640 );
641 }
642
643 #[test]
644 fn test_build_package_scan_results_body() {
645 let file_scan_results1 = vec![
646 FileScanResult {
647 path: PathBuf::default(),
648 rules: vec![RuleScore {
649 name: String::from("rule1"),
650 score: 5,
651 }],
652 },
653 FileScanResult {
654 path: PathBuf::default(),
655 rules: vec![RuleScore {
656 name: String::from("rule2"),
657 score: 7,
658 }],
659 },
660 ];
661 let distribution_scan_results1 = DistributionScanResults::new(
662 file_scan_results1,
663 reqwest::Url::parse("https://example.net/distrib1.tar.gz").unwrap(),
664 );
665
666 let file_scan_results2 = vec![
667 FileScanResult {
668 path: PathBuf::default(),
669 rules: vec![
670 RuleScore {
671 name: String::from("rule2"),
672 score: 7,
673 },
674 RuleScore {
675 name: String::from("rule3"),
676 score: 2,
677 },
678 ],
679 },
680 FileScanResult {
681 path: PathBuf::default(),
682 rules: vec![RuleScore {
683 name: String::from("rule4"),
684 score: 9,
685 }],
686 },
687 ];
688 let distribution_scan_results2 = DistributionScanResults::new(
689 file_scan_results2,
690 reqwest::Url::parse("https://example.net/distrib2.whl").unwrap(),
691 );
692
693 let package_scan_results = PackageScanResults {
694 name: String::from("remmy"),
695 version: String::from("4.20.69"),
696 attempt: 2,
697 assignment_id: String::from("e42e7f1e-1de7-443e-ad34-3ebdff663605"),
698 distribution_scan_results: vec![distribution_scan_results1, distribution_scan_results2],
699 commit_hash: String::from("abc"),
700 };
701
702 let body = package_scan_results.build_body();
703
704 assert_eq!(
705 body.inspector_url,
706 Some(String::from("https://example.net/distrib2.whl"))
707 );
708 assert_eq!(body.score, 18);
709 assert_eq!(body.attempt, 2);
710 assert_eq!(body.assignment_id, "e42e7f1e-1de7-443e-ad34-3ebdff663605");
711 assert_eq!(
712 HashSet::from([
713 "rule1".into(),
714 "rule2".into(),
715 "rule3".into(),
716 "rule4".into()
717 ]),
718 HashSet::from_iter(body.rules_matched)
719 );
720 }
721
722 #[test]
723 fn reuse_across_removed_distributions_preserves_scores_and_inspector_paths() {
724 let rules = Compiler::new()
725 .unwrap()
726 .add_rules_str(
727 r#"rule danger {
728 meta: filetype = ".py" weight = 7
729 strings: $a = "danger"
730 condition: $a
731 }"#,
732 )
733 .unwrap()
734 .compile_rules()
735 .unwrap();
736 let mut cache = crate::scan_cache::ScanCache::new(&rules, 10, 1024).unwrap();
737 for archive in ["wheel", "sdist"] {
738 let directory = tempdir().unwrap();
739 std::fs::write(directory.path().join("module.py"), b"danger").unwrap();
740 std::fs::write(directory.path().join("copy.txt"), b"danger").unwrap();
741 let mut distribution = super::Distribution {
742 dir: directory,
743 inspector_url: format!("https://example.com/{archive}/").parse().unwrap(),
744 };
745 let result = distribution.scan(&mut cache, 1024).unwrap();
746 assert_eq!(result.get_total_score(), 7);
747 assert_eq!(result.get_matched_rule_identifiers(), vec!["danger"]);
748 assert_eq!(
749 result.inspector_url(),
750 Some(format!("https://example.com/{archive}/module.py"))
751 );
752 }
754 assert_eq!((cache.scanned_files, cache.reused_files), (1, 3));
755 let fresh_cache = crate::scan_cache::ScanCache::new(&rules, 10, 1024).unwrap();
756 assert_eq!(
757 (fresh_cache.scanned_files, fresh_cache.reused_files),
758 (0, 0)
759 );
760 }
761
762 #[test]
763 fn test_scan_file() {
764 let rules = r#"
765 rule contains_rust {
766 meta:
767 weight = 5
768 strings:
769 $rust = "rust" nocase
770 condition:
771 $rust
772 }
773 "#;
774
775 let compiler = Compiler::new().unwrap().add_rules_str(rules).unwrap();
776
777 let rules = compiler.compile_rules().unwrap();
778
779 let tempdir = tempdir().unwrap();
780 let archive_root = tempfile::Builder::new().tempdir_in(tempdir.path()).unwrap();
781
782 let mut tmpfile = tempfile::NamedTempFile::new_in(archive_root.path()).unwrap();
783
784 writeln!(&mut tmpfile, "I hate Rust >:(").unwrap();
785
786 let distro = super::Distribution {
787 dir: tempdir,
788 inspector_url: "https://example.com".parse().unwrap(),
789 };
790
791 let mut cache = crate::scan_cache::ScanCache::new(&rules, 10, 1024).unwrap();
792 let result = distro.scan_file(tmpfile.path(), &mut cache, 1024).unwrap();
793
794 assert_eq!(
795 result.rules[0],
796 RuleScore {
797 name: "contains_rust".into(),
798 score: 5
799 }
800 );
801 assert_eq!(result.calculate_score(), 5);
802 }
803
804 #[test]
805 fn scan_file_rejects_files_over_the_limit_before_yara() {
806 let rules = Compiler::new()
807 .unwrap()
808 .add_rules_str("rule never { condition: false }")
809 .unwrap()
810 .compile_rules()
811 .unwrap();
812 let tempdir = tempdir().unwrap();
813 let mut tmpfile = tempfile::NamedTempFile::new_in(tempdir.path()).unwrap();
814 tmpfile.write_all(b"12345").unwrap();
815 let distro = super::Distribution {
816 dir: tempdir,
817 inspector_url: "https://example.com".parse().unwrap(),
818 };
819
820 let mut cache = crate::scan_cache::ScanCache::new(&rules, 10, 1024).unwrap();
821 let error = distro.scan_file(tmpfile.path(), &mut cache, 4).unwrap_err();
822
823 assert!(error.to_string().contains("4-byte scan limit"));
824 }
825
826 #[test]
827 fn test_relative_to_archive_root() {
828 let tempdir = tempdir().unwrap();
829
830 let input_path = &tempdir.path().join("name-version").join("README.md");
831 let expected_path = PathBuf::from("name-version/README.md");
832
833 let distro = super::Distribution {
834 dir: tempdir,
835 inspector_url: "https://example.com".parse().unwrap(),
836 };
837
838 let result = distro.relative_to_archive_root(input_path).unwrap();
839
840 assert_eq!(expected_path, result);
841 }
842
843 #[test]
844 fn scan_skips_directories() {
845 let rules = r#"
846 rule contains_rust {
847 meta:
848 weight = 5
849 strings:
850 $rust = "rust" nocase
851 condition:
852 $rust
853 }
854 "#;
855
856 let compiler = Compiler::new().unwrap().add_rules_str(rules).unwrap();
857
858 let rules = compiler.compile_rules().unwrap();
859 let tempdir = tempdir().unwrap();
860 let _subtempdir = tempdir_in(tempdir.path()).unwrap();
861 let mut tempfile = tempfile::NamedTempFile::new_in(tempdir.path()).unwrap();
862 writeln!(&mut tempfile, "rust").unwrap();
863
864 let mut distro = super::Distribution {
865 dir: tempdir,
866 inspector_url: "https://example.com".parse().unwrap(),
867 };
868
869 let mut cache = crate::scan_cache::ScanCache::new(&rules, 10, 1024).unwrap();
870 let results = distro.scan(&mut cache, 1024).unwrap();
871
872 assert_eq!(results.get_most_malicious_file().unwrap().rules.len(), 1);
873 }
874}