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
7 changes: 6 additions & 1 deletion src/fromager/packagesettings/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,10 +314,15 @@ class PurlConfig(pydantic.BaseModel):
"""

version: str | None = None
"""Override the purl version component (defaults to the resolved version)
"""Override the purl version component (defaults to the wheel version)

Also determines the version used in the SBOM document name and
documentNamespace.

.. versionchanged:: 0.102.0
The default is now the version of the built wheel, including any
local version added by a patch, instead of the resolved upstream
version.
"""

repository_url: AnyUrl | None = None
Expand Down
12 changes: 10 additions & 2 deletions src/fromager/sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def generate_sbom(
ctx: context.WorkContext,
req: Requirement,
version: Version,
wheel_version: Version | None = None,
) -> dict[str, typing.Any]:
"""Generate a minimal SPDX 2.3 JSON document for a wheel.

Expand All @@ -101,6 +102,13 @@ def generate_sbom(
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.

``version`` is the resolved upstream version. ``wheel_version`` is
the version of the wheel actually built, which may differ when a
patch adds a local version (e.g. ``1.2.3+local.1``). The wheel
package entry, its purl, and the document identity use
``wheel_version`` when given; the upstream package entry always
uses ``version``.
"""
sbom_settings = ctx.settings.sbom_settings
if sbom_settings is None:
Expand All @@ -116,7 +124,7 @@ def generate_sbom(

downstream = _build_downstream_purl(
name=name,
version=version,
version=wheel_version or version,
pbi=pbi,
sbom_settings=sbom_settings,
)
Expand All @@ -134,7 +142,7 @@ def generate_sbom(
# 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)
doc_version = downstream.version or str(wheel_version or version)
namespace = (
f"{sbom_settings.namespace!s}/{quote(doc_name, safe='')}"
f"-{quote(doc_version, safe='')}-{uuid.uuid4()}.spdx.json"
Expand Down
4 changes: 4 additions & 0 deletions src/fromager/wheels.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ def add_extra_metadata_to_wheels(
ctx=ctx,
req=req,
version=version,
# dist_version comes from the wheel filename and includes
# any local version added by a patch, so the SBOM
# identifies the wheel that actually contains it
wheel_version=dist_version,
)
sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir)

Expand Down
52 changes: 52 additions & 0 deletions tests/test_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,58 @@ def test_generate_sbom_upstream_uses_source_version_without_upstream_override(
_validate_spdx(doc)


def test_generate_sbom_wheel_version_used_without_purl_override(
tmp_path: pathlib.Path,
) -> None:
"""Verify the wheel entry uses the actual wheel version automatically.

When a patch adds a local version, the SBOM must identify the built
wheel without requiring a per-package ``purl.version`` override.
"""
ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings())
doc = sbom.generate_sbom(
ctx=ctx,
req=Requirement("test-pkg==0.5.5"),
version=Version("0.5.5"),
wheel_version=Version("0.5.5+local.1"),
)

assert doc["name"] == "test-pkg-0.5.5+local.1"
wheel, upstream = doc["packages"]
assert wheel["versionInfo"] == "0.5.5+local.1"
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"
)
_validate_spdx(doc)


def test_generate_sbom_purl_version_override_wins_over_wheel_version(
tmp_path: pathlib.Path,
) -> None:
"""Verify an explicit purl.version override still takes precedence."""
ctx = make_sbom_ctx(
tmp_path,
sbom_settings=SbomSettings(),
package_overrides={"purl": {"version": "0.5.5+corp.1"}},
)
doc = sbom.generate_sbom(
ctx=ctx,
req=Requirement("test-pkg==0.5.5"),
version=Version("0.5.5"),
wheel_version=Version("0.5.5+local.1"),
)

wheel = doc["packages"][0]
assert wheel["versionInfo"] == "0.5.5+corp.1"
assert doc["name"] == "test-pkg-0.5.5+corp.1"
_validate_spdx(doc)


def test_generate_sbom_namespace_is_unique_per_document(
tmp_path: pathlib.Path,
) -> None:
Expand Down
39 changes: 33 additions & 6 deletions tests/test_wheels.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import pathlib
import typing
import zipfile
Expand Down Expand Up @@ -139,44 +140,57 @@ def test_log_existing_sboms_when_absent(
def test_add_extra_metadata_generates_sbom_when_enabled(
mock_run: Mock, tmp_path: pathlib.Path
) -> None:
"""Verify SBOM is generated in .dist-info/sboms/ when sbom settings are configured."""
"""Verify SBOM is generated in .dist-info/sboms/ when sbom settings are configured.

The wheel carries a local version (as added by a patch) while the
resolved version has none, so the SBOM must describe the wheel it
is embedded in, not the upstream release.
"""
sbom_ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings())
sbom_ctx.setup()
req = Requirement("test_pkg==1.0.0")
version = Version("1.0.0")

wheel_dir = tmp_path / "wheel_build"
wheel_dir.mkdir()
wheel_file = wheel_dir / "test_pkg-1.0.0-py3-none-any.whl"
wheel_file = wheel_dir / "test_pkg-1.0.0+local.1-py3-none-any.whl"

with zipfile.ZipFile(wheel_file, "w") as zf:
zf.writestr("test_pkg/__init__.py", "")
zf.writestr(
"test_pkg-1.0.0.dist-info/METADATA",
"Name: test_pkg\nVersion: 1.0.0\n",
"test_pkg-1.0.0+local.1.dist-info/METADATA",
"Name: test_pkg\nVersion: 1.0.0+local.1\n",
)
zf.writestr(
"test_pkg-1.0.0.dist-info/WHEEL",
"test_pkg-1.0.0+local.1.dist-info/WHEEL",
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
)

mock_run.return_value = ""

# Create the repacked wheel that wheel pack would produce
repacked = wheel_dir / "test_pkg-1.0.0-0-py3-none-any.whl"
repacked = wheel_dir / "test_pkg-1.0.0+local.1-0-py3-none-any.whl"

sdist_dir = tmp_path / "sdist"
sdist_dir.mkdir()

# Capture the wheel contents before repack by inspecting what wheel pack receives
captured_contents: list[str] = []
captured_sbom: dict[str, typing.Any] = {}

def fake_run(cmd: list[str], **kwargs: object) -> str:
# wheel pack is called with the unpacked dir as second arg
unpacked_dir = pathlib.Path(cmd[2])
for f in unpacked_dir.rglob("*"):
if f.is_file():
captured_contents.append(str(f.relative_to(unpacked_dir)))
sbom_file = (
unpacked_dir
/ "test_pkg-1.0.0+local.1.dist-info"
/ "sboms"
/ ("fromager.spdx.json")
)
captured_sbom.update(json.loads(sbom_file.read_text()))
repacked.touch()
return ""

Expand All @@ -194,6 +208,19 @@ def fake_run(cmd: list[str], **kwargs: object) -> str:
# Verify the SBOM file was added to the unpacked wheel before repacking
assert any("sboms/fromager.spdx.json" in c for c in captured_contents)

# The SBOM must identify the wheel with its local version
assert captured_sbom["name"] == "test-pkg-1.0.0+local.1"
wheel_entry, upstream_entry = captured_sbom["packages"]
assert wheel_entry["versionInfo"] == "1.0.0+local.1"
assert wheel_entry["externalRefs"][0]["referenceLocator"] == (
"pkg:pypi/test-pkg@1.0.0%2Blocal.1"
)
# The upstream entry still identifies the resolved source release
assert upstream_entry["versionInfo"] == "1.0.0"
assert upstream_entry["externalRefs"][0]["referenceLocator"] == (
"pkg:pypi/test-pkg@1.0.0"
)


def test_download_wheel_unquotes_url_encoded_filenames(tmp_path: pathlib.Path) -> None:
"""Test that download_wheel properly unquotes URL-encoded characters in filenames."""
Expand Down
Loading