diff --git a/cargo-cyclonedx/src/cli.rs b/cargo-cyclonedx/src/cli.rs index ed17dd87..625bcd3c 100644 --- a/cargo-cyclonedx/src/cli.rs +++ b/cargo-cyclonedx/src/cli.rs @@ -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", diff --git a/cargo-cyclonedx/src/config.rs b/cargo-cyclonedx/src/config.rs index 8b6119ad..c76fda32 100644 --- a/cargo-cyclonedx/src/config.rs +++ b/cargo-cyclonedx/src/config.rs @@ -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 { @@ -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)), } } diff --git a/cargo-cyclonedx/src/generator.rs b/cargo-cyclonedx/src/generator.rs index 57e49900..8ba2c862 100644 --- a/cargo-cyclonedx/src/generator.rs +++ b/cargo-cyclonedx/src/generator.rs @@ -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; @@ -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> = 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); @@ -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 = 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 @@ -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 { + targets.iter().filter(|tgt| { + !(tgt.is_bench() || tgt.is_example() || tgt.is_test() || tgt.is_custom_build()) + }) +} + fn index_packages(packages: Vec) -> PackageMap { packages .into_iter() @@ -620,29 +627,58 @@ fn non_dev_dependencies(input: &[NodeDep]) -> impl Iterator { /// * `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>, } 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)?; } } @@ -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], + pattern: Pattern, + ) -> impl Iterator)> + '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 => { @@ -670,8 +761,9 @@ impl GeneratedSbom { }; format!( - "{}{}{}.{}", + "{}{}{}{}.{}", prefix, + target_kind_suffix, platform_suffix, output_options.cdx_extension.extension(), self.sbom_config.format() diff --git a/cargo-cyclonedx/src/main.rs b/cargo-cyclonedx/src/main.rs index 5c652b48..4f6e9611 100644 --- a/cargo-cyclonedx/src/main.rs +++ b/cargo-cyclonedx/src/main.rs @@ -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");