1use super::DiffResult;
9use crate::model::{NormalizedSbom, VulnerabilityCounts};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SbomInfo {
20 pub name: String,
22 pub file_path: String,
24 pub format: String,
26 pub component_count: usize,
28 pub dependency_count: usize,
30 pub vulnerability_counts: VulnerabilityCounts,
32 pub timestamp: Option<String>,
34}
35
36impl SbomInfo {
37 #[must_use]
38 pub fn from_sbom(sbom: &NormalizedSbom, name: String, file_path: String) -> Self {
39 Self {
40 name,
41 file_path,
42 format: sbom.document.format.to_string(),
43 component_count: sbom.component_count(),
44 dependency_count: sbom.edges.len(),
45 vulnerability_counts: sbom.vulnerability_counts(),
46 timestamp: Some(sbom.document.created.to_rfc3339()),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct MultiDiffResult {
58 pub baseline: SbomInfo,
60 pub comparisons: Vec<ComparisonResult>,
62 pub summary: MultiDiffSummary,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ComparisonResult {
69 pub target: SbomInfo,
71 pub diff: DiffResult,
73 pub unique_components: Vec<String>,
75 pub divergent_components: Vec<DivergentComponent>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct DivergentComponent {
82 pub id: String,
83 pub name: String,
84 pub baseline_version: Option<String>,
85 pub target_version: String,
86 pub versions_across_targets: HashMap<String, String>,
88 pub divergence_type: DivergenceType,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub enum DivergenceType {
93 VersionMismatch,
95 Added,
97 Removed,
99 LicenseMismatch,
101 SupplierMismatch,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct MultiDiffSummary {
112 pub baseline_component_count: usize,
114 pub universal_components: Vec<String>,
116 pub variable_components: Vec<VariableComponent>,
118 pub inconsistent_components: Vec<InconsistentComponent>,
120 pub deviation_scores: HashMap<String, f64>,
122 pub max_deviation: f64,
124 pub vulnerability_matrix: VulnerabilityMatrix,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct VariableComponent {
131 pub id: String,
132 pub name: String,
133 pub ecosystem: Option<String>,
134 pub version_spread: VersionSpread,
135 pub targets_with_component: Vec<String>,
136 pub security_impact: SecurityImpact,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct VersionSpread {
142 pub baseline: Option<String>,
144 pub min_version: Option<String>,
146 pub max_version: Option<String>,
148 pub unique_versions: Vec<String>,
150 pub is_consistent: bool,
152 pub major_version_spread: u32,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub enum SecurityImpact {
158 Critical,
160 High,
162 Medium,
164 Low,
166}
167
168impl SecurityImpact {
169 #[must_use]
170 pub const fn label(&self) -> &'static str {
171 match self {
172 Self::Critical => "CRITICAL",
173 Self::High => "high",
174 Self::Medium => "medium",
175 Self::Low => "low",
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct InconsistentComponent {
183 pub id: String,
184 pub name: String,
185 pub in_baseline: bool,
187 pub present_in: Vec<String>,
189 pub missing_from: Vec<String>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct VulnerabilityMatrix {
196 pub per_sbom: HashMap<String, VulnerabilityCounts>,
198 pub unique_vulnerabilities: HashMap<String, Vec<String>>,
200 pub common_vulnerabilities: Vec<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct TimelineResult {
211 pub sboms: Vec<SbomInfo>,
213 pub incremental_diffs: Vec<DiffResult>,
215 pub cumulative_from_first: Vec<DiffResult>,
217 pub evolution_summary: EvolutionSummary,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct EvolutionSummary {
224 pub components_added: Vec<ComponentEvolution>,
226 pub components_removed: Vec<ComponentEvolution>,
228 pub version_history: HashMap<String, Vec<VersionAtPoint>>,
230 pub vulnerability_trend: Vec<VulnerabilitySnapshot>,
232 pub license_changes: Vec<LicenseChange>,
234 pub dependency_trend: Vec<DependencySnapshot>,
236 pub compliance_trend: Vec<ComplianceSnapshot>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ComponentEvolution {
243 pub id: String,
244 pub name: String,
245 pub first_seen_index: usize,
247 pub first_seen_version: String,
248 pub last_seen_index: Option<usize>,
250 pub current_version: Option<String>,
252 pub version_change_count: usize,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct VersionAtPoint {
259 pub sbom_index: usize,
260 pub sbom_name: String,
261 pub version: Option<String>,
262 pub change_type: VersionChangeType,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub enum VersionChangeType {
267 Initial,
268 MajorUpgrade,
269 MinorUpgrade,
270 PatchUpgrade,
271 Downgrade,
272 Unchanged,
273 Removed,
274 Absent,
275}
276
277impl VersionChangeType {
278 #[must_use]
279 pub const fn symbol(&self) -> &'static str {
280 match self {
281 Self::Initial => "●",
282 Self::MajorUpgrade => "⬆",
283 Self::MinorUpgrade => "↑",
284 Self::PatchUpgrade => "↗",
285 Self::Downgrade => "⬇",
286 Self::Unchanged => "─",
287 Self::Removed => "✗",
288 Self::Absent => " ",
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ComplianceSnapshot {
296 pub sbom_index: usize,
297 pub sbom_name: String,
298 pub scores: Vec<ComplianceScoreEntry>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct ComplianceScoreEntry {
305 pub standard: String,
306 pub error_count: usize,
307 pub warning_count: usize,
308 pub info_count: usize,
309 pub is_compliant: bool,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct VulnerabilitySnapshot {
315 pub sbom_index: usize,
316 pub sbom_name: String,
317 pub counts: VulnerabilityCounts,
318 pub new_vulnerabilities: Vec<String>,
319 pub resolved_vulnerabilities: Vec<String>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct LicenseChange {
325 pub sbom_index: usize,
326 pub component_id: String,
327 pub component_name: String,
328 pub old_license: Vec<String>,
329 pub new_license: Vec<String>,
330 pub change_type: LicenseChangeType,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub enum LicenseChangeType {
335 MorePermissive,
336 MoreRestrictive,
337 Incompatible,
338 Equivalent,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct DependencySnapshot {
344 pub sbom_index: usize,
345 pub sbom_name: String,
346 pub direct_dependencies: usize,
347 pub transitive_dependencies: usize,
348 pub total_edges: usize,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct MatrixResult {
358 pub sboms: Vec<SbomInfo>,
360 pub diffs: Vec<Option<DiffResult>>,
363 pub similarity_scores: Vec<f64>,
366 pub clustering: Option<SbomClustering>,
368}
369
370impl MatrixResult {
371 #[must_use]
373 pub fn get_diff(&self, i: usize, j: usize) -> Option<&DiffResult> {
374 if i == j {
375 return None;
376 }
377 let (a, b) = if i < j { (i, j) } else { (j, i) };
378 let idx = self.matrix_index(a, b);
379 self.diffs.get(idx).and_then(|d| d.as_ref())
380 }
381
382 #[must_use]
384 pub fn get_similarity(&self, i: usize, j: usize) -> f64 {
385 if i == j {
386 return 1.0;
387 }
388 let (a, b) = if i < j { (i, j) } else { (j, i) };
389 let idx = self.matrix_index(a, b);
390 self.similarity_scores.get(idx).copied().unwrap_or(0.0)
391 }
392
393 fn matrix_index(&self, i: usize, j: usize) -> usize {
395 let n = self.sboms.len();
396 i * (2 * n - i - 1) / 2 + (j - i - 1)
398 }
399
400 #[must_use]
402 pub fn num_pairs(&self) -> usize {
403 let n = self.sboms.len();
404 n * (n - 1) / 2
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct SbomClustering {
411 pub clusters: Vec<SbomCluster>,
413 pub outliers: Vec<usize>,
415 pub algorithm: String,
417 pub threshold: f64,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct SbomCluster {
424 pub members: Vec<usize>,
426 pub centroid_index: usize,
428 pub internal_similarity: f64,
430 pub label: Option<String>,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct IncrementalChange {
441 pub from_index: usize,
442 pub to_index: usize,
443 pub from_name: String,
444 pub to_name: String,
445 pub components_added: usize,
446 pub components_removed: usize,
447 pub components_modified: usize,
448 pub vulnerabilities_introduced: usize,
449 pub vulnerabilities_resolved: usize,
450}
451
452impl IncrementalChange {
453 #[must_use]
454 pub fn from_diff(
455 from_idx: usize,
456 to_idx: usize,
457 from_name: &str,
458 to_name: &str,
459 diff: &DiffResult,
460 ) -> Self {
461 Self {
462 from_index: from_idx,
463 to_index: to_idx,
464 from_name: from_name.to_string(),
465 to_name: to_name.to_string(),
466 components_added: diff.summary.components_added,
467 components_removed: diff.summary.components_removed,
468 components_modified: diff.summary.components_modified,
469 vulnerabilities_introduced: diff.summary.vulnerabilities_introduced,
470 vulnerabilities_resolved: diff.summary.vulnerabilities_resolved,
471 }
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn test_security_impact_label() {
481 assert_eq!(SecurityImpact::Critical.label(), "CRITICAL");
482 assert_eq!(SecurityImpact::High.label(), "high");
483 assert_eq!(SecurityImpact::Medium.label(), "medium");
484 assert_eq!(SecurityImpact::Low.label(), "low");
485 }
486
487 #[test]
488 fn test_version_change_type_symbol() {
489 assert_eq!(VersionChangeType::Initial.symbol(), "●");
490 assert_eq!(VersionChangeType::MajorUpgrade.symbol(), "⬆");
491 assert_eq!(VersionChangeType::MinorUpgrade.symbol(), "↑");
492 assert_eq!(VersionChangeType::PatchUpgrade.symbol(), "↗");
493 assert_eq!(VersionChangeType::Downgrade.symbol(), "⬇");
494 assert_eq!(VersionChangeType::Unchanged.symbol(), "─");
495 assert_eq!(VersionChangeType::Removed.symbol(), "✗");
496 assert_eq!(VersionChangeType::Absent.symbol(), " ");
497 }
498
499 fn make_matrix(n: usize) -> MatrixResult {
500 let sboms = (0..n)
501 .map(|i| SbomInfo {
502 name: format!("sbom-{i}"),
503 file_path: format!("sbom-{i}.json"),
504 format: "CycloneDX".into(),
505 component_count: 10,
506 dependency_count: 5,
507 vulnerability_counts: VulnerabilityCounts::default(),
508 timestamp: None,
509 })
510 .collect::<Vec<_>>();
511 let num_pairs = n * (n - 1) / 2;
512 MatrixResult {
513 sboms,
514 diffs: vec![None; num_pairs],
515 similarity_scores: vec![0.5; num_pairs],
516 clustering: None,
517 }
518 }
519
520 #[test]
521 fn test_matrix_result_get_diff_self() {
522 let matrix = make_matrix(3);
523 assert!(matrix.get_diff(0, 0).is_none());
524 assert!(matrix.get_diff(1, 1).is_none());
525 }
526
527 #[test]
528 fn test_matrix_result_get_similarity_self() {
529 let matrix = make_matrix(3);
530 assert_eq!(matrix.get_similarity(0, 0), 1.0);
531 assert_eq!(matrix.get_similarity(2, 2), 1.0);
532 }
533
534 #[test]
535 fn test_matrix_result_get_similarity_symmetric() {
536 let matrix = make_matrix(3);
537 assert_eq!(matrix.get_similarity(0, 1), matrix.get_similarity(1, 0));
538 assert_eq!(matrix.get_similarity(0, 2), matrix.get_similarity(2, 0));
539 }
540
541 #[test]
542 fn test_matrix_result_num_pairs() {
543 assert_eq!(make_matrix(3).num_pairs(), 3);
544 assert_eq!(make_matrix(4).num_pairs(), 6);
545 assert_eq!(make_matrix(5).num_pairs(), 10);
546 }
547
548 #[test]
549 fn test_incremental_change_from_diff() {
550 let mut diff = DiffResult::new();
551 diff.summary.components_added = 5;
552 diff.summary.components_removed = 2;
553 diff.summary.components_modified = 3;
554 diff.summary.vulnerabilities_introduced = 1;
555 diff.summary.vulnerabilities_resolved = 4;
556
557 let change = IncrementalChange::from_diff(0, 1, "v1.0", "v2.0", &diff);
558 assert_eq!(change.from_index, 0);
559 assert_eq!(change.to_index, 1);
560 assert_eq!(change.from_name, "v1.0");
561 assert_eq!(change.to_name, "v2.0");
562 assert_eq!(change.components_added, 5);
563 assert_eq!(change.components_removed, 2);
564 assert_eq!(change.components_modified, 3);
565 assert_eq!(change.vulnerabilities_introduced, 1);
566 assert_eq!(change.vulnerabilities_resolved, 4);
567 }
568
569 #[test]
570 fn test_divergence_type_variants() {
571 let variants = [
573 DivergenceType::VersionMismatch,
574 DivergenceType::Added,
575 DivergenceType::Removed,
576 DivergenceType::LicenseMismatch,
577 DivergenceType::SupplierMismatch,
578 ];
579 for (i, a) in variants.iter().enumerate() {
580 for (j, b) in variants.iter().enumerate() {
581 if i == j {
582 assert_eq!(a, b);
583 } else {
584 assert_ne!(a, b);
585 }
586 }
587 }
588 }
589
590 #[test]
591 fn test_license_change_type_variants() {
592 let variants = [
593 LicenseChangeType::MorePermissive,
594 LicenseChangeType::MoreRestrictive,
595 LicenseChangeType::Incompatible,
596 LicenseChangeType::Equivalent,
597 ];
598 for (i, a) in variants.iter().enumerate() {
599 for (j, b) in variants.iter().enumerate() {
600 if i == j {
601 assert_eq!(a, b);
602 } else {
603 assert_ne!(a, b);
604 }
605 }
606 }
607 }
608}