dragonfly_client_rs/
scanner.rs

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/// The results of scanning a single file. Contains the file path and the rules it matched
24#[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    /// Returns the total score of all matched rules.
36    fn calculate_score(&self) -> i64 {
37        self.rules.iter().map(|i| i.score).sum()
38    }
39}
40
41/// A distribution consisting of an archive and an inspector url.
42struct 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    /// Scan a file given it's path, and compiled rules.
88    ///
89    /// # Arguments
90    /// * `path` - The path of the file to scan.
91    /// * `cache` - The package's content scan cache and compiled rules
92    #[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    /// Make the path relative to the archive root
108    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/// Struct representing the results of a scanned distribution
114#[derive(Debug)]
115pub struct DistributionScanResults {
116    /// The highest-scoring file in this distribution.
117    most_malicious_file: Option<FileScanResult>,
118
119    /// The unique rules matched across the distribution.
120    matched_rules: HashSet<RuleScore>,
121
122    /// The inspector URL pointing to this distribution's base
123    inspector_url: Url,
124}
125
126impl DistributionScanResults {
127    /// Create a new `DistributionScanResults` based off the results of its files and the base
128    /// inspector URL for this distribution.
129    #[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    /// Get the "most malicious file" in the distribution.
159    ///
160    /// This file with the greatest score is considered the most malicious. If multiple
161    /// files have the same score, an arbitrary file is picked.
162    pub fn get_most_malicious_file(&self) -> Option<&FileScanResult> {
163        self.most_malicious_file.as_ref()
164    }
165
166    /// Get all **unique** `RuleScore` objects that were matched for this distribution
167    #[cfg(test)]
168    fn get_matched_rules(&self) -> HashSet<&RuleScore> {
169        self.matched_rules.iter().collect()
170    }
171
172    /// Calculate the distribution score, counting each matched rule once.
173    pub fn get_total_score(&self) -> i64 {
174        self.matched_rules.iter().map(|rule| rule.score).sum()
175    }
176
177    /// Get a vector of the **unique** rule identifiers this distribution matched
178    #[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    /// Return the inspector URL of the most malicious file, or `None` if there is no most malicious
187    /// file
188    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    /// Format the package scan results into something that can be sent over the API
228    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
264/// Scan all the distributions of the given job against the given ruleset
265///
266/// Uses the provided HTTP client to download each distribution.
267pub 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            // Dropping the extracted archive must not invalidate cached content.
753        }
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}