Skip to content

Commit 25d5549

Browse files
woodruffwkonstin
andauthored
Evaluate extras and groups when determining auditable packages (#18511)
## Summary I've made `uv audit`'s approach to handling extras and groups (explicitly) subtractive: we don't support flags like `--dev` (since `uv audit` audits everything by default); instead, we only support flags like `--no-dev`, `--no-group`, etc., that remove items from the to-be-audited set. To accomplish that, I've abstracted the filtering into a new `Lock::packages_for_audit` API (maybe there's a better location for it?). Implementation wise, it does a BFS similar to the one used in `uv tree`. I _think_ there's some room/opportunity for DRYing there but I wanted to keep the PR small/local 🙂 See #18506. ## Test Plan None yet. --------- Signed-off-by: William Woodruff <william@astral.sh> Co-authored-by: konsti <konstin@mailbox.org>
1 parent 7447dd9 commit 25d5549

4 files changed

Lines changed: 181 additions & 118 deletions

File tree

crates/uv-cli/src/lib.rs

Lines changed: 16 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,8 +1197,12 @@ pub enum ProjectCommand {
11971197
Format(FormatArgs),
11981198
/// Audit the project's dependencies.
11991199
///
1200-
/// Dependencies are audited for known vulnerabilities, as well
1201-
/// as 'adverse' statuses such as deprecation and quarantine.
1200+
/// Dependencies are audited for known vulnerabilities, as well as 'adverse' statuses such as
1201+
/// deprecation and quarantine.
1202+
///
1203+
/// By default, all extras and groups within the project are audited. To exclude extras
1204+
/// and/or groups from the audit, use the `--no-extra`, `--no-group`, and related
1205+
/// options.
12021206
#[command(
12031207
after_help = "Use `uv help audit` for more details.",
12041208
after_long_help = ""
@@ -5138,100 +5142,45 @@ pub struct FormatArgs {
51385142

51395143
#[derive(Args)]
51405144
pub struct AuditArgs {
5141-
/// Include optional dependencies from the specified extra name.
5142-
///
5143-
/// May be provided more than once.
5144-
///
5145-
/// This option is only available when running in a project.
5146-
#[arg(
5147-
long,
5148-
conflicts_with = "all_extras",
5149-
conflicts_with = "only_group",
5150-
value_delimiter = ',',
5151-
value_parser = extra_name_with_clap_error,
5152-
value_hint = ValueHint::Other,
5153-
)]
5154-
pub extra: Option<Vec<ExtraName>>,
5155-
5156-
/// Include all optional dependencies.
5157-
///
5158-
/// Optional dependencies are defined via `project.optional-dependencies` in a `pyproject.toml`.
5159-
///
5160-
/// This option is only available when running in a project.
5161-
#[arg(long, conflicts_with = "extra", conflicts_with = "only_group")]
5162-
pub all_extras: bool,
5163-
5164-
/// Exclude the specified optional dependencies, if `--all-extras` is supplied.
5145+
/// Don't audit the specified optional dependencies.
51655146
///
51665147
/// May be provided multiple times.
51675148
#[arg(long, value_hint = ValueHint::Other)]
51685149
pub no_extra: Vec<ExtraName>,
51695150

5170-
#[arg(long, overrides_with("all_extras"), hide = true)]
5171-
pub no_all_extras: bool,
5172-
5173-
/// Include the development dependency group [env: UV_DEV=]
5174-
///
5175-
/// Development dependencies are defined via `dependency-groups.dev` or
5176-
/// `tool.uv.dev-dependencies` in a `pyproject.toml`.
5177-
///
5178-
/// This option is an alias for `--group dev`.
5179-
///
5180-
/// This option is only available when running in a project.
5181-
#[arg(long, overrides_with("no_dev"), hide = true, value_parser = clap::builder::BoolishValueParser::new())]
5182-
pub dev: bool,
5183-
5184-
/// Disable the development dependency group [env: UV_NO_DEV=]
5151+
/// Don't audit the development dependency group [env: UV_NO_DEV=]
51855152
///
51865153
/// This option is an alias of `--no-group dev`.
5187-
/// See `--no-default-groups` to disable all default groups instead.
5154+
/// See `--no-default-groups` to exclude all default groups instead.
51885155
///
51895156
/// This option is only available when running in a project.
5190-
#[arg(long, overrides_with("dev"), value_parser = clap::builder::BoolishValueParser::new())]
5157+
#[arg(long, value_parser = clap::builder::BoolishValueParser::new())]
51915158
pub no_dev: bool,
51925159

5193-
/// Include dependencies from the specified dependency group.
5194-
///
5195-
/// May be provided multiple times.
5196-
#[arg(long, conflicts_with_all = ["only_group", "only_dev"], value_hint = ValueHint::Other)]
5197-
pub group: Vec<GroupName>,
5198-
5199-
/// Disable the specified dependency group.
5200-
///
5201-
/// This option always takes precedence over default groups,
5202-
/// `--all-groups`, and `--group`.
5160+
/// Don't audit the specified dependency group.
52035161
///
52045162
/// May be provided multiple times.
52055163
#[arg(long, env = EnvVars::UV_NO_GROUP, value_delimiter = ' ', value_hint = ValueHint::Other)]
52065164
pub no_group: Vec<GroupName>,
52075165

5208-
/// Ignore the default dependency groups.
5209-
///
5210-
/// uv includes the groups defined in `tool.uv.default-groups` by default.
5211-
/// This disables that option, however, specific groups can still be included with `--group`.
5166+
/// Don't audit the default dependency groups.
52125167
#[arg(long, env = EnvVars::UV_NO_DEFAULT_GROUPS, value_parser = clap::builder::BoolishValueParser::new())]
52135168
pub no_default_groups: bool,
52145169

5215-
/// Only include dependencies from the specified dependency group.
5170+
/// Only audit dependencies from the specified dependency group.
52165171
///
52175172
/// The project and its dependencies will be omitted.
52185173
///
52195174
/// May be provided multiple times. Implies `--no-default-groups`.
5220-
#[arg(long, conflicts_with_all = ["group", "dev", "all_groups"], value_hint = ValueHint::Other)]
5175+
#[arg(long, value_hint = ValueHint::Other)]
52215176
pub only_group: Vec<GroupName>,
52225177

5223-
/// Include dependencies from all dependency groups.
5224-
///
5225-
/// `--no-group` can be used to exclude specific groups.
5226-
#[arg(long, conflicts_with_all = ["only_group", "only_dev"])]
5227-
pub all_groups: bool,
5228-
5229-
/// Only include the development dependency group.
5178+
/// Only audit the development dependency group.
52305179
///
52315180
/// The project and its dependencies will be omitted.
52325181
///
52335182
/// This option is an alias for `--only-group dev`. Implies `--no-default-groups`.
5234-
#[arg(long, conflicts_with_all = ["group", "all_groups", "no_dev"])]
5183+
#[arg(long, conflicts_with_all = ["no_dev"])]
52355184
pub only_dev: bool,
52365185

52375186
/// Assert that the `uv.lock` will remain unchanged [env: UV_LOCKED=]

crates/uv-resolver/src/lock/mod.rs

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ use petgraph::visit::EdgeRef;
1515
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
1616
use serde::Serializer;
1717
use toml_edit::{Array, ArrayOfTables, InlineTable, Item, Table, Value, value};
18-
use tracing::{debug, instrument};
18+
use tracing::{debug, instrument, trace};
1919
use url::Url;
2020

2121
use uv_cache_key::RepositoryUrl;
22-
use uv_configuration::{BuildOptions, Constraints, InstallTarget};
22+
use uv_configuration::{
23+
BuildOptions, Constraints, DependencyGroupsWithDefaults, ExtrasSpecificationWithDefaults,
24+
InstallTarget,
25+
};
2326
use uv_distribution::{DistributionDatabase, FlatRequiresDist};
2427
use uv_distribution_filename::{
2528
BuildTag, DistExtension, ExtensionError, SourceDistExtension, WheelFilename,
@@ -810,6 +813,147 @@ impl Lock {
810813
)
811814
}
812815

816+
/// Returns the set of packages that should be audited, respecting the given
817+
/// extras and dependency groups filters.
818+
///
819+
/// Workspace members and packages without version information are excluded
820+
/// unconditionally, since neither can be meaningfully looked up in a
821+
/// vulnerability database.
822+
pub fn packages_for_audit<'lock>(
823+
&'lock self,
824+
extras: &'lock ExtrasSpecificationWithDefaults,
825+
groups: &'lock DependencyGroupsWithDefaults,
826+
) -> Vec<(&'lock PackageName, &'lock Version)> {
827+
// Enqueue a dependency for auditability checks: base package (no extra) first, then each activated extra.
828+
fn enqueue_dep<'lock>(
829+
lock: &'lock Lock,
830+
seen: &mut FxHashSet<(&'lock PackageId, Option<&'lock ExtraName>)>,
831+
queue: &mut VecDeque<(&'lock Package, Option<&'lock ExtraName>)>,
832+
dep: &'lock Dependency,
833+
) {
834+
let dep_pkg = lock.find_by_id(&dep.package_id);
835+
for maybe_extra in std::iter::once(None).chain(dep.extra.iter().map(Some)) {
836+
if seen.insert((&dep.package_id, maybe_extra)) {
837+
queue.push_back((dep_pkg, maybe_extra));
838+
}
839+
}
840+
}
841+
842+
// Identify workspace members (the implicit root counts for single-member workspaces).
843+
let workspace_member_ids: FxHashSet<&PackageId> = if self.members().is_empty() {
844+
self.root().into_iter().map(|package| &package.id).collect()
845+
} else {
846+
self.packages
847+
.iter()
848+
.filter(|package| self.members().contains(&package.id.name))
849+
.map(|package| &package.id)
850+
.collect()
851+
};
852+
853+
// Lockfile traversal state: (package, optional extra to activate on that package).
854+
let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new();
855+
let mut seen: FxHashSet<(&PackageId, Option<&ExtraName>)> = FxHashSet::default();
856+
857+
// Seed from workspace members. Always queue with `None` so that we can traverse
858+
// their dependency groups; only queue extras when prod mode is active.
859+
for package in self
860+
.packages
861+
.iter()
862+
.filter(|p| workspace_member_ids.contains(&p.id))
863+
{
864+
if seen.insert((&package.id, None)) {
865+
queue.push_back((package, None));
866+
}
867+
if groups.prod() {
868+
for extra in extras.extra_names(package.optional_dependencies.keys()) {
869+
if seen.insert((&package.id, Some(extra))) {
870+
queue.push_back((package, Some(extra)));
871+
}
872+
}
873+
}
874+
}
875+
876+
// Seed from requirements attached directly to the lock (e.g., PEP 723 scripts).
877+
for requirement in self.requirements() {
878+
for package in self
879+
.packages
880+
.iter()
881+
.filter(|p| p.id.name == requirement.name)
882+
{
883+
if seen.insert((&package.id, None)) {
884+
queue.push_back((package, None));
885+
}
886+
}
887+
}
888+
889+
// Seed from dependency groups attached directly to the lock (e.g., project-less
890+
// workspace roots).
891+
for (group, requirements) in self.dependency_groups() {
892+
if !groups.contains(group) {
893+
continue;
894+
}
895+
for requirement in requirements {
896+
for package in self
897+
.packages
898+
.iter()
899+
.filter(|p| p.id.name == requirement.name)
900+
{
901+
if seen.insert((&package.id, None)) {
902+
queue.push_back((package, None));
903+
}
904+
}
905+
}
906+
}
907+
908+
let mut auditable: BTreeSet<(&PackageName, &Version)> = BTreeSet::default();
909+
910+
while let Some((package, extra)) = queue.pop_front() {
911+
let is_member = workspace_member_ids.contains(&package.id);
912+
913+
// Collect non-workspace packages that have version information.
914+
if !is_member {
915+
if let Some(version) = package.version() {
916+
auditable.insert((package.name(), version));
917+
} else {
918+
trace!(
919+
"Skipping audit for `{}` because it has no version information",
920+
package.name()
921+
);
922+
}
923+
}
924+
925+
// Follow allowed dependency groups.
926+
if is_member && extra.is_none() {
927+
for dep in package
928+
.dependency_groups
929+
.iter()
930+
.filter(|(group, _)| groups.contains(group))
931+
.flat_map(|(_, deps)| deps)
932+
{
933+
enqueue_dep(self, &mut seen, &mut queue, dep);
934+
}
935+
}
936+
937+
// Follow the regular/extra dependencies for this (package, extra) pair.
938+
// For workspace members in only-group mode, skip regular dependencies.
939+
let dependencies: &[Dependency] = match extra {
940+
Some(extra) => package
941+
.optional_dependencies
942+
.get(extra)
943+
.map(Vec::as_slice)
944+
.unwrap_or_default(),
945+
None if is_member && !groups.prod() => &[],
946+
None => &package.dependencies,
947+
};
948+
949+
for dep in dependencies {
950+
enqueue_dep(self, &mut seen, &mut queue, dep);
951+
}
952+
}
953+
954+
auditable.into_iter().collect()
955+
}
956+
813957
/// Return the workspace root used to generate this lock.
814958
pub fn root(&self) -> Option<&Package> {
815959
self.packages.iter().find(|package| {

crates/uv/src/commands/project/audit.rs

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ pub(crate) async fn audit(
8585
LockTarget::Workspace(_) => DefaultExtras::default(),
8686
LockTarget::Script(_) => DefaultExtras::default(),
8787
};
88-
let _extras = extras.with_defaults(default_extras);
88+
let extras = extras.with_defaults(default_extras);
8989

9090
// Determine whether we're performing a universal audit.
9191
let universal = python_version.is_none() && python_platform.is_none();
@@ -185,37 +185,10 @@ pub(crate) async fn audit(
185185
)
186186
});
187187

188-
// TODO: validate the sets of requested extras/groups against the lockfile?
189-
190-
// Build the list of auditable packages, skipping workspace members. Workspace members are
191-
// local by definition and have no meaningful external package identity to look up in a vuln
192-
// service. We also skip packages without a version, since we can't query for them.
193-
//
194-
// This mirrors the logic in `TreeDisplay::new`: for single-member workspaces, `lock.members()`
195-
// is empty and the root package (source at path "") is the implicit member.
196-
let workspace_root_name = lock.root().map(uv_resolver::Package::name);
197-
let auditable: Vec<_> = lock
198-
.packages()
199-
.iter()
200-
.filter(|p| {
201-
if lock.members().is_empty() {
202-
// Single-member workspace: skip the implicit root.
203-
workspace_root_name != Some(p.name())
204-
} else {
205-
!lock.members().contains(p.name())
206-
}
207-
})
208-
.filter_map(|p| {
209-
let Some(version) = p.version() else {
210-
trace!(
211-
"Skipping audit for {} because it has no version information",
212-
p.name()
213-
);
214-
return None;
215-
};
216-
Some((p.name(), version))
217-
})
218-
.collect();
188+
// Build the list of auditable packages by traversing the lockfile from workspace roots,
189+
// respecting the user's extras and dependency-group filters. Workspace members are excluded
190+
// (they are local and have no external package identity), as are packages without a version.
191+
let auditable = lock.packages_for_audit(&extras, &groups);
219192

220193
// Perform the audit.
221194
let reporter = AuditReporter::from(printer);
@@ -247,9 +220,7 @@ pub(crate) async fn audit(
247220
n_packages: auditable.len(),
248221
findings: all_findings,
249222
};
250-
display.render()?;
251-
252-
Ok(ExitStatus::Success)
223+
display.render()
253224
}
254225

255226
struct AuditResults {
@@ -259,7 +230,7 @@ struct AuditResults {
259230
}
260231

261232
impl AuditResults {
262-
fn render(&self) -> Result<()> {
233+
fn render(&self) -> Result<ExitStatus> {
263234
let (vulns, statuses): (Vec<_>, Vec<_>) =
264235
self.findings.iter().partition_map(|finding| match finding {
265236
Finding::Vulnerability(vuln) => itertools::Either::Left(vuln),
@@ -292,6 +263,8 @@ impl AuditResults {
292263
packages = format!("{npackages} packages", npackages = self.n_packages).bold()
293264
)?;
294265

266+
let has_findings = !vulns.is_empty() || !statuses.is_empty();
267+
295268
if !vulns.is_empty() {
296269
writeln!(self.printer.stdout_important(), "\nVulnerabilities:\n")?;
297270

@@ -357,6 +330,10 @@ impl AuditResults {
357330
// any adverse project statuses at the moment.
358331
}
359332

360-
Ok(())
333+
if has_findings {
334+
Ok(ExitStatus::Failure)
335+
} else {
336+
Ok(ExitStatus::Success)
337+
}
361338
}
362339
}

0 commit comments

Comments
 (0)