Skip to content

Commit 8ef5005

Browse files
nissessenapclaude
andcommitted
refactor: decide once which resolve edges cargo builds
The activation check ran per workspace member, per edge, per dep kind, and each call linearly scanned the parent's `[dependencies]` with a semver comparison per candidate - `filtered_dependencies` twice per node and `index_dep_kinds` re-walking the graph for every member. Whether cargo builds an edge does not depend on which member is being processed, so work it out once for the whole graph instead: `ActivationMap` becomes `BuiltEdges`, a set of `(parent, child, kind)`, and the call sites drop to a hash lookup. `packages` and the nested manifest lookups fall out of `index_dep_kinds`, `filtered_dependencies` and `add_filtered_dependencies`. `is_built` also narrows its candidate manifest entries by the name cargo uses for the edge - the rename, or the library target name - and not only by the version requirement. Two entries for the same crate under different renames were previously indistinguishable whenever the version requirement ruled out both, so an edge could be kept because its sibling was activated. Neither narrowing is trusted to be exhaustive: one that would leave no candidate at all is skipped, so the check still fails open. Output is unchanged on this workspace and on four adversarial ones, under `--all`, `--top-level`, `--all-features`, `--no-default-features` and `--target all`. Also fixes the ordering of the `[#766]` link reference, and ignores the `Cargo.lock` that `cargo test` regenerates under `cyclonedx-bom-macros/tests/deps/`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 84e78f9 commit 8ef5005

3 files changed

Lines changed: 100 additions & 81 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
**/*.cdx.json
1111
!cyclonedx-bom/tests/examples/**/*.cdx.json
1212

13+
# Regenerated by `cargo test`
14+
/cyclonedx-bom-macros/tests/deps/Cargo.lock
15+
1316
# Nix Flake
1417
/.direnv/
1518
/result

