From dba98b34542440188877bf19337345395bbf5d24 Mon Sep 17 00:00:00 2001 From: Emre K <110906681+kocaemre@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:47:29 +0200 Subject: [PATCH] fix(sbom): keep wheel purl settings out of upstream identity Derive the default source purl from the resolved package, while preserving explicit upstream purls. Document the configuration migration and cover name, type, namespace, and global type overrides. Co-Authored-By: Hermes Agent Closes: #1383 Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com> --- docs/reference/config-reference.rst | 10 +++++++ src/fromager/packagesettings/_models.py | 9 +++--- src/fromager/sbom.py | 16 ++++------ tests/test_sbom.py | 40 +++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/docs/reference/config-reference.rst b/docs/reference/config-reference.rst index f9c1df4d0..0429c379b 100644 --- a/docs/reference/config-reference.rst +++ b/docs/reference/config-reference.rst @@ -26,6 +26,16 @@ For example `flash_attn.yaml`. .. autopydantic_model:: fromager.packagesettings.SbomSettings +SBOM upstream purls +^^^^^^^^^^^^^^^^^^^ + +When ``purl.upstream`` is absent, the upstream SBOM entry identifies the +resolved source as ``pkg:pypi/@``. The +per-package ``purl.name``, ``purl.type``, and ``purl.namespace`` settings and +the global ``sbom.purl_type`` setting affect the wheel entry only. Previously, +those settings also changed the upstream entry. Set ``purl.upstream`` to the +full source purl when the source is not the resolved PyPI package. + Source Resolver ^^^^^^^^^^^^^^^ diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index 7aa54e0c0..0e6e42a2a 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -115,7 +115,7 @@ class SbomSettings(pydantic.BaseModel): """ purl_type: PurlType = "pypi" - """Default purl type for all packages (e.g. ``pypi``, ``generic``)""" + """Default purl type for wheel package entries (e.g. ``pypi``, ``generic``)""" repository_url: AnyUrl | None = None """Default purl ``repository_url`` qualifier for all packages @@ -333,9 +333,10 @@ class PurlConfig(pydantic.BaseModel): GENERATED_FROM relationship. Used for packages sourced from GitHub/GitLab rather than PyPI. - When absent, the upstream purl keeps the downstream name, type, and - namespace but uses the resolved source version instead of a local - downstream version override. The ``repository_url`` qualifier is omitted. + When absent, the upstream purl is ``pkg:pypi/@``. Wheel-only name, type, namespace, and version overrides and + the global ``sbom.purl_type`` do not change it. For non-PyPI sources, + set ``purl.upstream`` explicitly. """ diff --git a/src/fromager/sbom.py b/src/fromager/sbom.py index 1ce7a4092..1828cf970 100644 --- a/src/fromager/sbom.py +++ b/src/fromager/sbom.py @@ -62,26 +62,21 @@ def _build_upstream_purl( name: NormalizedName, version: Version, pbi: PackageBuildInfo, - sbom_settings: SbomSettings, ) -> PackageURL: """Build the upstream source package URL. If ``upstream`` is set in the per-package ``PurlConfig``, it is - used as-is. Otherwise, the upstream purl uses the resolved source - version rather than a downstream wheel version override, and excludes - the ``repository_url`` qualifier. + used as-is. Otherwise, the source is identified by its resolved + PyPI name and version, regardless of downstream wheel purl overrides. """ pc = pbi.purl_config if pc and pc.upstream: return PackageURL.from_string(pc.upstream) - purl_type = pc.type if pc else None - purl_namespace = pc.namespace if pc else None - purl_name = pc.name if pc else None return PackageURL( - type=purl_type or sbom_settings.purl_type, - namespace=purl_namespace, - name=purl_name or name, + type="pypi", + namespace=None, + name=name, version=str(version), ) @@ -124,7 +119,6 @@ def generate_sbom( name=name, version=version, pbi=pbi, - sbom_settings=sbom_settings, ) # Derive the document identity from the downstream purl so the diff --git a/tests/test_sbom.py b/tests/test_sbom.py index b228c3fba..8e60fee6f 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -4,6 +4,7 @@ import uuid from unittest.mock import patch +import pytest from conftest import make_sbom_ctx from packaging.requirements import Requirement from packaging.version import Version @@ -174,6 +175,41 @@ def test_generate_sbom_upstream_uses_source_version_without_upstream_override( _validate_spdx(doc) +@pytest.mark.parametrize( + ("package_purl", "sbom_type", "expected_wheel"), + [ + ({"name": "test-pkg-corp"}, "pypi", "pkg:pypi/test-pkg-corp@0.5.5"), + ({"type": "generic"}, "pypi", "pkg:generic/test-pkg@0.5.5"), + ({"namespace": "acme"}, "pypi", "pkg:pypi/acme/test-pkg@0.5.5"), + ({}, "generic", "pkg:generic/test-pkg@0.5.5"), + ], +) +def test_generate_sbom_upstream_ignores_wheel_purl_overrides( + tmp_path: pathlib.Path, + package_purl: dict[str, str], + sbom_type: str, + expected_wheel: str, +) -> None: + """Wheel-only purl settings must not rewrite the source identity.""" + ctx = make_sbom_ctx( + tmp_path, + sbom_settings=SbomSettings(purl_type=sbom_type), + package_overrides={"purl": package_purl}, + ) + doc = sbom.generate_sbom( + ctx=ctx, + req=Requirement("test-pkg==0.5.5"), + version=Version("0.5.5"), + ) + + wheel, upstream = doc["packages"] + assert wheel["externalRefs"][0]["referenceLocator"] == expected_wheel + assert upstream["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@0.5.5" + ) + _validate_spdx(doc) + + def test_generate_sbom_namespace_is_unique_per_document( tmp_path: pathlib.Path, ) -> None: @@ -245,9 +281,9 @@ def test_generate_sbom_purl_field_overrides(tmp_path: pathlib.Path) -> None: assert wheel["externalRefs"][0]["referenceLocator"] == ( "pkg:generic/custom-name@1.0.0" ) - # Field overrides carry through to upstream (without qualifiers) + # Wheel field overrides must not change the resolved source identity. assert upstream["externalRefs"][0]["referenceLocator"] == ( - "pkg:generic/custom-name@1.0.0" + "pkg:pypi/test-pkg@1.0.0" ) # Document identity follows the purl name override assert doc["name"] == "custom-name-1.0.0"