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 let label = if comp.change_type == crate::diff::ChangeType::Unchanged {
57 "Unchanged"
58 } else {
59 "Modified"
60 };
61 write_component_line(&mut content, label, comp);
62 }
63
64 content.push_str("\n# Vulnerabilities\n");
66 content.push_str("Status,ID,Severity,Type,SLA,Component,Description,VEX\n");
67
68 for vuln in &result.vulnerabilities.introduced {
69 write_vuln_line(&mut content, "Introduced", vuln);
70 }
71
72 for vuln in &result.vulnerabilities.resolved {
73 write_vuln_line(&mut content, "Resolved", vuln);
74 }
75
76 for vuln in &result.vulnerabilities.persistent {
77 write_vuln_line(&mut content, "Persistent", vuln);
78 }
79
80 Ok(content)
81 }
82
83 fn generate_view_report(
84 &self,
85 sbom: &NormalizedSbom,
86 _config: &ReportConfig,
87 ) -> Result<String, ReportError> {
88 let mut content = String::with_capacity(sbom.components.len() * 150 + 100);
90
91 content.push_str(
92 "Name,Version,Ecosystem,Type,PURL,Licenses,Vulnerabilities,EOL Status,EOL Date,Crypto Asset Type,Algorithm Family,Quantum Level\n",
93 );
94
95 for (_, comp) in &sbom.components {
96 let licenses = comp
97 .licenses
98 .declared
99 .iter()
100 .map(|l| l.display_name())
101 .collect::<Vec<_>>()
102 .join("; ");
103 let vuln_count = comp.vulnerabilities.len();
104 let ecosystem = comp.ecosystem.as_ref().map(|e| format!("{e:?}"));
105 let ecosystem = escape_csv_opt(ecosystem.as_deref());
106
107 let eol_status = comp.eol.as_ref().map_or("-", |e| e.status.label());
108 let eol_date = comp
109 .eol
110 .as_ref()
111 .and_then(|e| e.eol_date.map(|d| d.to_string()));
112 let eol_date = eol_date.as_deref().unwrap_or("-");
113
114 let crypto_type = comp
116 .crypto_properties
117 .as_ref()
118 .map(|cp| cp.asset_type.to_string())
119 .unwrap_or_default();
120 let algo_family = comp
121 .crypto_properties
122 .as_ref()
123 .and_then(|cp| {
124 cp.algorithm_properties
125 .as_ref()
126 .and_then(|a| a.algorithm_family.as_deref().map(escape_csv))
127 })
128 .unwrap_or_default();
129 let quantum_level = comp
130 .crypto_properties
131 .as_ref()
132 .and_then(|cp| {
133 cp.algorithm_properties
134 .as_ref()
135 .and_then(|a| a.nist_quantum_security_level.map(|l| l.to_string()))
136 })
137 .unwrap_or_default();
138
139 let _ = writeln!(
140 content,
141 "\"{}\",\"{}\",\"{}\",\"{:?}\",\"{}\",\"{}\",{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
142 escape_csv(&comp.name),
143 escape_csv_opt(comp.version.as_deref()),
144 ecosystem,
145 comp.component_type,
146 escape_csv_opt(comp.identifiers.purl.as_deref()),
147 escape_csv(&licenses),
148 vuln_count,
149 eol_status,
150 eol_date,
151 crypto_type,
152 algo_family,
153 quantum_level,
154 );
155 }
156
157 Ok(content)
158 }
159
160 fn format(&self) -> ReportFormat {
161 ReportFormat::Csv
162 }
163}
164
165fn write_component_line(
167 content: &mut String,
168 change_type: &str,
169 comp: &crate::diff::ComponentChange,
170) {
171 let _ = writeln!(
172 content,
173 "{},\"{}\",\"{}\",\"{}\",\"{}\"",
174 change_type,
175 escape_csv(&comp.name),
176 escape_csv_opt(comp.old_version.as_deref()),
177 escape_csv_opt(comp.new_version.as_deref()),
178 escape_csv_opt(comp.ecosystem.as_deref())
179 );
180}
181
182fn write_vuln_line(content: &mut String, status: &str, vuln: &VulnerabilityDetail) {
183 let depth_label = match vuln.component_depth {
184 Some(1) => "Direct",
185 Some(_) => "Transitive",
186 None => "-",
187 };
188 let sla_display = format_sla_csv(vuln);
189 let desc = vuln
190 .description
191 .as_deref()
192 .map(escape_csv)
193 .unwrap_or_default();
194 let vex_display = match vuln.vex_state.as_ref() {
195 Some(crate::model::VexState::NotAffected) => "Not Affected",
196 Some(crate::model::VexState::Fixed) => "Fixed",
197 Some(crate::model::VexState::Affected) => "Affected",
198 Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
199 None => "",
200 };
201
202 let _ = writeln!(
203 content,
204 "{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
205 status,
206 escape_csv(&vuln.id),
207 escape_csv(&vuln.severity),
208 depth_label,
209 sla_display,
210 escape_csv(&vuln.component_name),
211 desc,
212 vex_display,
213 );
214}
215
216fn escape_csv(s: &str) -> String {
223 let escaped = s.replace('"', "\"\"").replace('\n', " ");
224 if s.starts_with(['=', '+', '-', '@', '\t', '\r']) {
225 format!("'{escaped}")
226 } else {
227 escaped
228 }
229}
230
231fn escape_csv_opt(s: Option<&str>) -> String {
233 s.map_or_else(|| "-".to_string(), escape_csv)
234}
235
236fn format_sla_csv(vuln: &VulnerabilityDetail) -> String {
237 match vuln.sla_status() {
238 SlaStatus::Overdue(days) => format!("{days}d late"),
239 SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
240 SlaStatus::NoDueDate => vuln
241 .days_since_published
242 .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn escape_csv_guards_formula_triggers() {
252 assert_eq!(escape_csv("=1+2"), "'=1+2");
253 assert_eq!(escape_csv("+SUM(A1:A2)"), "'+SUM(A1:A2)");
254 assert_eq!(escape_csv("-2+3"), "'-2+3");
255 assert_eq!(escape_csv("@cmd"), "'@cmd");
256 assert_eq!(escape_csv("\tpayload"), "'\tpayload");
257 assert_eq!(escape_csv("\rpayload"), "'\rpayload");
258 assert_eq!(escape_csv("@types/node"), "'@types/node");
261 }
262
263 #[test]
264 fn escape_csv_guards_formula_after_quote_doubling() {
265 assert_eq!(escape_csv("=cmd|'/c calc'!A0"), "'=cmd|'/c calc'!A0");
266 assert_eq!(escape_csv("=\"evil\""), "'=\"\"evil\"\"");
267 }
268
269 #[test]
270 fn escape_csv_leaves_benign_values_alone() {
271 assert_eq!(escape_csv("lodash"), "lodash");
272 assert_eq!(escape_csv("1.2.3"), "1.2.3");
273 assert_eq!(
274 escape_csv("pkg:npm/lodash@4.17.21"),
275 "pkg:npm/lodash@4.17.21"
276 );
277 assert_eq!(escape_csv("MIT OR Apache-2.0"), "MIT OR Apache-2.0");
278 assert_eq!(escape_csv("name \"quoted\""), "name \"\"quoted\"\"");
279 assert_eq!(escape_csv("line1\nline2"), "line1 line2");
280 assert_eq!(escape_csv(""), "");
281 }
282
283 #[test]
284 fn escape_csv_opt_uses_placeholder_for_none() {
285 assert_eq!(escape_csv_opt(None), "-");
286 assert_eq!(escape_csv_opt(Some("=evil")), "'=evil");
287 assert_eq!(escape_csv_opt(Some("1.0.0")), "1.0.0");
288 }
289}