Skip to content

Exclude optional dependencies that no enabled feature activates - #877

Draft
nissessenap wants to merge 3 commits into
CycloneDX:mainfrom
nissessenap:fix/prune-unactivated-optional-deps
Draft

nissessenap wants to merge 3 commits into
CycloneDX:mainfrom
nissessenap:fix/prune-unactivated-optional-deps

Conversation

@nissessenap

@nissessenap nissessenap commented Aug 27, 2026

Copy link
Copy Markdown

Exclude optional dependencies that no enabled feature activates

Fixes #766.

The problem

cargo metadata lists optional dependencies in resolve.nodes[].deps whether or not
the resolver enabled them. generator.rs never read the feature information that comes
back alongside them, so every optional dependency of every crate in the graph ended up
in the SBOM — along with everything reachable only through it.

Concretely, a crate whose only dependency is serde_with 3 gets an SBOM with 41
components. cargo tree -e normal,build reports 13.

This is also what makes the README's claim to "honor a particular combination of enabled
Cargo features"
true rather than aspirational.

The fix

The resolve graph is pruned once, up front, by replaying the feature resolution
cargo metadata already recorded and dropping the edges it shows were never taken:

  • resolve.nodes[].features gives the features the resolver enabled on each package.
  • packages[].features expands those into the set of activated dependencies, following
    dep:foo, foo/bar (activating), foo?/bar (weak, not activating), and plain feature
    names recursively.
  • Each NodeDep is matched back to the [dependencies] entries that could have produced
    it — by package name, kind and target platform — and survives if any of them is
    non-optional or activated. Renamed dependencies are keyed on the rename (chrono_0_4),
    since that is what feature syntax refers to.

Because this happens before any workspace member is processed, the rest of the generator
is untouched: filtered_dependencies(), all_dependencies() and index_dep_kinds() see
a resolve graph that no longer contains the edges, so components disappear along with them
and an edge that is never built cannot raise a component's scope.

Every lookup fails open — an edge whose manifest entry cannot be identified is kept, so
the change can remove false positives but not introduce false negatives.

Measured

Against cargo tree -e normal,build --no-dedupe on the resolved graph:

crate under test before after cargo tree
serde_with 3 41 13 13
reqwest 0.12 (json, rustls-tls) 125 109 109
sqlx 0.8 (sqlite only) 108 101 101
rustls 0.23 22 19 19
reqwest --target x86_64-pc-windows-msvc 123 107 107
reqwest --target wasm32-unknown-unknown 62 62 62
tokio 1 (full), axum 0.8, clap 4 (derive) unchanged unchanged matches
polars 0.51 (lazy) 273 262 256
image 0.25 102 102 95

Zero false negatives anywhere: nothing cargo tree reports is missing from the patched
output. No measurable runtime cost (0.28s either way on the 273-component polars graph).

What this does not fix

  • Workspace feature unification (rust-lang/cargo#7754). cargo metadata resolves
    features across the whole workspace, so a member inherits the union of its siblings'
    features. The polars residual above is this: cargo metadata genuinely reports
    hex as enabled on polars-ops, and an edge filter has to believe it.
  • The --target flag does not seem to respect target-conditional features in generated SBOM. #871, target-conditional features. A crate with cfg(musl)/cfg(not(musl)) feature
    splits reports both branches in node.features, with or without --filter-platform.
    The image residual above is the related platform case (wasm-bindgen on a host build).

Both are upstream in Cargo, and neither is made worse by this change.

Tests

  • Unit tests for the feature expansion: dep:, implicit features, transitive expansion,
    foo/bar vs foo?/bar, renames, cyclic features, unknown features.
  • A new tests/fixtures/optional_deps workspace covering a non-optional dependency, one
    activated by dep:, one activated only through its rename, one named only weakly, and
    one never named at all. It fails against main.
  • weak_dep owns a private dependency, so pruning it cascades, and the same test asserts
    the pruned graph leaves no dangling dependencies references.

🤖 Generated with Claude Code

@codacy-production

codacy-production Bot commented Aug 27, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 53 complexity · 4 duplication

Metric Results
Complexity 53
Duplication 4

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

nissessenap and others added 3 commits August 27, 2026 09:44
`cargo metadata` lists optional dependencies in `resolve.nodes[].deps`
whether or not the resolver enabled them, and nothing read the feature
information that comes back alongside them. Every optional dependency of
every crate in the graph therefore ended up in the SBOM, along with
everything reachable only through it: a crate whose sole dependency is
`serde_with 3` got 41 components where `cargo tree` reports 13.

Replay the feature resolution cargo already recorded. `packages[].features`
expands the enabled features of a node into the set of dependencies they
activate, following `dep:foo`, `foo/bar`, weak `foo?/bar` and plain feature
names. Each edge is matched back to the `[dependencies]` entries that could
have produced it - by package name, kind and target platform - and survives
if any of them is non-optional or activated. Renamed dependencies are keyed
on the rename, since that is what feature syntax refers to.

Pruning propagates for free: `all_dependencies` already drops packages its
walk does not reach. `index_dep_kinds` gets the same check so an edge that
is never built cannot raise a component's scope.

Every lookup fails open - an edge whose manifest entry cannot be identified
is kept - so this can remove false positives but not introduce false
negatives. Verified against `cargo tree -e normal,build` on serde_with,
sqlx, reqwest (also under `--target`), rustls, tokio, axum, clap, polars
and image: no dependency cargo reports is missing from the output.

Workspace feature unification (rust-lang/cargo#7754) and target-conditional
features (CycloneDX#871) are unaffected; both are upstream in cargo and neither is
made worse.

Fixes CycloneDX#766

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Norling <edvin.norling@kognic.com>
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 `[CycloneDX#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>
Signed-off-by: Edvin Norling <edvin.norling@kognic.com>
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>
Signed-off-by: Edvin Norling <edvin.norling@kognic.com>
@nissessenap
nissessenap force-pushed the fix/prune-unactivated-optional-deps branch from f4bfdbd to 6f4597f Compare August 27, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optional Dependencies

1 participant