From 06f50f5b6f88027d4d63afa0a3e2b4ff3dd29eab Mon Sep 17 00:00:00 2001 From: arewm Date: Wed, 14 Oct 2026 16:35:21 -0400 Subject: [PATCH] sbom: Derive document name and namespace from downstream purl The document header previously used the resolved upstream version, so it could disagree with the SPDXRef-wheel package entry it DESCRIBES when purl name/version overrides were set. A random UUID suffix keeps the namespace unique per generated document as SPDX 2.3 requires, and percent-encoding the name and version keeps it a valid URI even when overrides contain URI-hostile characters. Assisted-by: goose (glm-5-3) Signed-off-by: arewm --- src/fromager/packagesettings/_models.py | 25 +++++- src/fromager/sbom.py | 27 ++++-- tests/test_sbom.py | 109 ++++++++++++++++++++++-- 3 files changed, 147 insertions(+), 14 deletions(-) diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index a3ad12816..896e0eaa5 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -95,7 +95,18 @@ class SbomSettings(pydantic.BaseModel): """SPDX supplier field for the wheel package (e.g. ``Organization: ExampleCo``)""" namespace: AnyUrl = AnyUrl("https://spdx.org/spdxdocs") - """Base URL for the SPDX documentNamespace""" + """Base URL for the SPDX documentNamespace + + The final document namespace is + ``/--.spdx.json``, where ``name`` + and ``version`` come from the downstream purl (including any + ``purl`` field overrides, percent-encoded to keep the namespace a + valid URI) and the UUID is randomly generated per document to + guarantee uniqueness. The downstream purl is used because it is + the identity the document actually describes, keeping the document + header consistent with the wheel package entry. The upstream + version is recorded separately in the upstream purl. + """ creators: list[str] = Field(default_factory=list) """Additional SPDX creator entries (e.g. ``Organization: ExampleCo``) @@ -296,10 +307,18 @@ class PurlConfig(pydantic.BaseModel): """Override the purl namespace component""" name: str | None = None - """Override the purl name component (defaults to the package name)""" + """Override the purl name component (defaults to the package name) + + Also determines the name used in the SBOM document name and + documentNamespace. + """ version: str | None = None - """Override the purl version component (defaults to the resolved version)""" + """Override the purl version component (defaults to the resolved version) + + Also determines the version used in the SBOM document name and + documentNamespace. + """ repository_url: AnyUrl | None = None """Per-package override for the purl ``repository_url`` qualifier. diff --git a/src/fromager/sbom.py b/src/fromager/sbom.py index 724aa7e2a..9ec4372f9 100644 --- a/src/fromager/sbom.py +++ b/src/fromager/sbom.py @@ -11,7 +11,9 @@ import logging import pathlib import typing +import uuid from datetime import UTC, datetime +from urllib.parse import quote from packageurl import PackageURL from packaging.requirements import Requirement @@ -96,6 +98,10 @@ def generate_sbom( The document contains the downstream wheel as the primary package, the upstream source as a second package, and DESCRIBES / GENERATED_FROM relationships. + + The document name and namespace are derived from the downstream + purl (including any ``purl`` field overrides), so the document + identity always matches the wheel package entry it describes. """ sbom_settings = ctx.settings.sbom_settings if sbom_settings is None: @@ -109,8 +115,6 @@ def generate_sbom( creators = list(sbom_settings.creators) creators.append(f"Tool: fromager-{fromager_version}") - namespace = f"{sbom_settings.namespace!s}/{name}-{version}.spdx.json" - downstream = _build_downstream_purl( name=name, version=version, @@ -124,10 +128,23 @@ def generate_sbom( sbom_settings=sbom_settings, ) + # Derive the document identity from the downstream purl so the + # document and the package it describes cannot drift apart. The + # random UUID keeps the namespace unique per generated document, + # as required by SPDX 2.3. The name and version are percent-encoded + # so the namespace is always a valid URI, even when purl overrides + # contain URI-hostile characters. + doc_name = downstream.name + doc_version = downstream.version or str(version) + namespace = ( + f"{sbom_settings.namespace!s}/{quote(doc_name, safe='')}" + f"-{quote(doc_version, safe='')}-{uuid.uuid4()}.spdx.json" + ) + wheel_entry: dict[str, typing.Any] = { "SPDXID": "SPDXRef-wheel", - "name": downstream.name, - "versionInfo": downstream.version or str(version), + "name": doc_name, + "versionInfo": doc_version, "downloadLocation": "NOASSERTION", "supplier": sbom_settings.supplier, "externalRefs": [ @@ -158,7 +175,7 @@ def generate_sbom( "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", - "name": f"{name}-{version}", + "name": f"{doc_name}-{doc_version}", "documentNamespace": namespace, "creationInfo": { "created": timestamp, diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 7a307d53d..50e38e075 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -1,6 +1,8 @@ import json import pathlib import typing +import uuid +from unittest.mock import patch from conftest import make_sbom_ctx from packaging.requirements import Requirement @@ -88,16 +90,18 @@ def test_generate_sbom_custom_settings(tmp_path: pathlib.Path) -> None: creators=["Organization: ExampleCo"], ) ctx = make_sbom_ctx(tmp_path, sbom_settings=settings) - doc = sbom.generate_sbom( - ctx=ctx, - req=Requirement("my-package==2.0.0"), - version=Version("2.0.0"), - ) + fixed_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678") + with patch("fromager.sbom.uuid.uuid4", return_value=fixed_uuid): + doc = sbom.generate_sbom( + ctx=ctx, + req=Requirement("my-package==2.0.0"), + version=Version("2.0.0"), + ) wheel = doc["packages"][0] assert wheel["supplier"] == "Organization: ExampleCo" assert doc["documentNamespace"] == ( - "https://www.example.com/my-package-2.0.0.spdx.json" + f"https://www.example.com/my-package-2.0.0-{fixed_uuid}.spdx.json" ) creators = doc["creationInfo"]["creators"] assert "Organization: ExampleCo" in creators @@ -105,6 +109,97 @@ def test_generate_sbom_custom_settings(tmp_path: pathlib.Path) -> None: _validate_spdx(doc) +def test_generate_sbom_document_identity_follows_purl_version_override( + tmp_path: pathlib.Path, +) -> None: + """Verify document name and namespace follow the purl version override. + + The document header must identify the built wheel, not the resolved + upstream version, and must not disagree with the package it + DESCRIBES. + """ + ctx = make_sbom_ctx( + tmp_path, + sbom_settings=SbomSettings(), + package_overrides={ + "purl": { + "version": "0.5.5+corp.1", + "upstream": "pkg:pypi/test-pkg@0.5.5", + }, + }, + ) + doc = sbom.generate_sbom( + ctx=ctx, + req=Requirement("test-pkg==0.5.5"), + version=Version("0.5.5"), + ) + + assert doc["name"] == "test-pkg-0.5.5+corp.1" + assert doc["documentNamespace"].startswith( + "https://spdx.org/spdxdocs/test-pkg-0.5.5%2Bcorp.1-" + ) + assert doc["documentNamespace"].endswith(".spdx.json") + wheel = doc["packages"][0] + assert wheel["name"] == "test-pkg" + assert wheel["versionInfo"] == "0.5.5+corp.1" + upstream = doc["packages"][1] + assert upstream["versionInfo"] == "0.5.5" + _validate_spdx(doc) + + +def test_generate_sbom_namespace_is_unique_per_document( + tmp_path: pathlib.Path, +) -> None: + """Verify each generated document gets a distinct namespace.""" + ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) + kwargs: dict[str, typing.Any] = { + "ctx": ctx, + "req": Requirement("test-pkg==0.5.5"), + "version": Version("0.5.5"), + } + + doc1 = sbom.generate_sbom(**kwargs) + doc2 = sbom.generate_sbom(**kwargs) + + assert doc1["name"] == doc2["name"] + assert doc1["documentNamespace"] != doc2["documentNamespace"] + _validate_spdx(doc1) + _validate_spdx(doc2) + + +def test_generate_sbom_namespace_percent_encodes_purl_overrides( + tmp_path: pathlib.Path, +) -> None: + """Verify URI-hostile purl overrides are percent-encoded in the namespace.""" + ctx = make_sbom_ctx( + tmp_path, + sbom_settings=SbomSettings(), + package_overrides={ + "purl": { + "name": "my package", + "version": "1.0 beta", + }, + }, + ) + doc = sbom.generate_sbom( + ctx=ctx, + req=Requirement("test-pkg==1.0"), + version=Version("1.0"), + ) + + # Document and package names keep the raw override values + assert doc["name"] == "my package-1.0 beta" + wheel = doc["packages"][0] + assert wheel["name"] == "my package" + assert wheel["versionInfo"] == "1.0 beta" + # The namespace percent-encodes them so it stays a valid URI + namespace = doc["documentNamespace"] + assert namespace.startswith("https://spdx.org/spdxdocs/my%20package-1.0%20beta-") + assert " " not in namespace + assert namespace.endswith(".spdx.json") + _validate_spdx(doc) + + def test_generate_sbom_purl_field_overrides(tmp_path: pathlib.Path) -> None: """Verify individual purl field overrides work.""" ctx = make_sbom_ctx( @@ -127,6 +222,8 @@ def test_generate_sbom_purl_field_overrides(tmp_path: pathlib.Path) -> None: assert upstream["externalRefs"][0]["referenceLocator"] == ( "pkg:generic/custom-name@1.0.0" ) + # Document identity follows the purl name override + assert doc["name"] == "custom-name-1.0.0" _validate_spdx(doc)