Skip to content
Open
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
10 changes: 10 additions & 0 deletions docs/reference/config-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package name>@<resolved version>``. 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
^^^^^^^^^^^^^^^

Expand Down
9 changes: 5 additions & 4 deletions src/fromager/packagesettings/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<package name>@<resolved
version>``. 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.
"""


Expand Down
16 changes: 5 additions & 11 deletions src/fromager/sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand Down Expand Up @@ -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
Expand Down
40 changes: 38 additions & 2 deletions tests/test_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
Loading