1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
7use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
8use crate::model::NormalizedSbom;
9use std::fmt::Write;
10
11pub 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 let estimated_lines = result.components.total()
37 + result.vulnerabilities.introduced.len()
38 + result.vulnerabilities.resolved.len()
39 + result.vulnerabilities.persistent.len()
40 + 10; let mut content = String::with_capacity(estimated_lines * 100);
42
43 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 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 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,Crypto Asset Type,Algorithm Family,Quantum Level\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 = escape_csv_opt(ecosystem.as_deref());
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 crypto_type = comp
111 .crypto_properties
112 .as_ref()
113 .map(|cp| cp.asset_type.to_string())
114 .unwrap_or_default();
115 let algo_family = comp
116 .crypto_properties
117 .as_ref()
118 .and_then(|cp| {
119 cp.algorithm_properties
120 .as_ref()
121 .and_then(|a| a.algorithm_family.as_deref().map(escape_csv))
122 })
123 .unwrap_or_default();
124 let quantum_level = comp
125 .crypto_properties
126 .as_ref()
127 .and_then(|cp| {
128 cp.algorithm_properties
129 .as_ref()
130 .and_then(|a| a.nist_quantum_security_level.map(|l| l.to_string()))
131 })
132 .unwrap_or_default();
133
134 let _ = writeln!(
135 content,
136 "\"{}\",\"{}\",\"{}\",\"{:?}\",\"{}\",\"{}\",{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
137 escape_csv(&comp.name),
138 escape_csv_opt(comp.version.as_deref()),
139 ecosystem,
140 comp.component_type,
141 escape_csv_opt(comp.identifiers.purl.as_deref()),
142 escape_csv(&licenses),
143 vuln_count,
144 eol_status,
145 eol_date,
146 crypto_type,
147 algo_family,
148 quantum_level,
149 );
150 }
151
152 Ok(content)
153 }
154
155 fn format(&self) -> ReportFormat {
156 ReportFormat::Csv
157 }
158}
159
160fn write_component_line(
162 content: &mut String,
163 change_type: &str,
164 comp: &crate::diff::ComponentChange,
165) {
166 let _ = writeln!(
167 content,
168 "{},\"{}\",\"{}\",\"{}\",\"{}\"",
169 change_type,
170 escape_csv(&comp.name),
171 escape_csv_opt(comp.old_version.as_deref()),
172 escape_csv_opt(comp.new_version.as_deref()),
173 escape_csv_opt(comp.ecosystem.as_deref())
174 );
175}
176
177fn write_vuln_line(content: &mut String, status: &str, vuln: &VulnerabilityDetail) {
178 let depth_label = match vuln.component_depth {
179 Some(1) => "Direct",
180 Some(_) => "Transitive",
181 None => "-",
182 };
183 let sla_display = format_sla_csv(vuln);
184 let desc = vuln
185 .description
186 .as_deref()
187 .map(escape_csv)
188 .unwrap_or_default();
189 let vex_display = match vuln.vex_state.as_ref() {
190 Some(crate::model::VexState::NotAffected) => "Not Affected",
191 Some(crate::model::VexState::Fixed) => "Fixed",
192 Some(crate::model::VexState::Affected) => "Affected",
193 Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
194 None => "",
195 };
196
197 let _ = writeln!(
198 content,
199 "{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
200 status,
201 escape_csv(&vuln.id),
202 escape_csv(&vuln.severity),
203 depth_label,
204 sla_display,
205 escape_csv(&vuln.component_name),
206 desc,
207 vex_display,
208 );
209}
210
211fn escape_csv(s: &str) -> String {
218 let escaped = s.replace('"', "\"\"").replace('\n', " ");
219 if s.starts_with(['=', '+', '-', '@', '\t', '\r']) {
220 format!("'{escaped}")
221 } else {
222 escaped
223 }
224}
225
226fn escape_csv_opt(s: Option<&str>) -> String {
228 s.map_or_else(|| "-".to_string(), escape_csv)
229}
230
231fn format_sla_csv(vuln: &VulnerabilityDetail) -> String {
232 match vuln.sla_status() {
233 SlaStatus::Overdue(days) => format!("{days}d late"),
234 SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
235 SlaStatus::NoDueDate => vuln
236 .days_since_published
237 .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn escape_csv_guards_formula_triggers() {
247 assert_eq!(escape_csv("=1+2"), "'=1+2");
248 assert_eq!(escape_csv("+SUM(A1:A2)"), "'+SUM(A1:A2)");
249 assert_eq!(escape_csv("-2+3"), "'-2+3");
250 assert_eq!(escape_csv("@cmd"), "'@cmd");
251 assert_eq!(escape_csv("\tpayload"), "'\tpayload");
252 assert_eq!(escape_csv("\rpayload"), "'\rpayload");
253 assert_eq!(escape_csv("@types/node"), "'@types/node");
256 }
257
258 #[test]
259 fn escape_csv_guards_formula_after_quote_doubling() {
260 assert_eq!(escape_csv("=cmd|'/c calc'!A0"), "'=cmd|'/c calc'!A0");
261 assert_eq!(escape_csv("=\"evil\""), "'=\"\"evil\"\"");
262 }
263
264 #[test]
265 fn escape_csv_leaves_benign_values_alone() {
266 assert_eq!(escape_csv("lodash"), "lodash");
267 assert_eq!(escape_csv("1.2.3"), "1.2.3");
268 assert_eq!(
269 escape_csv("pkg:npm/lodash@4.17.21"),
270 "pkg:npm/lodash@4.17.21"
271 );
272 assert_eq!(escape_csv("MIT OR Apache-2.0"), "MIT OR Apache-2.0");
273 assert_eq!(escape_csv("name \"quoted\""), "name \"\"quoted\"\"");
274 assert_eq!(escape_csv("line1\nline2"), "line1 line2");
275 assert_eq!(escape_csv(""), "");
276 }
277
278 #[test]
279 fn escape_csv_opt_uses_placeholder_for_none() {
280 assert_eq!(escape_csv_opt(None), "-");
281 assert_eq!(escape_csv_opt(Some("=evil")), "'=evil");
282 assert_eq!(escape_csv_opt(Some("1.0.0")), "1.0.0");
283 }
284}