bitvex 0.2.6

Automate CRA compliance: generate OpenVEX reports from Yocto SBOMs by filtering CVEs with kernel config and device tree analysis
Documentation
use std::collections::{HashMap, HashSet};
use std::path::Path;

use anyhow::{Context, Result};
use serde::Serialize;
use tabled::{Table, Tabled};

use crate::sbom::{SbomPackage, parse_spdx_sbom};

#[derive(Debug, Clone, Serialize)]
pub struct SbomDiff {
    pub added: Vec<SbomPackage>,
    pub removed: Vec<SbomPackage>,
    pub updated: Vec<PackageUpdate>,
}

#[derive(Debug, Clone, Serialize)]
pub struct PackageUpdate {
    pub name: String,
    pub old_version: Option<String>,
    pub new_version: Option<String>,
}

#[derive(Tabled)]
struct DiffRow {
    #[tabled(rename = "Change")]
    change: String,
    #[tabled(rename = "Package")]
    package: String,
    #[tabled(rename = "Old Version")]
    old_version: String,
    #[tabled(rename = "New Version")]
    new_version: String,
}

pub fn diff_sboms(old_sbom_path: &Path, new_sbom_path: &Path) -> Result<SbomDiff> {
    let old_data = std::fs::read(old_sbom_path)
        .with_context(|| format!("Failed to read old SBOM: {}", old_sbom_path.display()))?;
    let new_data = std::fs::read(new_sbom_path)
        .with_context(|| format!("Failed to read new SBOM: {}", new_sbom_path.display()))?;

    let old_packages = parse_spdx_sbom(&old_data)?;
    let new_packages = parse_spdx_sbom(&new_data)?;

    diff_packages(&old_packages, &new_packages)
}

pub fn diff_packages(
    old_packages: &[SbomPackage],
    new_packages: &[SbomPackage],
) -> Result<SbomDiff> {
    let old_map: HashMap<&str, &SbomPackage> =
        old_packages.iter().map(|p| (p.name.as_str(), p)).collect();

    let new_map: HashMap<&str, &SbomPackage> =
        new_packages.iter().map(|p| (p.name.as_str(), p)).collect();

    let old_names: HashSet<&str> = old_map.keys().copied().collect();
    let new_names: HashSet<&str> = new_map.keys().copied().collect();

    let mut added: Vec<SbomPackage> = new_names
        .difference(&old_names)
        .filter_map(|name| new_map.get(name).map(|p| (*p).clone()))
        .collect();

    let mut removed: Vec<SbomPackage> = old_names
        .difference(&new_names)
        .filter_map(|name| old_map.get(name).map(|p| (*p).clone()))
        .collect();

    let mut updated = Vec::new();
    for name in old_names.intersection(&new_names) {
        let old_pkg = old_map[name];
        let new_pkg = new_map[name];

        let old_ver = old_pkg.version.clone();
        let new_ver = new_pkg.version.clone();

        if old_ver != new_ver {
            updated.push(PackageUpdate {
                name: name.to_string(),
                old_version: old_ver,
                new_version: new_ver,
            });
        }
    }

    added.sort_by(|a, b| a.name.cmp(&b.name));
    removed.sort_by(|a, b| a.name.cmp(&b.name));
    updated.sort_by(|a, b| a.name.cmp(&b.name));

    Ok(SbomDiff {
        added,
        removed,
        updated,
    })
}

