use crate::{Result, builder::BuildOptions, crate_resolver::ResolvedCrate};
use serde_cyclonedx::cyclonedx::v_1_4::{
Component, ComponentBuilder, CycloneDxBuilder, Dependency, DependencyBuilder, Metadata, MetadataBuilder,
PropertyBuilder, ToolBuilder,
};
pub(crate) use serde_cyclonedx::cyclonedx::v_1_4::CycloneDx;
pub(crate) fn generate_sbom(
metadata: &cargo_metadata::Metadata,
resolved: &ResolvedCrate,
options: &BuildOptions,
) -> Result<CycloneDx> {
let main_component = build_main_component(resolved, options)?;
let (dependencies, _kind_map) = build_dependency_components(metadata)?;
let dependency_refs = build_dependency_graph(metadata)?;
let sbom_metadata = build_metadata(&main_component)?;
CycloneDxBuilder::default()
.bom_format("CycloneDX")
.spec_version("1.4")
.version(1)
.serial_number(format!("urn:uuid:{}", uuid::Uuid::new_v4()))
.metadata(sbom_metadata)
.components(dependencies)
.dependencies(dependency_refs)
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum DepKind {
Build, Runtime, }
impl DepKind {
fn from_cargo_kinds(kinds: &[cargo_metadata::DepKindInfo]) -> Self {
let has_runtime = kinds
.iter()
.any(|k| matches!(k.kind, cargo_metadata::DependencyKind::Normal));
if has_runtime {
DepKind::Runtime
} else {
DepKind::Build
}
}
}
fn analyze_dependencies(
metadata: &cargo_metadata::Metadata,
) -> std::collections::HashMap<cargo_metadata::PackageId, (usize, DepKind)> {
use std::collections::{HashMap, HashSet};
let resolve = match &metadata.resolve {
Some(r) => r,
None => return HashMap::new(),
};
let root_id = match &resolve.root {
Some(id) => id,
None => return HashMap::new(),
};
let proc_macros: HashSet<&cargo_metadata::PackageId> = metadata
.packages
.iter()
.filter(|pkg| {
pkg.targets.len() == 1
&& pkg.targets[0].kind.len() == 1
&& pkg.targets[0].kind[0] == cargo_metadata::TargetKind::ProcMacro
})
.map(|pkg| &pkg.id)
.collect();
let id_to_node: HashMap<&cargo_metadata::PackageId, &cargo_metadata::Node> =
resolve.nodes.iter().map(|n| (&n.id, n)).collect();
let mut id_to_kind: HashMap<&cargo_metadata::PackageId, DepKind> = HashMap::new();
id_to_kind.insert(root_id, DepKind::Runtime);
let mut queue = vec![id_to_node[root_id]];
let mut next_queue = Vec::new();
while !queue.is_empty() {
for parent_node in queue.drain(..) {
let parent_kind = id_to_kind[&parent_node.id];
for dep in &parent_node.deps {
let child_id = &dep.pkg;
let mut child_kind = DepKind::from_cargo_kinds(&dep.dep_kinds);
let is_dev_only = dep
.dep_kinds
.iter()
.all(|k| matches!(k.kind, cargo_metadata::DependencyKind::Development));
if is_dev_only {
continue;
}
child_kind = std::cmp::min(child_kind, parent_kind);
if proc_macros.contains(child_id) {
child_kind = DepKind::Build;
}
let should_visit = match id_to_kind.get(child_id) {
None => true,
Some(&existing_kind) => child_kind > existing_kind,
};
if should_visit {
id_to_kind.insert(child_id, child_kind);
if let Some(&child_node) = id_to_node.get(child_id) {
next_queue.push(child_node);
}
}
}
}
std::mem::swap(&mut queue, &mut next_queue);
}
let mut result = HashMap::new();
for (idx, (pkg_id, kind)) in id_to_kind.into_iter().enumerate() {
result.insert(pkg_id.clone(), (idx, kind));
}
result
}
fn build_main_component(resolved: &ResolvedCrate, options: &BuildOptions) -> Result<Component> {
let mut properties = vec![];
if let Some(ref profile) = options.profile {
properties.push(
PropertyBuilder::default()
.name("build:profile")
.value(profile.clone())
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?,
);
}
if options.all_features {
properties.push(
PropertyBuilder::default()
.name("build:all-features")
.value("true")
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?,
);
}
if options.no_default_features {
properties.push(
PropertyBuilder::default()
.name("build:no-default-features")
.value("true")
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?,
);
}
if !options.features.is_empty() {
properties.push(
PropertyBuilder::default()
.name("build:features")
.value(options.features.join(","))
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?,
);
}
if let Some(ref target) = options.target {
properties.push(
PropertyBuilder::default()
.name("build:target")
.value(target.clone())
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?,
);
}
if let Some(ref toolchain) = options.toolchain {
properties.push(
PropertyBuilder::default()
.name("build:toolchain")
.value(toolchain.clone())
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?,
);
}
let purl = format!("pkg:cargo/{}@{}", resolved.name, resolved.version);
let mut builder = ComponentBuilder::default();
builder
.type_("application")
.bom_ref(purl.clone())
.name(resolved.name.clone())
.version(resolved.version.to_string())
.purl(purl);
if !properties.is_empty() {
builder.properties(properties);
}
builder.build().map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})
}
fn build_dependency_components(
metadata: &cargo_metadata::Metadata,
) -> Result<(
Vec<Component>,
std::collections::HashMap<cargo_metadata::PackageId, DepKind>,
)> {
use std::collections::HashMap;
let dep_analysis = analyze_dependencies(metadata);
let workspace_member_ids: std::collections::HashSet<_> =
metadata.workspace_packages().iter().map(|p| &p.id).collect();
let mut packages: Vec<(&cargo_metadata::Package, DepKind)> = metadata
.packages
.iter()
.filter(|p| !workspace_member_ids.contains(&p.id))
.filter_map(|p| dep_analysis.get(&p.id).map(|(_idx, kind)| (p, *kind)))
.collect();
packages.sort_by(|a, b| {
a.0.name
.cmp(&b.0.name)
.then(a.0.version.cmp(&b.0.version))
.then(a.0.id.cmp(&b.0.id))
});
let mut components = Vec::new();
let mut kind_map = HashMap::new();
for (package, dep_kind) in packages {
let purl = format!("pkg:cargo/{}@{}", package.name, package.version);
let mut properties = Vec::new();
if dep_kind == DepKind::Build {
properties.push(
PropertyBuilder::default()
.name("cdx:rustc:dependency_kind")
.value("build")
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?,
);
}
let mut builder = ComponentBuilder::default();
builder
.type_("library")
.bom_ref(purl.clone())
.name(package.name.to_string())
.version(package.version.to_string())
.purl(purl);
if let Some(ref desc) = package.description {
builder.description(desc.clone());
}
if !properties.is_empty() {
builder.properties(properties);
}
let component = builder.build().map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?;
components.push(component);
kind_map.insert(package.id.clone(), dep_kind);
}
Ok((components, kind_map))
}
fn build_dependency_graph(metadata: &cargo_metadata::Metadata) -> Result<Vec<Dependency>> {
let mut dependencies = Vec::new();
let package_id_to_ref: std::collections::HashMap<_, _> = metadata
.packages
.iter()
.map(|p| {
let purl = format!("pkg:cargo/{}@{}", p.name, p.version);
(&p.id, purl)
})
.collect();
for package in &metadata.packages {
let purl = format!("pkg:cargo/{}@{}", package.name, package.version);
let dep_refs: Vec<String> = if let Some(ref resolve) = metadata.resolve {
resolve
.nodes
.iter()
.find(|node| node.id == package.id)
.map(|node| {
node.deps
.iter()
.filter_map(|dep| package_id_to_ref.get(&dep.pkg).cloned())
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
let mut builder = DependencyBuilder::default();
builder.ref_(purl);
if !dep_refs.is_empty() {
builder.depends_on(dep_refs);
}
let dependency = builder.build().map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?;
dependencies.push(dependency);
}
Ok(dependencies)
}
fn build_metadata(main_component: &Component) -> Result<Metadata> {
let tool = ToolBuilder::default()
.name("cgx")
.version(env!("CARGO_PKG_VERSION").to_string())
.vendor("cgx project".to_string())
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})?;
MetadataBuilder::default()
.tools(vec![tool])
.component(main_component.clone())
.timestamp(chrono::Utc::now().to_rfc3339())
.build()
.map_err(|e| {
crate::error::SbomBuilderSnafu {
message: e.to_string(),
}
.build()
})
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::{
cargo::{CargoMetadataOptions, CargoRunner},
crate_resolver::ResolvedSource,
testdata::CrateTestCase,
};
use serde_cyclonedx::cyclonedx::v_1_4::CycloneDx;
use snafu::ResultExt;
use std::path::Path;
fn test_cargo_runner() -> impl CargoRunner {
crate::cargo::find_cargo(crate::messages::MessageReporter::null()).unwrap()
}
fn generate_sbom_for_testcase(testcase: &CrateTestCase, options: BuildOptions) -> Result<String> {
let cargo_runner = test_cargo_runner();
let metadata_opts = CargoMetadataOptions::from(&options);
let metadata = cargo_runner.metadata(testcase.path(), &metadata_opts)?;
let root_pkg = metadata.root_package().unwrap();
let resolved = ResolvedCrate {
name: root_pkg.name.to_string(),
version: root_pkg.version.clone(),
source: ResolvedSource::CratesIo,
};
let cyclonedx = generate_sbom(&metadata, &resolved, &options)?;
serde_json::to_string_pretty(&cyclonedx).context(crate::error::JsonSnafu)
}
fn normalize_sbom(mut sbom: CycloneDx) -> CycloneDx {
if let Some(ref mut metadata) = sbom.metadata {
metadata.timestamp = None;
}
sbom.serial_number = None;
sbom
}
fn read_and_normalize_sbom(path: &Path) -> CycloneDx {
let json_str = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("Failed to read SBOM from {}: {}", path.display(), e));
let sbom: CycloneDx = serde_json::from_str(&json_str)
.unwrap_or_else(|e| panic!("Failed to parse SBOM from {}: {}", path.display(), e));
normalize_sbom(sbom)
}
#[allow(dead_code)]
pub(crate) fn get_sbom_component_version(sbom_path: &Path, component_name: &str) -> Option<String> {
let json_str = std::fs::read_to_string(sbom_path)
.unwrap_or_else(|e| panic!("Failed to read SBOM from {}: {}", sbom_path.display(), e));
let sbom: CycloneDx = serde_json::from_str(&json_str)
.unwrap_or_else(|e| panic!("Failed to parse SBOM from {}: {}", sbom_path.display(), e));
sbom.components
.unwrap_or_default()
.iter()
.find(|c| c.name.as_str() == component_name)
.and_then(|c| c.version.clone())
}
#[allow(dead_code)]
pub(crate) fn assert_sboms_eq(path1: &Path, path2: &Path) {
let sbom1 = read_and_normalize_sbom(path1);
let sbom2 = read_and_normalize_sbom(path2);
assert_json_diff::assert_json_eq!(sbom1, sbom2);
}
pub(crate) fn assert_sboms_ne(path1: &Path, path2: &Path) {
let sbom1 = read_and_normalize_sbom(path1);
let sbom2 = read_and_normalize_sbom(path2);
let json1 = serde_json::to_value(&sbom1).unwrap();
let json2 = serde_json::to_value(&sbom2).unwrap();
if json1 == json2 {
panic!(
"SBOMs are unexpectedly equal:\n {}\n {}\n\nBoth normalized to:\n{}",
path1.display(),
path2.display(),
serde_json::to_string_pretty(&json1).unwrap()
);
}
}
#[test]
fn smoke_test_all_testcases() {
for testcase in CrateTestCase::all() {
if testcase.name == "workspace-all-libs" || testcase.name == "workspace-multiple-bin-crates" {
continue;
}
let result = generate_sbom_for_testcase(&testcase, BuildOptions::default());
assert!(
result.is_ok(),
"SBOM generation failed for {}: {:?}",
testcase.name,
result.err()
);
}
}
#[cfg(target_os = "linux")]
mod snapshots {
use super::*;
fn normalize_sbom_json(json_str: &str) -> String {
let sbom: CycloneDx = serde_json::from_str(json_str).unwrap();
let normalized = normalize_sbom(sbom);
serde_json::to_string_pretty(&normalized).unwrap()
}
#[test]
fn snapshot_simple_bin_no_deps() {
let tc = CrateTestCase::simple_bin_no_deps();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let normalized = normalize_sbom_json(&sbom);
insta::with_settings!({filters => vec![
(r#""version": "\d+\.\d+\.\d+""#, r#""version": "[VERSION]""#),
(r"@\d+\.\d+\.\d+", "@[VERSION]"),
]}, {
insta::assert_snapshot!(normalized);
});
}
#[test]
fn snapshot_simple_lib_no_deps() {
let tc = CrateTestCase::simple_lib_no_deps();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let normalized = normalize_sbom_json(&sbom);
insta::with_settings!({filters => vec![
(r#""version": "\d+\.\d+\.\d+""#, r#""version": "[VERSION]""#),
(r"@\d+\.\d+\.\d+", "@[VERSION]"),
]}, {
insta::assert_snapshot!(normalized);
});
}
#[test]
fn snapshot_timestamp_default_features() {
let tc = CrateTestCase::timestamp();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let normalized = normalize_sbom_json(&sbom);
insta::with_settings!({filters => vec![
(r#""version": "\d+\.\d+\.\d+""#, r#""version": "[VERSION]""#),
(r"@\d+\.\d+\.\d+", "@[VERSION]"),
]}, {
insta::assert_snapshot!(normalized);
});
}
#[test]
fn snapshot_timestamp_no_default_features() {
let tc = CrateTestCase::timestamp();
let options = BuildOptions {
no_default_features: true,
..Default::default()
};
let sbom = generate_sbom_for_testcase(&tc, options).unwrap();
let normalized = normalize_sbom_json(&sbom);
insta::with_settings!({filters => vec![
(r#""version": "\d+\.\d+\.\d+""#, r#""version": "[VERSION]""#),
(r"@\d+\.\d+\.\d+", "@[VERSION]"),
]}, {
insta::assert_snapshot!(normalized);
});
}
#[test]
fn snapshot_timestamp_all_features() {
let tc = CrateTestCase::timestamp();
let options = BuildOptions {
all_features: true,
..Default::default()
};
let sbom = generate_sbom_for_testcase(&tc, options).unwrap();
let normalized = normalize_sbom_json(&sbom);
insta::with_settings!({filters => vec![
(r#""version": "\d+\.\d+\.\d+""#, r#""version": "[VERSION]""#),
(r"@\d+\.\d+\.\d+", "@[VERSION]"),
]}, {
insta::assert_snapshot!(normalized);
});
}
#[test]
fn snapshot_timestamp_frobnulator_only() {
let tc = CrateTestCase::timestamp();
let options = BuildOptions {
features: vec!["frobnulator".to_string()],
no_default_features: true,
..Default::default()
};
let sbom = generate_sbom_for_testcase(&tc, options).unwrap();
let normalized = normalize_sbom_json(&sbom);
insta::with_settings!({filters => vec![
(r#""version": "\d+\.\d+\.\d+""#, r#""version": "[VERSION]""#),
(r"@\d+\.\d+\.\d+", "@[VERSION]"),
]}, {
insta::assert_snapshot!(normalized);
});
}
#[test]
fn snapshot_stale_serde() {
let tc = CrateTestCase::stale_serde();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let normalized = normalize_sbom_json(&sbom);
insta::with_settings!({filters => vec![
(r#""version": "\d+\.\d+\.\d+""#, r#""version": "[VERSION]""#),
(r"@\d+\.\d+\.\d+", "@[VERSION]"),
]}, {
insta::assert_snapshot!(normalized);
});
}
#[test]
fn snapshot_thicc() {
let tc = CrateTestCase::thicc();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let normalized = normalize_sbom_json(&sbom);
insta::with_settings!({filters => vec![
(r#""version": "\d+\.\d+\.\d+""#, r#""version": "[VERSION]""#),
(r"@\d+\.\d+\.\d+", "@[VERSION]"),
]}, {
insta::assert_snapshot!(normalized);
});
}
}
#[test]
fn test_feature_conditional_deps_default() {
let tc = CrateTestCase::timestamp();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let bom: CycloneDx = serde_json::from_str(&sbom).unwrap();
let components = bom.components.unwrap();
let names: Vec<_> = components.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"serde"), "Default features should include serde");
assert!(
!names.contains(&"chrono"),
"Default features should not include chrono"
);
}
#[test]
fn test_feature_conditional_deps_all_features() {
let tc = CrateTestCase::timestamp();
let options = BuildOptions {
all_features: true,
..Default::default()
};
let sbom = generate_sbom_for_testcase(&tc, options).unwrap();
let bom: CycloneDx = serde_json::from_str(&sbom).unwrap();
let components = bom.components.unwrap();
let names: Vec<_> = components.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"serde"), "All features should include serde");
assert!(names.contains(&"chrono"), "All features should include chrono");
}
#[test]
fn test_proc_macro_marked_as_build_dep() {
let tc = CrateTestCase::proc_macro_dep();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let bom: CycloneDx = serde_json::from_str(&sbom).unwrap();
let components = bom.components.unwrap();
let serde_derive = components
.iter()
.find(|c| c.name.as_str() == "serde_derive")
.unwrap();
if let Some(ref props) = serde_derive.properties {
let has_build_kind = props.iter().any(|p| {
p.name.as_deref() == Some("cdx:rustc:dependency_kind") && p.value.as_deref() == Some("build")
});
assert!(has_build_kind, "proc-macro should be marked as build dependency");
} else {
panic!("proc-macro should have dependency_kind property");
}
}
#[test]
fn test_os_specific_deps_filtered_by_platform() {
let tc = CrateTestCase::os_specific_deps();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let bom: CycloneDx = serde_json::from_str(&sbom).unwrap();
let components = bom.components.unwrap();
let names: Vec<_> = components.iter().map(|c| c.name.as_str()).collect();
assert!(
names.contains(&"serde"),
"serde should be present on all platforms"
);
#[cfg(target_os = "linux")]
{
assert!(names.contains(&"inotify"), "inotify should be present on Linux");
assert!(names.contains(&"libc"), "libc should be present on Linux");
assert!(names.contains(&"nix"), "nix should be present on Linux");
assert!(names.contains(&"procfs"), "procfs should be present on Linux");
assert!(!names.contains(&"cocoa"), "cocoa should not be present on Linux");
assert!(!names.contains(&"metal"), "metal should not be present on Linux");
assert!(!names.contains(&"objc"), "objc should not be present on Linux");
assert!(
!names.contains(&"security-framework"),
"security-framework should not be present on Linux"
);
}
#[cfg(target_os = "macos")]
{
assert!(names.contains(&"cocoa"), "cocoa should be present on macOS");
assert!(names.contains(&"metal"), "metal should be present on macOS");
assert!(names.contains(&"objc"), "objc should be present on macOS");
assert!(
names.contains(&"security-framework"),
"security-framework should be present on macOS"
);
assert!(
!names.contains(&"inotify"),
"inotify should not be present on macOS"
);
assert!(!names.contains(&"nix"), "nix should not be present on macOS");
assert!(
!names.contains(&"procfs"),
"procfs should not be present on macOS"
);
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
assert!(
!names.contains(&"inotify"),
"inotify should not be present on non-Linux/macOS"
);
assert!(
!names.contains(&"nix"),
"nix should not be present on non-Linux/macOS"
);
assert!(
!names.contains(&"procfs"),
"procfs should not be present on non-Linux/macOS"
);
assert!(
!names.contains(&"cocoa"),
"cocoa should not be present on non-Linux/macOS"
);
assert!(
!names.contains(&"metal"),
"metal should not be present on non-Linux/macOS"
);
assert!(
!names.contains(&"objc"),
"objc should not be present on non-Linux/macOS"
);
assert!(
!names.contains(&"security-framework"),
"security-framework should not be present on non-Linux/macOS"
);
}
}
#[test]
fn test_version_resolution_with_lockfile() {
let tc = CrateTestCase::stale_serde();
let sbom = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let bom: CycloneDx = serde_json::from_str(&sbom).unwrap();
let components = bom.components.unwrap();
let serde = components
.iter()
.find(|c| c.name.as_str() == "serde")
.expect("serde should be in components");
assert_eq!(
serde.version.as_deref(),
Some("1.0.5"),
"Should use old version from lockfile"
);
}
#[test]
fn test_version_resolution_without_lockfile() {
let tc = CrateTestCase::stale_serde();
let lockfile = tc.path().join("Cargo.lock");
assert!(lockfile.exists());
std::fs::remove_file(&lockfile).unwrap();
let sbom = generate_sbom_for_testcase(
&tc,
BuildOptions {
locked: false,
..Default::default()
},
)
.unwrap();
let bom: CycloneDx = serde_json::from_str(&sbom).unwrap();
let components = bom.components.unwrap();
let serde = components
.iter()
.find(|c| c.name.as_str() == "serde")
.expect("serde should be in components");
let version = serde.version.as_deref().unwrap();
assert_ne!(version, "1.0.5", "Should resolve to newer serde without lockfile");
assert!(version.starts_with("1.0."), "Should still be serde 1.0.x");
}
#[test]
fn test_build_options_in_metadata() {
let tc = CrateTestCase::simple_bin_no_deps();
let options = BuildOptions {
profile: Some("release".to_string()),
all_features: true,
target: Some("x86_64-unknown-linux-musl".to_string()),
toolchain: Some("stable".to_string()),
..Default::default()
};
let sbom = generate_sbom_for_testcase(&tc, options).unwrap();
let bom: CycloneDx = serde_json::from_str(&sbom).unwrap();
let metadata = bom.metadata.unwrap();
let component = metadata.component.unwrap();
let props = component.properties.unwrap();
assert!(
props
.iter()
.any(|p| p.name.as_deref() == Some("build:profile") && p.value.as_deref() == Some("release"))
);
assert!(
props.iter().any(
|p| p.name.as_deref() == Some("build:all-features") && p.value.as_deref() == Some("true")
)
);
assert!(props.iter().any(|p| p.name.as_deref() == Some("build:target")
&& p.value.as_deref() == Some("x86_64-unknown-linux-musl")));
assert!(props.iter().any(|p| p.name.as_deref() == Some("build:toolchain")
&& p.value.as_deref() == Some("stable")));
}
#[test]
fn test_lockfile_affects_sbom() {
let tc = CrateTestCase::stale_serde();
let sbom_with_lock = generate_sbom_for_testcase(&tc, BuildOptions::default()).unwrap();
let path_with_lock = tc.path().join("sbom_with_lock.json");
std::fs::write(&path_with_lock, sbom_with_lock).unwrap();
let lockfile = tc.path().join("Cargo.lock");
std::fs::remove_file(&lockfile).unwrap();
let sbom_without_lock = generate_sbom_for_testcase(
&tc,
BuildOptions {
locked: false,
..Default::default()
},
)
.unwrap();
let path_without_lock = tc.path().join("sbom_without_lock.json");
std::fs::write(&path_without_lock, sbom_without_lock).unwrap();
assert_sboms_ne(&path_with_lock, &path_without_lock);
}
}