diff --git a/share/README.md b/share/README.md index b88d114..033135b 100644 --- a/share/README.md +++ b/share/README.md @@ -1,8 +1,17 @@ -# share/ +# wolfssl-compliance-tooling SBOM files -This is the only vendorable set. Use `tools/wolfssl-compliance-tooling-sync` to copy these files -into a product at `tools/sbom/`, together with the pin files (`VERSION` and -`.wolfssl-compliance-tooling-rev`). Do not copy the `share/` folder name; copy the files. +These are the SBOM files that a product vendors from +[wolfssl-compliance-tooling](https://github.com/wolfSSL/wolfssl-compliance-tooling). +The source is the `share/` folder of that repository. A product keeps a copy in +`tools/sbom/`, together with the pin files (`VERSION` and +`.wolfssl-compliance-tooling-rev`). Do not edit the copy in a product. + +To change the pin, run the sync tool from a checkout of +wolfssl-compliance-tooling. The tool is not vendored into the product: + + /tools/wolfssl-compliance-tooling-sync --dest --prune + +Add `--check` to find drift, including lost executable bits, without a copy. ## Contents @@ -17,6 +26,10 @@ into a product at `tools/sbom/`, together with the pin files (`VERSION` and | `frontends/zephyr_sbom.py` | Zephyr module, from a west build's `compile_commands.json`. | | `build/sbom.mk` | Shared plain-Make fragment and `wolfssl_compliance_tooling_sbom_rule` macro. | | `build/sbom.cmake` | Shared CMake helper: `wolfssl_compliance_tooling_add_sbom()`. | +| `build/sbom-with-sde.cmake` | Runs the driver with `SOURCE_DATE_EPOCH`, after the `STAGE_TARGET` install. | +| `build/sbom-validate.cmake` | `VALIDATE` step: pyspdxtools check and SPDX tag-value file. | +| `build/sbom-fail.cmake` | Target body when python3 or pyspdxtools is missing. | +| `build/install-sbom.cmake`, `build/uninstall-sbom.cmake`, `build/sbom-paths.cmake` | `install-sbom` and `uninstall-sbom`. | | `gen-sbom` | The vendored SBOM generator. | | `sbom.am` | Shared autotools fragment. | @@ -78,7 +91,11 @@ A product does not copy logic. It describes itself: `/share/doc/`) and read `DESTDIR` when the target runs. Pass `CDX_OUT` / `SPDX_OUT` when the file name is not `-` (wolfBoot's config tag). - wolfTPM writes the tag-value file in a later `sbom` target: pass + To hash the installed library and validate the SPDX file as `make sbom` + does, pass `STAGE_TARGET ` and `VALIDATE`. Then + `install-sbom` also installs the tag-value file. A missing python3 or + pyspdxtools does not fail configure; the `sbom` target fails when it is built. + A product that writes the tag-value file in its own later target can pass `NO_INSTALL` and call `wolfssl_compliance_tooling_add_sbom_install(DEPENDS sbom TV ...)` after that target exists. `SBOM_GEN` is the canonical generator override; `GEN_SBOM` remains a legacy diff --git a/share/build/sbom-fail.cmake b/share/build/sbom-fail.cmake new file mode 100644 index 0000000..4a22849 --- /dev/null +++ b/share/build/sbom-fail.cmake @@ -0,0 +1,6 @@ +# cmake -P script: stop the build of an SBOM target that cannot run. +# sbom.cmake uses it when python3, or pyspdxtools for VALIDATE, was not +# found at configure time. Configure succeeds, and the build of the target +# gives the reason. + +message(FATAL_ERROR "sbom: ${WOLFSSL_COMPLIANCE_TOOLING_SBOM_ERROR}") diff --git a/share/build/sbom-paths.cmake b/share/build/sbom-paths.cmake index 97d5ab3..936a501 100644 --- a/share/build/sbom-paths.cmake +++ b/share/build/sbom-paths.cmake @@ -2,8 +2,9 @@ # The names match the driver default: /-.cdx.json # and the .spdx.json sibling. A caller that set CDX_OUT / SPDX_OUT passes # those paths and this file leaves them unchanged. The tag-value path is -# used only when the caller sets TV / TV_OUT. A .spdx sibling is not -# inferred, so a stale file is not installed or removed. +# used only when the caller sets TV / TV_OUT, or when VALIDATE writes the +# .spdx sibling. Otherwise the sibling is not inferred, so a stale file is +# not installed or removed. function(_wolfssl_compliance_tooling_read_version file macro out_var) set(_ver "") @@ -71,6 +72,11 @@ function(wolfssl_compliance_tooling_sbom_resolve_outputs out_cdx out_spdx out_tv endif() endif() + # VALIDATE writes the tag-value file next to the SPDX file. + if(_tv STREQUAL "" AND WOLFSSL_COMPLIANCE_TOOLING_SBOM_TV_FROM_SPDX) + string(REGEX REPLACE "\\.json$" "" _tv "${_spdx}") + endif() + set(${out_cdx} "${_cdx}" PARENT_SCOPE) set(${out_spdx} "${_spdx}" PARENT_SCOPE) set(${out_tv} "${_tv}" PARENT_SCOPE) diff --git a/share/build/sbom-validate.cmake b/share/build/sbom-validate.cmake new file mode 100644 index 0000000..58fa6d4 --- /dev/null +++ b/share/build/sbom-validate.cmake @@ -0,0 +1,28 @@ +# cmake -P script: validate the SPDX file and write the tag-value file. +# pyspdxtools fails on an invalid document, so this is the same check as +# the pyspdxtools step of `make sbom`. +# +# The paths use the rules of sbom-paths.cmake, so install-sbom installs the +# file that this script writes. + +if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_PYSPDXTOOLS OR + WOLFSSL_COMPLIANCE_TOOLING_PYSPDXTOOLS STREQUAL "") + message(FATAL_ERROR "sbom-validate.cmake: WOLFSSL_COMPLIANCE_TOOLING_PYSPDXTOOLS is required") +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/sbom-paths.cmake") +set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_TV_FROM_SPDX ON) +wolfssl_compliance_tooling_sbom_resolve_outputs(_cdx _spdx _tv) + +if(NOT EXISTS "${_spdx}") + message(FATAL_ERROR "sbom-validate.cmake: missing SPDX file ${_spdx}") +endif() + +execute_process( + COMMAND "${WOLFSSL_COMPLIANCE_TOOLING_PYSPDXTOOLS}" + --infile "${_spdx}" --outfile "${_tv}" + RESULT_VARIABLE _rc) +if(NOT _rc EQUAL 0) + file(REMOVE "${_tv}") + message(FATAL_ERROR "SPDX validation failed (${_rc}) for ${_spdx}") +endif() diff --git a/share/build/sbom-with-sde.cmake b/share/build/sbom-with-sde.cmake index cdd12cf..cf8f196 100644 --- a/share/build/sbom-with-sde.cmake +++ b/share/build/sbom-with-sde.cmake @@ -7,6 +7,10 @@ # toplevel, so the outer commit time is not used. A tree with no git # leaves the variable empty, and gen-sbom uses its own default. # +# When SBOM_STAGE_DIR is set, the build tree SBOM_STAGE_BUILD_DIR is first +# installed with DESTDIR=SBOM_STAGE_DIR, and SBOM_STAGE_LIB must then exist. +# The staging directory is removed after the driver runs, also on failure. +# # Arguments after "--" are the driver command. if(NOT DEFINED SBOM_ROOT) @@ -66,7 +70,49 @@ if(NOT _args) message(FATAL_ERROR "sbom-with-sde.cmake: no command after --") endif() +if(NOT DEFINED SBOM_STAGE_DIR) + set(SBOM_STAGE_DIR "") +endif() +if(NOT SBOM_STAGE_DIR STREQUAL "") + file(REMOVE_RECURSE "${SBOM_STAGE_DIR}") + # DESTDIR also moves absolute install directories, which --prefix does + # not. The prefix stays the real one, so the staged file has the same + # bytes (RPATH, install name) as an installed one. A DESTDIR that the + # caller set for install-sbom is not used here. + set(_saved_destdir_set FALSE) + if(DEFINED ENV{DESTDIR}) + set(_saved_destdir_set TRUE) + set(_saved_destdir "$ENV{DESTDIR}") + endif() + set(ENV{DESTDIR} "${SBOM_STAGE_DIR}") + set(_install "${CMAKE_COMMAND}" --install "${SBOM_STAGE_BUILD_DIR}") + if(DEFINED SBOM_STAGE_CONFIG AND NOT SBOM_STAGE_CONFIG STREQUAL "") + list(APPEND _install --config "${SBOM_STAGE_CONFIG}") + endif() + execute_process(COMMAND ${_install} RESULT_VARIABLE _rc) + if(_saved_destdir_set) + set(ENV{DESTDIR} "${_saved_destdir}") + else() + unset(ENV{DESTDIR}) + endif() + if(NOT _rc EQUAL 0) + file(REMOVE_RECURSE "${SBOM_STAGE_DIR}") + message(FATAL_ERROR + "SBOM staging install failed (${_rc}). Build the project first.") + endif() + if(NOT EXISTS "${SBOM_STAGE_LIB}") + file(REMOVE_RECURSE "${SBOM_STAGE_DIR}") + message(FATAL_ERROR + "SBOM staging: ${SBOM_STAGE_LIB} was not installed. Install the " + "STAGE_TARGET to CMAKE_INSTALL_LIBDIR, or to CMAKE_INSTALL_BINDIR " + "for a program or a Windows DLL.") + endif() +endif() + execute_process(COMMAND ${_args} RESULT_VARIABLE _rc) +if(NOT SBOM_STAGE_DIR STREQUAL "") + file(REMOVE_RECURSE "${SBOM_STAGE_DIR}") +endif() if(NOT _rc EQUAL 0) message(FATAL_ERROR "SBOM generation failed (${_rc})") endif() diff --git a/share/build/sbom.cmake b/share/build/sbom.cmake index ae9b309..e22f1ad 100644 --- a/share/build/sbom.cmake +++ b/share/build/sbom.cmake @@ -57,6 +57,25 @@ # LICENSE_OVERRIDE (required by SPDX 2.3). # NO_ARTIFACT_HASH Record a placeholder checksum. Do not pass TARGETS # or LIB with this option. +# STAGE_TARGET Hash the installed copy of library or program , +# as `make sbom` does. When the target runs, the whole +# project is installed with DESTDIR into a private +# staging directory under the build tree. The file is +# 's file name in CMAKE_INSTALL_FULL_LIBDIR, or in +# CMAKE_INSTALL_FULL_BINDIR for a program or a Windows +# DLL. The staging directory is removed after the +# driver runs, also when it fails. A DESTDIR in the +# environment does not change the staging, and an +# absolute install directory stays inside it. Do not +# pass LIB with this option. +# VALIDATE Validate the SPDX file with pyspdxtools and write the +# SPDX tag-value file, as `make sbom` does. The +# tag-value file is TV_OUT, or the SPDX path without +# ".json". install-sbom then installs it. +# PYSPDXTOOLS pyspdxtools for VALIDATE (default: find_program). +# +# When python3 is missing, or pyspdxtools is missing with VALIDATE, configure +# does not fail. The target fails with the reason when someone builds it. # # USER_SETTINGS Path to wolfssl/wolfcrypt/settings.h. This is the # generator's pcpp entry point. Also pass @@ -102,9 +121,10 @@ get_filename_component(_WOLFSSL_COMPLIANCE_TOOLING_DRIVER find_package(Python3 COMPONENTS Interpreter) function(wolfssl_compliance_tooling_add_sbom) - set(_opts NO_ARTIFACT_HASH SOURCE_ONLY NO_INSTALL) + set(_opts NO_ARTIFACT_HASH SOURCE_ONLY NO_INSTALL VALIDATE) set(_one NAME TARGET_NAME VERSION VERSION_FILE VERSION_MACRO LICENSE - SBOM_GEN GEN_SBOM HOSTCC ROOT LIB USER_SETTINGS OPTIONS_H SETTINGS_H + SBOM_GEN GEN_SBOM HOSTCC ROOT LIB STAGE_TARGET PYSPDXTOOLS + USER_SETTINGS OPTIONS_H SETTINGS_H DEP_WOLFSSL DEP_WOLFCRYPT DEP_OPENSSL DEP_LIBZ CRYPTO_ONLY CDX_OUT SPDX_OUT TV_OUT COMPONENT_TYPE DOCUMENT_NAMESPACE COPYRIGHT_START @@ -118,13 +138,26 @@ function(wolfssl_compliance_tooling_add_sbom) message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom: NAME is required") endif() if(SB_NO_ARTIFACT_HASH) - if(SB_TARGETS OR SB_LIB) + if(SB_TARGETS OR SB_LIB OR SB_STAGE_TARGET) message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom: NO_ARTIFACT_HASH stands " - "alone. Do not pass TARGETS or LIB.") + "alone. Do not pass TARGETS, LIB or STAGE_TARGET.") + endif() + elseif(NOT SB_TARGETS AND NOT SB_LIB AND NOT SB_STAGE_TARGET) + message(FATAL_ERROR + "wolfssl_compliance_tooling_add_sbom: set TARGETS, LIB or STAGE_TARGET") + endif() + if(SB_STAGE_TARGET) + if(SB_LIB) + message(FATAL_ERROR + "wolfssl_compliance_tooling_add_sbom: STAGE_TARGET names the " + "hashed file. Do not pass LIB.") + endif() + if(NOT TARGET ${SB_STAGE_TARGET}) + message(FATAL_ERROR + "wolfssl_compliance_tooling_add_sbom: STAGE_TARGET " + "${SB_STAGE_TARGET} is not a target") endif() - elseif(NOT SB_TARGETS AND NOT SB_LIB) - message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom: set TARGETS or LIB") endif() if(NOT SB_ROOT) set(SB_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) @@ -166,9 +199,46 @@ function(wolfssl_compliance_tooling_add_sbom) endforeach() list(REMOVE_DUPLICATES _srcs) + set(_sbom_error "") if(NOT Python3_EXECUTABLE) - message(FATAL_ERROR - "wolfssl_compliance_tooling_add_sbom: python3 was not found") + set(_sbom_error + "python3 was not found. Cannot generate the ${SB_NAME} SBOM.") + elseif(SB_VALIDATE AND NOT SB_PYSPDXTOOLS) + find_program(WOLFSSL_COMPLIANCE_TOOLING_PYSPDXTOOLS pyspdxtools) + if(WOLFSSL_COMPLIANCE_TOOLING_PYSPDXTOOLS) + set(SB_PYSPDXTOOLS ${WOLFSSL_COMPLIANCE_TOOLING_PYSPDXTOOLS}) + else() + set(_sbom_error + "pyspdxtools was not found (pip install spdx-tools). " + "Cannot validate the ${SB_NAME} SBOM.") + endif() + endif() + + if(SB_STAGE_TARGET) + set(_stage_dir "${CMAKE_CURRENT_BINARY_DIR}/_sbom_staging") + get_target_property(_stage_type ${SB_STAGE_TARGET} TYPE) + if(_stage_type STREQUAL "EXECUTABLE" OR + (WIN32 AND _stage_type STREQUAL "SHARED_LIBRARY")) + set(_stage_kind BINDIR) + set(_stage_default bin) + else() + set(_stage_kind LIBDIR) + set(_stage_default lib) + endif() + if(CMAKE_INSTALL_FULL_${_stage_kind}) + set(_stage_inst "${CMAKE_INSTALL_FULL_${_stage_kind}}") + elseif(CMAKE_INSTALL_${_stage_kind}) + set(_stage_inst "${CMAKE_INSTALL_${_stage_kind}}") + else() + set(_stage_inst "${_stage_default}") + endif() + if(NOT IS_ABSOLUTE "${_stage_inst}") + set(_stage_inst "${CMAKE_INSTALL_PREFIX}/${_stage_inst}") + endif() + # file(INSTALL) drops the drive letter when it prepends DESTDIR. + string(REGEX REPLACE "^[A-Za-z]:" "" _stage_inst "${_stage_inst}") + set(SB_LIB + "${_stage_dir}${_stage_inst}/$") endif() set(_cmd ${CMAKE_COMMAND} -E env HOSTCC=${SB_HOSTCC} @@ -302,15 +372,56 @@ function(wolfssl_compliance_tooling_add_sbom) # SOURCE_DATE_EPOCH is applied when the target runs. A value set only # for the build is kept. Git supplies the commit time only when the # variable is empty. Configure time does not freeze it. - add_custom_target(${SB_TARGET_NAME} - COMMAND ${CMAKE_COMMAND} - -DSBOM_ROOT=${SB_ROOT} - -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/sbom-with-sde.cmake" - -- - ${_cmd} - WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} - VERBATIM - COMMENT "Generating ${SB_NAME} SBOM (CycloneDX 1.6 + SPDX 2.3)") + set(_paths + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX=${SB_CDX_OUT} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX=${SB_SPDX_OUT} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV=${SB_TV_OUT} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV_FROM_SPDX=${SB_VALIDATE} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=${SB_NAME} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION=${SB_VERSION} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE=${SB_VERSION_FILE} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO=${SB_VERSION_MACRO} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR=${CMAKE_CURRENT_BINARY_DIR}) + + if(NOT _sbom_error STREQUAL "") + add_custom_target(${SB_TARGET_NAME} + COMMAND ${CMAKE_COMMAND} + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_ERROR=${_sbom_error}" + -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/sbom-fail.cmake" + VERBATIM + COMMENT "${SB_NAME} SBOM prerequisites missing") + else() + set(_stage_args "") + if(SB_STAGE_TARGET) + set(_stage_args + -DSBOM_STAGE_DIR=${_stage_dir} + -DSBOM_STAGE_BUILD_DIR=${CMAKE_BINARY_DIR} + -DSBOM_STAGE_CONFIG=$ + -DSBOM_STAGE_LIB=${SB_LIB}) + endif() + set(_validate "") + if(SB_VALIDATE) + set(_validate + COMMAND ${CMAKE_COMMAND} + -DWOLFSSL_COMPLIANCE_TOOLING_PYSPDXTOOLS=${SB_PYSPDXTOOLS} + ${_paths} + -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/sbom-validate.cmake") + endif() + add_custom_target(${SB_TARGET_NAME} + COMMAND ${CMAKE_COMMAND} + -DSBOM_ROOT=${SB_ROOT} + ${_stage_args} + -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/sbom-with-sde.cmake" + -- + ${_cmd} + ${_validate} + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + VERBATIM + COMMENT "Generating ${SB_NAME} SBOM (CycloneDX 1.6 + SPDX 2.3)") + if(SB_STAGE_TARGET) + add_dependencies(${SB_TARGET_NAME} ${SB_STAGE_TARGET}) + endif() + endif() if(NOT SB_NO_INSTALL) set(_install_depends ${SB_TARGET_NAME}) @@ -323,6 +434,7 @@ function(wolfssl_compliance_tooling_add_sbom) CDX "${SB_CDX_OUT}" SPDX "${SB_SPDX_OUT}" TV "${SB_TV_OUT}" + TV_FROM_SPDX "${SB_VALIDATE}" VERSION "${SB_VERSION}" VERSION_FILE "${SB_VERSION_FILE}" VERSION_MACRO "${SB_VERSION_MACRO}" @@ -339,8 +451,8 @@ endfunction() # target validates SPDX and writes the tag-value file after the helper # returns. Pass DEPENDS sbom and TV the tag-value path. function(wolfssl_compliance_tooling_add_sbom_install) - set(_one NAME DEPENDS INSTALL_DIR CDX SPDX TV VERSION VERSION_FILE - VERSION_MACRO BINDIR INSTALL_TARGET UNINSTALL_TARGET) + set(_one NAME DEPENDS INSTALL_DIR CDX SPDX TV TV_FROM_SPDX VERSION + VERSION_FILE VERSION_MACRO BINDIR INSTALL_TARGET UNINSTALL_TARGET) cmake_parse_arguments(SI "" "${_one}" "" ${ARGN}) if(NOT SI_NAME) @@ -378,6 +490,7 @@ function(wolfssl_compliance_tooling_add_sbom_install) -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX=${SI_CDX} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX=${SI_SPDX} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV=${SI_TV} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV_FROM_SPDX=${SI_TV_FROM_SPDX} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=${SI_NAME} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION=${SI_VERSION} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE=${SI_VERSION_FILE} @@ -394,6 +507,7 @@ function(wolfssl_compliance_tooling_add_sbom_install) -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX=${SI_CDX} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX=${SI_SPDX} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV=${SI_TV} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV_FROM_SPDX=${SI_TV_FROM_SPDX} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=${SI_NAME} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION=${SI_VERSION} -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE=${SI_VERSION_FILE} diff --git a/tests/test_sbom_cmake_install.py b/tests/test_sbom_cmake_install.py index 240a7dd..4939a56 100644 --- a/tests/test_sbom_cmake_install.py +++ b/tests/test_sbom_cmake_install.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 """CMake install-sbom / uninstall-sbom and build-time SOURCE_DATE_EPOCH.""" +import hashlib import os import shutil import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -327,5 +329,185 @@ def test_second_call_keeps_its_install_target_name(self): self.assertIn("uninstall-sbom-extra", text) +FAKE_PYSPDXTOOLS = """#!{python} +import json, sys +args = dict(zip(sys.argv[1::2], sys.argv[2::2])) +doc = json.load(open(args["--infile"])) +if doc.get("spdxVersion") != "SPDX-2.3" or "{fail}" == "yes": + sys.exit("invalid SPDX") +open(args["--outfile"], "w").write("SPDXVersion: SPDX-2.3\\n") +""" + + +class HelperBuildTests(unittest.TestCase): + """Build the sbom and install-sbom targets of a small C project.""" + + def setUp(self): + if not CMAKE: + self.skipTest("cmake is not installed") + if not shutil.which("cc"): + self.skipTest("cc is not installed") + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + self.src = self.dir / "src" + self.src.mkdir() + (self.src / "x.c").write_text("int x(void) { return 1; }\n") + (self.src / "opts.h").write_text("#define DEMO 1\n") + (self.src / "LICENSE").write_text("demo\n") + self.build = self.dir / "build" + self.env = os.environ.copy() + self.env.pop("DESTDIR", None) + self.env["SOURCE_DATE_EPOCH"] = "1700000000" + + def _fake_pyspdxtools(self, fail=False): + tool = self.dir / "pyspdxtools" + tool.write_text(FAKE_PYSPDXTOOLS.format( + python=sys.executable, fail="yes" if fail else "no")) + tool.chmod(0o755) + return tool + + def _project(self, call, prelude=""): + (self.src / "CMakeLists.txt").write_text(f""" +cmake_minimum_required(VERSION 3.16) +project(demo C) +include(GNUInstallDirs) +{prelude} +add_library(demo SHARED x.c) +install(TARGETS demo + LIBRARY DESTINATION ${{CMAKE_INSTALL_LIBDIR}} + ARCHIVE DESTINATION ${{CMAKE_INSTALL_LIBDIR}} + RUNTIME DESTINATION ${{CMAKE_INSTALL_BINDIR}}) +include("{BUILD / "sbom.cmake"}") +{call} +""") + + def _configure(self, *args): + result = run([ + CMAKE, "-S", str(self.src), "-B", str(self.build), + "-G", "Unix Makefiles", *args, + ], env=self.env) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + + def _build(self, *targets, env=None): + cmd = [CMAKE, "--build", str(self.build)] + for target in targets: + cmd += ["--target", target] + return run(cmd, env=env or self.env) + + def test_missing_python_fails_at_build_time(self): + self._project(""" +wolfssl_compliance_tooling_add_sbom( + NAME demo + VERSION 1 + STAGE_TARGET demo + OPTIONS_H ${CMAKE_CURRENT_SOURCE_DIR}/opts.h) +""") + self._configure("-DCMAKE_DISABLE_FIND_PACKAGE_Python3=ON") + built = self._build("sbom") + self.assertNotEqual(built.returncode, 0) + self.assertIn("python3 was not found", built.stdout + built.stderr) + installed = self._build("install-sbom") + self.assertNotEqual(installed.returncode, 0) + + def test_validate_without_pyspdxtools_fails_at_build_time(self): + # The compiler and make are found first. Then no search path is + # left, so a pyspdxtools on this host is not found either. + self._project(""" +wolfssl_compliance_tooling_add_sbom( + NAME demo + VERSION 1 + STAGE_TARGET demo + VALIDATE + OPTIONS_H ${CMAKE_CURRENT_SOURCE_DIR}/opts.h) +""", prelude=""" +set(CMAKE_FIND_USE_SYSTEM_ENVIRONMENT_PATH OFF) +set(CMAKE_FIND_USE_CMAKE_SYSTEM_PATH OFF) +""") + self._configure(f"-DPython3_EXECUTABLE={sys.executable}") + built = self._build("sbom") + self.assertNotEqual(built.returncode, 0) + self.assertIn("pyspdxtools was not found", + built.stdout + built.stderr) + self.assertFalse((self.build / "demo-1.cdx.json").exists()) + + def test_stage_validate_and_install(self): + # An absolute libdir must stay inside the staging tree, and a + # DESTDIR meant for install-sbom must not move the staging. + abs_libdir = self.dir / "abs-libdir" + docdir = self.dir / "doc" + self._project(f""" +wolfssl_compliance_tooling_add_sbom( + NAME demo + VERSION 1 + STAGE_TARGET demo + VALIDATE + PYSPDXTOOLS {self._fake_pyspdxtools()} + OPTIONS_H ${{CMAKE_CURRENT_SOURCE_DIR}}/opts.h + INSTALL_DIR {docdir}) +""") + self._configure(f"-DCMAKE_INSTALL_LIBDIR={abs_libdir}", + f"-DCMAKE_INSTALL_PREFIX={self.dir / 'prefix'}") + self.assertEqual(self._build().returncode, 0) + + dest = self.dir / "dest" + env = dict(self.env, DESTDIR=str(dest)) + built = self._build("install-sbom", env=env) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + + self.assertFalse(abs_libdir.exists()) + self.assertFalse((self.build / "_sbom_staging").exists()) + for name in ("demo-1.cdx.json", "demo-1.spdx.json", "demo-1.spdx"): + self.assertTrue((self.build / name).is_file(), name) + staged = Path(str(dest) + str(docdir)) / name + self.assertTrue(staged.is_file(), staged) + + # The recorded hash is the hash of an installed copy. + real = self.dir / "real" + installed = run([CMAKE, "--install", str(self.build)], + env=dict(self.env, DESTDIR=str(real))) + self.assertEqual(installed.returncode, 0, installed.stderr) + libs = [p for p in Path(str(real) + str(abs_libdir)).iterdir() + if p.is_file() and not p.is_symlink()] + self.assertEqual(len(libs), 1, libs) + digest = hashlib.sha256(libs[0].read_bytes()).hexdigest() + self.assertIn(digest, (self.build / "demo-1.cdx.json").read_text()) + + def test_failed_validation_fails_the_target(self): + self._project(f""" +wolfssl_compliance_tooling_add_sbom( + NAME demo + VERSION 1 + STAGE_TARGET demo + VALIDATE + PYSPDXTOOLS {self._fake_pyspdxtools(fail=True)} + OPTIONS_H ${{CMAKE_CURRENT_SOURCE_DIR}}/opts.h) +""") + self._configure() + self.assertEqual(self._build().returncode, 0) + built = self._build("install-sbom") + self.assertNotEqual(built.returncode, 0) + self.assertIn("SPDX validation failed", built.stdout + built.stderr) + self.assertFalse((self.build / "demo-1.spdx").exists()) + self.assertFalse((self.build / "_sbom_staging").exists()) + + def test_staging_without_a_build_fails_and_cleans_up(self): + self._project(""" +wolfssl_compliance_tooling_add_sbom( + NAME demo + VERSION 1 + STAGE_TARGET demo + OPTIONS_H ${CMAKE_CURRENT_SOURCE_DIR}/opts.h) +add_executable(other x.c) +install(TARGETS other RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +""") + self._configure() + built = self._build("sbom") + self.assertNotEqual(built.returncode, 0) + self.assertIn("SBOM staging install failed", + built.stdout + built.stderr) + self.assertFalse((self.build / "_sbom_staging").exists()) + + if __name__ == "__main__": unittest.main()