use super::DiffResult;
use crate::model::{NormalizedSbom, VulnerabilityCounts};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomInfo {
pub name: String,
pub file_path: String,
pub format: String,
pub component_count: usize,
pub dependency_count: usize,
pub vulnerability_counts: VulnerabilityCounts,
pub timestamp: Option<String>,
}
impl SbomInfo {
#[must_use]
pub fn from_sbom(sbom: &NormalizedSbom, name: String, file_path: String) -> Self {
Self {
name,
file_path,
format: sbom.document.format.to_string(),
component_count: sbom.component_count(),
dependency_count: sbom.edges.len(),
vulnerability_counts: sbom.vulnerability_counts(),
timestamp: Some(sbom.document.created.to_rfc3339()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiDiffResult {
pub baseline: SbomInfo,
pub comparisons: Vec<ComparisonResult>,
pub summary: MultiDiffSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComparisonResult {
pub target: SbomInfo,
pub diff: DiffResult,
pub unique_components: Vec<String>,
pub divergent_components: Vec<DivergentComponent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DivergentComponent {
pub id: String,
pub name: String,
pub baseline_version: Option<String>,
pub target_version: String,
pub versions_across_targets: HashMap<String, String>,
pub divergence_type: DivergenceType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DivergenceType {
VersionMismatch,
Added,
Removed,
LicenseMismatch,
SupplierMismatch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiDiffSummary {
pub baseline_component_count: usize,
pub universal_components: Vec<String>,
pub variable_components: Vec<VariableComponent>,
pub inconsistent_components: Vec<InconsistentComponent>,
pub deviation_scores: HashMap<String, f64>,
pub max_deviation: f64,
pub vulnerability_matrix: VulnerabilityMatrix,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VariableComponent {
pub id: String,
pub name: String,
pub ecosystem: Option<String>,
pub version_spread: VersionSpread,
pub targets_with_component: Vec<String>,
pub security_impact: SecurityImpact,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionSpread {
pub baseline: Option<String>,
pub min_version: Option<String>,
pub max_version: Option<String>,
pub unique_versions: Vec<String>,
pub is_consistent: bool,
pub major_version_spread: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SecurityImpact {
Critical,
High,
Medium,
Low,
}
impl SecurityImpact {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Critical => "CRITICAL",
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InconsistentComponent {
pub id: String,
pub name: String,
pub in_baseline: bool,
pub present_in: Vec<String>,
pub missing_from: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnerabilityMatrix {
pub per_sbom: HashMap<String, VulnerabilityCounts>,
pub unique_vulnerabilities: HashMap<String, Vec<String>>,
pub common_vulnerabilities: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineResult {
pub sboms: Vec<SbomInfo>,
pub incremental_diffs: Vec<DiffResult>,
pub cumulative_from_first: Vec<DiffResult>,
pub evolution_summary: EvolutionSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionSummary {
pub components_added: Vec<ComponentEvolution>,
pub components_removed: Vec<ComponentEvolution>,
pub version_history: HashMap<String, Vec<VersionAtPoint>>,
pub vulnerability_trend: Vec<VulnerabilitySnapshot>,
pub license_changes: Vec<LicenseChange>,
pub dependency_trend: Vec<DependencySnapshot>,
pub compliance_trend: Vec<ComplianceSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentEvolution {
pub id: String,
pub name: String,
pub first_seen_index: usize,
pub first_seen_version: String,
pub last_seen_index: Option<usize>,
pub current_version: Option<String>,
pub version_change_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionAtPoint {
pub sbom_index: usize,
pub sbom_name: String,
pub version: Option<String>,
pub change_type: VersionChangeType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionChangeType {
Initial,
MajorUpgrade,
MinorUpgrade,
PatchUpgrade,
Downgrade,
Unchanged,
Removed,
Absent,
}
impl VersionChangeType {
#[must_use]
pub const fn symbol(&self) -> &'static str {
match self {
Self::Initial => "●",
Self::MajorUpgrade => "⬆",
Self::MinorUpgrade => "↑",
Self::PatchUpgrade => "↗",
Self::Downgrade => "⬇",
Self::Unchanged => "─",
Self::Removed => "✗",
Self::Absent => " ",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceSnapshot {
pub sbom_index: usize,
pub sbom_name: String,
pub scores: Vec<ComplianceScoreEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceScoreEntry {
pub standard: String,
pub error_count: usize,
pub warning_count: usize,
pub info_count: usize,
pub is_compliant: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnerabilitySnapshot {
pub sbom_index: usize,
pub sbom_name: String,
pub counts: VulnerabilityCounts,
pub new_vulnerabilities: Vec<String>,
pub resolved_vulnerabilities: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseChange {
pub sbom_index: usize,
pub component_id: String,
pub component_name: String,
pub old_license: Vec<String>,
pub new_license: Vec<String>,
pub change_type: LicenseChangeType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LicenseChangeType {
MorePermissive,
MoreRestrictive,
Incompatible,
Equivalent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencySnapshot {
pub sbom_index: usize,
pub sbom_name: String,
pub direct_dependencies: usize,
pub transitive_dependencies: usize,
pub total_edges: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixResult {
pub sboms: Vec<SbomInfo>,
pub diffs: Vec<Option<DiffResult>>,
pub similarity_scores: Vec<f64>,
pub clustering: Option<SbomClustering>,
}
impl MatrixResult {
#[must_use]
pub fn get_diff(&self, i: usize, j: usize) -> Option<&DiffResult> {
if i == j {
return None;
}
let (a, b) = if i < j { (i, j) } else { (j, i) };
let idx = self.matrix_index(a, b);
self.diffs.get(idx).and_then(|d| d.as_ref())
}
#[must_use]
pub fn get_similarity(&self, i: usize, j: usize) -> f64 {
if i == j {
return 1.0;
}
let (a, b) = if i < j { (i, j) } else { (j, i) };
let idx = self.matrix_index(a, b);
self.similarity_scores.get(idx).copied().unwrap_or(0.0)
}
fn matrix_index(&self, i: usize, j: usize) -> usize {
let n = self.sboms.len();
i * (2 * n - i - 1) / 2 + (j - i - 1)
}
#[must_use]
pub fn num_pairs(&self) -> usize {
let n = self.sboms.len();
n * (n - 1) / 2
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomClustering {
pub clusters: Vec<SbomCluster>,
pub outliers: Vec<usize>,
pub algorithm: String,
pub threshold: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomCluster {
pub members: Vec<usize>,
pub centroid_index: usize,
pub internal_similarity: f64,
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncrementalChange {
pub from_index: usize,
pub to_index: usize,
pub from_name: String,
pub to_name: String,
pub components_added: usize,
pub components_removed: usize,
pub components_modified: usize,
pub vulnerabilities_introduced: usize,
pub vulnerabilities_resolved: usize,
}
impl IncrementalChange {
#[must_use]
pub fn from_diff(
from_idx: usize,
to_idx: usize,
from_name: &str,
to_name: &str,
diff: &DiffResult,
) -> Self {
Self {
from_index: from_idx,
to_index: to_idx,
from_name: from_name.to_string(),
to_name: to_name.to_string(),
components_added: diff.summary.components_added,
components_removed: diff.summary.components_removed,
components_modified: diff.summary.components_modified,
vulnerabilities_introduced: diff.summary.vulnerabilities_introduced,
vulnerabilities_resolved: diff.summary.vulnerabilities_resolved,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_security_impact_label() {
assert_eq!(SecurityImpact::Critical.label(), "CRITICAL");
assert_eq!(SecurityImpact::High.label(), "high");
assert_eq!(SecurityImpact::Medium.label(), "medium");
assert_eq!(SecurityImpact::Low.label(), "low");
}
#[test]
fn test_version_change_type_symbol() {
assert_eq!(VersionChangeType::Initial.symbol(), "●");
assert_eq!(VersionChangeType::MajorUpgrade.symbol(), "⬆");
assert_eq!(VersionChangeType::MinorUpgrade.symbol(), "↑");
assert_eq!(VersionChangeType::PatchUpgrade.symbol(), "↗");
assert_eq!(VersionChangeType::Downgrade.symbol(), "⬇");
assert_eq!(VersionChangeType::Unchanged.symbol(), "─");
assert_eq!(VersionChangeType::Removed.symbol(), "✗");
assert_eq!(VersionChangeType::Absent.symbol(), " ");
}
fn make_matrix(n: usize) -> MatrixResult {
let sboms = (0..n)
.map(|i| SbomInfo {
name: format!("sbom-{i}"),
file_path: format!("sbom-{i}.json"),
format: "CycloneDX".into(),
component_count: 10,
dependency_count: 5,
vulnerability_counts: VulnerabilityCounts::default(),
timestamp: None,
})
.collect::<Vec<_>>();
let num_pairs = n * (n - 1) / 2;
MatrixResult {
sboms,
diffs: vec![None; num_pairs],
similarity_scores: vec![0.5; num_pairs],
clustering: None,
}
}
#[test]
fn test_matrix_result_get_diff_self() {
let matrix = make_matrix(3);
assert!(matrix.get_diff(0, 0).is_none());
assert!(matrix.get_diff(1, 1).is_none());
}
#[test]
fn test_matrix_result_get_similarity_self() {
let matrix = make_matrix(3);
assert_eq!(matrix.get_similarity(0, 0), 1.0);
assert_eq!(matrix.get_similarity(2, 2), 1.0);
}
#[test]
fn test_matrix_result_get_similarity_symmetric() {
let matrix = make_matrix(3);
assert_eq!(matrix.get_similarity(0, 1), matrix.get_similarity(1, 0));
assert_eq!(matrix.get_similarity(0, 2), matrix.get_similarity(2, 0));
}
#[test]
fn test_matrix_result_num_pairs() {
assert_eq!(make_matrix(3).num_pairs(), 3);
assert_eq!(make_matrix(4).num_pairs(), 6);
assert_eq!(make_matrix(5).num_pairs(), 10);
}
#[test]
fn test_incremental_change_from_diff() {
let mut diff = DiffResult::new();
diff.summary.components_added = 5;
diff.summary.components_removed = 2;
diff.summary.components_modified = 3;
diff.summary.vulnerabilities_introduced = 1;
diff.summary.vulnerabilities_resolved = 4;
let change = IncrementalChange::from_diff(0, 1, "v1.0", "v2.0", &diff);
assert_eq!(change.from_index, 0);
assert_eq!(change.to_index, 1);
assert_eq!(change.from_name, "v1.0");
assert_eq!(change.to_name, "v2.0");
assert_eq!(change.components_added, 5);
assert_eq!(change.components_removed, 2);
assert_eq!(change.components_modified, 3);
assert_eq!(change.vulnerabilities_introduced, 1);
assert_eq!(change.vulnerabilities_resolved, 4);
}
#[test]
fn test_divergence_type_variants() {
let variants = [
DivergenceType::VersionMismatch,
DivergenceType::Added,
DivergenceType::Removed,
DivergenceType::LicenseMismatch,
DivergenceType::SupplierMismatch,
];
for (i, a) in variants.iter().enumerate() {
for (j, b) in variants.iter().enumerate() {
if i == j {
assert_eq!(a, b);
} else {
assert_ne!(a, b);
}
}
}
}
#[test]
fn test_license_change_type_variants() {
let variants = [
LicenseChangeType::MorePermissive,
LicenseChangeType::MoreRestrictive,
LicenseChangeType::Incompatible,
LicenseChangeType::Equivalent,
];
for (i, a) in variants.iter().enumerate() {
for (j, b) in variants.iter().enumerate() {
if i == j {
assert_eq!(a, b);
} else {
assert_ne!(a, b);
}
}
}
}
}