Skip to content

Commit f4bfdbd

Browse files
nissessenapclaude
andcommitted
refactor: prune unbuilt resolve edges once, up front
Replaces the `BuiltEdges` set threaded through five functions with a single pass over the resolve map before any workspace member is processed. `index_dep_kinds`, `all_dependencies`, `top_level_dependencies`, `add_filtered_dependencies` and `filtered_dependencies` go back to their pre-change form, and pruning per `dep_kinds` entry keeps the `target` that a `(parent, child, kind)` key discarded. Also drops the narrowing by edge name in `is_built`: a resolve edge carries a single `PackageId`, so two renames of the same package cannot be told apart by it, and narrowing to the unactivated alias could drop an edge that is built. The `.gitignore` entry for the lockfile `cargo test` regenerates is unrelated to this fix and goes in its own PR. SBOM output is unchanged: identical components, versions, scopes and dependency edges on serde_with, reqwest, sqlx, rustls, polars and image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8ef5005 commit f4bfdbd

4 files changed

Lines changed: 86 additions & 208 deletions

File tree

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@
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-
1613
# Nix Flake
1714
/.direnv/
1815
/result

cargo-cyclonedx/src/generator.rs

Lines changed: 71 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,6 @@ use validator::ValidateEmail;
7474
type PackageMap = BTreeMap<PackageId, Package>;
7575
type ResolveMap = BTreeMap<PackageId, Node>;
7676
type DependencyKindMap = BTreeMap<PackageId, DependencyKind>;
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)>;
8277

