From f9dcb0cf5eb27eb712258e0789758a9cd601d56a 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 1/2] 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 | 41 +++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/docs/reference/config-reference.rst b/docs/reference/config-reference.rst index 4d305de8..bd5c07f9 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 5ff69fc8..28476220 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -120,7 +120,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 @@ -343,9 +343,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 0c41dd98..80ef44bc 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), ) @@ -132,7 +127,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 47e689a6..e926275e 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 @@ -196,7 +197,6 @@ def test_generate_sbom_wheel_version_used_without_purl_override( assert wheel["externalRefs"][0]["referenceLocator"] == ( "pkg:pypi/test-pkg@0.5.5%2Blocal.1" ) - # Upstream still identifies the resolved source release assert upstream["versionInfo"] == "0.5.5" assert upstream["externalRefs"][0]["referenceLocator"] == ( "pkg:pypi/test-pkg@0.5.5" @@ -204,6 +204,41 @@ def test_generate_sbom_wheel_version_used_without_purl_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_purl_version_override_wins_over_wheel_version( tmp_path: pathlib.Path, ) -> None: @@ -297,9 +332,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" From 40cd10bbac16f60e187d723f526390cdbf407b31 Mon Sep 17 00:00:00 2001 From: Emre K <110906681+kocaemre@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:09:12 +0100 Subject: [PATCH 2/2] test(sbom): guard upstream package name against wheel overrides Co-Authored-By: Hermes Agent Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com> --- tests/test_sbom.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_sbom.py b/tests/test_sbom.py index e926275e..8bc081cc 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -233,6 +233,7 @@ def test_generate_sbom_upstream_ignores_wheel_purl_overrides( wheel, upstream = doc["packages"] assert wheel["externalRefs"][0]["referenceLocator"] == expected_wheel + assert upstream["name"] == "test-pkg" assert upstream["externalRefs"][0]["referenceLocator"] == ( "pkg:pypi/test-pkg@0.5.5" )