1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
4use crate::diff::DiffResult;
5use crate::model::NormalizedSbom;
6use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult};
7use chrono::Utc;
8use serde::Serialize;
9
10pub struct JsonReporter {
12 summary_only: bool,
14 pretty: bool,
16}
17
18impl JsonReporter {
19 pub fn new() -> Self {
21 Self {
22 summary_only: false,
23 pretty: true,
24 }
25 }
26
27 pub fn summary_only() -> Self {
29 Self {
30 summary_only: true,
31 pretty: true,
32 }
33 }
34
35 pub fn pretty(mut self, pretty: bool) -> Self {
37 self.pretty = pretty;
38 self
39 }
40}
41
42impl Default for JsonReporter {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl ReportGenerator for JsonReporter {
49 fn generate_diff_report(
50 &self,
51 result: &DiffResult,
52 old_sbom: &NormalizedSbom,
53 new_sbom: &NormalizedSbom,
54 config: &ReportConfig,
55 ) -> Result<String, ReportError> {
56 let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
57 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
58 });
59 let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
60 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
61 });
62 let cra_compliance = CraCompliance {
63 old: CraComplianceDetail::from_result(old_cra),
64 new: CraComplianceDetail::from_result(new_cra),
65 };
66
67 let report = JsonDiffReport {
68 metadata: JsonReportMetadata {
69 tool: ToolInfo {
70 name: "sbom-tools".to_string(),
71 version: env!("CARGO_PKG_VERSION").to_string(),
72 },
73 generated_at: Utc::now().to_rfc3339(),
74 old_sbom: SbomInfo {
75 format: old_sbom.document.format.to_string(),
76 file_path: config.metadata.old_sbom_path.clone(),
77 component_count: old_sbom.component_count(),
78 },
79 new_sbom: SbomInfo {
80 format: new_sbom.document.format.to_string(),
81 file_path: config.metadata.new_sbom_path.clone(),
82 component_count: new_sbom.component_count(),
83 },
84 },
85 summary: JsonSummary {
86 total_changes: result.summary.total_changes,
87 components: ComponentSummary {
88 added: result.summary.components_added,
89 removed: result.summary.components_removed,
90 modified: result.summary.components_modified,
91 },
92 vulnerabilities: VulnerabilitySummary {
93 introduced: result.summary.vulnerabilities_introduced,
94 resolved: result.summary.vulnerabilities_resolved,
95 persistent: result.summary.vulnerabilities_persistent,
96 },
97 semantic_score: result.semantic_score,
98 },
99 cra_compliance,
100 reports: if self.summary_only {
101 None
102 } else {
103 Some(JsonReports {
104 components: if config.includes(ReportType::Components) {
105 Some(ComponentsReport {
106 added: &result.components.added,
107 removed: &result.components.removed,
108 modified: &result.components.modified,
109 })
110 } else {
111 None
112 },
113 dependencies: if config.includes(ReportType::Dependencies) {
114 Some(DependenciesReport {
115 added: &result.dependencies.added,
116 removed: &result.dependencies.removed,
117 })
118 } else {
119 None
120 },
121 licenses: if config.includes(ReportType::Licenses) {
122 Some(LicensesReport {
123 new_licenses: &result.licenses.new_licenses,
124 removed_licenses: &result.licenses.removed_licenses,
125 conflicts: &result.licenses.conflicts,
126 })
127 } else {
128 None
129 },
130 vulnerabilities: if config.includes(ReportType::Vulnerabilities) {
131 Some(VulnerabilitiesReport {
132 introduced: &result.vulnerabilities.introduced,
133 resolved: &result.vulnerabilities.resolved,
134 persistent: &result.vulnerabilities.persistent,
135 })
136 } else {
137 None
138 },
139 })
140 },
141 };
142
143 let json = if self.pretty {
144 serde_json::to_string_pretty(&report)
145 } else {
146 serde_json::to_string(&report)
147 }
148 .map_err(|e| ReportError::SerializationError(e.to_string()))?;
149
150 Ok(json)
151 }
152
153 fn generate_view_report(
154 &self,
155 sbom: &NormalizedSbom,
156 config: &ReportConfig,
157 ) -> Result<String, ReportError> {
158 let cra_result = config.view_cra_compliance.clone().unwrap_or_else(|| {
159 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom)
160 });
161 let compliance = CraComplianceDetail::from_result(cra_result);
162
163 let report = JsonViewReport {
164 metadata: JsonViewMetadata {
165 tool: ToolInfo {
166 name: "sbom-tools".to_string(),
167 version: env!("CARGO_PKG_VERSION").to_string(),
168 },
169 generated_at: Utc::now().to_rfc3339(),
170 sbom: SbomInfo {
171 format: sbom.document.format.to_string(),
172 file_path: config.metadata.old_sbom_path.clone(),
173 component_count: sbom.component_count(),
174 },
175 },
176 summary: ViewSummary {
177 total_components: sbom.component_count(),
178 total_dependencies: sbom.edges.len(),
179 ecosystems: sbom.ecosystems().iter().map(|e| e.to_string()).collect(),
180 vulnerability_counts: sbom.vulnerability_counts(),
181 },
182 compliance,
183 components: sbom
184 .components
185 .values()
186 .map(|c| ComponentView {
187 name: c.name.clone(),
188 version: c.version.clone(),
189 ecosystem: c.ecosystem.as_ref().map(|e| e.to_string()),
190 licenses: c
191 .licenses
192 .declared
193 .iter()
194 .map(|l| l.expression.clone())
195 .collect(),
196 supplier: c.supplier.as_ref().map(|s| s.name.clone()),
197 vulnerabilities: c.vulnerabilities.len(),
198 })
199 .collect(),
200 };
201
202 let json = if self.pretty {
203 serde_json::to_string_pretty(&report)
204 } else {
205 serde_json::to_string(&report)
206 }
207 .map_err(|e| ReportError::SerializationError(e.to_string()))?;
208
209 Ok(json)
210 }
211
212 fn format(&self) -> ReportFormat {
213 ReportFormat::Json
214 }
215}
216
217#[derive(Serialize)]
220struct JsonDiffReport<'a> {
221 metadata: JsonReportMetadata,
222 summary: JsonSummary,
223 cra_compliance: CraCompliance,
224 #[serde(skip_serializing_if = "Option::is_none")]
225 reports: Option<JsonReports<'a>>,
226}
227
228#[derive(Serialize)]
229struct CraCompliance {
230 old: CraComplianceDetail,
231 new: CraComplianceDetail,
232}
233
234#[derive(Serialize)]
235struct CraComplianceDetail {
236 #[serde(flatten)]
237 result: ComplianceResult,
238 article_summary: CraArticleSummary,
240}
241
242#[derive(Serialize)]
243struct CraArticleSummary {
244 #[serde(rename = "art_13_4_machine_readable_format")]
246 art_13_4: usize,
247 #[serde(rename = "art_13_6_vulnerability_disclosure")]
249 art_13_6: usize,
250 #[serde(rename = "art_13_7_coordinated_disclosure")]
252 art_13_7: usize,
253 #[serde(rename = "art_13_8_support_period")]
255 art_13_8: usize,
256 #[serde(rename = "art_13_11_component_lifecycle")]
258 art_13_11: usize,
259 #[serde(rename = "art_13_12_product_identification")]
261 art_13_12: usize,
262 #[serde(rename = "art_13_15_manufacturer_identification")]
264 art_13_15: usize,
265 #[serde(rename = "annex_i_technical_documentation")]
267 annex_i: usize,
268 #[serde(rename = "annex_vii_declaration_of_conformity")]
270 annex_vii: usize,
271}
272
273impl CraComplianceDetail {
274 fn from_result(result: ComplianceResult) -> Self {
275 let mut summary = CraArticleSummary {
276 art_13_4: 0,
277 art_13_6: 0,
278 art_13_7: 0,
279 art_13_8: 0,
280 art_13_11: 0,
281 art_13_12: 0,
282 art_13_15: 0,
283 annex_i: 0,
284 annex_vii: 0,
285 };
286
287 for violation in &result.violations {
289 let req = violation.requirement.to_lowercase();
290 if req.contains("art. 13(4)") || req.contains("art.13(4)") {
291 summary.art_13_4 += 1;
292 } else if req.contains("art. 13(6)") || req.contains("art.13(6)") {
293 summary.art_13_6 += 1;
294 } else if req.contains("art. 13(7)") || req.contains("art.13(7)") {
295 summary.art_13_7 += 1;
296 } else if req.contains("art. 13(8)") || req.contains("art.13(8)") {
297 summary.art_13_8 += 1;
298 } else if req.contains("art. 13(11)") || req.contains("art.13(11)") {
299 summary.art_13_11 += 1;
300 } else if req.contains("art. 13(12)") || req.contains("art.13(12)") {
301 summary.art_13_12 += 1;
302 } else if req.contains("art. 13(15)") || req.contains("art.13(15)") {
303 summary.art_13_15 += 1;
304 } else if req.contains("annex vii") {
305 summary.annex_vii += 1;
306 } else if req.contains("annex i") || req.contains("annex_i") {
307 summary.annex_i += 1;
308 }
309 }
310
311 Self {
312 result,
313 article_summary: summary,
314 }
315 }
316}
317
318#[derive(Serialize)]
319struct JsonReportMetadata {
320 tool: ToolInfo,
321 generated_at: String,
322 old_sbom: SbomInfo,
323 new_sbom: SbomInfo,
324}
325
326#[derive(Serialize)]
327struct ToolInfo {
328 name: String,
329 version: String,
330}
331
332#[derive(Serialize)]
333struct SbomInfo {
334 format: String,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 file_path: Option<String>,
337 component_count: usize,
338}
339
340#[derive(Serialize)]
341struct JsonSummary {
342 total_changes: usize,
343 components: ComponentSummary,
344 vulnerabilities: VulnerabilitySummary,
345 semantic_score: f64,
346}
347
348#[derive(Serialize)]
349struct ComponentSummary {
350 added: usize,
351 removed: usize,
352 modified: usize,
353}
354
355#[derive(Serialize)]
356struct VulnerabilitySummary {
357 introduced: usize,
358 resolved: usize,
359 persistent: usize,
360}
361
362#[derive(Serialize)]
363struct JsonReports<'a> {
364 #[serde(skip_serializing_if = "Option::is_none")]
365 components: Option<ComponentsReport<'a>>,
366 #[serde(skip_serializing_if = "Option::is_none")]
367 dependencies: Option<DependenciesReport<'a>>,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 licenses: Option<LicensesReport<'a>>,
370 #[serde(skip_serializing_if = "Option::is_none")]
371 vulnerabilities: Option<VulnerabilitiesReport<'a>>,
372}
373
374#[derive(Serialize)]
375struct ComponentsReport<'a> {
376 added: &'a [crate::diff::ComponentChange],
377 removed: &'a [crate::diff::ComponentChange],
378 modified: &'a [crate::diff::ComponentChange],
379}
380
381#[derive(Serialize)]
382struct DependenciesReport<'a> {
383 added: &'a [crate::diff::DependencyChange],
384 removed: &'a [crate::diff::DependencyChange],
385}
386
387#[derive(Serialize)]
388struct LicensesReport<'a> {
389 new_licenses: &'a [crate::diff::LicenseChange],
390 removed_licenses: &'a [crate::diff::LicenseChange],
391 conflicts: &'a [crate::diff::LicenseConflict],
392}
393
394#[derive(Serialize)]
395struct VulnerabilitiesReport<'a> {
396 introduced: &'a [crate::diff::VulnerabilityDetail],
397 resolved: &'a [crate::diff::VulnerabilityDetail],
398 persistent: &'a [crate::diff::VulnerabilityDetail],
399}
400
401#[derive(Serialize)]
404struct JsonViewReport {
405 metadata: JsonViewMetadata,
406 summary: ViewSummary,
407 compliance: CraComplianceDetail,
408 components: Vec<ComponentView>,
409}
410
411#[derive(Serialize)]
412struct JsonViewMetadata {
413 tool: ToolInfo,
414 generated_at: String,
415 sbom: SbomInfo,
416}
417
418#[derive(Serialize)]
419struct ViewSummary {
420 total_components: usize,
421 total_dependencies: usize,
422 ecosystems: Vec<String>,
423 vulnerability_counts: crate::model::VulnerabilityCounts,
424}
425
426#[derive(Serialize)]
427struct ComponentView {
428 name: String,
429 version: Option<String>,
430 ecosystem: Option<String>,
431 licenses: Vec<String>,
432 supplier: Option<String>,
433 vulnerabilities: usize,
434}