use crate::{
Finding, ScanEntry, ScanQuery, ScanReport, ScanSummary, SensitiveCandidate, Severity,
scan_summary::ScanSummaryStats,
};
#[derive(Debug, Clone, Default, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ScanResults<K> {
entries: Vec<ScanEntry<K>>,
}
impl<K> ScanResults<K> {
pub(crate) const fn new(entries: Vec<ScanEntry<K>>) -> Self {
Self { entries }
}
#[must_use]
pub const fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn as_slice(&self) -> &[ScanEntry<K>] {
&self.entries
}
pub fn iter(&self) -> std::slice::Iter<'_, ScanEntry<K>> {
self.entries.iter()
}
pub fn query(&self) -> ScanQuery<'_, K> {
ScanQuery::new(&self.entries)
}
pub fn findings(&self) -> impl Iterator<Item = (&K, &Finding)> {
self.entries.iter().flat_map(|entry| {
entry
.report()
.iter()
.map(move |finding| (entry.key(), finding))
})
}
pub fn candidates(&self) -> impl Iterator<Item = (&K, &SensitiveCandidate)> {
self.entries.iter().flat_map(|entry| {
entry
.report()
.candidates()
.iter()
.map(move |candidate| (entry.key(), candidate))
})
}
pub fn total_bytes(&self) -> usize {
self.entries.iter().map(ScanEntry::source_bytes).sum()
}
pub fn total_findings(&self) -> usize {
self.entries.iter().map(|entry| entry.report().len()).sum()
}
pub fn total_candidates(&self) -> usize {
self.entries
.iter()
.map(|entry| entry.report().candidate_len())
.sum()
}
pub fn has_critical(&self) -> bool {
self.entries
.iter()
.any(|entry| entry.report().has_critical())
}
pub fn failed(&self) -> impl DoubleEndedIterator<Item = &ScanEntry<K>> {
self.entries
.iter()
.filter(|entry| !entry.report().is_empty())
}
pub fn review(&self) -> impl DoubleEndedIterator<Item = &ScanEntry<K>> {
self.entries
.iter()
.filter(|entry| entry.report().is_empty() && entry.report().has_candidates())
}
pub fn clean(&self) -> impl DoubleEndedIterator<Item = &ScanEntry<K>> {
self.entries
.iter()
.filter(|entry| !entry.report().needs_review())
}
pub fn summary(&self) -> ScanSummary {
let mut stats = ScanSummaryStats {
scanned_sources: self.entries.len(),
..ScanSummaryStats::default()
};
for entry in &self.entries {
stats.scanned_bytes += entry.source_bytes();
let report = entry.report();
if report.is_empty() {
stats.reports_without_findings += 1;
} else {
stats.reports_with_findings += 1;
}
if report.has_candidates() {
stats.reports_with_candidates += 1;
stats.total_candidates += report.candidate_len();
}
for finding in report {
stats.total_findings += 1;
match finding.severity() {
Severity::Critical => stats.critical += 1,
Severity::High => stats.high += 1,
Severity::Medium => stats.medium += 1,
Severity::Low => stats.low += 1,
Severity::Info => stats.info += 1,
}
}
}
ScanSummary::from_stats(stats)
}
pub fn into_inner(self) -> Vec<ScanEntry<K>> {
self.entries
}
pub fn single_report(&self) -> Option<&ScanReport> {
match self.entries.as_slice() {
[entry] => Some(entry.report()),
_ => None,
}
}
}
impl<'a, K> IntoIterator for &'a ScanResults<K> {
type Item = &'a ScanEntry<K>;
type IntoIter = std::slice::Iter<'a, ScanEntry<K>>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
impl<K> IntoIterator for ScanResults<K> {
type Item = ScanEntry<K>;
type IntoIter = std::vec::IntoIter<ScanEntry<K>>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_results_have_no_entries_or_findings() {
let results = ScanResults::<&str>::default();
assert!(results.is_empty());
assert_eq!(results.len(), 0);
assert_eq!(results.findings().count(), 0);
assert_eq!(results.candidates().count(), 0);
assert_eq!(results.total_candidates(), 0);
assert!(results.single_report().is_none());
}
fn report_with(severities: &[Severity]) -> ScanReport {
let findings = severities
.iter()
.enumerate()
.map(|(index, severity)| {
crate::Finding::new(
crate::RuleId::from(format!("rule-{index}")),
crate::Location::from_span(index, index + 1),
*severity,
crate::Confidence::High,
None,
)
})
.collect();
ScanReport::new_with_candidates(findings, Vec::new())
}
#[test]
fn exposes_batch_helpers_without_cloning() {
let results = ScanResults::new(vec![
ScanEntry::new("clean", 10, ScanReport::default()),
ScanEntry::new(
"failed",
20,
report_with(&[Severity::High, Severity::Critical]),
),
]);
assert_eq!(results.total_bytes(), 30);
assert_eq!(results.total_findings(), 2);
assert!(results.has_critical());
assert_eq!(results.clean().count(), 1);
assert_eq!(results.failed().count(), 1);
assert_eq!(results.clean().next().unwrap().key(), &"clean");
assert_eq!(results.failed().next().unwrap().key(), &"failed");
}
#[test]
fn summary_aggregates_sources_bytes_and_severities() {
let results = ScanResults::new(vec![
ScanEntry::new("clean", 5, ScanReport::default()),
ScanEntry::new(
"failed-a",
7,
report_with(&[Severity::Critical, Severity::High]),
),
ScanEntry::new(
"failed-b",
11,
report_with(&[Severity::Medium, Severity::Low, Severity::Info]),
),
]);
let summary = results.summary();
assert_eq!(summary.scanned_sources(), 3);
assert_eq!(summary.scanned_bytes(), 23);
assert_eq!(summary.reports_with_findings(), 2);
assert_eq!(summary.reports_without_findings(), 1);
assert_eq!(summary.total_findings(), 5);
assert_eq!(summary.critical(), 1);
assert_eq!(summary.high(), 1);
assert_eq!(summary.medium(), 1);
assert_eq!(summary.low(), 1);
assert_eq!(summary.info(), 1);
assert!(summary.has_critical());
assert!(!summary.is_clean());
}
#[test]
fn creates_borrowed_query_over_findings() {
let results = ScanResults::new(vec![ScanEntry::new(
"source",
4,
report_with(&[Severity::High, Severity::Medium]),
)]);
let query = results.query();
assert_eq!(query.count(), 2);
assert_eq!(query.first().unwrap().0, &"source");
}
fn candidate_report() -> ScanReport {
ScanReport::new_with_candidates(
Vec::new(),
vec![crate::SensitiveCandidate::new(
crate::SensitiveCandidateKind::RecoveryLikeCode,
crate::Location::from_span(0, 19),
crate::CandidateEvidence::Structural,
)],
)
}
#[test]
fn candidates_are_exposed_without_becoming_findings() {
let results = ScanResults::new(vec![ScanEntry::new("review", 19, candidate_report())]);
assert_eq!(results.total_findings(), 0);
assert_eq!(results.total_candidates(), 1);
assert_eq!(results.findings().count(), 0);
assert_eq!(results.candidates().count(), 1);
assert_eq!(results.review().count(), 1);
assert_eq!(results.failed().count(), 0);
assert_eq!(results.clean().count(), 0);
}
#[test]
fn summary_keeps_candidates_separate_from_findings() {
let results = ScanResults::new(vec![
ScanEntry::new("clear", 4, ScanReport::default()),
ScanEntry::new("review", 19, candidate_report()),
]);
let summary = results.summary();
assert_eq!(summary.scanned_sources(), 2);
assert_eq!(summary.total_findings(), 0);
assert_eq!(summary.total_candidates(), 1);
assert_eq!(summary.reports_with_candidates(), 1);
assert!(summary.has_candidates());
assert!(!summary.is_clean());
}
}