From 29285db48e1917db8d93081f7008484aceab8543 Mon Sep 17 00:00:00 2001 From: Tom Schafer <54135831+thomasschafer@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:19:34 +0000 Subject: [PATCH] Fix missing dependencies on synthetic root in SBOM export (#17363) --- .../src/lock/export/cyclonedx_json.rs | 24 +++-- crates/uv/tests/it/export.rs | 87 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/uv-resolver/src/lock/export/cyclonedx_json.rs b/crates/uv-resolver/src/lock/export/cyclonedx_json.rs index 65ea3ceb2..9f4a7eb4e 100644 --- a/crates/uv-resolver/src/lock/export/cyclonedx_json.rs +++ b/crates/uv-resolver/src/lock/export/cyclonedx_json.rs @@ -349,7 +349,7 @@ pub fn from_lock<'lock>( let mut dependencies = create_dependencies(&nodes, &component_builder); - // With `--all-packages`, use synthetic root which depends on workspace root and all workspace members. + // With `--all-packages`, use synthetic root which depends on root and all workspace members. // This ensures that we don't have any dangling components resulting from workspace packages not depended on by the workspace root. if all_packages { let synthetic_root = component_builder.create_synthetic_root_component(root); @@ -357,19 +357,29 @@ pub fn from_lock<'lock>( .bom_ref .clone() .expect("bom-ref should always exist"); - let workspace_root = metadata.component.replace(synthetic_root); + let root = metadata.component.replace(synthetic_root); - if let Some(workspace_root) = workspace_root { + let mut synthetic_root_deps = workspace_member_ids + .iter() + .filter_map(|c| component_builder.get_component(c)) + .map(|c| c.bom_ref.clone().expect("bom-ref should always exist")) + .collect::>(); + if let Some(ref root_component) = root + && let Some(ref root_bom_ref) = root_component.bom_ref + { + synthetic_root_deps.push(root_bom_ref.clone()); + } + + if let Some(workspace_root) = root { components.push(workspace_root); } dependencies.push(Dependency { dependency_ref: synthetic_root_bom_ref, - dependencies: workspace_member_ids - .iter() - .filter_map(|c| component_builder.get_component(c)) - .map(|c| c.bom_ref.clone().expect("bom-ref should always exist")) + dependencies: synthetic_root_deps + .into_iter() .sorted_unstable() + .unique() .collect(), }); } diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index c1b96bb47..ef7f02248 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -6257,6 +6257,93 @@ fn cyclonedx_export_workspace_all_packages() -> Result<()> { Ok(()) } +#[test] +fn cyclonedx_export_all_packages_non_workspace_root_dependency() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "my-project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["urllib3==2.2.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--all-packages"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "my-project-3", + "name": "my-project" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "urllib3-2@2.2.0", + "name": "urllib3", + "version": "2.2.0", + "purl": "pkg:pypi/urllib3@2.2.0" + }, + { + "type": "library", + "bom-ref": "my-project-1@0.1.0", + "name": "my-project", + "version": "0.1.0" + } + ], + "dependencies": [ + { + "ref": "my-project-1@0.1.0", + "dependsOn": [ + "urllib3-2@2.2.0" + ] + }, + { + "ref": "urllib3-2@2.2.0", + "dependsOn": [] + }, + { + "ref": "my-project-3", + "dependsOn": [ + "my-project-1@0.1.0" + ] + } + ] + } + ----- stderr ----- + Resolved 2 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + // Contains a combination of combination of workspace and registry deps, with another workspace dep not depended on by the root #[test] fn cyclonedx_export_workspace_mixed_dependencies() -> Result<()> {