Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/fromager/packagesettings/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
``<namespace>/<name>-<version>-<uuid>.spdx.json``, where ``name``
and ``version`` come from the downstream purl (including any
Comment thread
mprpic marked this conversation as resolved.
``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``)
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 22 additions & 5 deletions src/fromager/sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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": [
Expand Down Expand Up @@ -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,
Expand Down
109 changes: 103 additions & 6 deletions tests/test_sbom.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -88,23 +90,116 @@ 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
assert any("fromager" in c for c in creators)
_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(
Expand All @@ -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)


Expand Down
Loading