use std::borrow::Cow;
use crate::deviation::DeviatedFinding;
use crate::Finding;
#[cfg(feature = "serde")]
use crate::de_cow_static;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct EvaluationReport {
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub profile_id: Cow<'static, str>,
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub profile_version: Cow<'static, str>,
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub rule_bundle_version: Cow<'static, str>,
pub chain_length: usize,
pub evaluated_at_unix: u64,
pub findings: Vec<Finding>,
pub deviated_findings: Vec<DeviatedFinding>,
}
const fn severity_of(r: &crate::LintResult) -> Option<crate::Severity> {
match r {
crate::LintResult::Warn(_) => Some(crate::Severity::Warn),
crate::LintResult::Error(_) => Some(crate::Severity::Error),
crate::LintResult::Fatal(_) => Some(crate::Severity::Fatal),
_ => None,
}
}
impl EvaluationReport {
#[must_use]
pub fn new(
profile_id: impl Into<Cow<'static, str>>,
profile_version: impl Into<Cow<'static, str>>,
rule_bundle_version: impl Into<Cow<'static, str>>,
chain_length: usize,
evaluated_at_unix: u64,
) -> Self {
Self {
profile_id: profile_id.into(),
profile_version: profile_version.into(),
rule_bundle_version: rule_bundle_version.into(),
chain_length,
evaluated_at_unix,
findings: Vec::new(),
deviated_findings: Vec::new(),
}
}
#[must_use]
pub fn has_findings(&self) -> bool {
self.findings.iter().any(Finding::is_finding)
}
pub fn findings_at_or_above(
&self,
min_severity: crate::Severity,
) -> impl Iterator<Item = &Finding> + '_ {
let threshold = min_severity.rank();
self.findings
.iter()
.filter(move |f| severity_of(&f.result).is_some_and(|s| s.rank() >= threshold))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{LintResult, Severity};
fn empty_report() -> EvaluationReport {
EvaluationReport::new("cabf.br.tls", "SC-081", "v0.2.0", 2, 1_780_272_000)
}
fn pass_finding() -> Finding {
Finding {
lint_id: std::borrow::Cow::Borrowed("test.lint"),
citation: std::borrow::Cow::Borrowed("test"),
rule_bundle_version: std::borrow::Cow::Borrowed("v0.2.0"),
result: LintResult::Pass,
cert_index: Some(0),
evaluated_at_unix: 1_780_272_000,
cert_sha256: None,
}
}
fn error_finding() -> Finding {
Finding {
result: LintResult::error("something wrong"),
..pass_finding()
}
}
fn warn_finding() -> Finding {
Finding {
result: LintResult::warn("advisory"),
..pass_finding()
}
}
#[test]
fn empty_report_has_no_findings() {
let r = empty_report();
assert!(!r.has_findings());
assert!(r.findings.is_empty());
assert!(r.deviated_findings.is_empty());
}
#[test]
fn report_with_only_pass_has_no_findings() {
let mut r = empty_report();
r.findings.push(pass_finding());
assert!(!r.has_findings(), "Pass result must not count as a finding");
}
#[test]
fn report_with_error_has_findings() {
let mut r = empty_report();
r.findings.push(error_finding());
assert!(r.has_findings());
}
#[test]
fn findings_at_or_above_error_excludes_warn() {
let mut r = empty_report();
r.findings.push(error_finding());
r.findings.push(warn_finding());
let at_error: Vec<&Finding> = r.findings_at_or_above(Severity::Error).collect();
assert_eq!(at_error.len(), 1, "only Error findings at Error threshold");
assert!(matches!(at_error[0].result, LintResult::Error(_)));
}
#[test]
fn findings_at_or_above_warn_includes_error() {
let mut r = empty_report();
r.findings.push(error_finding());
r.findings.push(warn_finding());
let at_warn_count = r.findings_at_or_above(Severity::Warn).count();
assert_eq!(at_warn_count, 2, "both Error and Warn at Warn threshold");
}
#[test]
fn report_metadata_fields_preserved() {
let r = EvaluationReport::new("cabf.br.tls", "SC-081", "bundle-v1", 3, 999_000);
assert_eq!(r.profile_id, "cabf.br.tls");
assert_eq!(r.profile_version, "SC-081");
assert_eq!(r.rule_bundle_version, "bundle-v1");
assert_eq!(r.chain_length, 3);
assert_eq!(r.evaluated_at_unix, 999_000);
}
#[test]
#[cfg(feature = "serde")]
fn json_round_trip() {
let mut r = empty_report();
r.findings.push(error_finding());
r.findings.push(warn_finding());
let json = serde_json::to_string_pretty(&r).expect("serialization must succeed");
assert!(
json.contains("\"profile_id\""),
"JSON must contain profile_id"
);
assert!(
json.contains("\"cabf.br.tls\""),
"JSON must contain profile id value"
);
assert!(json.contains("\"lint_id\""), "JSON must contain lint_id");
assert!(
json.contains("\"rule_bundle_version\""),
"JSON must contain rule_bundle_version"
);
assert!(
json.contains("\"evaluated_at_unix\""),
"JSON must contain evaluated_at_unix"
);
let r2: EvaluationReport =
serde_json::from_str(&json).expect("deserialization must succeed");
assert_eq!(r2.profile_id, r.profile_id);
assert_eq!(r2.profile_version, r.profile_version);
assert_eq!(r2.rule_bundle_version, r.rule_bundle_version);
assert_eq!(r2.findings.len(), r.findings.len());
match (&r.findings[0].result, &r2.findings[0].result) {
(LintResult::Error(a), LintResult::Error(b)) => assert_eq!(a, b),
(a, b) => panic!("unexpected result variants: {a:?} vs {b:?}"),
}
}
#[test]
#[cfg(feature = "serde")]
fn lint_result_dynamic_detail_round_trip() {
let actual = 400u64;
let detail_text = format!("validity {actual} days exceeds 398-day cap");
let f = Finding {
lint_id: std::borrow::Cow::Borrowed("test.lint"),
citation: std::borrow::Cow::Borrowed("test"),
rule_bundle_version: std::borrow::Cow::Borrowed("v0.3.0"),
result: LintResult::error(detail_text.clone()),
cert_index: Some(0),
evaluated_at_unix: 0,
cert_sha256: None,
};
let json = serde_json::to_string(&f).expect("serialize");
let f2: Finding = serde_json::from_slice(json.as_bytes()).expect("from_slice must succeed");
match &f2.result {
LintResult::Error(d) => assert_eq!(d.as_ref(), detail_text.as_str()),
other => panic!("expected Error variant, got {other:?}"),
}
}
}