pub fn print_diff_summary(diff: &SbomDiff) {
    let mut rows = Vec::new();

    for pkg in &diff.added {
        rows.push(DiffRow {
            change: "+".to_string(),
            package: pkg.name.clone(),
            old_version: "-".to_string(),
            new_version: pkg.version.clone().unwrap_or_else(|| "?".to_string()),
        });
    }

    for pkg in &diff.removed {
        rows.push(DiffRow {
            change: "-".to_string(),
            package: pkg.name.clone(),
            old_version: pkg.version.clone().unwrap_or_else(|| "?".to_string()),
            new_version: "-".to_string(),
        });
    }

    for update in &diff.updated {
        rows.push(DiffRow {
            change: "~".to_string(),
            package: update.name.clone(),
            old_version: update
                .old_version
                .clone()
                .unwrap_or_else(|| "?".to_string()),
            new_version: update
                .new_version
                .clone()
                .unwrap_or_else(|| "?".to_string()),
        });
    }

    println!();
    println!("╔══════════════════════════════════════════════════════╗");
    println!("║          BitVex - SBOM Diff Report                  ║");
    println!("╠══════════════════════════════════════════════════════╣");
    println!(
        "║  Packages added:       {:<5}",
        diff.added.len()
    );
    println!(
        "║  Packages removed:     {:<5}",
        diff.removed.len()
    );
    println!(
        "║  Packages updated:     {:<5}",
        diff.updated.len()
    );
    println!("╚══════════════════════════════════════════════════════╝");

    if !rows.is_empty() {
        println!();
        let table = Table::new(rows).to_string();
        println!("{}", table);
    }

    println!();
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_pkg(name: &str, version: &str) -> SbomPackage {
        SbomPackage {
            _spdx_id: format!("SPDXRef-{}", name),
            name: name.into(),
            version: Some(version.into()),
            purl: Some(format!("pkg:generic/{}@{}", name, version)),
        }
    }

    #[test]
    fn test_diff_added_packages() {
        let old = vec![make_pkg("openssl", "3.0.13")];
        let new = vec![make_pkg("openssl", "3.0.13"), make_pkg("curl", "8.5.0")];

        let diff = diff_packages(&old, &new).unwrap();
        assert_eq!(diff.added.len(), 1);
        assert_eq!(diff.added[0].name, "curl");
        assert_eq!(diff.removed.len(), 0);
        assert_eq!(diff.updated.len(), 0);
    }

    #[test]
    fn test_diff_removed_packages() {
        let old = vec![make_pkg("openssl", "3.0.13"), make_pkg("curl", "8.5.0")];
        let new = vec![make_pkg("openssl", "3.0.13")];

        let diff = diff_packages(&old, &new).unwrap();
        assert_eq!(diff.added.len(), 0);
        assert_eq!(diff.removed.len(), 1);
        assert_eq!(diff.removed[0].name, "curl");
    }

    #[test]
    fn test_diff_updated_packages() {
        let old = vec![make_pkg("openssl", "3.0.12")];
        let new = vec![make_pkg("openssl", "3.0.13")];

        let diff = diff_packages(&old, &new).unwrap();
        assert_eq!(diff.added.len(), 0);
        assert_eq!(diff.removed.len(), 0);
        assert_eq!(diff.updated.len(), 1);
        assert_eq!(diff.updated[0].name, "openssl");
        assert_eq!(diff.updated[0].old_version, Some("3.0.12".into()));
        assert_eq!(diff.updated[0].new_version, Some("3.0.13".into()));
    }

    #[test]
    fn test_diff_no_changes() {
        let old = vec![make_pkg("openssl", "3.0.13")];
        let new = vec![make_pkg("openssl", "3.0.13")];

        let diff = diff_packages(&old, &new).unwrap();
        assert_eq!(diff.added.len(), 0);
        assert_eq!(diff.removed.len(), 0);
        assert_eq!(diff.updated.len(), 0);
    }

    #[test]
    fn test_diff_mixed_changes() {
        let old = vec![
            make_pkg("openssl", "3.0.12"),
            make_pkg("curl", "8.1.2"),
            make_pkg("glibc", "2.37"),
        ];
        let new = vec![
            make_pkg("openssl", "3.0.13"),
            make_pkg("bash", "5.2.15"),
            make_pkg("glibc", "2.37"),
        ];

        let diff = diff_packages(&old, &new).unwrap();
        assert_eq!(diff.added.len(), 1);
        assert_eq!(diff.added[0].name, "bash");
        assert_eq!(diff.removed.len(), 1);
        assert_eq!(diff.removed[0].name, "curl");
        assert_eq!(diff.updated.len(), 1);
        assert_eq!(diff.updated[0].name, "openssl");
    }
}