cargo-cyclonedx/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
188188
[#746]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/746
189189
[#755]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/755
190190
[#762]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/762
191+
[#766]: https://github.com/CycloneDX/cyclonedx-rust-cargo/issues/766
191192
[#770]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/770
192193
[#772]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/772
193194
[#808]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/808
@@ -199,7 +200,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
199200
[#847]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/847
200201
[#848]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/848
201202
[#849]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/849
202-
[#766]: https://github.com/CycloneDX/cyclonedx-rust-cargo/issues/766
203203
[#852]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/852
204204
[#853]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/853
205205
[#856]: https://github.com/CycloneDX/cyclonedx-rust-cargo/pull/853

cargo-cyclonedx/src/generator.rs

Lines changed: 96 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,11 @@ use validator::ValidateEmail;
7474
type PackageMap = BTreeMap<PackageId, Package>;
7575
type ResolveMap = BTreeMap<PackageId, Node>;
7676
type DependencyKindMap = BTreeMap<PackageId, DependencyKind>;
77-
/// Which optional dependencies each package's enabled features activate,
78-
/// keyed on the name feature syntax refers to them by. Worked out once per
79-
/// `cargo metadata` invocation because it does not vary by workspace member.
80-
type ActivationMap<'a> = HashMap<&'a PackageId, HashSet<&'a str>>;
77+
/// The edges of the resolve graph that cargo actually builds, as
78+
/// `(parent, child, kind)`. Optional dependencies that no enabled feature
79+
/// activates are absent. Worked out once per `cargo metadata` invocation
80+
/// because it does not vary by workspace member.
81+
type BuiltEdges<'a> = HashSet<(&'a PackageId, &'a PackageId, DependencyKind)>;
8182

8283
/// The values are ordered from weakest to strongest so that casting to integer would make sense
8384
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Hash)]
@@ -132,19 +133,19 @@ impl SbomGenerator {
132133
let packages = index_packages(meta.packages);
133134
let resolve = index_resolve(meta.resolve.unwrap().nodes);
134135

135-
let activated = index_activated_dependencies(&packages, &resolve);
136+
let built = index_built_edges(&packages, &resolve);
136137

137138
let mut result = Vec::with_capacity(members.len());
138139
for member in members.iter() {
139140
log::trace!("Processing the package {}", member);
140141

141-
let dep_kinds = index_dep_kinds(member, &packages, &resolve, &activated);
142+
let dep_kinds = index_dep_kinds(member, &resolve, &built);
142143

143144
let (dependencies, pruned_resolve) =
144145
if config.included_dependencies() == IncludedDependencies::AllDependencies {
145-
all_dependencies(member, &packages, &resolve, &activated, config)
146+
all_dependencies(member, &packages, &resolve, &built, config)
146147
} else {
147-
top_level_dependencies(member, &packages, &resolve, &activated, config)
148+
top_level_dependencies(member, &packages, &resolve, &built, config)
148149
};
149150

150151
let manifest_path = packages[member].manifest_path.clone().into_std_path_buf();
@@ -637,9 +638,8 @@ fn index_resolve(packages: Vec<Node>) -> ResolveMap {
637638

638639
fn index_dep_kinds(
639640
root: &PackageId,
640-
packages: &PackageMap,
641641
resolve: &ResolveMap,
642-
activated: &ActivationMap,
642+
built: &BuiltEdges,
643643
) -> DependencyKindMap {
644644
// cache strongest found dependency kind for every node
645645
let mut id_to_dep_kind: HashMap<PackageId, PrivateDepKind> = HashMap::new();
@@ -672,19 +672,15 @@ fn index_dep_kinds(
672672
}
673673

674674
let node = &resolve[&pkg_id];
675-
let parent = packages.get(&pkg_id).zip(activated.get(&pkg_id));
676675
for child_dep in &node.deps {
677-
let child = packages.get(&child_dep.pkg);
678676
for dep_kind in &child_dep.dep_kinds {
679677
// Unlike `filtered_dependencies` this walk deliberately keeps
680678
// dev- and build-dependencies, since their whole purpose here is
681679
// to mark components as `Excluded`. Optional dependencies that
682680
// were never activated are still skipped: an edge that is not
683681
// built must not raise the scope of a package reached elsewhere.
684-
if let (Some((parent, activated)), Some(child)) = (parent, child) {
685-
if !is_activated(parent, child, dep_kind, activated) {
686-
continue;
687-
}
682+
if !built.contains(&(&pkg_id, &child_dep.pkg, dep_kind.kind)) {
683+
continue;
688684
}
689685
let current_kind = PrivateDepKind::from(&dep_kind.kind);
690686
let new_path_node_kind = min(current_kind, path_node_kind);
@@ -745,13 +741,13 @@ fn top_level_dependencies(
745741
root: &PackageId,
746742
packages: &PackageMap,
747743
resolve: &ResolveMap,
748-
activated: &ActivationMap,
744+
built: &BuiltEdges,
749745
config: &SbomConfig,
750746
) -> (PackageMap, ResolveMap) {
751747
log::trace!("Adding top-level dependencies to SBOM");
752748

753749
// Only include packages that have dependency kinds other than "Development"
754-
let root_node = add_filtered_dependencies(&resolve[root], packages, activated, config);
750+
let root_node = add_filtered_dependencies(&resolve[root], built, config);
755751

756752
let mut pkg_result = PackageMap::new();
757753

@@ -779,7 +775,7 @@ fn all_dependencies(
779775
root: &PackageId,
780776
packages: &PackageMap,
781777
resolve: &ResolveMap,
782-
activated: &ActivationMap,
778+
built: &BuiltEdges,
783779
config: &SbomConfig,
784780
) -> (PackageMap, ResolveMap) {
785781
log::trace!("Adding all dependencies to SBOM");
@@ -803,12 +799,11 @@ fn all_dependencies(
803799
// Add the node to the output
804800
out_resolve.insert(
805801
node.id.to_owned(),
806-
add_filtered_dependencies(node, packages, activated, config),
802+
add_filtered_dependencies(node, built, config),
807803
);
808804
// Queue its dependencies for the next BFS loop iteration
809805
next_queue.extend(
810-
filtered_dependencies(node, packages, activated, config)
811-
.map(|dep| &resolve[&dep.pkg]),
806+
filtered_dependencies(node, built, config).map(|dep| &resolve[&dep.pkg]),
812807
);
813808
}
814809
}
@@ -825,14 +820,9 @@ fn all_dependencies(
825820
(out_packages, out_resolve)
826821
}
827822

828-
fn add_filtered_dependencies(
829-
node: &Node,
830-
packages: &PackageMap,
831-
activated: &ActivationMap,
832-
config: &SbomConfig,
833-
) -> Node {
823+
fn add_filtered_dependencies(node: &Node, built: &BuiltEdges, config: &SbomConfig) -> Node {
834824
let mut node_copy = node.clone();
835-
node_copy.deps = filtered_dependencies(node, packages, activated, config)
825+
node_copy.deps = filtered_dependencies(node, built, config)
836826
.cloned()
837827
.collect();
838828
node_copy.dependencies = node_copy.deps.iter().map(|d| d.pkg.to_owned()).collect();
@@ -852,43 +842,46 @@ fn add_filtered_dependencies(
852842
/// <https://github.com/CycloneDX/cyclonedx-rust-cargo/issues/766>.
853843
fn filtered_dependencies<'a>(
854844
node: &'a Node,
855-
packages: &'a PackageMap,
856-
activated: &'a ActivationMap,
845+
built: &'a BuiltEdges<'a>,
857846
config: &'a SbomConfig,
858847
) -> impl Iterator<Item = &'a NodeDep> {
859-
let parent = packages.get(&node.id).zip(activated.get(&node.id));
860-
861848
node.deps.iter().filter(move |edge| {
862849
edge.dep_kinds.iter().any(|dep_kind| {
863850
included_kind(dep_kind.kind, config)
864-
&& match (parent, packages.get(&edge.pkg)) {
865-
(Some((parent, activated)), Some(child)) => {
866-
is_activated(parent, child, dep_kind, activated)
867-
}
868-
// Without both manifests there is nothing to check against,
869-
// so keep the edge rather than risk dropping a real dependency.
870-
_ => true,
871-
}
851+
&& built.contains(&(&node.id, &edge.pkg, dep_kind.kind))
872852
})
873853
})
874854
}
875855

876-
/// Works out, for every package in the resolve graph, which of its optional
877-
/// dependencies its enabled features activate.
878-
fn index_activated_dependencies<'a>(
879-
packages: &'a PackageMap,
880-
resolve: &'a ResolveMap,
881-
) -> ActivationMap<'a> {
882-
resolve
883-
.iter()
884-
.filter_map(|(id, node)| {
885-
let package = packages.get(id)?;
886-
Some((
887-
id,
888-
activated_dependencies(&package.features, &node.features),
889-
))
890-
})
891-
.collect()
856+
/// Works out which edges of the resolve graph cargo actually builds, so that the
857+
/// optional dependencies no enabled feature activates can be left out.
858+
fn index_built_edges<'a>(packages: &'a PackageMap, resolve: &'a ResolveMap) -> BuiltEdges<'a> {
859+
let mut built = BuiltEdges::new();
860+
861+
for (parent_id, node) in resolve {
862+
// Without the parent's manifest there is nothing to check against, so keep
863+
// all of its edges rather than risk dropping a real dependency.
864+
let parent = packages.get(parent_id);
865+
let activated = parent
866+
.map(|parent| activated_dependencies(&parent.features, &node.features))
867+
.unwrap_or_default();
868+
869+
for edge in &node.deps {
870+
for dep_kind in &edge.dep_kinds {
871+
let keep = match (parent, packages.get(&edge.pkg)) {
872+
(Some(parent), Some(child)) => {
873+
is_built(parent, child, &edge.name, dep_kind, &activated)
874+
}
875+
_ => true,
876+
};
877+
if keep {
878+
built.insert((parent_id, &edge.pkg, dep_kind.kind));
879+
}
880+
}
881+
}
882+
}
883+
884+
built
892885
}
893886

894887
/// Whether a dependency of this kind belongs in the SBOM at all.
@@ -900,41 +893,58 @@ fn included_kind(kind: DependencyKind, config: &SbomConfig) -> bool {
900893
}
901894
}
902895

903-
/// Whether `parent` actually builds `child` as a dependency of the given kind and platform.
896+
/// Whether `parent` actually builds `child` as a dependency of the given kind and
897+
/// platform, or whether it is an optional dependency that no enabled feature
898+
/// activates.
904899
///
905900
/// A single edge in the resolve graph can be backed by more than one entry in the
906901
/// parent's manifest - the same crate can be depended on twice under different
907-
/// renames - so the edge survives if any of those entries is non-optional or is
908-
/// activated by an enabled feature.
909-
fn is_activated(
902+
/// renames - so the candidates are narrowed down to the entry that produced this
903+
/// edge, and the edge survives if what is left is non-optional or activated.
904+
fn is_built(
910905
parent: &Package,
911906
child: &Package,
907+
edge_name: &str,
912908
dep_kind: &DepKindInfo,
913909
activated: &HashSet<&str>,
914910
) -> bool {
915-
let declares_edge = |dep: &&CargoDependency| {
916-
dep.name == child.name && dep.kind == dep_kind.kind && dep.target == dep_kind.target
917-
};
918-
let is_built =
919-
|dep: &&CargoDependency| !dep.optional || activated.contains(dependency_key(dep));
920-
921-
// The version requirement tells same-named entries apart.
922-
let mut by_version = parent
911+
let candidates: Vec<&CargoDependency> = parent
923912
.dependencies
924913
.iter()
925-
.filter(declares_edge)
926-
.filter(|dep| dep.req.matches(&child.version))
927-
.peekable();
928-
if by_version.peek().is_some() {
929-
return by_version.any(|dep| is_built(&dep));
914+
.filter(|dep| {
915+
dep.name == child.name && dep.kind == dep_kind.kind && dep.target == dep_kind.target
916+
})
917+
.collect();
918+
919+
// An edge with no matching manifest entry at all is not something we
920+
// understand, so keep it.
921+
if candidates.is_empty() {
922+
return true;
930923
}
931924

932-
// If the version requirement ruled out every entry - pre-release versions and
933-
// `[patch]` can both do that - fall back to matching on the name alone rather
934-
// than dropping the edge. An edge with no matching manifest entry at all is
935-
// not something we understand, so keep that too.
936-
let mut by_name = parent.dependencies.iter().filter(declares_edge).peekable();
937-
by_name.peek().is_none() || by_name.any(|dep| is_built(&dep))
925+
// Either narrowing can rule out every candidate - pre-release versions and
926+
// `[patch]` defeat the version requirement, and a `[lib] name` that differs
927+
// from the package name defeats the edge name - so a narrowing that would
928+
// leave nothing to choose from is skipped.
929+
let candidates = narrow(candidates, |dep| dep.req.matches(&child.version));
930+
let candidates = narrow(candidates, |dep| {
931+
dependency_key(dep).replace('-', "_") == edge_name
932+
});
933+
934+
candidates
935+
.iter()
936+
.any(|dep| !dep.optional || activated.contains(dependency_key(dep)))
937+
}
938+
939+
/// Keeps the candidates matching `predicate`, or all of them if that would leave
940+
/// none.
941+
fn narrow<T: Copy>(candidates: Vec<T>, predicate: impl Fn(&T) -> bool) -> Vec<T> {
942+
let narrowed: Vec<T> = candidates.iter().copied().filter(&predicate).collect();
943+
if narrowed.is_empty() {
944+
candidates
945+
} else {
946+
narrowed
947+
}
938948
}
939949

940950
/// The name a dependency is known by in feature syntax: the rename if it was
@@ -1364,6 +1374,12 @@ mod test {
13641374
assert_eq!(activated(&features, &["a"]), set(&["x"]));
13651375
}
13661376

1377+
#[test]
1378+
fn narrowing_that_leaves_nothing_is_skipped() {
1379+
assert_eq!(narrow(vec![1, 2, 3], |n| *n > 1), vec![2, 3]);
1380+
assert_eq!(narrow(vec![1, 2, 3], |n| *n > 9), vec![1, 2, 3]);
1381+
}
1382+
13671383
#[test]
13681384
fn unknown_features_are_ignored() {
13691385
assert_eq!(activated(&features(&[]), &["not-a-feature"]), set(&[]));

0 commit comments

Comments
 (0)