From 1d8bd0f8a6b2806ffacf861d91ca3203e4e44216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hozza?= Date: Wed, 26 Jun 2024 17:02:26 +0200 Subject: [PATCH] Support SBOM for depsolving in osbuild-depsolve-dnf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend osbuild-depsolve-dnf, to return JSON with SPDX SBOM that corresponds to the depsolved package set, if it has been requested. For now, only DNF4 is supported. Cover the new functionality with unit test. Signed-off-by: Tomáš Hozza --- osbuild/solver/dnf.py | 22 +++++++++++++++-- osbuild/solver/dnf5.py | 9 ++++++- tools/osbuild-depsolve-dnf | 40 ++++++++++++++++++++++++++++++ tools/test/test_depsolve.py | 49 ++++++++++++++++++++++++++++--------- 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/osbuild/solver/dnf.py b/osbuild/solver/dnf.py index 2b330e0a..a7b726ae 100755 --- a/osbuild/solver/dnf.py +++ b/osbuild/solver/dnf.py @@ -2,12 +2,14 @@ import os import os.path import tempfile from datetime import datetime -from typing import List +from typing import Dict, List import dnf import hawkey from osbuild.solver import DepsolveError, MarkingError, RepoError, SolverBase, modify_rootdir_path, read_keys +from osbuild.util.sbom.dnf import dnf_pkgset_to_sbom_pkgset +from osbuild.util.sbom.spdx import bom_pkgset_to_spdx2_doc class DNF(SolverBase): @@ -158,6 +160,17 @@ class DNF(SolverBase): def _timestamp_to_rfc3339(timestamp): return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ') + @staticmethod + def _sbom_for_pkgset(pkgset: List[dnf.package.Package]) -> Dict: + """ + Create an SBOM document for the given package set. + + For now, only SPDX v2 is supported. + """ + pkgset = dnf_pkgset_to_sbom_pkgset(pkgset) + spdx_doc = bom_pkgset_to_spdx2_doc(pkgset) + return spdx_doc.to_dict() + def dump(self): packages = [] for package in self.base.sack.query().available(): @@ -230,7 +243,8 @@ class DNF(SolverBase): return packages def depsolve(self, arguments): - # # Return an empty list when 'transactions' key is missing or when it is None + want_sbom = "sbom" in arguments + # Return an empty list when 'transactions' key is missing or when it is None transactions = arguments.get("transactions") or [] # collect repo IDs from the request so we know whether to translate gpg key paths request_repo_ids = set(repo["id"] for repo in arguments.get("repos", [])) @@ -310,4 +324,8 @@ class DNF(SolverBase): "packages": packages, "repos": repositories, } + + if want_sbom: + response["sbom"] = self._sbom_for_pkgset(last_transaction) + return response diff --git a/osbuild/solver/dnf5.py b/osbuild/solver/dnf5.py index 5510ce18..90e1e814 100755 --- a/osbuild/solver/dnf5.py +++ b/osbuild/solver/dnf5.py @@ -11,7 +11,14 @@ from libdnf5.common import QueryCmp_CONTAINS as CONTAINS from libdnf5.common import QueryCmp_EQ as EQ from libdnf5.common import QueryCmp_GLOB as GLOB -from osbuild.solver import DepsolveError, MarkingError, RepoError, SolverBase, modify_rootdir_path, read_keys +from osbuild.solver import ( + DepsolveError, + MarkingError, + RepoError, + SolverBase, + modify_rootdir_path, + read_keys, +) def remote_location(package, schemes=("http", "ftp", "file", "https")): diff --git a/tools/osbuild-depsolve-dnf b/tools/osbuild-depsolve-dnf index 98e4bf05..9e7132ad 100755 --- a/tools/osbuild-depsolve-dnf +++ b/tools/osbuild-depsolve-dnf @@ -145,11 +145,13 @@ def validate_request(request): "kind": "InvalidRequest", "reason": "no 'module_platform_id' specified" } + if not request.get("releasever"): return { "kind": "InvalidRequest", "reason": "no 'releasever' specified" } + arguments = request.get("arguments") if not arguments: return { @@ -157,6 +159,44 @@ def validate_request(request): "reason": "empty 'arguments'" } + sbom = request["arguments"].get("sbom") + if sbom is not None: + # NB: check the DNF5 flag here, instead of in the dnf5 module, + # to consistently return this error message, even if there are other + # potential errors in the request, such as broken repository. + if config.get("use_dnf5", False): + return { + "kind": "InvalidRequest", + "reason": "SBOM support for DNF5 is not implemented" + } + + if command != "depsolve": + return { + "kind": "InvalidRequest", + "reason": "SBOM is only supported with 'depsolve' command" + } + if not isinstance(sbom, dict): + return { + "kind": "InvalidRequest", + "reason": "invalid 'sbom' value" + } + sbom_type = sbom.get("type") + if sbom_type is None: + return { + "kind": "InvalidRequest", + "reason": "missing 'type' in 'sbom'" + } + if not isinstance(sbom_type, str): + return { + "kind": "InvalidRequest", + "reason": "invalid 'type' in 'sbom'" + } + if sbom_type != "spdx": + return { + "kind": "InvalidRequest", + "reason": "Unsupported SBOM type" + } + if not arguments.get("repos") and not arguments.get("root_dir"): return { "kind": "InvalidRequest", diff --git a/tools/test/test_depsolve.py b/tools/test/test_depsolve.py index 287b0d4f..13ffad38 100644 --- a/tools/test/test_depsolve.py +++ b/tools/test/test_depsolve.py @@ -13,6 +13,7 @@ from itertools import combinations from tempfile import TemporaryDirectory from typing import Tuple +import jsonschema import pytest REPO_PATHS = [ @@ -38,7 +39,7 @@ def assert_dnf(): raise RuntimeError("Cannot import libdnf") -def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata) -> Tuple[dict, int]: +def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata, with_sbom=False) -> Tuple[dict, int]: req = { "command": "depsolve", "arch": ARCH, @@ -53,12 +54,15 @@ def depsolve(transactions, repos, root_dir, cache_dir, dnf_config, opt_metadata) } } + if with_sbom: + req["arguments"]["sbom"] = {"type": "spdx"} + # If there is a config file, write it to a temporary file and pass it to the depsolver with TemporaryDirectory() as cfg_dir: env = None if dnf_config: cfg_file = pathlib.Path(cfg_dir) / "solver.json" - cfg_file.write_text(dnf_config) + json.dump(dnf_config, cfg_file.open("w")) env = {"OSBUILD_SOLVER_CONFIG": os.fspath(cfg_file)} p = sp.run(["./tools/osbuild-depsolve-dnf"], input=json.dumps(req), env=env, @@ -1240,13 +1244,15 @@ def config_combos(tmp_path, servers): yield repo_configs, os.fspath(root_dir), opt_metadata +# pylint: disable=too-many-branches @pytest.mark.parametrize("test_case", depsolve_test_cases, ids=tcase_idfn) +@pytest.mark.parametrize("with_sbom", [False, True]) @pytest.mark.parametrize("dnf_config, detect_fn", [ - (None, assert_dnf), - ('{"use_dnf5": false}', assert_dnf), - ('{"use_dnf5": true}', assert_dnf5), + ({}, assert_dnf), + ({"use_dnf5": False}, assert_dnf), + ({"use_dnf5": True}, assert_dnf5), ], ids=["no-config", "dnf4", "dnf5"]) -def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case): +def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom, test_case): try: detect_fn() except RuntimeError as e: @@ -1260,7 +1266,7 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case): "error_pkg_not_in_enabled_repos", ] - if dnf_config == '{"use_dnf5": true}' and test_case["id"] in dnf5_broken_test_cases: + if dnf_config.get("use_dnf5", False) and test_case["id"] in dnf5_broken_test_cases: pytest.skip("This test case is known to be broken with dnf5") transactions = test_case["transactions"] @@ -1271,7 +1277,15 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case): for repo_configs, root_dir, opt_metadata in config_combos(tmp_path, repo_servers_copy): with TemporaryDirectory() as cache_dir: - res, exit_code = depsolve(transactions, repo_configs, root_dir, cache_dir, dnf_config, opt_metadata) + res, exit_code = depsolve(transactions, repo_configs, root_dir, + cache_dir, dnf_config, opt_metadata, with_sbom) + + # NB: dnf5 implementation does not support SBOM yet + if dnf_config.get("use_dnf5", False) and with_sbom: + assert exit_code != 0 + assert res["kind"] == "InvalidRequest" + assert res["reason"] == "SBOM support for DNF5 is not implemented" + continue if test_case.get("error", False): assert exit_code != 0 @@ -1285,6 +1299,20 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case): for repo in res["repos"].values(): assert repo["gpgkeys"] == [TEST_KEY + repo["id"]] assert repo["sslverify"] is False + if with_sbom: + assert "sbom" in res + + spdx_2_3_1_schema_file = './test/data/spdx/spdx-schema-v2.3.1.json' + with open(spdx_2_3_1_schema_file, encoding="utf-8") as f: + spdx_schema = json.load(f) + validator = jsonschema.Draft4Validator + validator.check_schema(spdx_schema) + spdx_validator = validator(spdx_schema) + spdx_validator.validate(res["sbom"]) + + assert {pkg["name"] for pkg in res["sbom"]["packages"]} == test_case["results"]["packages"] + else: + assert "sbom" not in res # if opt_metadata includes 'filelists', then each repository 'repodata' must include a file that matches # *filelists* @@ -1294,10 +1322,7 @@ def test_depsolve(tmp_path, repo_servers, dnf_config, detect_fn, test_case): else: assert n_filelist_files == 0 - if dnf_config: - use_dnf5 = json.loads(dnf_config)["use_dnf5"] - else: - use_dnf5 = False + use_dnf5 = dnf_config.get("use_dnf5", False) if use_dnf5: assert res["solver"] == "dnf5" else: