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"