8378
/// The values are ordered from weakest to strongest so that casting to integer would make sense
8479
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Hash)]
@@ -131,21 +126,19 @@ impl SbomGenerator {
131126
log::trace!("Processing the workspace {}", meta.workspace_root);
132127
let members: Vec<PackageId> = meta.workspace_members;
133128
let packages = index_packages(meta.packages);
134-
let resolve = index_resolve(meta.resolve.unwrap().nodes);
135-
136-
let built = index_built_edges(&packages, &resolve);
129+
let resolve = prune_unbuilt_edges(index_resolve(meta.resolve.unwrap().nodes), &packages);
137130

138131
let mut result = Vec::with_capacity(members.len());
139132
for member in members.iter() {
140133
log::trace!("Processing the package {}", member);
141134

142-
let dep_kinds = index_dep_kinds(member, &resolve, &built);
135+
let dep_kinds = index_dep_kinds(member, &resolve);
143136

144137
let (dependencies, pruned_resolve) =
145138
if config.included_dependencies() == IncludedDependencies::AllDependencies {
146-
all_dependencies(member, &packages, &resolve, &built, config)
139+
all_dependencies(member, &packages, &resolve, config)
147140
} else {
148-
top_level_dependencies(member, &packages, &resolve, &built, config)
141+
top_level_dependencies(member, &packages, &resolve, config)
149142
};
150143

151144
let manifest_path = packages[member].manifest_path.clone().into_std_path_buf();
@@ -636,11 +629,7 @@ fn index_resolve(packages: Vec<Node>) -> ResolveMap {
636629
.collect()
637630
}
638631

639-
fn index_dep_kinds(
640-
root: &PackageId,
641-
resolve: &ResolveMap,
642-
built: &BuiltEdges,
643-
) -> DependencyKindMap {
632+
fn index_dep_kinds(root: &PackageId, resolve: &ResolveMap) -> DependencyKindMap {
644633
// cache strongest found dependency kind for every node
645634
let mut id_to_dep_kind: HashMap<PackageId, PrivateDepKind> = HashMap::new();
646635
id_to_dep_kind.insert(root.clone(), PrivateDepKind::Runtime);
@@ -674,14 +663,6 @@ fn index_dep_kinds(
674663
let node = &resolve[&pkg_id];
675664
for child_dep in &node.deps {
676665
for dep_kind in &child_dep.dep_kinds {
677-
// Unlike `filtered_dependencies` this walk deliberately keeps
678-
// dev- and build-dependencies, since their whole purpose here is
679-
// to mark components as `Excluded`. Optional dependencies that
680-
// were never activated are still skipped: an edge that is not
681-
// built must not raise the scope of a package reached elsewhere.
682-
if !built.contains(&(&pkg_id, &child_dep.pkg, dep_kind.kind)) {
683-
continue;
684-
}
685666
let current_kind = PrivateDepKind::from(&dep_kind.kind);
686667
let new_path_node_kind = min(current_kind, path_node_kind);
687668

@@ -741,13 +722,12 @@ fn top_level_dependencies(
741722
root: &PackageId,
742723
packages: &PackageMap,
743724
resolve: &ResolveMap,
744-
built: &BuiltEdges,
745725
config: &SbomConfig,
746726
) -> (PackageMap, ResolveMap) {
747727
log::trace!("Adding top-level dependencies to SBOM");
748728

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

752732
let mut pkg_result = PackageMap::new();
753733

@@ -775,7 +755,6 @@ fn all_dependencies(
775755
root: &PackageId,
776756
packages: &PackageMap,
777757
resolve: &ResolveMap,
778-
built: &BuiltEdges,
779758
config: &SbomConfig,
780759
) -> (PackageMap, ResolveMap) {
781760
log::trace!("Adding all dependencies to SBOM");
@@ -797,13 +776,10 @@ fn all_dependencies(
797776
// If we haven't processed this node yet...
798777
if !out_resolve.contains_key(&node.id) {
799778
// Add the node to the output
800-
out_resolve.insert(
801-
node.id.to_owned(),
802-
add_filtered_dependencies(node, built, config),
803-
);
779+
out_resolve.insert(node.id.to_owned(), add_filtered_dependencies(node, config));
804780
// Queue its dependencies for the next BFS loop iteration
805781
next_queue.extend(
806-
filtered_dependencies(node, built, config).map(|dep| &resolve[&dep.pkg]),
782+
filtered_dependencies(&node.deps, config).map(|dep| &resolve[&dep.pkg]),
807783
);
808784
}
809785
}
@@ -820,91 +796,72 @@ fn all_dependencies(
820796
(out_packages, out_resolve)
821797
}
822798

823-
fn add_filtered_dependencies(node: &Node, built: &BuiltEdges, config: &SbomConfig) -> Node {
824-
let mut node_copy = node.clone();
825-
node_copy.deps = filtered_dependencies(node, built, config)
826-
.cloned()
827-
.collect();
828-
node_copy.dependencies = node_copy.deps.iter().map(|d| d.pkg.to_owned()).collect();
829-
node_copy
799+
fn add_filtered_dependencies(node: &Node, config: &SbomConfig) -> Node {
800+
let mut node = node.clone();
801+
node.deps = filtered_dependencies(&node.deps, config).cloned().collect();
802+
node.dependencies = node.deps.iter().map(|d| d.pkg.to_owned()).collect();
803+
node
830804
}
831805

832-
/// Filters out the dependencies of `node` that do not end up being built:
833-
///
834-
/// * dependencies only used for development, specified under `[dev-dependencies]`
835-
/// in `Cargo.toml` (and also build dependencies, if so configured);
836-
/// * optional dependencies that none of the enabled features activate.
837-
///
838-
/// `cargo metadata` reports optional dependencies in `resolve.nodes[].deps`
839-
/// whether or not the resolver enabled them, so the second group has to be
840-
/// recovered by replaying the feature resolution recorded in
841-
/// `resolve.nodes[].features`. See
842-
/// <https://github.com/CycloneDX/cyclonedx-rust-cargo/issues/766>.
806+
/// Filters out dependencies only used for development, and not affecting the final binary.
807+
/// These are specified under `[dev-dependencies]` in Cargo.toml.
843808
fn filtered_dependencies<'a>(
844-
node: &'a Node,
845-
built: &'a BuiltEdges<'a>,
809+
input: &'a [NodeDep],
846810
config: &'a SbomConfig,
847811
) -> impl Iterator<Item = &'a NodeDep> {
848-
node.deps.iter().filter(move |edge| {
849-
edge.dep_kinds.iter().any(|dep_kind| {
850-
included_kind(dep_kind.kind, config)
851-
&& built.contains(&(&node.id, &edge.pkg, dep_kind.kind))
812+
input.iter().filter(|p| {
813+
p.dep_kinds.iter().any(|dep| {
814+
if let Some(true) = config.only_normal_deps {
815+
dep.kind == DependencyKind::Normal
816+
} else {
817+
dep.kind != DependencyKind::Development
818+
}
852819
})
853820
})
854821
}
855822

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
885-
}
823+
/// Removes the resolve edges cargo does not actually build: optional dependencies
824+
/// that no enabled feature activates.
825+
///
826+
/// `cargo metadata` reports those in `resolve.nodes[].deps` whether or not the
827+
/// resolver enabled them, so they have to be recovered by replaying the feature
828+
/// resolution recorded alongside them in `resolve.nodes[].features`. See
829+
/// <https://github.com/CycloneDX/cyclonedx-rust-cargo/issues/766>.
830+
///
831+
/// Every lookup fails open: an edge that cannot be matched to a manifest entry is
832+
/// kept, so this can remove false positives but not introduce false negatives.
833+
fn prune_unbuilt_edges(mut resolve: ResolveMap, packages: &PackageMap) -> ResolveMap {
834+
for (parent_id, node) in resolve.iter_mut() {
835+
// Without the parent's manifest there is nothing to check against.
836+
let Some(parent) = packages.get(parent_id) else {
837+
continue;
838+
};
839+
let activated = activated_dependencies(&parent.features, &node.features);
886840

887-
/// Whether a dependency of this kind belongs in the SBOM at all.
888-
fn included_kind(kind: DependencyKind, config: &SbomConfig) -> bool {
889-
if let Some(true) = config.only_normal_deps {
890-
kind == DependencyKind::Normal
891-
} else {
892-
kind != DependencyKind::Development
841+
node.deps.retain_mut(|edge| {
842+
let Some(child) = packages.get(&edge.pkg) else {
843+
return true;
844+
};
845+
edge.dep_kinds
846+
.retain(|dep_kind| is_built(parent, child, dep_kind, &activated));
847+
!edge.dep_kinds.is_empty()
848+
});
849+
node.dependencies = node.deps.iter().map(|edge| edge.pkg.clone()).collect();
893850
}
851+
852+
resolve
894853
}
895854

896855
/// Whether `parent` actually builds `child` as a dependency of the given kind and
897856
/// platform, or whether it is an optional dependency that no enabled feature
898857
/// activates.
899858
///
900-
/// A single edge in the resolve graph can be backed by more than one entry in the
901-
/// parent's manifest - the same crate can be depended on twice under different
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.
859+
/// One resolve edge can be backed by several entries in the parent's manifest -
860+
/// the same crate can be depended on twice under different renames - so the edge
861+
/// survives if any entry that could have produced it is non-optional or activated.
904862
fn is_built(
905863
parent: &Package,
906864
child: &Package,
907-
edge_name: &str,
908865
dep_kind: &DepKindInfo,
909866
activated: &HashSet<&str>,
910867
) -> bool {
@@ -922,50 +879,32 @@ fn is_built(
922879
return true;
923880
}
924881

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
882+
// Pre-release versions and `[patch]` can defeat the version requirement, so a
883+
// narrowing that would leave nothing to choose from is skipped.
884+
let matching: Vec<&CargoDependency> = candidates
935885
.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() {
886+
.copied()
887+
.filter(|dep| dep.req.matches(&child.version))
888+
.collect();
889+
let candidates = if matching.is_empty() {
944890
candidates
945891
} else {
946-
narrowed
947-
}
948-
}
892+
matching
893+
};
949894

950-
/// The name a dependency is known by in feature syntax: the rename if it was
951-
/// renamed with `package = "..."`, and the package name otherwise.
952-
fn dependency_key(dep: &CargoDependency) -> &str {
953-
dep.rename.as_deref().unwrap_or(&dep.name)
895+
// Feature syntax refers to a renamed dependency by its rename.
896+
candidates
897+
.iter()
898+
.any(|dep| !dep.optional || activated.contains(dep.rename.as_deref().unwrap_or(&dep.name)))
954899
}
955900

956901
/// Collects the optional dependencies that `enabled_features` activate, given a
957902
/// package's `[features]` table as reported by `cargo metadata`.
958903
///
959-
/// Every entry of a feature is one of four things:
960-
///
961-
/// * `dep:foo` - activates the optional dependency `foo`;
962-
/// * `foo/bar` - activates `foo` if it is optional, and enables `bar` on it;
963-
/// * `foo?/bar` - enables `bar` on `foo`, but only if something else activates it;
964-
/// * `bar` - another feature of this package, expanded recursively.
965-
///
966-
/// Optional dependencies that are never named with `dep:` also get an implicit
967-
/// feature of their own, but `cargo metadata` normalizes those into an explicit
968-
/// `"foo": ["dep:foo"]` entry, so they need no special handling here.
904+
/// `dep:foo` and `foo/bar` activate `foo`, the weak `foo?/bar` does not, and
905+
/// anything else is another feature of this package, expanded recursively.
906+
/// Optional dependencies with an implicit feature of their own arrive normalized
907+
/// as `"foo": ["dep:foo"]`, so they need no special handling.
969908
fn activated_dependencies<'a>(
970909
features: &'a BTreeMap<String, Vec<String>>,
971910
enabled_features: &'a [String],
@@ -1328,15 +1267,6 @@ mod test {
13281267
assert_eq!(activated(&features, &[]), set(&[]));
13291268
}
13301269

1331-
#[test]
1332-
fn implicit_features_activate_an_optional_dependency() {
1333-
// An optional dependency that no feature mentions with `dep:` gets a
1334-
// feature of its own, which `cargo metadata` reports in this normalized form.
1335-
let features = features(&[("serde_json", &["dep:serde_json"])]);
1336-
1337-
assert_eq!(activated(&features, &["serde_json"]), set(&["serde_json"]));
1338-
}
1339-
13401270
#[test]
13411271
fn features_are_expanded_transitively() {
13421272
let features = features(&[
@@ -1358,28 +1288,13 @@ mod test {
13581288
assert_eq!(activated(&features, &["strong", "weak"]), set(&["chrono"]));
13591289
}
13601290

1361-
#[test]
1362-
fn dependencies_are_keyed_on_the_rename() {
1363-
// `chrono_0_4 = { package = "chrono", optional = true }` is activated as
1364-
// `chrono_0_4`, never as `chrono`.
1365-
let features = features(&[("dates", &["dep:chrono_0_4"])]);
1366-
1367-
assert_eq!(activated(&features, &["dates"]), set(&["chrono_0_4"]));
1368-
}
1369-
13701291
#[test]
13711292
fn cyclic_features_terminate() {
13721293
let features = features(&[("a", &["b", "dep:x"]), ("b", &["a"])]);
13731294

13741295
assert_eq!(activated(&features, &["a"]), set(&["x"]));
13751296
}
13761297

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-
13831298
#[test]
13841299
fn unknown_features_are_ignored() {
13851300
assert_eq!(activated(&features(&[]), &["not-a-feature"]), set(&[]));

0 commit comments

Comments
 (0)