use std::path::PathBuf;
use std::process::Command;
fn sbom_diff() -> Command {
Command::new(env!("CARGO_BIN_EXE_sbom-diff"))
}
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(name)
}
#[test]
fn fail_on_added_components_exits_3() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("added-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on added-components"),
"stderr should mention the violated condition"
);
}
#[test]
fn fail_on_removed_components_exits_3() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("removed-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on removed-components"));
}
#[test]
fn fail_on_changed_components_exits_3() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("changed-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on changed-components"));
}
#[test]
fn fail_on_deps_exits_3() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("deps")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on deps"));
}
#[test]
fn fail_on_deps_kind_changed_reports_error() {
let out = sbom_diff()
.arg(fixture("kind-change-old.spdx.json"))
.arg(fixture("kind-change-new.spdx.json"))
.arg("--fail-on")
.arg("deps")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("changed kind"),
"stderr should mention a kind change, got: {}",
stderr
);
assert!(
stderr.contains("dev -> runtime"),
"stderr should report old and new kind, got: {}",
stderr
);
}
#[test]
fn fail_on_missing_hashes_exits_3() {
let out = sbom_diff()
.arg(fixture("missing-hashes-old.json"))
.arg(fixture("missing-hashes-new.json"))
.arg("--fail-on")
.arg("missing-hashes")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on missing-hashes"));
}
#[test]
fn fail_on_no_violation_exits_0() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-old.json"))
.arg("--fail-on")
.arg("added-components")
.arg("--fail-on")
.arg("removed-components")
.arg("--fail-on")
.arg("changed-components")
.arg("--fail-on")
.arg("deps")
.arg("--fail-on")
.arg("license-changed")
.arg("--fail-on")
.arg("hash-algorithm-downgrade")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_multiple_conditions_all_checked() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("added-components")
.arg("--fail-on")
.arg("removed-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on added-components"),
"should report added-components violation"
);
assert!(
stderr.contains("--fail-on removed-components"),
"should report removed-components violation"
);
}
#[test]
fn fail_on_license_changed_exits_3() {
let out = sbom_diff()
.arg(fixture("license-changed-old.json"))
.arg(fixture("license-changed-new.json"))
.arg("--fail-on")
.arg("license-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on license-changed"),
"stderr should mention the violated condition, got: {}",
stderr
);
assert!(
stderr.contains("license changed on component"),
"stderr should report the changed component, got: {}",
stderr
);
assert!(
stderr.contains("introduces license(s)"),
"stderr should report the added component's licenses, got: {}",
stderr
);
}
#[test]
fn fail_on_license_changed_no_change_exits_0() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--fail-on")
.arg("license-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_license_changed_no_violation_exits_0() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-old.json"))
.arg("--fail-on")
.arg("license-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn deny_license_match_exits_2() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--deny-license")
.arg("MIT")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("license MIT is denied"));
}
#[test]
fn deny_license_case_insensitive() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--deny-license")
.arg("mit")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn deny_license_no_match_exits_0() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--deny-license")
.arg("GPL-3.0-only")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn allow_license_violation_exits_2() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--allow-license")
.arg("Apache-2.0")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("not allowed"));
}
#[test]
fn allow_license_all_match_exits_0() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--allow-license")
.arg("MIT")
.arg("--allow-license")
.arg("Apache-2.0")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn deny_license_catches_licenseref_in_mixed_expression() {
let out = sbom_diff()
.arg(fixture("cli-license-ref.json"))
.arg(fixture("cli-license-ref.json"))
.arg("--deny-license")
.arg("LicenseRef-proprietary")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("LicenseRef-proprietary"));
}
#[test]
fn deny_license_catches_spdx_id_in_mixed_expression() {
let out = sbom_diff()
.arg(fixture("cli-license-ref.json"))
.arg(fixture("cli-license-ref.json"))
.arg("--deny-license")
.arg("Apache-2.0")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("Apache-2.0"));
}
#[test]
fn allow_license_requires_licenseref_in_mixed_expression() {
let out = sbom_diff()
.arg(fixture("cli-license-ref.json"))
.arg(fixture("cli-license-ref.json"))
.arg("--allow-license")
.arg("MIT")
.arg("--allow-license")
.arg("Apache-2.0")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("LicenseRef-proprietary"));
}
#[test]
fn license_violation_takes_precedence_over_fail_on() {
let license_only = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--deny-license")
.arg("MIT")
.arg("--fail-on")
.arg("added-components")
.output()
.unwrap();
assert_eq!(license_only.status.code(), Some(2));
let fail_on_only = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("added-components")
.output()
.unwrap();
assert_eq!(fail_on_only.status.code(), Some(3));
}
#[test]
fn quiet_suppresses_stdout() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--quiet")
.output()
.unwrap();
assert!(
out.stdout.is_empty(),
"stdout should be empty with --quiet, got: {}",
String::from_utf8_lossy(&out.stdout)
);
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn quiet_still_emits_stderr_on_violation() {
let out = sbom_diff()
.arg(fixture("cli-license.json"))
.arg(fixture("cli-license.json"))
.arg("--quiet")
.arg("--deny-license")
.arg("MIT")
.output()
.unwrap();
assert!(out.stdout.is_empty(), "stdout should be empty with --quiet");
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("license MIT is denied"),
"stderr should still contain error messages"
);
}
#[test]
fn quiet_preserves_fail_on_exit_code() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--quiet")
.arg("--fail-on")
.arg("added-components")
.output()
.unwrap();
assert!(out.stdout.is_empty());
assert_eq!(out.status.code(), Some(3));
}
#[test]
fn auto_detects_cyclonedx_json() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
}
#[test]
fn auto_detects_spdx_json() {
let out = sbom_diff()
.arg(fixture("golden-old.spdx.json"))
.arg(fixture("golden-new.spdx.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
}
#[test]
fn auto_detects_cyclonedx_xml() {
let out = sbom_diff()
.arg(fixture("golden-old.cdx.xml"))
.arg(fixture("golden-new.cdx.xml"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
}
#[test]
fn auto_detects_spdx_tag_value() {
let out = sbom_diff()
.arg(fixture("golden-old.spdx"))
.arg(fixture("golden-new.spdx"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
}
#[test]
fn explicit_format_overrides_auto() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--format")
.arg("cyclonedx")
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 1);
}
#[test]
fn wrong_explicit_format_fails() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--format")
.arg("spdx")
.output()
.unwrap();
assert_ne!(out.status.code(), Some(0));
}
#[test]
fn summary_text_is_compact() {
let full = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.output()
.unwrap();
let summary = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.output()
.unwrap();
assert_eq!(summary.status.code(), Some(0));
let full_out = String::from_utf8_lossy(&full.stdout);
let summary_out = String::from_utf8_lossy(&summary.stdout);
assert!(
summary_out.len() < full_out.len(),
"summary ({} bytes) should be shorter than full output ({} bytes)",
summary_out.len(),
full_out.len()
);
assert!(summary_out.contains("Added"));
assert!(summary_out.contains("Removed"));
}
#[test]
fn summary_json_output() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("summary JSON should parse");
assert_eq!(v["added"], 1);
assert_eq!(v["removed"], 1);
assert_eq!(v["changed"], 2);
assert_eq!(v["unchanged"], 2);
assert_eq!(v["edge_changes"], 1);
}
#[test]
fn summary_markdown_output() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--output")
.arg("markdown")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!stdout.is_empty());
}
#[test]
fn summary_with_quiet_produces_no_output() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--quiet")
.output()
.unwrap();
assert!(out.stdout.is_empty());
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_version_downgrade_exits_3() {
let out = sbom_diff()
.arg(fixture("version-downgrade-old.json"))
.arg(fixture("version-downgrade-new.json"))
.arg("--fail-on")
.arg("version-downgrade")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on version-downgrade"),
"stderr should mention the violated condition, got: {}",
stderr
);
assert!(
stderr.contains("version downgrade on component"),
"stderr should report the downgraded component, got: {}",
stderr
);
assert!(
stderr.contains("2.0.0 -> 1.5.0"),
"stderr should show old and new versions, got: {}",
stderr
);
}
#[test]
fn fail_on_version_downgrade_upgrade_exits_0() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("version-downgrade")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_version_downgrade_no_change_exits_0() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-old.json"))
.arg("--fail-on")
.arg("version-downgrade")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_supplier_changed_exits_3() {
let out = sbom_diff()
.arg(fixture("supplier-changed-old.json"))
.arg(fixture("supplier-changed-new.json"))
.arg("--fail-on")
.arg("supplier-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on supplier-changed"),
"stderr should mention the violated condition, got: {}",
stderr
);
assert!(
stderr.contains("supplier changed on component"),
"stderr should report the changed component, got: {}",
stderr
);
assert!(
stderr.contains("Acme Corp -> Evil Corp"),
"stderr should show old and new suppliers, got: {}",
stderr
);
assert!(
stderr.contains("added component") && stderr.contains("has supplier"),
"stderr should report the added component's supplier, got: {}",
stderr
);
}
#[test]
fn fail_on_supplier_changed_no_change_exits_0() {
let out = sbom_diff()
.arg(fixture("supplier-changed-old.json"))
.arg(fixture("supplier-changed-old.json"))
.arg("--fail-on")
.arg("supplier-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn identity_diff_exits_0_with_no_output_changes() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-old.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["added"], 0);
assert_eq!(v["removed"], 0);
assert_eq!(v["changed"], 0);
}
#[test]
fn include_ecosystem_filters_to_matching() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--include-ecosystem")
.arg("npm")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Added: 1"));
assert!(stdout.contains("Removed: 1"));
assert!(stdout.contains("Changed: 2"));
}
#[test]
fn include_ecosystem_non_matching_shows_zero() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--include-ecosystem")
.arg("cargo")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Added: 0"));
assert!(stdout.contains("Removed: 0"));
assert!(stdout.contains("Changed: 0"));
assert!(stdout.contains("Old total: 0 components"));
}
#[test]
fn exclude_ecosystem_removes_matching() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--exclude-ecosystem")
.arg("npm")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Added: 0"));
assert!(stdout.contains("Removed: 0"));
assert!(stdout.contains("Changed: 0"));
}
#[test]
fn include_ecosystem_case_insensitive() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--include-ecosystem")
.arg("NPM")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Added: 1"));
}
#[test]
fn include_ecosystem_json_output() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.arg("--include-ecosystem")
.arg("cargo")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["added"], 0);
assert_eq!(v["removed"], 0);
assert_eq!(v["changed"], 0);
assert_eq!(v["old_total"], 0);
}
#[test]
fn exclude_ecosystem_does_not_affect_non_matching() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--exclude-ecosystem")
.arg("cargo")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Added: 1"));
assert!(stdout.contains("Removed: 1"));
assert!(stdout.contains("Changed: 2"));
}
#[test]
fn include_and_exclude_ecosystem_combined() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--summary")
.arg("--include-ecosystem")
.arg("npm")
.arg("--exclude-ecosystem")
.arg("npm")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Added: 0"));
}
#[test]
fn include_ecosystem_with_fail_on_respects_filter() {
let out = sbom_diff()
.arg(fixture("golden-old.json"))
.arg(fixture("golden-new.json"))
.arg("--fail-on")
.arg("added-components")
.arg("--include-ecosystem")
.arg("cargo")
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(0),
"filtering out all adds should prevent fail-on trigger"
);
}
#[test]
fn fail_on_hash_algorithm_downgrade_exits_3() {
let out = sbom_diff()
.arg(fixture("hash-downgrade-old.json"))
.arg(fixture("hash-downgrade-new.json"))
.arg("--fail-on")
.arg("hash-algorithm-downgrade")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on hash-algorithm-downgrade"),
"stderr should mention the violated condition, got: {}",
stderr
);
assert!(
stderr.contains("hash algorithm downgrade on component"),
"stderr should report the downgraded component, got: {}",
stderr
);
}
#[test]
fn fail_on_hash_algorithm_downgrade_no_change_exits_0() {
let out = sbom_diff()
.arg(fixture("hash-downgrade-old.json"))
.arg(fixture("hash-downgrade-old.json"))
.arg("--fail-on")
.arg("hash-algorithm-downgrade")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_hash_algorithm_downgrade_only_downgraded_component_reported() {
let out = sbom_diff()
.arg(fixture("hash-downgrade-old.json"))
.arg(fixture("hash-downgrade-new.json"))
.arg("--fail-on")
.arg("hash-algorithm-downgrade")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("pkg-a"),
"stderr should mention pkg-a, got: {}",
stderr
);
assert!(
!stderr.contains("pkg-b"),
"stderr should NOT mention pkg-b (unchanged hash), got: {}",
stderr
);
}
#[test]
fn mixed_eco_include_npm_text() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--include-ecosystem")
.arg("npm")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Old total: 2 components"));
assert!(stdout.contains("New total: 2 components"));
assert!(stdout.contains("Added: 1"));
assert!(stdout.contains("Removed: 1"));
assert!(stdout.contains("Changed: 1"));
assert!(stdout.contains("pkg:npm/axios@1.0.0"));
assert!(stdout.contains("pkg:npm/express@4.18.0"));
assert!(stdout.contains("pkg:npm/lodash@4.17.21"));
assert!(!stdout.contains("pkg:cargo/"));
assert!(!stdout.contains("pkg:pypi/"));
}
#[test]
fn mixed_eco_include_cargo_text() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--include-ecosystem")
.arg("cargo")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Old total: 1 components"));
assert!(stdout.contains("New total: 2 components"));
assert!(stdout.contains("Added: 1"));
assert!(stdout.contains("Removed: 0"));
assert!(stdout.contains("Changed: 0"));
assert!(stdout.contains("Unchanged: 1"));
assert!(stdout.contains("pkg:cargo/tokio@1.0.0"));
assert!(!stdout.contains("pkg:npm/"));
assert!(!stdout.contains("pkg:pypi/"));
}
#[test]
fn mixed_eco_exclude_npm_text() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--exclude-ecosystem")
.arg("npm")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Old total: 3 components"));
assert!(stdout.contains("New total: 3 components"));
assert!(stdout.contains("Added: 1"));
assert!(stdout.contains("Removed: 1"));
assert!(stdout.contains("Changed: 0"));
assert!(stdout.contains("Unchanged: 2"));
assert!(stdout.contains("pkg:cargo/tokio@1.0.0"));
assert!(stdout.contains("pkg:pypi/requests@2.28.0"));
assert!(!stdout.contains("pkg:npm/"));
}
#[test]
fn mixed_eco_include_npm_json() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--output")
.arg("json")
.arg("--include-ecosystem")
.arg("npm")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["old_total"], 2);
assert_eq!(v["new_total"], 2);
assert_eq!(v["unchanged"], 0);
let added = v["added"].as_array().unwrap();
assert_eq!(added.len(), 1);
assert_eq!(added[0]["ecosystem"], "npm");
assert_eq!(added[0]["name"], "axios");
let removed = v["removed"].as_array().unwrap();
assert_eq!(removed.len(), 1);
assert_eq!(removed[0]["ecosystem"], "npm");
assert_eq!(removed[0]["name"], "express");
let changed = v["changed"].as_array().unwrap();
assert_eq!(changed.len(), 1);
assert_eq!(changed[0]["new"]["name"], "lodash");
}
#[test]
fn mixed_eco_exclude_npm_json() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--output")
.arg("json")
.arg("--exclude-ecosystem")
.arg("npm")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["old_total"], 3);
assert_eq!(v["new_total"], 3);
assert_eq!(v["unchanged"], 2);
let added = v["added"].as_array().unwrap();
assert_eq!(added.len(), 1);
assert_eq!(added[0]["ecosystem"], "cargo");
let removed = v["removed"].as_array().unwrap();
assert_eq!(removed.len(), 1);
assert_eq!(removed[0]["ecosystem"], "pypi");
let changed = v["changed"].as_array().unwrap();
assert!(changed.is_empty());
}
#[test]
fn mixed_eco_include_cargo_markdown() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--output")
.arg("markdown")
.arg("--include-ecosystem")
.arg("cargo")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("| Old total | 1 |"));
assert!(stdout.contains("| New total | 2 |"));
assert!(stdout.contains("| Added | 1 |"));
assert!(stdout.contains("| Removed | 0 |"));
assert!(stdout.contains("`pkg:cargo/tokio@1.0.0`"));
assert!(!stdout.contains("npm"));
assert!(!stdout.contains("pypi"));
}
#[test]
fn mixed_eco_exclude_npm_markdown() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--output")
.arg("markdown")
.arg("--exclude-ecosystem")
.arg("npm")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("| Old total | 3 |"));
assert!(stdout.contains("| Added | 1 |"));
assert!(stdout.contains("| Removed | 1 |"));
assert!(stdout.contains("`pkg:cargo/tokio@1.0.0`"));
assert!(stdout.contains("`pkg:pypi/requests@2.28.0`"));
assert!(!stdout.contains("npm"));
}
#[test]
fn mixed_eco_multi_include_text() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--include-ecosystem")
.arg("npm")
.arg("--include-ecosystem")
.arg("cargo")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Old total: 3 components"));
assert!(stdout.contains("New total: 4 components"));
assert!(stdout.contains("Added: 2"));
assert!(stdout.contains("Removed: 1"));
assert!(stdout.contains("Changed: 1"));
assert!(stdout.contains("Unchanged: 1"));
assert!(stdout.contains("pkg:npm/axios@1.0.0"));
assert!(stdout.contains("pkg:cargo/tokio@1.0.0"));
assert!(stdout.contains("pkg:npm/express@4.18.0"));
assert!(stdout.contains("pkg:npm/lodash@4.17.21"));
assert!(!stdout.contains("pkg:pypi/"));
}
#[test]
fn mixed_eco_include_unknown_text() {
let out = sbom_diff()
.arg(fixture("mixed-eco-old.json"))
.arg(fixture("mixed-eco-new.json"))
.arg("--include-ecosystem")
.arg("unknown")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Old total: 1 components"));
assert!(stdout.contains("New total: 1 components"));
assert!(stdout.contains("Unchanged: 1"));
assert!(stdout.contains("Added: 0"));
assert!(stdout.contains("Removed: 0"));
assert!(stdout.contains("Changed: 0"));
}
#[test]
fn fail_on_cyclic_dependency_exits_3() {
let out = sbom_diff()
.arg(fixture("cyclic-dep-old.json"))
.arg(fixture("cyclic-dep-new.json"))
.arg("--fail-on")
.arg("cyclic-dependency")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on cyclic-dependency"),
"stderr should mention the violated condition"
);
assert!(
stderr.contains("dependency cycle detected"),
"stderr should describe the cycle"
);
}
#[test]
fn fail_on_cyclic_dependency_no_cycle_exits_0() {
let out = sbom_diff()
.arg(fixture("cyclic-dep-old.json"))
.arg(fixture("cyclic-dep-old.json"))
.arg("--fail-on")
.arg("cyclic-dependency")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn fail_on_cyclic_dependency_quiet_suppresses_output() {
let out = sbom_diff()
.arg(fixture("cyclic-dep-old.json"))
.arg(fixture("cyclic-dep-new.json"))
.arg("--fail-on")
.arg("cyclic-dependency")
.arg("--quiet")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.is_empty(), "stdout should be empty in quiet mode");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("dependency cycle detected"));
}
#[test]
fn fail_on_cyclic_dependency_combined_with_other_conditions() {
let out = sbom_diff()
.arg(fixture("cyclic-dep-old.json"))
.arg(fixture("cyclic-dep-new.json"))
.arg("--fail-on")
.arg("cyclic-dependency")
.arg("--fail-on")
.arg("deps")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--fail-on cyclic-dependency"));
}
#[test]
fn cross_format_identity_no_changes() {
let out = sbom_diff()
.arg(fixture("cross-format-base.json"))
.arg(fixture("cross-format-base.spdx.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 0, "no components should be added");
assert_eq!(v["removed"], 0, "no components should be removed");
assert_eq!(v["changed"], 0, "no components should be changed");
assert_eq!(v["unchanged"], 4, "all four components should be unchanged");
}
#[test]
fn cross_format_identity_spdx_to_cdx() {
let out = sbom_diff()
.arg(fixture("cross-format-base.spdx.json"))
.arg(fixture("cross-format-base.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["added"], 0);
assert_eq!(v["removed"], 0);
assert_eq!(v["changed"], 0);
assert_eq!(v["unchanged"], 4);
}
#[test]
fn cross_format_identity_fail_on_gates_pass() {
let out = sbom_diff()
.arg(fixture("cross-format-base.json"))
.arg(fixture("cross-format-base.spdx.json"))
.arg("--fail-on")
.arg("added-components")
.arg("--fail-on")
.arg("removed-components")
.arg("--fail-on")
.arg("changed-components")
.arg("--fail-on")
.arg("deps")
.arg("--fail-on")
.arg("license-changed")
.arg("--fail-on")
.arg("hash-algorithm-downgrade")
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(0),
"identical cross-format SBOMs should pass all gates, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn cross_format_modification_detected() {
let out = sbom_diff()
.arg(fixture("cross-format-base.spdx.json"))
.arg(fixture("cross-format-modified.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["changed"], 2, "alpha and beta should be changed");
assert_eq!(v["unchanged"], 2, "gamma and delta should be unchanged");
assert_eq!(v["added"], 0);
assert_eq!(v["removed"], 0);
}
#[test]
fn cross_format_modification_full_json() {
let out = sbom_diff()
.arg(fixture("cross-format-base.spdx.json"))
.arg(fixture("cross-format-modified.json"))
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
let changed = v["changed"].as_array().expect("changed should be an array");
assert_eq!(changed.len(), 2);
let names: Vec<&str> = changed
.iter()
.map(|c| c["new"]["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"alpha"), "alpha should appear in changed");
assert!(names.contains(&"beta"), "beta should appear in changed");
}
#[test]
fn cross_format_fail_on_changed_components_exits_3() {
let out = sbom_diff()
.arg(fixture("cross-format-base.spdx.json"))
.arg(fixture("cross-format-modified.json"))
.arg("--fail-on")
.arg("changed-components")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on changed-components"),
"stderr should mention the violated condition, got: {}",
stderr
);
}
#[test]
fn cross_format_fail_on_license_changed_exits_3() {
let out = sbom_diff()
.arg(fixture("cross-format-base.spdx.json"))
.arg(fixture("cross-format-modified.json"))
.arg("--fail-on")
.arg("license-changed")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on license-changed"),
"stderr should mention the violated condition, got: {}",
stderr
);
}
#[test]
fn cross_format_hash_canonicalisation_no_false_diff() {
let out = sbom_diff()
.arg(fixture("cross-format-base.json"))
.arg(fixture("cross-format-base.spdx.json"))
.arg("--output")
.arg("json")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
let changed = v["changed"].as_array().expect("changed should be an array");
assert!(
changed.is_empty(),
"hash algorithm naming differences (SHA-256 vs SHA256) should not \
produce false diffs after canonicalisation, got {} changes",
changed.len()
);
}
#[test]
fn cross_format_ecosystem_filter_works() {
let out = sbom_diff()
.arg(fixture("cross-format-base.spdx.json"))
.arg(fixture("cross-format-modified.json"))
.arg("--summary")
.arg("--output")
.arg("json")
.arg("--include-ecosystem")
.arg("cargo")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(v["changed"], 0, "cargo components are unchanged");
assert_eq!(v["unchanged"], 2, "gamma and delta are cargo");
assert_eq!(v["added"], 0);
assert_eq!(v["removed"], 0);
}
#[test]
fn only_masking_fail_on_gate_warns_and_silently_passes() {
let out = sbom_diff()
.arg(fixture("version-downgrade-old.json"))
.arg(fixture("version-downgrade-new.json"))
.arg("--only")
.arg("license")
.arg("--fail-on")
.arg("version-downgrade")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on version-downgrade")
&& stderr.contains("--only")
&& stderr.contains("silently pass"),
"expected a masking warning, got: {stderr}"
);
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn only_including_gate_field_emits_no_masking_warning() {
let out = sbom_diff()
.arg(fixture("version-downgrade-old.json"))
.arg(fixture("version-downgrade-new.json"))
.arg("--only")
.arg("version")
.arg("--fail-on")
.arg("version-downgrade")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("silently pass"),
"expected no masking warning, got: {stderr}"
);
assert_eq!(out.status.code(), Some(3));
}
#[test]
fn fail_on_without_only_emits_no_masking_warning() {
let out = sbom_diff()
.arg(fixture("version-downgrade-old.json"))
.arg(fixture("version-downgrade-new.json"))
.arg("--fail-on")
.arg("version-downgrade")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("silently pass"),
"expected no masking warning, got: {stderr}"
);
assert_eq!(out.status.code(), Some(3));
}
#[test]
fn only_masking_deps_gate_warns_and_silently_passes() {
let out = sbom_diff()
.arg(fixture("edge-change-old.json"))
.arg(fixture("edge-change-new.json"))
.arg("--only")
.arg("license")
.arg("--fail-on")
.arg("deps")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--fail-on deps") && stderr.contains("silently pass"),
"expected a deps masking warning, got: {stderr}"
);
assert_eq!(out.status.code(), Some(0));
}