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
15 changes: 11 additions & 4 deletions cargo-cyclonedx/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ Defaults to the host target, as printed by 'rustc -vV'"
/// The CycloneDX specification version to output: `1.3`, `1.4` or `1.5`. Defaults to 1.3
#[clap(long = "spec-version")]
pub spec_version: Option<SpecVersion>,

/// List only dependencies of kind normal (no build deps, no dev deps)
#[clap(name = "no-build-deps", long = "no-build-deps")]
pub no_build_deps: bool,
}

impl Args {
Expand Down Expand Up @@ -170,6 +174,7 @@ impl Args {

let describe = self.describe;
let spec_version = self.spec_version;
let only_normal_deps = Some(self.no_build_deps);

Ok(SbomConfig {
format: self.format,
Expand All @@ -180,6 +185,7 @@ impl Args {
license_parser,
describe,
spec_version,
only_normal_deps,
})
}
}
Expand All @@ -190,6 +196,11 @@ pub enum ArgsError {
FilenameOverrideError(#[from] FilenameOverrideError),
}

#[cfg(test)]
pub fn parse_to_config(args: &[&str]) -> SbomConfig {
Args::parse_from(args.iter()).as_config().unwrap()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -222,10 +233,6 @@ mod tests {
assert!(!contains_feature(&config, ""));
}

fn parse_to_config(args: &[&str]) -> SbomConfig {
Args::parse_from(args.iter()).as_config().unwrap()
}

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

impl SbomConfig {
Expand All @@ -57,6 +58,7 @@ impl SbomConfig {
.or_else(|| self.license_parser.clone()),
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),
}
}

Expand Down
141 changes: 125 additions & 16 deletions cargo-cyclonedx/src/generator.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::config::Describe;
use std::cmp::min;
use std::collections::HashSet;
/*
* This file is part of CycloneDX Rust Cargo.
*
Expand Down Expand Up @@ -68,6 +70,35 @@ use validator::validate_email;
// Maps from PackageId to Package for efficiency - faster lookups than in a Vec
type PackageMap = BTreeMap<PackageId, Package>;
type ResolveMap = BTreeMap<PackageId, Node>;
type DependencyKindMap = BTreeMap<PackageId, DependencyKind>;

/// The values are ordered from weakest to strongest so that casting to integer would make sense
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Hash)]
enum PrivateDepKind {
Development,
Build,
Runtime,
}
impl From<PrivateDepKind> for DependencyKind {
fn from(priv_kind: PrivateDepKind) -> Self {
match priv_kind {
PrivateDepKind::Development => DependencyKind::Development,
PrivateDepKind::Build => DependencyKind::Build,
PrivateDepKind::Runtime => DependencyKind::Normal,
}
}
}

impl From<&DependencyKind> for PrivateDepKind {
fn from(kind: &DependencyKind) -> Self {
match kind {
DependencyKind::Normal => PrivateDepKind::Runtime,
DependencyKind::Development => PrivateDepKind::Development,
DependencyKind::Build => PrivateDepKind::Build,
_ => panic!("Unknown dependency kind"),
}
}
}

pub struct SbomGenerator {
config: SbomConfig,
Expand Down Expand Up @@ -98,11 +129,13 @@ impl SbomGenerator {
for member in members.iter() {
log::trace!("Processing the package {}", member);

let dep_kinds = index_dep_kinds(member, &resolve);

let (dependencies, pruned_resolve) =
if config.included_dependencies() == IncludedDependencies::AllDependencies {
all_dependencies(member, &packages, &resolve)
all_dependencies(member, &packages, &resolve, config)
} else {
top_level_dependencies(member, &packages, &resolve)
top_level_dependencies(member, &packages, &resolve, config)
};

let manifest_path = packages[member].manifest_path.clone().into_std_path_buf();
Expand All @@ -128,7 +161,7 @@ impl SbomGenerator {
crate_hashes,
};
let (bom, target_kinds) =
generator.create_bom(member, &dependencies, &pruned_resolve)?;
generator.create_bom(member, &dependencies, &pruned_resolve, &dep_kinds)?;

let generated = GeneratedSbom {
bom,
Expand All @@ -149,14 +182,15 @@ impl SbomGenerator {
package: &PackageId,
packages: &PackageMap,
resolve: &ResolveMap,
dep_kinds: &DependencyKindMap,
) -> Result<(Bom, TargetKinds), GeneratorError> {
let mut bom = Bom::default();
let root_package = &packages[package];

let components: Vec<_> = packages
.values()
.filter(|p| &p.id != package)
.map(|component| self.create_component(component, root_package))
.map(|component| self.create_component(component, root_package, dep_kinds))
.collect();

bom.components = Some(Components(components));
Expand All @@ -170,7 +204,12 @@ impl SbomGenerator {
Ok((bom, target_kinds))
}

fn create_component(&self, package: &Package, root_package: &Package) -> Component {
fn create_component(
&self,
package: &Package,
root_package: &Package,
dep_kinds: &DependencyKindMap,
) -> Component {
let name = package.name.to_owned().trim().to_string();
let version = package.version.to_string();

Expand All @@ -190,7 +229,13 @@ impl SbomGenerator {
);

component.purl = purl;
component.scope = Some(Scope::Required);
component.scope = match dep_kinds
.get(&package.id)
.unwrap_or(&DependencyKind::Normal)
{
DependencyKind::Normal => Some(Scope::Required),
_ => Some(Scope::Excluded),
};
component.external_references = Self::get_external_references(package);
component.licenses = self.get_licenses(package);
component.hashes = self.get_hashes(package);
Expand All @@ -206,7 +251,7 @@ impl SbomGenerator {
/// Same as [Self::create_component] but also includes information
/// on binaries and libraries comprising it as subcomponents
fn create_toplevel_component(&self, package: &Package) -> (Component, TargetKinds) {
let mut top_component = self.create_component(package, package);
let mut top_component = self.create_component(package, package, &DependencyKindMap::new());
let mut subcomponents: Vec<Component> = Vec::new();
let mut target_kinds = HashMap::new();
for tgt in filter_targets(&package.targets) {
Expand Down Expand Up @@ -542,6 +587,57 @@ fn index_resolve(packages: Vec<Node>) -> ResolveMap {
.collect()
}

fn index_dep_kinds(root: &PackageId, resolve: &ResolveMap) -> DependencyKindMap {
// cache strongest found dependency kind for every node
let mut id_to_dep_kind: HashMap<PackageId, PrivateDepKind> = HashMap::new();
id_to_dep_kind.insert(root.clone(), PrivateDepKind::Runtime);

type DepNode = (PackageId, PrivateDepKind, PrivateDepKind);

let mut nodes_to_visit: Vec<DepNode> = vec![];
nodes_to_visit.push((
root.clone(),
PrivateDepKind::Runtime,
PrivateDepKind::Runtime,
));

let mut visited_nodes: HashSet<DepNode> = HashSet::new();

// perform a simple iterative DFS over the dependencies,
// mark child deps with the minimum of parent kind and their own strongest value
// therefore e.g. mark decendants of build dependencies as build dependencies,
// as long as they never occur as normal dependency.
while let Some((pkg_id, node_kind, path_node_kind)) = nodes_to_visit.pop() {
visited_nodes.insert((pkg_id.clone(), node_kind, path_node_kind));

let dep_kind_on_previous_visit = id_to_dep_kind.get(&pkg_id);
// insert/update a nodes dependency kind, when its new or stronger than the previous value
if dep_kind_on_previous_visit.is_none()
|| path_node_kind > *dep_kind_on_previous_visit.unwrap()
{
let _ = id_to_dep_kind.insert(pkg_id.clone(), path_node_kind);
}

let node = &resolve[&pkg_id];
for child_dep in &node.deps {
for dep_kind in &child_dep.dep_kinds {
let current_kind = PrivateDepKind::from(&dep_kind.kind);
let new_path_node_kind = min(current_kind, path_node_kind);

let dep_node: DepNode = (child_dep.pkg.clone(), current_kind, new_path_node_kind);
if !visited_nodes.contains(&dep_node) {
nodes_to_visit.push(dep_node);
}
}
}
}

id_to_dep_kind
.iter()
.map(|(x, y)| ((*x).clone(), DependencyKind::from(*y)))
.collect()
}

#[derive(Error, Debug)]
pub enum GeneratorError {
#[error("Expected a root package in the cargo config: {config_filepath}")]
Expand Down Expand Up @@ -584,13 +680,15 @@ fn top_level_dependencies(
root: &PackageId,
packages: &PackageMap,
resolve: &ResolveMap,
config: &SbomConfig,
) -> (PackageMap, ResolveMap) {
log::trace!("Adding top-level dependencies to SBOM");

// Only include packages that have dependency kinds other than "Development"
let root_node = strip_dev_dependencies(&resolve[root]);
let root_node = add_filtered_dependencies(&resolve[root], config);

let mut pkg_result = PackageMap::new();

// Record the root package, then its direct non-dev dependencies
pkg_result.insert(root.to_owned(), packages[root].to_owned());
for id in &root_node.dependencies {
Expand All @@ -615,6 +713,7 @@ fn all_dependencies(
root: &PackageId,
packages: &PackageMap,
resolve: &ResolveMap,
config: &SbomConfig,
) -> (PackageMap, ResolveMap) {
log::trace!("Adding all dependencies to SBOM");

Expand All @@ -635,9 +734,11 @@ fn all_dependencies(
// If we haven't processed this node yet...
if !out_resolve.contains_key(&node.id) {
// Add the node to the output
out_resolve.insert(node.id.to_owned(), strip_dev_dependencies(node));
out_resolve.insert(node.id.to_owned(), add_filtered_dependencies(node, config));
// Queue its dependencies for the next BFS loop iteration
next_queue.extend(non_dev_dependencies(&node.deps).map(|dep| &resolve[&dep.pkg]));
next_queue.extend(
filtered_dependencies(&node.deps, config).map(|dep| &resolve[&dep.pkg]),
);
}
}
std::mem::swap(&mut current_queue, &mut next_queue);
Expand All @@ -653,20 +754,27 @@ fn all_dependencies(
(out_packages, out_resolve)
}

fn strip_dev_dependencies(node: &Node) -> Node {
fn add_filtered_dependencies(node: &Node, config: &SbomConfig) -> Node {
let mut node = node.clone();
node.deps = non_dev_dependencies(&node.deps).cloned().collect();
node.deps = filtered_dependencies(&node.deps, config).cloned().collect();
node.dependencies = node.deps.iter().map(|d| d.pkg.to_owned()).collect();
node
}

/// Filters out dependencies only used for development, and not affecting the final binary.
/// These are specified under `[dev-dependencies]` in Cargo.toml.
fn non_dev_dependencies(input: &[NodeDep]) -> impl Iterator<Item = &NodeDep> {
fn filtered_dependencies<'a>(
input: &'a [NodeDep],
config: &'a SbomConfig,
) -> impl Iterator<Item = &'a NodeDep> {
input.iter().filter(|p| {
p.dep_kinds
.iter()
.any(|dep| dep.kind != DependencyKind::Development)
p.dep_kinds.iter().any(|dep| {
if let Some(true) = config.only_normal_deps {
dep.kind == DependencyKind::Normal
} else {
dep.kind != DependencyKind::Development
}
})
})
}

Expand All @@ -677,6 +785,7 @@ fn non_dev_dependencies(input: &[NodeDep]) -> impl Iterator<Item = &NodeDep> {
/// * `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`
#[derive(Debug)]
pub struct GeneratedSbom {
pub bom: Bom,
pub manifest_path: PathBuf,
Expand Down
Loading