pub(crate) mod console;
pub(crate) mod csv;
pub(crate) mod html;
pub(crate) mod json;
pub(crate) mod markdown;
pub(crate) mod txt;
use std::io::Write;
use crate::cli::convert::ReportFormat;
use crate::engine::analyzer::AnalysisResult;
#[cfg(test)]
use crate::engine::analyzer::{Finding, Severity};
pub(crate) fn generate(results: &[AnalysisResult], format: ReportFormat, title: &str, out: &mut dyn Write) -> anyhow::Result<()> {
match format {
ReportFormat::Console => console::render(results, out),
ReportFormat::Json => json::render(results, out),
ReportFormat::Txt => txt::render(results, out),
ReportFormat::Csv => csv::render(results, out),
ReportFormat::Markdown => markdown::render(results, title, out),
ReportFormat::Html => html::render(results, title, out),
}
}
#[cfg(test)]
pub(crate) fn risk_score(findings: &[Finding]) -> u32 {
findings.iter().map(|f| severity_weight(f.severity)).sum()
}
#[cfg(test)]
pub(crate) fn deduplicate_findings(findings: &mut Vec<Finding>) {
let mut seen = std::collections::HashSet::new();
findings.retain(|f| {
let key = (f.id.clone(), f.export.clone());
seen.insert(key)
});
}
#[cfg(test)]
const fn severity_weight(sev: Severity) -> u32 {
match sev {
Severity::Critical => 10,
Severity::High => 7,
Severity::Medium => 4,
Severity::Low => 1,
Severity::Info => 0,
}
}
#[cfg(test)]
mod tests {
#![expect(clippy::pedantic, reason = "unit test -- lints are suppressed per project policy")]
use super::*;
use crate::engine::analyzer::ExportAnalysis;
fn make_test_finding(id: &str, severity: Severity, export: Option<&str>) -> Finding {
Finding { id: id.to_owned(), title: format!("Test finding {id}"), severity, description: "Test description".to_owned(), evidence: "Test evidence".to_owned(), remediation: "Test remediation".to_owned(), export: export.map(str::to_owned) }
}
fn make_test_result(findings: Vec<Finding>) -> AnalysisResult {
AnalysisResult {
host: "192.168.1.1".to_owned(),
timestamp: "2025-01-01T00:00:00Z".to_owned(),
os_guess: Some("Linux/Ext4".to_owned()),
impl_fingerprint: None,
nfs_versions: vec!["NFSv3".to_owned()],
exports: vec![ExportAnalysis { path: "/export".to_owned(), allowed_hosts: vec!["*".to_owned()], auth_methods: vec!["1".to_owned()], writable: false, no_root_squash: None, escape_possible: false, file_handle: "01020304".to_owned(), file_access_tests: Vec::new(), nfs4_acls: Vec::new() }],
findings,
}
}
#[test]
fn risk_score_empty_findings_is_zero() {
assert_eq!(risk_score(&[]), 0);
}
#[test]
fn risk_score_single_critical_is_10() {
let findings = vec![make_test_finding("F-1.1", Severity::Critical, None)];
assert_eq!(risk_score(&findings), 10);
}
#[test]
fn risk_score_mixed_severities_sums_correctly() {
let findings = vec![
make_test_finding("F-1.1", Severity::Critical, None), make_test_finding("F-2.1", Severity::High, None), make_test_finding("F-3.1", Severity::Medium, None), make_test_finding("F-4.1", Severity::Low, None), make_test_finding("F-5.1", Severity::Info, None), ];
assert_eq!(risk_score(&findings), 22);
}
#[test]
fn deduplicate_findings_removes_dupes() {
let mut findings = vec![
make_test_finding("F-1.1", Severity::High, Some("/export")),
make_test_finding("F-1.1", Severity::High, Some("/export")), make_test_finding("F-2.1", Severity::Critical, Some("/export")),
];
deduplicate_findings(&mut findings);
assert_eq!(findings.len(), 2);
}
#[test]
fn deduplicate_findings_preserves_unique() {
let mut findings = vec![make_test_finding("F-1.1", Severity::High, Some("/a")), make_test_finding("F-1.1", Severity::High, Some("/b")), make_test_finding("F-2.1", Severity::Critical, Some("/a"))];
deduplicate_findings(&mut findings);
assert_eq!(findings.len(), 3, "distinct (id, export) pairs must be preserved");
}
#[test]
fn generate_json_format_produces_valid_json() {
let results = vec![make_test_result(vec![make_test_finding("F-1.1", Severity::High, Some("/export"))])];
let mut buf = Vec::new();
generate(&results, ReportFormat::Json, "Test", &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let _parsed: serde_json::Value = serde_json::from_str(&output).expect("JSON output must be valid");
}
#[test]
fn generate_txt_format_produces_non_empty_output() {
let results = vec![make_test_result(vec![make_test_finding("F-1.1", Severity::High, Some("/export"))])];
let mut buf = Vec::new();
generate(&results, ReportFormat::Txt, "Test", &mut buf).unwrap();
assert!(!buf.is_empty(), "TXT output must be non-empty");
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("192.168.1.1"), "TXT must contain host");
}
#[test]
fn generate_csv_format_contains_header_row() {
let results = vec![make_test_result(vec![make_test_finding("F-1.1", Severity::High, Some("/export"))])];
let mut buf = Vec::new();
generate(&results, ReportFormat::Csv, "Test", &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.starts_with("host,"), "CSV must start with header row");
assert!(output.contains("finding_id"), "CSV header must contain finding_id");
}
#[test]
fn generate_html_format_contains_html_tag() {
let results = vec![make_test_result(vec![make_test_finding("F-1.1", Severity::High, Some("/export"))])];
let mut buf = Vec::new();
generate(&results, ReportFormat::Html, "Test Report", &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("<html"), "HTML output must contain html tag");
assert!(output.contains("</html>"), "HTML output must be closed");
}
#[test]
fn generate_markdown_format_contains_heading() {
let results = vec![make_test_result(vec![make_test_finding("F-1.1", Severity::High, Some("/export"))])];
let mut buf = Vec::new();
generate(&results, ReportFormat::Markdown, "Security Report", &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("# Security Report"), "Markdown must contain # heading");
}
}