diff --git a/docs/reference/files.md b/docs/reference/files.md index 117377c8..a5dd7774 100644 --- a/docs/reference/files.md +++ b/docs/reference/files.md @@ -114,6 +114,39 @@ 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, +and the SPDX document records a `comment` naming the CycloneDX files it merged. + +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 724aa7e2..1eb13983 100644 --- a/src/fromager/sbom.py +++ b/src/fromager/sbom.py @@ -10,13 +10,14 @@ import json import logging import pathlib +import re import typing from datetime import UTC, datetime 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 +182,268 @@ def generate_sbom( return doc +# CycloneDX hash algorithm names mapped to their SPDX equivalents. +_CYCLONEDX_HASH_TO_SPDX = { + "SHA-1": "SHA1", + "SHA-224": "SHA224", + "SHA-256": "SHA256", + "SHA-384": "SHA384", + "SHA-512": "SHA512", + "SHA3-224": "SHA3-224", + "SHA3-256": "SHA3-256", + "SHA3-384": "SHA3-384", + "SHA3-512": "SHA3-512", + "BLAKE2B-256": "BLAKE2b-256", + "BLAKE2B-384": "BLAKE2b-384", + "BLAKE2B-512": "BLAKE2b-512", +} + + +def _clean_purl(purl: str) -> str: + """Drop local ``file://`` download qualifiers, which are build-only paths.""" + try: + parsed = PackageURL.from_string(purl) + except ValueError: + return purl + qualifiers = dict(parsed.qualifiers or {}) + if not qualifiers.get("download_url", "").startswith("file://"): + return purl + del qualifiers["download_url"] + return PackageURL( + type=parsed.type, + namespace=parsed.namespace, + name=parsed.name, + version=parsed.version, + qualifiers=qualifiers or None, + subpath=parsed.subpath, + ).to_string() + + +def _iter_components( + component: dict[str, typing.Any], +) -> typing.Iterator[dict[str, typing.Any]]: + """Yield a component and all of its nested sub-components.""" + yield component + for nested in component.get("components", []): + yield from _iter_components(nested) + + +def _component_key(component: dict[str, typing.Any]) -> str: + """Return a stable identity used to deduplicate components across files.""" + purl = component.get("purl") + if purl: + return _clean_purl(purl) + return "\x00".join(component.get(field, "") for field in ("type", "group", "name")) + + +def _versions_match(left: str, right: str) -> bool: + try: + return Version(left) == Version(right) + except InvalidVersion: + return left == right + + +def _matches_wheel( + component: dict[str, typing.Any], + wheel: dict[str, typing.Any], +) -> bool: + """True if a CycloneDX root is the wheel itself (e.g. an auditwheel root). + + Such a root is folded into ``SPDXRef-wheel`` so its native dependencies are + not attached to the upstream source. + """ + purl = component.get("purl") + if not purl: + return False + try: + parsed = PackageURL.from_string(purl) + except ValueError: + return False + if parsed.type != "pypi" or not parsed.name or not parsed.version: + return False + return canonicalize_name(parsed.name) == canonicalize_name( + wheel["name"] + ) and _versions_match(parsed.version, wheel["versionInfo"]) + + +def _cyclonedx_license(component: dict[str, typing.Any]) -> str | None: + """Return the component's declared license as an SPDX expression. + + CycloneDX allows either a single SPDX ``expression`` (already valid SPDX, + passed through unchanged) or a list of license objects. cargo/maturin always + emit exactly one entry. A list with multiple entries has undefined AND/OR + semantics, so we warn and skip it rather than guess a relationship. A named + (non-SPDX) license has no valid SPDX identifier and is likewise skipped. + """ + licenses = component.get("licenses", []) + if not licenses: + return None + if len(licenses) > 1: + logger.warning( + "component %s has %d license entries with undefined AND/OR " + "semantics; skipping license", + component.get("purl") or component.get("name"), + len(licenses), + ) + return None + entry = licenses[0] + expression = entry.get("expression") + if expression: + return str(expression) + license_info = entry.get("license") + if isinstance(license_info, dict): + identifier = license_info.get("id") + return str(identifier) if identifier else None + return None + + +def _cyclonedx_checksums(component: dict[str, typing.Any]) -> list[dict[str, str]]: + """Convert a component's hashes into SPDX checksum entries.""" + checksums = [] + for entry in component.get("hashes", []): + algorithm = _CYCLONEDX_HASH_TO_SPDX.get(entry.get("alg", "").upper()) + content = entry.get("content") + if algorithm and content: + checksums.append({"algorithm": algorithm, "checksumValue": content}) + return checksums + + +def _cyclonedx_package( + component: dict[str, typing.Any], + spdx_id: str, +) -> dict[str, typing.Any]: + """Build an SPDX package entry from a CycloneDX component.""" + purl = component.get("purl") + purl = _clean_purl(purl) if purl else None + package: dict[str, typing.Any] = { + "SPDXID": spdx_id, + "name": component.get("name") or purl or component.get("bom-ref") or "unknown", + "versionInfo": component.get("version") or "NOASSERTION", + "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(component) + if license_expression: + package["licenseDeclared"] = license_expression + scope = component.get("scope") + if scope and scope != "required": + package["comment"] = f"CycloneDX scope: {scope}" + return package + + +def _spdx_id(component: dict[str, typing.Any], used_ids: set[str]) -> str: + """Build a readable, unique SPDXID from the component name and version. + + SPDXIDs allow only ``A-Za-z0-9.-``; other characters (e.g. cargo's ``_``) + are replaced with ``-``. Distinct components can share a name and version + (e.g. a crate and its library target), so a numeric suffix disambiguates + collisions to keep every SPDXID unique. + """ + name = component.get("name") or "unknown" + version = component.get("version") or "unknown" + base = re.sub(r"[^A-Za-z0-9.-]", "-", f"SPDXRef-{name}-{version}") + candidate = base + suffix = 1 + while candidate in used_ids: + suffix += 1 + candidate = f"{base}-{suffix}" + used_ids.add(candidate) + return candidate + + +def _iter_all_components( + cyclonedx: dict[str, typing.Any], +) -> typing.Iterator[tuple[dict[str, typing.Any], bool]]: + """Yield every ``(component, is_root)`` pair in a CycloneDX document.""" + root = cyclonedx.get("metadata", {}).get("component") + if root: + for component in _iter_components(root): + yield component, component is root + for component in cyclonedx.get("components", []): + for nested in _iter_components(component): + yield nested, False + + +def merge_cyclonedx_sboms( + *, + sbom: dict[str, typing.Any], + sboms_dir: pathlib.Path, +) -> None: + """Merge Maturin CycloneDX components into a Fromager SPDX document. + + Each non-excluded component becomes an SPDX package linked to the wheel with + ``CONTAINS``. A CycloneDX root matching the wheel is folded into + ``SPDXRef-wheel``. Local ``file://`` download qualifiers are stripped from + PURLs. The CycloneDX dependency graph is not copied and the original files + are left in place. + """ + if not sboms_dir.is_dir(): + return + + wheel = next( + (p for p in sbom["packages"] if p.get("SPDXID") == "SPDXRef-wheel"), None + ) + packages = sbom["packages"] + relationships = sbom["relationships"] + used_ids = {p["SPDXID"] for p in packages} + key_to_id: dict[str, str] = {} + contained: set[str] = set() + merged_files: list[str] = [] + + for sbom_path in sorted(sboms_dir.iterdir()): + if not sbom_path.is_file() or sbom_path.name == SBOM_FILENAME: + continue + try: + cyclonedx = json.loads(sbom_path.read_text(encoding="utf-8")) + 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 + merged_files.append(sbom_path.name) + + for component, is_root in _iter_all_components(cyclonedx): + if component.get("scope") == "excluded": + continue + + if is_root and wheel is not None and _matches_wheel(component, wheel): + spdx_id = "SPDXRef-wheel" + else: + key = _component_key(component) + spdx_id = key_to_id.get(key, "") + if not spdx_id: + spdx_id = _spdx_id(component, used_ids) + key_to_id[key] = spdx_id + packages.append(_cyclonedx_package(component, spdx_id)) + + if spdx_id != "SPDXRef-wheel" and spdx_id not in contained: + contained.add(spdx_id) + relationships.append( + { + "spdxElementId": "SPDXRef-wheel", + "relationshipType": "CONTAINS", + "relatedSpdxElement": spdx_id, + } + ) + + if merged_files: + sbom["comment"] = ( + "Includes components merged from CycloneDX SBOM(s): " + + ", ".join(sorted(merged_files)) + ) + + def write_sbom( *, sbom: dict[str, typing.Any], diff --git a/src/fromager/wheels.py b/src/fromager/wheels.py index dc9bd524..60ac640e 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 7a307d53..9f521668 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,424 @@ 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_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) + + +def test_merge_cyclonedx_sboms_names_spdx_ids_and_disambiguates( + tmp_path: pathlib.Path, +) -> None: + """Verify SPDXIDs use name+version and stay unique on collisions.""" + 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"), + ) + # Two distinct components share the same name and version but differ by + # purl subpath, exactly like a crate and its library target. + cyclonedx = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "components": [ + { + "type": "library", + "name": "hf_xet", + "version": "1.6.0", + "purl": "pkg:cargo/hf_xet@1.6.0", + }, + { + "type": "library", + "name": "hf_xet", + "version": "1.6.0", + "purl": "pkg:cargo/hf_xet@1.6.0#src/lib.rs", + }, + ], + } + sboms_dir = tmp_path / "sboms" + sboms_dir.mkdir() + (sboms_dir / "hf_xet.cyclonedx.json").write_text(json.dumps(cyclonedx)) + + sbom.merge_cyclonedx_sboms(sbom=document, sboms_dir=sboms_dir) + + ids = [package["SPDXID"] for package in document["packages"]] + # cargo's underscore is sanitized to a dash and IDs remain unique. + assert "SPDXRef-hf-xet-1.6.0" in ids + assert "SPDXRef-hf-xet-1.6.0-2" in ids + assert len(ids) == len(set(ids)) + _validate_spdx(document) + + +def test_merge_cyclonedx_sboms_warns_on_multiple_license_entries( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture +) -> None: + """Verify ambiguous multi-license components are skipped with a warning.""" + 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"), + ) + cyclonedx = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "components": [ + { + "type": "library", + "name": "multi", + "version": "1.0.0", + "purl": "pkg:cargo/multi@1.0.0", + "licenses": [ + {"license": {"id": "MIT"}}, + {"license": {"id": "Apache-2.0"}}, + ], + } + ], + } + sboms_dir = tmp_path / "sboms" + sboms_dir.mkdir() + (sboms_dir / "multi.cyclonedx.json").write_text(json.dumps(cyclonedx)) + + with caplog.at_level("WARNING", logger="fromager.sbom"): + sbom.merge_cyclonedx_sboms(sbom=document, sboms_dir=sboms_dir) + + multi = next(p for p in document["packages"] if p["name"] == "multi") + assert "licenseDeclared" not in multi + assert "license entries" in caplog.text + _validate_spdx(document) + + +def test_merge_cyclonedx_sboms_adds_document_comment( + tmp_path: pathlib.Path, +) -> None: + """Verify a document comment references the merged CycloneDX files.""" + 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) + + assert "example.cyclonedx.json" in document["comment"] + _validate_spdx(document) diff --git a/tests/test_wheels.py b/tests/test_wheels.py index 1c589d01..3d62522f 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: