sbom-tools 0.1.19

Semantic SBOM diff and analysis tool
Documentation
use sbom_tools::config::DiffConfigBuilder;
use sbom_tools::diff::DependencyChangeType;
use sbom_tools::parsers::parse_sbom;
use sbom_tools::pipeline::compute_diff;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};

const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");

fn fixture_path(name: &str) -> PathBuf {
    Path::new(FIXTURES_DIR).join(name)
}

fn sbom_tools_bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_sbom-tools"))
}

fn base_command() -> Command {
    let mut cmd = Command::new(sbom_tools_bin());
    cmd.arg("--no-color");
    cmd.env("RUST_LOG", "error");
    cmd.env("RUST_LOG_STYLE", "never");
    cmd
}

fn stdout(output: &Output) -> String {
    String::from_utf8(output.stdout.clone()).expect("stdout should be utf-8")
}

fn stderr(output: &Output) -> String {
    String::from_utf8(output.stderr.clone()).expect("stderr should be utf-8")
}

fn json_stdout(output: &Output) -> serde_json::Value {
    let text = stdout(output);
    let start = text
        .find(|ch| ['{', '['].contains(&ch))
        .expect("stdout should contain a JSON object or array");

    serde_json::from_str(&text[start..]).expect("stdout payload should be valid json")
}

#[test]
fn cli_diff_summary_supports_match_explanations_and_threshold_recommendation() {
    let output = base_command()
        .arg("diff")
        .arg(fixture_path("demo-old.cdx.json"))
        .arg(fixture_path("demo-new.cdx.json"))
        .args([
            "--explain-matches",
            "--recommend-threshold",
            "-o",
            "summary",
        ])
        .output()
        .expect("diff command should run");

    assert!(output.status.success(), "{}", stderr(&output));
    let text = stdout(&output);
    assert!(text.contains("=== Match Explanations ==="));
    assert!(text.contains("=== Threshold Recommendation ==="));
    assert!(text.contains("SBOM Diff Summary"));
}

#[test]
fn cli_diff_json_reports_supply_chain_attack_examples() {
    let output = base_command()
        .arg("diff")
        .arg(fixture_path("showcase/supply-chain-baseline.cdx.json"))
        .arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
        .args(["-o", "json"])
        .output()
        .expect("diff command should run");

    assert!(output.status.success(), "{}", stderr(&output));

    let json = json_stdout(&output);

    let added = json["reports"]["components"]["added"]
        .as_array()
        .expect("added component array");
    let modified = json["reports"]["components"]["modified"]
        .as_array()
        .expect("modified component array");
    let introduced = json["reports"]["vulnerabilities"]["introduced"]
        .as_array()
        .expect("introduced vulnerabilities array");

    assert!(
        added.iter().any(|c| c["name"] == "axios-builder"),
        "axios-builder should be reported as added: {added:?}"
    );
    assert!(
        added.iter().any(|c| c["name"] == "vllm-plugins"),
        "vllm-plugins should be reported as added: {added:?}"
    );
    assert!(
        modified.iter().any(|c| {
            c["name"] == "lodash" && c["old_version"] == "4.17.21" && c["new_version"] == "4.17.15"
        }),
        "lodash downgrade should be reported as modified: {modified:?}"
    );
    assert_eq!(json["summary"]["vulnerabilities"]["introduced"], 3);
    assert!(
        introduced.iter().any(|v| v["id"] == "MAL-2025-190832"),
        "axios malicious advisory should be present"
    );
    assert!(
        introduced.iter().any(|v| v["id"] == "MAL-2026-844"),
        "vllm malicious advisory should be present"
    );
}

#[test]
fn cli_query_detects_recent_supply_chain_examples() {
    let axios_output = base_command()
        .arg("query")
        .args(["--affected-by", "MAL-2025-190832", "-o", "json"])
        .arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
        .output()
        .expect("query command should run");

    assert!(axios_output.status.success(), "{}", stderr(&axios_output));
    let axios_json = json_stdout(&axios_output);
    assert_eq!(axios_json["matches"].as_array().unwrap().len(), 1);
    assert_eq!(axios_json["matches"][0]["name"], "axios-builder");

    let vllm_output = base_command()
        .arg("query")
        .args(["--affected-by", "MAL-2026-844", "-o", "json"])
        .arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
        .output()
        .expect("query command should run");

    assert!(vllm_output.status.success(), "{}", stderr(&vllm_output));
    let vllm_json = json_stdout(&vllm_output);
    assert_eq!(vllm_json["matches"].as_array().unwrap().len(), 1);
    assert_eq!(vllm_json["matches"][0]["name"], "vllm-plugins");
}

#[test]
fn cli_view_filters_supply_chain_findings_by_severity() {
    let output = base_command()
        .arg("view")
        .arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
        .args(["--vulnerable-only", "--severity", "high", "-o", "json"])
        .output()
        .expect("view command should run");

    assert!(output.status.success(), "{}", stderr(&output));
    let json = json_stdout(&output);

    let components = json["components"].as_array().expect("component array");
    assert_eq!(components.len(), 2);
    assert!(
        components.iter().any(|c| c["name"] == "axios-builder"),
        "expected axios-builder in filtered view output"
    );
    assert!(
        components.iter().any(|c| c["name"] == "vllm-plugins"),
        "expected vllm-plugins in filtered view output"
    );
}

#[test]
fn cli_vex_status_reports_partial_coverage_for_supply_chain_incident() {
    let output = base_command()
        .arg("vex")
        .arg("status")
        .arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
        .arg("--vex")
        .arg(fixture_path("showcase/supply-chain-vex.openvex.json"))
        .args(["-o", "json"])
        .output()
        .expect("vex status command should run");

    assert!(output.status.success(), "{}", stderr(&output));
    let json = json_stdout(&output);

    assert_eq!(json["total_vulnerabilities"], 3);
    assert_eq!(json["with_vex"], 2);
    assert_eq!(json["without_vex"], 1);
    assert!(
        json["gaps"]
            .as_array()
            .expect("gaps array")
            .iter()
            .any(|g| g["id"] == "MAL-2026-844"),
        "expected MAL-2026-844 to remain as a VEX gap"
    );
}

#[test]
fn cli_diff_fail_on_vex_gap_returns_exit_code_4() {
    let output = base_command()
        .arg("diff")
        .arg(fixture_path("showcase/supply-chain-baseline.cdx.json"))
        .arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
        .arg("--vex")
        .arg(fixture_path("showcase/supply-chain-vex.openvex.json"))
        .args(["--fail-on-vex-gap", "-o", "json"])
        .output()
        .expect("diff command should run");

    assert_eq!(output.status.code(), Some(4), "{}", stderr(&output));
    assert!(
        stderr(&output).contains("VEX gap"),
        "stderr should explain the VEX gap: {}",
        stderr(&output)
    );
}

#[test]
fn cli_diff_multi_showcases_portfolio_comparison() {
    let output = base_command()
        .arg("diff-multi")
        .arg(fixture_path("showcase/fleet-v1.cdx.json"))
        .arg(fixture_path("showcase/fleet-v2.cdx.json"))
        .arg(fixture_path("showcase/fleet-v3.cdx.json"))
        .args(["-o", "json"])
        .output()
        .expect("diff-multi command should run");

    assert!(output.status.success(), "{}", stderr(&output));
    let json = json_stdout(&output);

    assert_eq!(json["comparisons"].as_array().unwrap().len(), 2);
    assert!(
        json["summary"]["universal_components"]
            .as_array()
            .map_or(false, |items| !items.is_empty()),
        "expected universal components in diff-multi summary"
    );
    assert!(
        json["summary"]["inconsistent_components"]
            .as_array()
            .map_or(false, |items| !items.is_empty()),
        "expected inconsistent components in diff-multi summary"
    );
}

#[test]
fn cli_timeline_showcases_release_evolution() {
    let output = base_command()
        .arg("timeline")
        .arg(fixture_path("showcase/fleet-v1.cdx.json"))
        .arg(fixture_path("showcase/fleet-v2.cdx.json"))
        .arg(fixture_path("showcase/fleet-v3.cdx.json"))
        .args(["-o", "json"])
        .output()
        .expect("timeline command should run");

    assert!(output.status.success(), "{}", stderr(&output));
    let json = json_stdout(&output);

    assert_eq!(json["sboms"].as_array().unwrap().len(), 3);
    assert_eq!(json["incremental_diffs"].as_array().unwrap().len(), 2);
    assert_eq!(json["cumulative_from_first"].as_array().unwrap().len(), 2);
}

#[test]
fn cli_matrix_showcases_similarity_and_clustering() {
    let output = base_command()
        .arg("matrix")
        .arg(fixture_path("showcase/fleet-v1.cdx.json"))
        .arg(fixture_path("showcase/fleet-v2.cdx.json"))
        .arg(fixture_path("showcase/fleet-v3.cdx.json"))
        .args(["--cluster-threshold", "0.7", "-o", "json"])
        .output()
        .expect("matrix command should run");

    assert!(output.status.success(), "{}", stderr(&output));
    let json = json_stdout(&output);

    assert_eq!(json["sboms"].as_array().unwrap().len(), 3);
    assert!(json.get("similarity_scores").is_some());
}

#[test]
fn cli_quality_security_profile_outputs_machine_readable_report() {
    let output = base_command()
        .arg("quality")
        .arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
        .args([
            "--profile",
            "security",
            "--recommendations",
            "--metrics",
            "-o",
            "json",
        ])
        .output()
        .expect("quality command should run");

    assert!(output.status.success(), "{}", stderr(&output));
    let json = json_stdout(&output);

    assert_eq!(json["profile"], "security");
    assert!(json["report"]["overall_score"].is_number());
    assert!(json["report"]["recommendations"].is_array());
}

#[test]
fn cli_validate_summary_outputs_multi_standard_json() {
    let output = base_command()
        .arg("validate")
        .arg(fixture_path("showcase/supply-chain-incident.cdx.json"))
        .args(["--standard", "ntia,cra", "--summary"])
        .output()
        .expect("validate command should run");

    assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
    let json = json_stdout(&output);
    let summaries = json.as_array().expect("multi-standard summary array");
    assert_eq!(summaries.len(), 2);
    assert!(
        summaries
            .iter()
            .any(|s| s["standard"] == "NTIA Minimum Elements")
    );
    assert!(
        summaries
            .iter()
            .any(|s| s["standard"] == "EU CRA Phase 2 (2029)")
    );
}

#[test]
fn cli_license_check_flags_agpl_and_propagation_risks() {
    let output = base_command()
        .arg("license-check")
        .arg(fixture_path("showcase/license-risk.cdx.json"))
        .args(["--strict", "--check-propagation"])
        .output()
        .expect("license-check command should run");

    assert_eq!(output.status.code(), Some(5), "{}", stderr(&output));
    let text = stdout(&output);
    assert!(text.contains("AGPL-3.0-only"));
    assert!(text.contains("License Propagation Risks"));
}

#[test]
fn library_graph_diff_detects_reparenting_and_depth_changes() {
    let old_path = fixture_path("showcase/graph-baseline.cdx.json");
    let new_path = fixture_path("showcase/graph-reorg.cdx.json");
    let old = parse_sbom(&old_path).expect("old graph fixture should parse");
    let new = parse_sbom(&new_path).expect("new graph fixture should parse");

    let config = DiffConfigBuilder::new()
        .old_path(old_path)
        .new_path(new_path)
        .graph_diff(true)
        .build()
        .expect("graph diff config should build");

    let result = compute_diff(&config, &old, &new).expect("graph diff should succeed");
    let summary = result
        .graph_summary
        .expect("graph summary should be present");

    assert!(summary.reparented > 0, "expected reparented graph changes");
    assert!(
        summary.depth_changed > 0,
        "expected depth-change graph changes"
    );
    assert!(
        result
            .graph_changes
            .iter()
            .any(|c| matches!(c.change, DependencyChangeType::Reparented { .. })),
        "expected at least one reparented graph change"
    );
    assert!(
        result
            .graph_changes
            .iter()
            .any(|c| matches!(c.change, DependencyChangeType::DepthChanged { .. })),
        "expected at least one depth-changed graph change"
    );
}