Skip to main content

sbom_tools/reports/
csv.rs

1//! CSV report generator.
2//!
3//! Generates comma-separated reports for diff and view modes,
4//! suitable for spreadsheet import and data analysis pipelines.
5
6use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
7use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
8use crate::model::NormalizedSbom;
9use std::fmt::Write;
10
11/// CSV report generator.
12pub struct CsvReporter;
13
14impl CsvReporter {
15    #[must_use]
16    pub const fn new() -> Self {
17        Self
18    }
19}
20
21impl Default for CsvReporter {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl ReportGenerator for CsvReporter {
28    fn generate_diff_report(
29        &self,
30        result: &DiffResult,
31        _old_sbom: &NormalizedSbom,
32        _new_sbom: &NormalizedSbom,
33        _config: &ReportConfig,
34    ) -> Result<String, ReportError> {
35        // Pre-allocate based on estimated output size
36        let estimated_lines = result.components.total()
37            + result.vulnerabilities.introduced.len()
38            + result.vulnerabilities.resolved.len()
39            + result.vulnerabilities.persistent.len()
40            + 10; // headers
41        let mut content = String::with_capacity(estimated_lines * 100);
42
43        // Components CSV
44        content.push_str("# Components\n");
45        content.push_str("Change,Name,Old Version,New Version,Ecosystem\n");
46
47        for comp in &result.components.added {
48            write_component_line(&mut content, "Added", comp);
49        }
50
51        for comp in &result.components.removed {
52            write_component_line(&mut content, "Removed", comp);
53        }
54
55        for comp in &result.components.modified {
56            write_component_line(&mut content, "Modified", comp);
57        }
58
59        // Vulnerabilities CSV
60        content.push_str("\n# Vulnerabilities\n");
61        content.push_str("Status,ID,Severity,Type,SLA,Component,Description,VEX\n");
62
63        for vuln in &result.vulnerabilities.introduced {
64            write_vuln_line(&mut content, "Introduced", vuln);
65        }
66
67        for vuln in &result.vulnerabilities.resolved {
68            write_vuln_line(&mut content, "Resolved", vuln);
69        }
70
71        for vuln in &result.vulnerabilities.persistent {
72            write_vuln_line(&mut content, "Persistent", vuln);
73        }
74
75        Ok(content)
76    }
77
78    fn generate_view_report(
79        &self,
80        sbom: &NormalizedSbom,
81        _config: &ReportConfig,
82    ) -> Result<String, ReportError> {
83        // Pre-allocate based on component count
84        let mut content = String::with_capacity(sbom.components.len() * 150 + 100);
85
86        content.push_str(
87            "Name,Version,Ecosystem,Type,PURL,Licenses,Vulnerabilities,EOL Status,EOL Date\n",
88        );
89
90        for (_, comp) in &sbom.components {
91            let licenses = comp
92                .licenses
93                .declared
94                .iter()
95                .map(|l| l.expression.as_str())
96                .collect::<Vec<_>>()
97                .join("; ");
98            let vuln_count = comp.vulnerabilities.len();
99            let ecosystem = comp.ecosystem.as_ref().map(|e| format!("{e:?}"));
100            let ecosystem = ecosystem.as_deref().unwrap_or("-");
101
102            let eol_status = comp.eol.as_ref().map_or("-", |e| e.status.label());
103            let eol_date = comp
104                .eol
105                .as_ref()
106                .and_then(|e| e.eol_date.map(|d| d.to_string()));
107            let eol_date = eol_date.as_deref().unwrap_or("-");
108
109            let _ = writeln!(
110                content,
111                "\"{}\",\"{}\",\"{}\",\"{:?}\",\"{}\",\"{}\",{},\"{}\",\"{}\"",
112                escape_csv(&comp.name),
113                comp.version.as_deref().unwrap_or("-"),
114                ecosystem,
115                comp.component_type,
116                comp.identifiers.purl.as_deref().unwrap_or("-"),
117                escape_csv(&licenses),
118                vuln_count,
119                eol_status,
120                eol_date,
121            );
122        }
123
124        Ok(content)
125    }
126
127    fn format(&self) -> ReportFormat {
128        ReportFormat::Csv
129    }
130}
131
132/// Write a component line using write! macro to avoid format! allocation.
133fn write_component_line(
134    content: &mut String,
135    change_type: &str,
136    comp: &crate::diff::ComponentChange,
137) {
138    let _ = writeln!(
139        content,
140        "{},\"{}\",\"{}\",\"{}\",\"{}\"",
141        change_type,
142        escape_csv(&comp.name),
143        comp.old_version.as_deref().unwrap_or("-"),
144        comp.new_version.as_deref().unwrap_or("-"),
145        comp.ecosystem.as_deref().unwrap_or("-")
146    );
147}
148
149fn write_vuln_line(content: &mut String, status: &str, vuln: &VulnerabilityDetail) {
150    let depth_label = match vuln.component_depth {
151        Some(1) => "Direct",
152        Some(_) => "Transitive",
153        None => "-",
154    };
155    let sla_display = format_sla_csv(vuln);
156    let desc = vuln
157        .description
158        .as_deref()
159        .map(escape_csv)
160        .unwrap_or_default();
161    let vex_display = match vuln.vex_state.as_ref() {
162        Some(crate::model::VexState::NotAffected) => "Not Affected",
163        Some(crate::model::VexState::Fixed) => "Fixed",
164        Some(crate::model::VexState::Affected) => "Affected",
165        Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
166        None => "",
167    };
168
169    let _ = writeln!(
170        content,
171        "{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
172        status,
173        escape_csv(&vuln.id),
174        escape_csv(&vuln.severity),
175        depth_label,
176        sla_display,
177        escape_csv(&vuln.component_name),
178        desc,
179        vex_display,
180    );
181}
182
183/// Escape a string for CSV embedding: double-quote escaping per RFC 4180,
184/// plus newline flattening since fields are already wrapped in double quotes.
185fn escape_csv(s: &str) -> String {
186    s.replace('"', "\"\"").replace('\n', " ")
187}
188
189fn format_sla_csv(vuln: &VulnerabilityDetail) -> String {
190    match vuln.sla_status() {
191        SlaStatus::Overdue(days) => format!("{days}d late"),
192        SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
193        SlaStatus::NoDueDate => vuln
194            .days_since_published
195            .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
196    }
197}