diff --git a/docs/reference/files.md b/docs/reference/files.md index 117377c8c..a402d2464 100644 --- a/docs/reference/files.md +++ b/docs/reference/files.md @@ -114,6 +114,38 @@ The following example shows `graph.json` file for the top-level dependency `whee } ``` +## Wheel SBOMs + +When SBOM generation is enabled, Fromager writes the canonical SPDX 2.3 +document to `.dist-info/sboms/fromager.spdx.json`. During source builds, +CycloneDX SBOMs generated by Maturin are read from the same directory and +their components are merged into the canonical SPDX document. Each imported +component is related to the wheel with `CONTAINS`; the CycloneDX dependency +graph is not copied. Nested target components are included, while components +with CycloneDX scope `excluded` are omitted because they are not shipped +runtime dependencies. Local `file://` download qualifiers are removed from +imported PURLs because those paths are only meaningful in the build +environment. + +A CycloneDX root with a PyPI PURL matching the wheel's normalized name and +version is associated with the wheel package, so auditwheel components are not +attached to the upstream source. The original CycloneDX files are preserved. + +Maturin must be version 1.12.0 or newer and must be built with its `sbom` +feature enabled. Fromager does not enable that Maturin feature automatically. +For example, a packaging environment can pass Maturin's +`MATURIN_SETUP_ARGS` with a feature set that includes `sbom`. The exact +feature set depends on the platform and packaging environment. + +This merge is applied to wheels processed by Fromager's source-build path. +Downloaded prebuilt wheels retain any native SBOM files, but are not guaranteed +to receive a merged Fromager SPDX document until they go through a separate +post-download processing path. + +```{versionchanged} 0.96.0 +Maturin CycloneDX SBOMs are merged into the canonical Fromager SPDX SBOM. +``` + ## Output Directories During the wheel building process, fromager generates multiple output directories namely `sdists-repo`, `wheels-repo` and `work-dir`. These directories contain important information related to the wheel build. diff --git a/src/fromager/sbom.py b/src/fromager/sbom.py index 724aa7e2a..7115ef1a4 100644 --- a/src/fromager/sbom.py +++ b/src/fromager/sbom.py @@ -6,17 +6,20 @@ from __future__ import annotations +import hashlib import importlib.metadata import json import logging import pathlib +import re import typing from datetime import UTC, datetime +from license_expression import ExpressionError, get_spdx_licensing from packageurl import PackageURL from packaging.requirements import Requirement from packaging.utils import NormalizedName, canonicalize_name -from packaging.version import Version +from packaging.version import InvalidVersion, Version if typing.TYPE_CHECKING: from . import context @@ -181,6 +184,433 @@ def generate_sbom( return doc +_CYCLONEDX_HASH_TO_SPDX = { + "SHA-1": "SHA1", + "SHA-224": "SHA224", + "SHA-256": "SHA256", + "SHA-384": "SHA384", + "SHA-512": "SHA512", + # SPDX 2.3 has no SHA3-224 algorithm, so it is intentionally omitted. + "SHA3-256": "SHA3-256", + "SHA3-384": "SHA3-384", + "SHA3-512": "SHA3-512", + "BLAKE2B-256": "BLAKE2b-256", + "BLAKE2B-384": "BLAKE2b-384", + "BLAKE2B-512": "BLAKE2b-512", +} + +# Reused across components; parsing SPDX license expressions is relatively costly. +_SPDX_LICENSING = get_spdx_licensing() + +# SPDX checksum values must be lowercase or uppercase hexadecimal digits. +_HEX_RE = re.compile(r"[0-9a-fA-F]+") + + +def _cyclonedx_string(value: typing.Any) -> str | None: + if isinstance(value, str) and value: + return value + return None + + +def _cyclonedx_component_purl(component: dict[str, typing.Any]) -> str | None: + return _cyclonedx_string(component.get("purl")) + + +def _cyclonedx_spdx_purl(component: dict[str, typing.Any]) -> str | None: + purl = _cyclonedx_component_purl(component) + if purl is None: + return None + try: + parsed_purl = PackageURL.from_string(purl) + except ValueError: + return purl + + qualifiers = dict(parsed_purl.qualifiers or {}) + download_url = qualifiers.get("download_url") + if download_url is None or not download_url.startswith("file://"): + return purl + del qualifiers["download_url"] + return PackageURL( + type=parsed_purl.type, + namespace=parsed_purl.namespace, + name=parsed_purl.name, + version=parsed_purl.version, + qualifiers=qualifiers or None, + subpath=parsed_purl.subpath, + ).to_string() + + +def _iter_cyclonedx_components( + component: dict[str, typing.Any], +) -> typing.Iterator[dict[str, typing.Any]]: + yield component + nested_components = component.get("components") + if not isinstance(nested_components, list): + return + for nested_component in nested_components: + if isinstance(nested_component, dict): + yield from _iter_cyclonedx_components(nested_component) + + +def _versions_match(left: str, right: str) -> bool: + try: + return Version(left) == Version(right) + except InvalidVersion: + return left == right + + +def _is_python_wheel_root( + component: dict[str, typing.Any], + sbom: dict[str, typing.Any], +) -> bool: + purl = _cyclonedx_component_purl(component) + if purl is None: + return False + try: + parsed_purl = PackageURL.from_string(purl) + except ValueError: + return False + if ( + parsed_purl.type != "pypi" + or parsed_purl.name is None + or parsed_purl.version is None + ): + return False + + component_name = _cyclonedx_string(component.get("name")) + component_version = _cyclonedx_string(component.get("version")) + if component_name is None or component_version is None: + return False + + packages = sbom.get("packages") + if not isinstance(packages, list): + return False + wheel = next( + ( + package + for package in packages + if isinstance(package, dict) and package.get("SPDXID") == "SPDXRef-wheel" + ), + None, + ) + if wheel is None: + return False + + wheel_name = _cyclonedx_string(wheel.get("name")) + wheel_version = _cyclonedx_string(wheel.get("versionInfo")) + if wheel_name is None or wheel_version is None: + return False + return ( + canonicalize_name(parsed_purl.name) == canonicalize_name(wheel_name) + and _versions_match(parsed_purl.version, wheel_version) + and canonicalize_name(component_name) == canonicalize_name(wheel_name) + and _versions_match(component_version, wheel_version) + ) + + +def _cyclonedx_component_identity(component: dict[str, typing.Any]) -> str: + purl = _cyclonedx_spdx_purl(component) + if purl: + return f"purl:{purl}" + + return "component:" + "\x00".join( + [ + _cyclonedx_string(component.get("type")) or "", + _cyclonedx_string(component.get("group")) or "", + _cyclonedx_string(component.get("name")) or "", + _cyclonedx_string(component.get("version")) or "", + ] + ) + + +def _cyclonedx_spdx_id(component: dict[str, typing.Any]) -> str: + identity = _cyclonedx_component_identity(component) + digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:20] + return f"SPDXRef-cyclonedx-{digest}" + + +def _cyclonedx_license_expression( + component: dict[str, typing.Any], +) -> str | None: + licenses = component.get("licenses") + if not isinstance(licenses, list): + return None + + expressions: list[str] = [] + for license_choice in licenses: + if not isinstance(license_choice, dict): + continue + expression = _cyclonedx_string(license_choice.get("expression")) + if not expression: + license_info = license_choice.get("license") + if isinstance(license_info, dict): + expression = _cyclonedx_string(license_info.get("id")) + if not expression: + continue + if _valid_spdx_expression(expression): + expressions.append(expression) + else: + logger.warning( + "component %s has an invalid SPDX license expression %r; skipping it", + _cyclonedx_component_purl(component) or component.get("name"), + expression, + ) + + if not expressions: + return None + return " AND ".join(expressions) + + +def _valid_spdx_expression(expression: str) -> bool: + """Return True if *expression* is a valid SPDX license expression.""" + try: + _SPDX_LICENSING.parse(expression, validate=True) + except ExpressionError: + return False + return True + + +def _cyclonedx_checksums( + component: dict[str, typing.Any], +) -> list[dict[str, str]]: + hashes = component.get("hashes") + if not isinstance(hashes, list): + return [] + + checksums: list[dict[str, str]] = [] + for hash_info in hashes: + if not isinstance(hash_info, dict): + continue + algorithm = _cyclonedx_string(hash_info.get("alg")) + content = _cyclonedx_string(hash_info.get("content")) + if not algorithm or not content: + continue + normalized_algorithm = _CYCLONEDX_HASH_TO_SPDX.get(algorithm.upper()) + if normalized_algorithm is None: + continue + if not _HEX_RE.fullmatch(content): + logger.warning( + "component %s has a non-hexadecimal %s checksum %r; skipping it", + _cyclonedx_component_purl(component) or component.get("name"), + normalized_algorithm, + content, + ) + continue + checksums.append({"algorithm": normalized_algorithm, "checksumValue": content}) + return checksums + + +def _cyclonedx_package( + component: dict[str, typing.Any], + spdx_id: str, +) -> dict[str, typing.Any]: + purl = _cyclonedx_spdx_purl(component) + name = ( + _cyclonedx_string(component.get("name")) + or purl + or _cyclonedx_string(component.get("bom-ref")) + or "unknown" + ) + version = _cyclonedx_string(component.get("version")) or "NOASSERTION" + package: dict[str, typing.Any] = { + "SPDXID": spdx_id, + "name": name, + "versionInfo": version, + "downloadLocation": "NOASSERTION", + "supplier": "NOASSERTION", + } + + if purl: + package["externalRefs"] = [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": purl, + } + ] + + checksums = _cyclonedx_checksums(component) + if checksums: + package["checksums"] = checksums + + license_expression = _cyclonedx_license_expression(component) + if license_expression: + package["licenseDeclared"] = license_expression + + scope = _cyclonedx_string(component.get("scope")) + if scope and scope != "required": + package["comment"] = f"CycloneDX scope: {scope}" + + return package + + +def _merge_cyclonedx_package( + package: dict[str, typing.Any], + component: dict[str, typing.Any], +) -> None: + """Add non-conflicting metadata from a duplicate CycloneDX component.""" + component_checksums = _cyclonedx_checksums(component) + if component_checksums: + checksums = package.setdefault("checksums", []) + if isinstance(checksums, list): + existing_checksums = { + (item.get("algorithm"), item.get("checksumValue")) + for item in checksums + if isinstance(item, dict) + } + for checksum in component_checksums: + key = (checksum["algorithm"], checksum["checksumValue"]) + if key not in existing_checksums: + checksums.append(checksum) + existing_checksums.add(key) + + license_expression = _cyclonedx_license_expression(component) + if license_expression and "licenseDeclared" not in package: + package["licenseDeclared"] = license_expression + + scope = _cyclonedx_string(component.get("scope")) + if scope and scope != "required" and "comment" not in package: + package["comment"] = f"CycloneDX scope: {scope}" + + +def _purl_to_spdx_id(sbom: dict[str, typing.Any]) -> dict[str, str]: + purls: dict[str, str] = {} + packages = sbom.get("packages") + if not isinstance(packages, list): + return purls + + for package in packages: + if not isinstance(package, dict): + continue + spdx_id = _cyclonedx_string(package.get("SPDXID")) + external_refs = package.get("externalRefs") + if not spdx_id or not isinstance(external_refs, list): + continue + for external_ref in external_refs: + if not isinstance(external_ref, dict): + continue + if external_ref.get("referenceType") != "purl": + continue + purl = _cyclonedx_string(external_ref.get("referenceLocator")) + if purl: + purls[purl] = spdx_id + return purls + + +def _add_spdx_relationship( + sbom: dict[str, typing.Any], + relationship: tuple[str, str, str], +) -> None: + relationships = sbom.setdefault("relationships", []) + if not isinstance(relationships, list): + return + existing = { + ( + item.get("spdxElementId"), + item.get("relationshipType"), + item.get("relatedSpdxElement"), + ) + for item in relationships + if isinstance(item, dict) + } + if relationship not in existing: + relationships.append( + { + "spdxElementId": relationship[0], + "relationshipType": relationship[1], + "relatedSpdxElement": relationship[2], + } + ) + + +def merge_cyclonedx_sboms( + *, + sbom: dict[str, typing.Any], + sboms_dir: pathlib.Path, +) -> None: + """Merge CycloneDX components into a Fromager SPDX document. + + Imported components are related to the wheel with ``CONTAINS``. Local file + download qualifiers are removed from their PURLs. The original CycloneDX + files are only read and remain alongside ``fromager.spdx.json``. + """ + if not sboms_dir.is_dir(): + return + + purl_to_spdx_id = _purl_to_spdx_id(sbom) + identity_to_spdx_id: dict[str, str] = {} + packages = sbom.setdefault("packages", []) + if not isinstance(packages, list): + return + + for sbom_path in sorted(sboms_dir.iterdir()): + if not sbom_path.is_file() or sbom_path.name == SBOM_FILENAME: + continue + try: + with sbom_path.open(encoding="utf-8") as sbom_file: + cyclonedx = json.load(sbom_file) + except (OSError, json.JSONDecodeError) as err: + logger.warning("could not read SBOM file %s: %s", sbom_path, err) + continue + + if not isinstance(cyclonedx, dict) or cyclonedx.get("bomFormat") != "CycloneDX": + continue + + metadata = cyclonedx.get("metadata") + root = metadata.get("component") if isinstance(metadata, dict) else None + components: list[tuple[dict[str, typing.Any], bool]] = [] + if isinstance(root, dict): + components.extend( + (component, component is root) + for component in _iter_cyclonedx_components(root) + ) + raw_components = cyclonedx.get("components") + if isinstance(raw_components, list): + for component in raw_components: + if isinstance(component, dict): + components.extend( + (nested_component, False) + for nested_component in _iter_cyclonedx_components(component) + ) + + for component, is_root in components: + if _cyclonedx_string(component.get("scope")) == "excluded": + continue + + identity = _cyclonedx_component_identity(component) + purl = _cyclonedx_spdx_purl(component) + spdx_id: str | None + if is_root and _is_python_wheel_root(component, sbom): + spdx_id = "SPDXRef-wheel" + if purl: + purl_to_spdx_id[purl] = spdx_id + else: + spdx_id = identity_to_spdx_id.get(identity) + if purl: + mapped_spdx_id = purl_to_spdx_id.get(purl) + if mapped_spdx_id is not None: + spdx_id = mapped_spdx_id + if spdx_id is None: + spdx_id = _cyclonedx_spdx_id(component) + packages.append(_cyclonedx_package(component, spdx_id)) + if purl: + purl_to_spdx_id[purl] = spdx_id + else: + package = next( + package + for package in packages + if package.get("SPDXID") == spdx_id + ) + _merge_cyclonedx_package(package, component) + + identity_to_spdx_id[identity] = spdx_id + if spdx_id != "SPDXRef-wheel": + _add_spdx_relationship( + sbom, + ("SPDXRef-wheel", "CONTAINS", spdx_id), + ) + + def write_sbom( *, sbom: dict[str, typing.Any], diff --git a/src/fromager/wheels.py b/src/fromager/wheels.py index dc9bd5241..60ac640ed 100644 --- a/src/fromager/wheels.py +++ b/src/fromager/wheels.py @@ -262,6 +262,10 @@ def add_extra_metadata_to_wheels( req=req, version=version, ) + sbom.merge_cyclonedx_sboms( + sbom=sbom_doc, + sboms_dir=dist_info_dir / "sboms", + ) sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir) build_tag_from_settings = pbi.build_tag(version) diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 7a307d53d..364afa7c1 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -2,9 +2,11 @@ import pathlib import typing +import pytest from conftest import make_sbom_ctx from packaging.requirements import Requirement from packaging.version import Version +from pydantic import AnyUrl from spdx_tools.spdx.parser.jsonlikedict.json_like_dict_parser import ( JsonLikeDictParser, ) @@ -21,6 +23,58 @@ def _validate_spdx(doc: dict[str, typing.Any]) -> None: assert not errors, "\n".join(e.validation_message for e in errors) +def _write_cyclonedx_sbom( + sboms_dir: pathlib.Path, + filename: str, + *, + root_name: str = "example", + root_version: str = "1.0.0", +) -> None: + """Write a representative Maturin CycloneDX 1.5 document.""" + root_purl = f"pkg:cargo/{root_name}@{root_version}" + serde_purl = "pkg:cargo/serde@1.0.0" + pyo3_purl = "pkg:cargo/pyo3@0.21.0" + document = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": f"urn:uuid:{root_name}", + "metadata": { + "tools": [{"vendor": "PyO3", "name": "maturin", "version": "1.15.0"}], + "component": { + "type": "library", + "bom-ref": root_purl, + "name": root_name, + "version": root_version, + "purl": root_purl, + }, + }, + "components": [ + { + "type": "library", + "bom-ref": serde_purl, + "name": "serde", + "version": "1.0.0", + "scope": "required", + "purl": serde_purl, + "hashes": [{"alg": "SHA-256", "content": "a" * 64}], + "licenses": [{"license": {"id": "MIT"}}], + }, + { + "type": "library", + "bom-ref": pyo3_purl, + "name": "pyo3", + "version": "0.21.0", + "scope": "excluded", + "purl": pyo3_purl, + "licenses": [{"expression": "Apache-2.0"}], + }, + ], + "dependencies": [{"ref": root_purl, "dependsOn": [serde_purl, pyo3_purl]}], + } + sboms_dir.mkdir(parents=True, exist_ok=True) + (sboms_dir / filename).write_text(json.dumps(document)) + + def test_generate_sbom_structure(tmp_path: pathlib.Path) -> None: """Verify the generated SBOM has the required SPDX 2.3 fields.""" ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) @@ -285,3 +339,372 @@ def test_write_sbom_preserves_existing_files(tmp_path: pathlib.Path) -> None: assert json.loads(existing.read_text())["bomFormat"] == "CycloneDX" # New file should also exist assert (sboms_dir / "fromager.spdx.json").exists() + + +def test_merge_cyclonedx_sbom_imports_packages_and_relationships( + tmp_path: pathlib.Path, +) -> None: + """Verify Maturin CycloneDX data is represented in SPDX.""" + ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) + document = sbom.generate_sbom( + ctx=ctx, + req=Requirement("example-pkg==1.2.3"), + version=Version("1.2.3"), + ) + sboms_dir = tmp_path / "sboms" + _write_cyclonedx_sbom(sboms_dir, "example.cyclonedx.json") + + sbom.merge_cyclonedx_sboms(sbom=document, sboms_dir=sboms_dir) + + packages = {package["name"]: package for package in document["packages"]} + assert {"example-pkg", "example", "serde"} <= packages.keys() + assert "pyo3" not in packages + serde = packages["serde"] + assert serde["externalRefs"][0]["referenceLocator"] == "pkg:cargo/serde@1.0.0" + assert serde["checksums"] == [{"algorithm": "SHA256", "checksumValue": "a" * 64}] + assert serde["licenseDeclared"] == "MIT" + assert "comment" not in serde + spdx_ids = {package["name"]: package["SPDXID"] for package in document["packages"]} + relationships = { + ( + relationship["spdxElementId"], + relationship["relationshipType"], + relationship["relatedSpdxElement"], + ) + for relationship in document["relationships"] + } + assert ("SPDXRef-wheel", "CONTAINS", spdx_ids["example"]) in relationships + assert ("SPDXRef-wheel", "CONTAINS", spdx_ids["serde"]) in relationships + assert not any( + relationship_type == "DEPENDS_ON" for _, relationship_type, _ in relationships + ) + _validate_spdx(document) + + +def test_cyclonedx_checksums_skips_unsupported_algorithm() -> None: + """SHA3-224 has no SPDX 2.3 equivalent and must be dropped.""" + component = { + "name": "serde", + "hashes": [ + {"alg": "SHA3-224", "content": "a" * 56}, + {"alg": "SHA-256", "content": "b" * 64}, + ], + } + + checksums = sbom._cyclonedx_checksums(component) + + assert checksums == [{"algorithm": "SHA256", "checksumValue": "b" * 64}] + + +def test_cyclonedx_checksums_skips_non_hex_content() -> None: + """Non-hexadecimal checksum content would fail SPDX validation.""" + component = { + "name": "serde", + "hashes": [ + {"alg": "SHA-256", "content": "not-a-valid-hash"}, + {"alg": "SHA-512", "content": "c" * 128}, + ], + } + + checksums = sbom._cyclonedx_checksums(component) + + assert checksums == [{"algorithm": "SHA512", "checksumValue": "c" * 128}] + + +def test_cyclonedx_license_expression_skips_invalid() -> None: + """Invalid SPDX expressions must not be copied into licenseDeclared.""" + component = { + "name": "serde", + "licenses": [ + {"expression": "MIT"}, + {"expression": "Totally Not A License"}, + ], + } + + assert sbom._cyclonedx_license_expression(component) == "MIT" + + +def test_cyclonedx_license_expression_all_invalid_returns_none() -> None: + """A component with only invalid expressions declares no license.""" + component = {"name": "serde", "licenses": [{"license": {"id": "Bogus-9.9"}}]} + + assert sbom._cyclonedx_license_expression(component) is None + + +def test_merge_cyclonedx_sboms_maps_python_root_to_wheel( + tmp_path: pathlib.Path, +) -> None: + """Verify an auditwheel Python root is attached to the wheel package.""" + settings = SbomSettings(repository_url=AnyUrl("https://packages.redhat.com")) + ctx = make_sbom_ctx(tmp_path, sbom_settings=settings) + document = sbom.generate_sbom( + ctx=ctx, + req=Requirement("pillow==12.3.0"), + version=Version("12.3.0"), + ) + sboms_dir = tmp_path / "sboms" + auditwheel = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "metadata": { + "component": { + "type": "library", + "bom-ref": "pkg:pypi/pillow@12.3.0?file_name=pillow.whl", + "name": "Pillow", + "version": "12.3.0", + "purl": "pkg:pypi/pillow@12.3.0?file_name=pillow.whl", + } + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:rpm/almalinux/libXau@1.0.9-3.el8", + "name": "libXau", + "version": "1.0.9-3.el8", + "purl": "pkg:rpm/almalinux/libXau@1.0.9-3.el8", + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/pillow@12.3.0?file_name=pillow.whl", + "dependsOn": ["pkg:rpm/almalinux/libXau@1.0.9-3.el8"], + } + ], + } + sboms_dir.mkdir(parents=True) + (sboms_dir / "auditwheel.cdx.json").write_text(json.dumps(auditwheel)) + + sbom.merge_cyclonedx_sboms(sbom=document, sboms_dir=sboms_dir) + + packages = document["packages"] + assert len(packages) == 3 + rpm = next(package for package in packages if package["name"] == "libXau") + relationships = { + ( + relationship["spdxElementId"], + relationship["relationshipType"], + relationship["relatedSpdxElement"], + ) + for relationship in document["relationships"] + } + assert ("SPDXRef-wheel", "CONTAINS", rpm["SPDXID"]) in relationships + assert not any( + relationship_type == "DEPENDS_ON" for _, relationship_type, _ in relationships + ) + _validate_spdx(document) + + +def test_merge_cyclonedx_sboms_does_not_map_unrelated_python_root_to_wheel( + tmp_path: pathlib.Path, +) -> None: + """Verify an unrelated PyPI root remains its own SPDX package.""" + ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) + document = sbom.generate_sbom( + ctx=ctx, + req=Requirement("pillow==12.3.0"), + version=Version("12.3.0"), + ) + root_purl = "pkg:pypi/unrelated@2.0" + dependency_purl = "pkg:rpm/almalinux/libXau@1.0.9-3.el8" + cyclonedx = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "metadata": { + "component": { + "type": "library", + "bom-ref": root_purl, + "name": "unrelated", + "version": "2.0", + "purl": root_purl, + } + }, + "components": [ + { + "type": "library", + "bom-ref": dependency_purl, + "name": "libXau", + "version": "1.0.9-3.el8", + "purl": dependency_purl, + } + ], + "dependencies": [ + {"ref": root_purl, "dependsOn": [dependency_purl]}, + ], + } + sboms_dir = tmp_path / "sboms" + sboms_dir.mkdir() + (sboms_dir / "custom.cyclonedx.json").write_text(json.dumps(cyclonedx)) + + sbom.merge_cyclonedx_sboms(sbom=document, sboms_dir=sboms_dir) + + packages = {package["name"]: package for package in document["packages"]} + assert {"unrelated", "libXau"} <= packages.keys() + assert packages["unrelated"]["SPDXID"] != "SPDXRef-wheel" + relationships = { + ( + relationship["spdxElementId"], + relationship["relationshipType"], + relationship["relatedSpdxElement"], + ) + for relationship in document["relationships"] + } + assert ( + "SPDXRef-wheel", + "CONTAINS", + packages["unrelated"]["SPDXID"], + ) in relationships + assert ( + "SPDXRef-wheel", + "CONTAINS", + packages["libXau"]["SPDXID"], + ) in relationships + assert not any( + relationship_type == "DEPENDS_ON" for _, relationship_type, _ in relationships + ) + _validate_spdx(document) + + +def test_merge_cyclonedx_sboms_imports_nested_components( + tmp_path: pathlib.Path, +) -> None: + """Verify nested target components are imported and linked.""" + ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) + document = sbom.generate_sbom( + ctx=ctx, + req=Requirement("example-pkg==1.2.3"), + version=Version("1.2.3"), + ) + root_purl = "pkg:cargo/example@1.0.0?download_url=file://." + target_purl = "pkg:cargo/example-target@1.0.0?download_url=file://../target" + nested_purl = "pkg:cargo/example-nested@1.0.0" + cyclonedx = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "metadata": { + "component": { + "type": "application", + "bom-ref": root_purl, + "name": "example", + "version": "1.0.0", + "purl": root_purl, + } + }, + "components": [ + { + "type": "library", + "bom-ref": target_purl, + "name": "example-target", + "version": "1.0.0", + "purl": target_purl, + "components": [ + { + "type": "library", + "bom-ref": nested_purl, + "name": "example-nested", + "version": "1.0.0", + "purl": nested_purl, + } + ], + } + ], + "dependencies": [ + {"ref": root_purl, "dependsOn": [target_purl]}, + {"ref": target_purl, "dependsOn": [nested_purl]}, + ], + } + sboms_dir = tmp_path / "sboms" + sboms_dir.mkdir() + (sboms_dir / "nested.cyclonedx.json").write_text(json.dumps(cyclonedx)) + + sbom.merge_cyclonedx_sboms(sbom=document, sboms_dir=sboms_dir) + + packages = {package["name"]: package for package in document["packages"]} + assert {"example", "example-target", "example-nested"} <= packages.keys() + relationships = { + ( + relationship["spdxElementId"], + relationship["relationshipType"], + relationship["relatedSpdxElement"], + ) + for relationship in document["relationships"] + } + assert { + package["externalRefs"][0]["referenceLocator"] + for package in ( + packages["example"], + packages["example-target"], + packages["example-nested"], + ) + } == { + "pkg:cargo/example@1.0.0", + "pkg:cargo/example-target@1.0.0", + "pkg:cargo/example-nested@1.0.0", + } + for package_name in ("example", "example-target", "example-nested"): + assert ( + "SPDXRef-wheel", + "CONTAINS", + packages[package_name]["SPDXID"], + ) in relationships + assert not any( + relationship_type == "DEPENDS_ON" for _, relationship_type, _ in relationships + ) + _validate_spdx(document) + + +def test_merge_cyclonedx_sboms_deduplicates_packages_by_purl( + tmp_path: pathlib.Path, +) -> None: + """Verify repeated Cargo components produce one SPDX package.""" + ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) + document = sbom.generate_sbom( + ctx=ctx, + req=Requirement("example-pkg==1.2.3"), + version=Version("1.2.3"), + ) + sboms_dir = tmp_path / "sboms" + _write_cyclonedx_sbom(sboms_dir, "first.cyclonedx.json") + _write_cyclonedx_sbom( + sboms_dir, + "second.cyclonedx.json", + root_name="another", + ) + + sbom.merge_cyclonedx_sboms(sbom=document, sboms_dir=sboms_dir) + + serde_packages = [ + package for package in document["packages"] if package["name"] == "serde" + ] + assert len(serde_packages) == 1 + assert ( + sum( + relationship["relationshipType"] == "CONTAINS" + and relationship["relatedSpdxElement"] == serde_packages[0]["SPDXID"] + for relationship in document["relationships"] + ) + == 1 + ) + _validate_spdx(document) + + +def test_merge_cyclonedx_sboms_ignores_invalid_and_other_formats( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture +) -> None: + """Verify unrelated or unreadable SBOM files do not break the merge.""" + ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) + document = sbom.generate_sbom( + ctx=ctx, + req=Requirement("example-pkg==1.2.3"), + version=Version("1.2.3"), + ) + sboms_dir = tmp_path / "sboms" + sboms_dir.mkdir() + (sboms_dir / "not-an-sbom.json").write_text('{"format": "other"}') + (sboms_dir / "broken.json").write_text("{") + + with caplog.at_level("WARNING", logger="fromager.sbom"): + sbom.merge_cyclonedx_sboms(sbom=document, sboms_dir=sboms_dir) + + assert len(document["packages"]) == 2 + assert "could not read" in caplog.text + _validate_spdx(document) diff --git a/tests/test_wheels.py b/tests/test_wheels.py index 1c589d012..3d62522f6 100644 --- a/tests/test_wheels.py +++ b/tests/test_wheels.py @@ -1,3 +1,4 @@ +import json import pathlib import zipfile from unittest.mock import Mock, patch @@ -147,6 +148,34 @@ def test_add_extra_metadata_generates_sbom_when_enabled( wheel_dir.mkdir() wheel_file = wheel_dir / "test_pkg-1.0.0-py3-none-any.whl" + native_sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "metadata": { + "component": { + "type": "library", + "bom-ref": "pkg:cargo/test-pkg@1.0.0", + "name": "test-pkg", + "version": "1.0.0", + "purl": "pkg:cargo/test-pkg@1.0.0", + } + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:cargo/serde@1.0.0", + "name": "serde", + "version": "1.0.0", + "purl": "pkg:cargo/serde@1.0.0", + } + ], + "dependencies": [ + { + "ref": "pkg:cargo/test-pkg@1.0.0", + "dependsOn": ["pkg:cargo/serde@1.0.0"], + } + ], + } with zipfile.ZipFile(wheel_file, "w") as zf: zf.writestr("test_pkg/__init__.py", "") zf.writestr( @@ -157,6 +186,10 @@ def test_add_extra_metadata_generates_sbom_when_enabled( "test_pkg-1.0.0.dist-info/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", ) + zf.writestr( + "test_pkg-1.0.0.dist-info/sboms/rust.cyclonedx.json", + json.dumps(native_sbom), + ) mock_run.return_value = "" @@ -167,14 +200,14 @@ def test_add_extra_metadata_generates_sbom_when_enabled( sdist_dir.mkdir() # Capture the wheel contents before repack by inspecting what wheel pack receives - captured_contents: list[str] = [] + captured_contents: dict[str, str] = {} 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))) + captured_contents[str(f.relative_to(unpacked_dir))] = f.read_text() repacked.touch() return "" @@ -189,8 +222,16 @@ def fake_run(cmd: list[str], **kwargs: object) -> str: wheel_file=wheel_file, ) - # 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) + # Verify the canonical SBOM was merged and the native file was preserved. + assert "test_pkg-1.0.0.dist-info/sboms/fromager.spdx.json" in captured_contents + assert "test_pkg-1.0.0.dist-info/sboms/rust.cyclonedx.json" in captured_contents + merged_sbom = json.loads( + captured_contents["test_pkg-1.0.0.dist-info/sboms/fromager.spdx.json"] + ) + assert {package["name"] for package in merged_sbom["packages"]} >= { + "test-pkg", + "serde", + } def test_download_wheel_unquotes_url_encoded_filenames(tmp_path: pathlib.Path) -> None: