Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cargo-cyclonedx/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Defaults to the host target, as printed by 'rustc -vV'"
#[clap(long = "output-cdx")]
pub output_cdx: bool,

/// Prefix patterns to use for the filename: bom, package
/// Prefix patterns to use for the filename: bom, package, binary, cargo-target
/// Values other than 'bom' imply --output-cdx
#[clap(
name = "output-pattern",
Expand Down
6 changes: 6 additions & 0 deletions cargo-cyclonedx/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ pub enum Pattern {
#[default]
Bom,
Package,
Binary,
/// Not to be confused with a compilation target:
/// https://doc.rust-lang.org/cargo/reference/cargo-targets.html
CargoTarget,
}

impl FromStr for Pattern {
Expand All @@ -173,6 +177,8 @@ impl FromStr for Pattern {
match s {
"bom" => Ok(Self::Bom),
"package" => Ok(Self::Package),
"binary" => Ok(Self::Binary),
"cargo-target" => Ok(Self::CargoTarget),
_ => Err(format!("Expected bom or package, got `{}`", s)),
}
}
Expand Down
246 changes: 169 additions & 77 deletions cargo-cyclonedx/src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ use std::convert::TryFrom;
use std::fs::File;
use std::io::BufWriter;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
use validator::validate_email;
Expand Down Expand Up @@ -98,18 +99,20 @@ impl SbomGenerator {
};
let bom = generator.create_bom(member, &dependencies, &pruned_resolve)?;

if cfg!(debug_assertions) {
let result = bom.validate();
if let ValidationResult::Failed { reasons } = result {
panic!("The generated SBOM failed validation: {:?}", &reasons);
}
}
// Figure out the types of the various produced artifacts.
// This is additional information on top of the SBOM structure
// that is used to implement emitting a separate SBOM for each binary or artifact.
let root_package = &packages[member];
let target_kinds: Vec<Vec<String>> = filter_targets(&root_package.targets)
.map(|tgt| tgt.kind.clone())
.collect();

let generated = GeneratedSbom {
bom,
manifest_path: packages[member].manifest_path.clone().into_std_path_buf(),
package_name: packages[member].name.clone(),
sbom_config: generator.config,
target_kinds,
};

result.push(generated);
Expand Down Expand Up @@ -181,71 +184,65 @@ impl SbomGenerator {
fn create_toplevel_component(&self, package: &Package) -> Component {
let mut top_component = self.create_component(package, package);
let mut subcomponents: Vec<Component> = Vec::new();
let mut subcomp_count: u32 = 0;
for tgt in &package.targets {
// Ignore tests, benches, examples and build scripts.
// They are not part of the final build artifacts, which is what we are after.
if !(tgt.is_bench() || tgt.is_example() || tgt.is_test() || tgt.is_custom_build()) {
// classification
#[allow(clippy::if_same_then_else)]
let cdx_type = if tgt.is_bin() {
Classification::Application
// sadly no .is_proc_macro() yet
} else if tgt.kind.iter().any(|kind| kind == "proc-macro") {
// There isn't a better way to express it with CycloneDX types
Classification::Library
} else if tgt.kind.iter().any(|kind| kind.contains("lib")) {
Classification::Library
} else {
log::warn!(
"Target {} is neither a binary nor a library! Kinds: {}",
tgt.name,
tgt.kind.join(", ")
);
continue;
};

// bom_ref
let bom_ref = format!(
"{} bin-target-{}",
top_component.bom_ref.as_ref().unwrap(),
subcomp_count
);
subcomp_count += 1;

// create the subcomponent
let mut subcomponent = Component::new(
cdx_type,
&tgt.name,
&package.version.to_string(),
Some(bom_ref),
for tgt in filter_targets(&package.targets) {
// classification
#[allow(clippy::if_same_then_else)]
let cdx_type = if tgt.is_bin() {
Classification::Application
// sadly no .is_proc_macro() yet
} else if tgt.kind.iter().any(|kind| kind == "proc-macro") {
// There isn't a better way to express it with CycloneDX types
Classification::Library
} else if tgt.kind.iter().any(|kind| kind.contains("lib")) {
Classification::Library
} else {
log::warn!(
"Target {} is neither a binary nor a library! Kinds: {}",
tgt.name,
tgt.kind.join(", ")
);
continue;
};

// PURL subpaths are computed relative to the directory with the `Cargo.toml`
// *for this specific package*, not the workspace root.
// This is done because the tarball uploaded to crates.io only contains the package,
// not the workspace, so paths resolved relatively to the workspace root would not be valid.
//
// When using a git repo that contains a workspace, Cargo will automatically select
// the right package out of the workspace. Paths can then be resolved relatively to it.
// So the information we encode here is sufficient to idenfity the file in git too.
let package_dir = package
.manifest_path
.parent()
.expect("manifest_path in `cargo metadata` output is not a file!");
if let Ok(relative_path) = tgt.src_path.strip_prefix(package_dir) {
subcomponent.purl =
get_purl(package, package, &self.workspace_root, Some(relative_path)).ok();
} else {
log::warn!(
"Source path \"{}\" is not a subpath of workspace root \"{}\"",
tgt.src_path,
self.workspace_root
);
}
// bom_ref
let bom_ref = format!(
"{} bin-target-{}",
top_component.bom_ref.as_ref().unwrap(),
subcomponents.len(), // numbers the components
);

subcomponents.push(subcomponent);
// create the subcomponent
let mut subcomponent = Component::new(
cdx_type,
&tgt.name,
&package.version.to_string(),
Some(bom_ref),
);

// PURL subpaths are computed relative to the directory with the `Cargo.toml`
// *for this specific package*, not the workspace root.
// This is done because the tarball uploaded to crates.io only contains the package,
// not the workspace, so paths resolved relatively to the workspace root would not be valid.
//
// When using a git repo that contains a workspace, Cargo will automatically select
// the right package out of the workspace. Paths can then be resolved relatively to it.
// So the information we encode here is sufficient to idenfity the file in git too.
let package_dir = package
.manifest_path
.parent()
.expect("manifest_path in `cargo metadata` output is not a file!");
if let Ok(relative_path) = tgt.src_path.strip_prefix(package_dir) {
subcomponent.purl =
get_purl(package, package, &self.workspace_root, Some(relative_path)).ok();
} else {
log::warn!(
"Source path \"{}\" is not a subpath of workspace root \"{}\"",
tgt.src_path,
self.workspace_root
);
}

subcomponents.push(subcomponent);
}
top_component.components = Some(Components(subcomponents));
top_component
Expand Down Expand Up @@ -472,6 +469,16 @@ impl SbomGenerator {
}
}

/// Ignore tests, benches, examples and build scripts.
/// They are not part of the final build artifacts, which is what we are after.
fn filter_targets(
targets: &[cargo_metadata::Target],
) -> impl Iterator<Item = &cargo_metadata::Target> {
targets.iter().filter(|tgt| {
!(tgt.is_bench() || tgt.is_example() || tgt.is_test() || tgt.is_custom_build())
})
}

fn index_packages(packages: Vec<Package>) -> PackageMap {
packages
.into_iter()
Expand Down Expand Up @@ -620,29 +627,58 @@ fn non_dev_dependencies(input: &[NodeDep]) -> impl Iterator<Item = &NodeDep> {
/// * `manifest_path` - Folder containing the `Cargo.toml` manifest
/// * `package_name` - Package from which this SBOM was generated
/// * `sbom_config` - Configuration options used during generation
/// * `target_kinds` - Detailed information on the kinds of targets in `sbom`
pub struct GeneratedSbom {
pub bom: Bom,
pub manifest_path: PathBuf,
pub package_name: String,
pub sbom_config: SbomConfig,
pub target_kinds: Vec<Vec<String>>,
}

impl GeneratedSbom {
/// Writes SBOM to either a JSON or XML file in the same folder as `Cargo.toml` manifest
pub fn write_to_file(self) -> Result<(), SbomWriterError> {
let path = self.manifest_path.with_file_name(self.filename());
pub fn write_to_files(self) -> Result<(), SbomWriterError> {
match self.sbom_config.output_options().prefix {
Prefix::Pattern(Pattern::Bom | Pattern::Package) | Prefix::Custom(_) => {
let path = self.manifest_path.with_file_name(self.filename(None, &[]));
Self::write_to_file(self.bom, &path, &self.sbom_config)
}
Prefix::Pattern(pattern @ (Pattern::Binary | Pattern::CargoTarget)) => {
for (sbom, target_kind) in
Self::per_artifact_sboms(&self.bom, &self.target_kinds, pattern)
{
let meta = sbom.metadata.as_ref().unwrap();
let name = meta.component.as_ref().unwrap().name.as_ref();
let path = self
.manifest_path
.with_file_name(self.filename(Some(name), &target_kind));
Self::write_to_file(sbom, &path, &self.sbom_config)?;
}
Ok(())
}
}
}

fn write_to_file(bom: Bom, path: &Path, config: &SbomConfig) -> Result<(), SbomWriterError> {
// If running in debug mode, validate that the SBOM is self-consistent and well-formed
if cfg!(debug_assertions) {
let result = bom.validate();
if let ValidationResult::Failed { reasons } = result {
panic!("The generated SBOM failed validation: {:?}", &reasons);
}
}

log::info!("Outputting {}", path.display());
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
match self.sbom_config.format() {
match config.format() {
Format::Json => {
self.bom
.output_as_json_v1_3(&mut writer)
bom.output_as_json_v1_3(&mut writer)
.map_err(SbomWriterError::JsonWriteError)?;
}
Format::Xml => {
self.bom
.output_as_xml_v1_3(&mut writer)
bom.output_as_xml_v1_3(&mut writer)
.map_err(SbomWriterError::XmlWriteError)?;
}
}
Expand All @@ -653,14 +689,69 @@ impl GeneratedSbom {
Ok(())
}

fn filename(&self) -> String {
/// Returns an iterator over SBOMs and their associated target kinds
fn per_artifact_sboms<'a>(
bom: &'a Bom,
target_kinds: &'a [Vec<String>],
pattern: Pattern,
) -> impl Iterator<Item = (Bom, Vec<String>)> + 'a {
let meta = bom.metadata.as_ref().unwrap();
let crate_component = meta.component.as_ref().unwrap();
let components = crate_component.components.as_ref().unwrap();
// Narrow down the set of targets for which we emit a SBOM depending on the configuration
components
.0
.iter()
.zip(target_kinds.iter())
.filter(move |(_component, target_kind)| {
match pattern {
Pattern::Binary => {
// only record binary artifacts
// TODO: refactor this to use an enum, coming Soon(tm) to cargo-metadata:
// https://github.com/oli-obk/cargo_metadata/pull/258
target_kind.contains(&"bin".to_owned())
|| target_kind.contains(&"cdylib".to_owned())
}
Pattern::CargoTarget => true, // pass everything through
Pattern::Bom | Pattern::Package => unreachable!(),
}
})
.map(|(component, target_kind)| {
// In the original SBOM the toplevel component describes a crate.
// We need to change it to describe a specific binary.
// Most properties apply to the entire package and should be kept;
// we just need to update the name, type and purl.
let mut new_bom = bom.clone();
let metadata = new_bom.metadata.as_mut().unwrap();
let toplevel_component = metadata.component.as_mut().unwrap();
toplevel_component.name = component.name.clone();
toplevel_component.component_type = component.component_type.clone();
toplevel_component.purl = component.purl.clone();

(new_bom, target_kind.clone())
})
}

fn filename(&self, binary_name: Option<&str>, target_kind: &[String]) -> String {
let output_options = self.sbom_config.output_options();
let prefix = match output_options.prefix {
let prefix = match &output_options.prefix {
Prefix::Pattern(Pattern::Bom) => "bom".to_string(),
Prefix::Pattern(Pattern::Package) => self.package_name.clone(),
Prefix::Pattern(Pattern::Binary) => binary_name.unwrap().to_owned(),
Prefix::Pattern(Pattern::CargoTarget) => binary_name.unwrap().to_owned(),
Prefix::Custom(c) => c.to_string(),
};

let target_kind_suffix = if !target_kind.is_empty() {
debug_assert!(matches!(
&output_options.prefix,
Prefix::Pattern(Pattern::Binary | Pattern::CargoTarget)
));
format!("_{}", target_kind.join("-"))
} else {
"".to_owned()
};

let platform_suffix = match output_options.platform_suffix {
PlatformSuffix::NotIncluded => "".to_owned(),
PlatformSuffix::Included => {
Expand All @@ -670,8 +761,9 @@ impl GeneratedSbom {
};

format!(
"{}{}{}.{}",
"{}{}{}{}.{}",
prefix,
target_kind_suffix,
platform_suffix,
output_options.cdx_extension.extension(),
self.sbom_config.format()
Expand Down
2 changes: 1 addition & 1 deletion cargo-cyclonedx/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ fn main() -> anyhow::Result<()> {

log::trace!("SBOM output started");
for bom in boms {
bom.write_to_file()?;
bom.write_to_files()?;
}
log::trace!("SBOM output finished");

Expand Down