Skip to content
Open
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
6 changes: 6 additions & 0 deletions cargo-cyclonedx/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- Added `--workspace-sboms` flag to control whether SBOMs are generated for every workspace member (`all`, default) or only for the manifest specified by `--manifest-path` or the current directory (`manifest-only`).

## 0.5.9 - 2026-03-19

### Added
Expand Down
6 changes: 6 additions & 0 deletions cargo-cyclonedx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ This produces a `bom.xml` file adjacent to every `Cargo.toml` file that exists i
- binaries: A separate SBOM is emitted for each binary (bin, cdylib) while all other targets are ignored
- all-cargo-targets: A separate SBOM is emitted for each Cargo target, including things that aren't directly executable (e.g rlib)

--workspace-sboms <WORKSPACE_SBOMS>
Which workspace members to include when generating SBOMs
Possible values:
- all: Generate an SBOM for every workspace member (default)
- manifest-only: Generate an SBOM only for the manifest specified by `--manifest-path` or the current directory

-v, --verbose...
Use verbose output (-vv for debug logging, -vvv for tracing)

Expand Down
29 changes: 28 additions & 1 deletion cargo-cyclonedx/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use cargo_cyclonedx::{
config::{
Describe, Features, FilenameOverride, FilenameOverrideError, FilenamePattern,
IncludedDependencies, LicenseParserOptions, OutputOptions, ParseMode, PlatformSuffix,
SbomConfig, Target,
SbomConfig, Target, WorkspaceSboms,
},
format::Format,
};
Expand Down Expand Up @@ -42,6 +42,10 @@ pub struct Args {
#[clap(long = "describe")]
pub describe: Option<Describe>,

// the ValueEnum derive provides ample help text
#[clap(long = "workspace-sboms")]
pub workspace_sboms: Option<WorkspaceSboms>,

/// Use verbose output (-vv for debug logging, -vvv for tracing)
#[clap(long = "verbose", short = 'v', action = clap::ArgAction::Count)]
pub verbose: u8,
Expand Down Expand Up @@ -182,6 +186,7 @@ impl Args {
let describe = self.describe;
let spec_version = self.spec_version;
let only_normal_deps = Some(self.no_build_deps);
let workspace_sboms = self.workspace_sboms;

Ok(SbomConfig {
format: self.format,
Expand All @@ -193,6 +198,7 @@ impl Args {
describe,
spec_version,
only_normal_deps,
workspace_sboms,
})
}
}
Expand Down Expand Up @@ -240,6 +246,27 @@ mod tests {
assert!(!contains_feature(&config, ""));
}

#[test]
fn parse_workspace_sboms() {
let args = vec!["cyclonedx"];
let config = parse_to_config(&args);
assert!(config.workspace_sboms.is_none());

let args = vec!["cyclonedx", "--workspace-sboms=all"];
let config = parse_to_config(&args);
assert_eq!(
config.workspace_sboms,
Some(WorkspaceSboms::All)
);

let args = vec!["cyclonedx", "--workspace-sboms=manifest-only"];
let config = parse_to_config(&args);
assert_eq!(
config.workspace_sboms,
Some(WorkspaceSboms::ManifestOnly)
);
}

fn contains_feature(config: &SbomConfig, feature: &str) -> bool {
config
.features
Expand Down
17 changes: 17 additions & 0 deletions cargo-cyclonedx/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub struct SbomConfig {
pub describe: Option<Describe>,
pub spec_version: Option<SpecVersion>,
pub only_normal_deps: Option<bool>,
pub workspace_sboms: Option<WorkspaceSboms>,
}

impl SbomConfig {
Expand All @@ -60,6 +61,7 @@ impl SbomConfig {
describe: other.describe.or(self.describe),
spec_version: other.spec_version.or(self.spec_version),
only_normal_deps: other.only_normal_deps.or(self.only_normal_deps),
workspace_sboms: other.workspace_sboms.or(self.workspace_sboms),
}
}

Expand Down Expand Up @@ -94,6 +96,10 @@ impl SbomConfig {
pub fn license_parser(&self) -> LicenseParserOptions {
self.license_parser.clone().unwrap_or_default()
}

pub fn workspace_sboms(&self) -> WorkspaceSboms {
self.workspace_sboms.unwrap_or_default()
}
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -250,6 +256,17 @@ pub enum ParseMode {
Lax,
}

/// Which workspace members to include when generating SBOMs.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum WorkspaceSboms {
/// Generate an SBOM for every workspace member (default)
#[default]
All,
/// Generate an SBOM only for the manifest specified by `--manifest-path` or the current directory
#[value(name = "manifest-only")]
ManifestOnly,
}

/// What does the SBOM describe?
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum Describe {
Expand Down
29 changes: 27 additions & 2 deletions cargo-cyclonedx/src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use std::collections::HashSet;
use crate::config::FilenamePattern;
use crate::config::PlatformSuffix;
use crate::config::SbomConfig;
use crate::config::WorkspaceSboms;
use crate::config::{IncludedDependencies, ParseMode};
use crate::format::Format;
use crate::purl::get_purl;
Expand Down Expand Up @@ -119,15 +120,24 @@ pub struct TargetKinds(
impl SbomGenerator {
pub fn create_sboms(
meta: CargoMetadata,
root_manifest_path: &Path,
config: &SbomConfig,
) -> Result<Vec<GeneratedSbom>, GeneratorError> {
log::trace!("Processing the workspace {}", meta.workspace_root);
let members: Vec<PackageId> = meta.workspace_members;
let packages = index_packages(meta.packages);
let resolve = index_resolve(meta.resolve.unwrap().nodes);
let root_manifest_path = absolute_path(root_manifest_path);
let manifest_only = config.workspace_sboms() == WorkspaceSboms::ManifestOnly;

let mut result = Vec::with_capacity(members.len());
for member in members.iter() {
let manifest_path = packages[member].manifest_path.clone().into_std_path_buf();

if manifest_only && absolute_path(&manifest_path) != root_manifest_path {
continue;
}

log::trace!("Processing the package {}", member);

let dep_kinds = index_dep_kinds(member, &resolve);
Expand All @@ -139,8 +149,6 @@ impl SbomGenerator {
top_level_dependencies(member, &packages, &resolve, config)
};

let manifest_path = packages[member].manifest_path.clone().into_std_path_buf();

let mut crate_hashes = HashMap::new();
match locate_cargo_lock(&manifest_path) {
Ok(path) => match Lockfile::load(path) {
Expand Down Expand Up @@ -175,6 +183,12 @@ impl SbomGenerator {
result.push(generated);
}

if manifest_only && result.is_empty() {
return Err(GeneratorError::NoMatchingWorkspaceMember {
manifest_path: root_manifest_path.display().to_string(),
});
}

Ok(result)
}

Expand Down Expand Up @@ -702,6 +716,12 @@ pub enum GeneratorError {

#[error("Could not parse author string: {}", .0)]
AuthorParseError(String),

#[error(
"No workspace member matches manifest path {manifest_path}. \
Point --manifest-path at a crate's Cargo.toml, or use --workspace-sboms all."
)]
NoMatchingWorkspaceMember { manifest_path: String },
}

/// Generates the `Dependencies` field in the final SBOM
Expand Down Expand Up @@ -996,6 +1016,11 @@ impl GeneratedSbom {
}
}

/// Normalizes a path for comparison without resolving symlinks, matching `locate_manifest`.
fn absolute_path(path: &Path) -> PathBuf {
std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
}

/// Locates the corresponding `Cargo.lock` file given the location of `Cargo.toml`.
/// This must be run **after** `cargo metadata` which will generate the `Cargo.lock` file
/// and make sure it's up to date.
Expand Down
122 changes: 111 additions & 11 deletions cargo-cyclonedx/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ fn generate_sboms(args: &Args) -> Result<Vec<GeneratedSbom>> {
log::trace!("Running `cargo metadata` finished");

log::trace!("SBOM generation started");
let boms = SbomGenerator::create_sboms(metadata, &config)?;
let boms = SbomGenerator::create_sboms(metadata, &manifest_path, &config)?;
log::trace!("SBOM generation finished");

Ok(boms)
Expand Down Expand Up @@ -193,18 +193,35 @@ fn get_metadata(
#[cfg(test)]
mod tests {
use cyclonedx_bom::prelude::NormalizedString;
use std::path::{Path, PathBuf};

fn fixture_manifest(crate_path: &[&str]) -> PathBuf {
let mut manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest.push("tests/fixtures/build_then_runtime_dep");
for segment in crate_path {
manifest.push(segment);
}
manifest.push("Cargo.toml");
manifest
}

fn workspace_member_count(manifest: &Path) -> usize {
cargo_metadata::MetadataCommand::new()
.manifest_path(manifest)
.no_deps()
.exec()
.expect("fixture manifest should be valid for cargo metadata")
.workspace_members
.len()
}

#[test]
fn parse_toml_only_normal() {
use crate::cli;
use crate::generate_sboms;
use clap::Parser;
use cyclonedx_bom::models::component::Scope;
use std::path::PathBuf;

let mut test_cargo_toml = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
test_cargo_toml.push("tests/fixtures/build_then_runtime_dep/Cargo.toml");

let test_cargo_toml = fixture_manifest(&[]);
let path_arg = &format!("--manifest-path={}", test_cargo_toml.display());
let args = ["cyclonedx", path_arg, "--no-build-deps"];
let args_parsed = cli::Args::parse_from(args.iter());
Expand All @@ -215,7 +232,7 @@ mod tests {
assert!(components
.0
.iter()
.all(|f| f.scope == Some(Scope::Required)));
.all(|f| f.scope == Some(cyclonedx_bom::models::component::Scope::Required)));
}

#[test]
Expand All @@ -224,11 +241,8 @@ mod tests {
use crate::generate_sboms;
use clap::Parser;
use cyclonedx_bom::models::component::Scope;
use std::path::PathBuf;

let mut test_cargo_toml = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
test_cargo_toml.push("tests/fixtures/build_then_runtime_dep/Cargo.toml");

let test_cargo_toml = fixture_manifest(&[]);
let path_arg = &format!("--manifest-path={}", test_cargo_toml.display());
let args = ["cyclonedx", path_arg];
let args_parsed = cli::Args::parse_from(args.iter());
Expand All @@ -247,4 +261,90 @@ mod tests {
!= NormalizedString::new("runtime_dep_of_build_dep")
|| c.scope == Some(Scope::Excluded)));
}

#[test]
fn generate_all_workspace_sboms_by_default() {
use crate::cli;
use crate::generate_sboms;
use clap::Parser;

let workspace_manifest = fixture_manifest(&[]);
let expected_member_count = workspace_member_count(&workspace_manifest);
assert!(
expected_member_count > 1,
"fixture should be a multi-member workspace"
);

let path_arg = &format!("--manifest-path={}", workspace_manifest.display());
let args = ["cyclonedx", path_arg];
let args_parsed = cli::Args::parse_from(args.iter());

let sboms = generate_sboms(&args_parsed).unwrap();

assert_eq!(sboms.len(), expected_member_count);
}

#[test]
fn member_manifest_still_generates_all_by_default() {
use crate::cli;
use crate::generate_sboms;
use clap::Parser;

let workspace_manifest = fixture_manifest(&[]);
let member_manifest = fixture_manifest(&["top_level_crate"]);
let expected_member_count = workspace_member_count(&workspace_manifest);

let path_arg = &format!("--manifest-path={}", member_manifest.display());
let args = ["cyclonedx", path_arg];
let args_parsed = cli::Args::parse_from(args.iter());

let sboms = generate_sboms(&args_parsed).unwrap();

assert_eq!(sboms.len(), expected_member_count);
}

#[test]
fn generate_manifest_only_workspace_sbom() {
use crate::cli;
use crate::generate_sboms;
use clap::Parser;

let member_manifest = fixture_manifest(&["top_level_crate"]);

let path_arg = &format!("--manifest-path={}", member_manifest.display());
let args = ["cyclonedx", path_arg, "--workspace-sboms=manifest-only"];
let args_parsed = cli::Args::parse_from(args.iter());

let sboms = generate_sboms(&args_parsed).unwrap();

assert_eq!(sboms.len(), 1);
assert_eq!(sboms[0].package_name, "top_level_crate");
}

#[test]
fn manifest_only_errors_on_virtual_workspace_root() {
use crate::cli;
use crate::generate_sboms;
use clap::Parser;

let workspace_manifest = fixture_manifest(&[]);
let path_arg = &format!("--manifest-path={}", workspace_manifest.display());
let args = [
"cyclonedx",
path_arg,
"--workspace-sboms=manifest-only",
];
let args_parsed = cli::Args::parse_from(args.iter());

let error = generate_sboms(&args_parsed).expect_err(
"virtual workspace root manifest should not match any workspace member",
);

assert!(
error
.to_string()
.contains("No workspace member matches manifest path"),
"unexpected error: {error}"
);
}
}
Loading