From 20bcb135b4ccc0974eccba039275fc0d2904ef72 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 21 Oct 2026 01:45:00 +0000 Subject: [PATCH] cmake: install and uninstall the SBOM from the helper wolfssl_compliance_tooling_add_sbom() now adds install-sbom and uninstall-sbom. The install copies the CycloneDX and SPDX files the caller set, and DESTDIR is applied when the target runs. The script directory uses the renamed variable. Git supplies a commit time only when the source tree is the repository toplevel. CMake before 3.24 treats a separate --version after -P as its own flag, so the driver version is one argument. The toplevel check uses get_filename_component, which CMake 3.16 provides. A second call can name its install targets. The low-findings tests load modules with exec_module for Python 3.15. --- .github/workflows/selftest.yml | 4 + README.md | 2 +- share/README.md | 8 + share/build/install-sbom.cmake | 38 ++++ share/build/sbom-paths.cmake | 77 +++++++ share/build/sbom-with-sde.cmake | 72 +++++++ share/build/sbom.cmake | 164 +++++++++++++-- share/build/uninstall-sbom.cmake | 30 +++ share/frontends/compdb_sbom.py | 2 +- tests/test_low_findings.py | 27 ++- tests/test_sbom_cmake_install.py | 331 +++++++++++++++++++++++++++++++ 11 files changed, 730 insertions(+), 25 deletions(-) create mode 100644 share/build/install-sbom.cmake create mode 100644 share/build/sbom-paths.cmake create mode 100644 share/build/sbom-with-sde.cmake create mode 100644 share/build/uninstall-sbom.cmake create mode 100644 tests/test_sbom_cmake_install.py diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 63a564b..2d2374b 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -53,6 +53,7 @@ jobs: tests/test_sbom_identity.py \ tests/test_low_findings.py \ tests/test_iar_sbom.py \ + tests/test_sbom_cmake_install.py \ tests/test_wolfssl_compliance_tooling_sync.py - name: Run generator unit tests @@ -73,6 +74,9 @@ jobs: - name: Run IAR frontend tests run: python -m unittest tests/test_iar_sbom.py + - name: Run CMake SBOM install tests + run: python -m unittest tests/test_sbom_cmake_install.py + - name: Run wolfssl-compliance-tooling-sync tests run: python -m unittest tests/test_wolfssl_compliance_tooling_sync.py diff --git a/README.md b/README.md index 8bc38aa..7f8a432 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ system it ships) while all of them converge on the same engine. | Frontend | Build system | What it extracts | |---|---|---| | `sbom.am` | Autotools | Stages a private `make install`, discovers the installed library/binary, hashes it; config from `AM_CPPFLAGS`/`config.h`. | -| `build/sbom.cmake` | CMake | Same shape, called as `wolfssl_compliance_tooling_add_sbom()` with `NAME`/`TARGETS`/`DEFS`. | +| `build/sbom.cmake` | CMake | Same shape, called as `wolfssl_compliance_tooling_add_sbom()` with `NAME`/`TARGETS`/`DEFS`. Also adds `install-sbom` and `uninstall-sbom`. | | `build/sbom.mk` | Plain Make | Product sets `SBOM_NAME`/`SBOM_SRCS`/`SBOM_CFLAGS` (plus `SBOM_SETTINGS_H` when a `user_settings.h` derives the config), includes the fragment. | | `frontends/compdb_sbom.py` | Any `compile_commands.json` | The universal fallback — TI CCS, MPLAB X, Renesas e2studio, or anything wrappable with `bear -- make ...`. | | `frontends/iar_sbom.py` | IAR Embedded Workbench `.ewp` | Parses the project XML directly — there's no build step to hook into. | diff --git a/share/README.md b/share/README.md index a918001..4c3fddd 100644 --- a/share/README.md +++ b/share/README.md @@ -65,6 +65,14 @@ A product does not copy logic. It describes itself: describes a configuration nobody built. - CMake: `include(tools/sbom/build/sbom.cmake)` and call `wolfssl_compliance_tooling_add_sbom()` with `NAME`, `VERSION_FILE`, `VERSION_MACRO`, `TARGETS`, `DEFS`, `LICENSE`. + The same call adds `install-sbom` and `uninstall-sbom`. They copy the + CycloneDX and SPDX files to `CMAKE_INSTALL_FULL_DOCDIR` (or + `/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 + `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 alias for compatibility. - Autotools: set the `SBOM_*` variables and `include tools/sbom/sbom.am`. diff --git a/share/build/install-sbom.cmake b/share/build/install-sbom.cmake new file mode 100644 index 0000000..9afbc78 --- /dev/null +++ b/share/build/install-sbom.cmake @@ -0,0 +1,38 @@ +# cmake -P script: install SBOM artifacts. +# DESTDIR is read from the environment when this script runs, so +# DESTDIR=/staging cmake --build . --target install-sbom stages the files. +# +# The CycloneDX and SPDX JSON files must exist. The tag-value file is +# copied only when the caller set its path and the file exists. + +if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR OR WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR STREQUAL "") + message(FATAL_ERROR "install-sbom.cmake: WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR is required") +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/sbom-paths.cmake") +wolfssl_compliance_tooling_sbom_resolve_outputs(_cdx _spdx _tv) + +# file(INSTALL) applies DESTDIR itself. Prepending it here would stage +# the files twice. file(MAKE_DIRECTORY) does not apply DESTDIR, so the +# directory is created at the staged path. +if(DEFINED ENV{DESTDIR}) + set(_destdir "$ENV{DESTDIR}") +else() + set(_destdir "") +endif() +set(_staged "${_destdir}${WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR}") + +if(NOT EXISTS "${_cdx}") + message(FATAL_ERROR "install-sbom.cmake: missing CycloneDX file ${_cdx}") +endif() +if(NOT EXISTS "${_spdx}") + message(FATAL_ERROR "install-sbom.cmake: missing SPDX file ${_spdx}") +endif() + +file(MAKE_DIRECTORY "${_staged}") +file(INSTALL "${_cdx}" "${_spdx}" DESTINATION "${WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR}") +if(NOT _tv STREQUAL "" AND EXISTS "${_tv}") + file(INSTALL "${_tv}" DESTINATION "${WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR}") +endif() + +message(STATUS "Installed SBOM to ${_staged}") diff --git a/share/build/sbom-paths.cmake b/share/build/sbom-paths.cmake new file mode 100644 index 0000000..97d5ab3 --- /dev/null +++ b/share/build/sbom-paths.cmake @@ -0,0 +1,77 @@ +# Shared path rules for install-sbom.cmake and uninstall-sbom.cmake. +# 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. + +function(_wolfssl_compliance_tooling_read_version file macro out_var) + set(_ver "") + if(NOT file STREQUAL "" AND NOT macro STREQUAL "" AND EXISTS "${file}") + file(STRINGS "${file}" _lines) + foreach(_line IN LISTS _lines) + if(_line MATCHES "${macro}[ \t]+\"([^\"]*)\"") + set(_ver "${CMAKE_MATCH_1}") + break() + endif() + endforeach() + endif() + set(${out_var} "${_ver}" PARENT_SCOPE) +endfunction() + +function(wolfssl_compliance_tooling_sbom_resolve_outputs out_cdx out_spdx out_tv) + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_TV) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_TV "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR "") + endif() + + set(_cdx "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX}") + set(_spdx "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX}") + set(_tv "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_TV}") + + if(_cdx STREQUAL "" OR _spdx STREQUAL "") + set(_ver "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION}") + if(_ver STREQUAL "") + _wolfssl_compliance_tooling_read_version( + "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE}" + "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO}" + _ver) + endif() + if(WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME STREQUAL "" OR _ver STREQUAL "" + OR WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR STREQUAL "") + message(FATAL_ERROR + "sbom paths: pass CDX and SPDX, or NAME, BINDIR, and VERSION " + "(or VERSION_FILE and VERSION_MACRO)") + endif() + if(_cdx STREQUAL "") + set(_cdx "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR}/${WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME}-${_ver}.cdx.json") + endif() + if(_spdx STREQUAL "") + set(_spdx "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR}/${WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME}-${_ver}.spdx.json") + endif() + endif() + + set(${out_cdx} "${_cdx}" PARENT_SCOPE) + set(${out_spdx} "${_spdx}" PARENT_SCOPE) + set(${out_tv} "${_tv}" PARENT_SCOPE) +endfunction() diff --git a/share/build/sbom-with-sde.cmake b/share/build/sbom-with-sde.cmake new file mode 100644 index 0000000..cdd12cf --- /dev/null +++ b/share/build/sbom-with-sde.cmake @@ -0,0 +1,72 @@ +# cmake -P script: run the SBOM driver with SOURCE_DATE_EPOCH set at +# build time. +# +# Keep a value that is already in the environment. When it is empty, use +# the last git commit time of SBOM_ROOT when that directory is the git +# toplevel. A tarball unpacked inside another checkout is not that +# 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. +# +# Arguments after "--" are the driver command. + +if(NOT DEFINED SBOM_ROOT) + set(SBOM_ROOT "") +endif() + +set(_sde "$ENV{SOURCE_DATE_EPOCH}") +if(_sde STREQUAL "" AND NOT SBOM_ROOT STREQUAL "") + find_program(_wolfssl_compliance_tooling_git git) + if(_wolfssl_compliance_tooling_git) + set(_sde "") + execute_process( + COMMAND "${_wolfssl_compliance_tooling_git}" -C "${SBOM_ROOT}" + rev-parse --show-toplevel + OUTPUT_VARIABLE _git_top + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE _git_rc) + if(_git_rc EQUAL 0 AND NOT _git_top STREQUAL "") + # REALPATH is available on CMake 3.16. file(REAL_PATH) needs 3.19. + get_filename_component(_root_real "${SBOM_ROOT}" REALPATH) + get_filename_component(_top_real "${_git_top}" REALPATH) + if(_root_real STREQUAL _top_real) + execute_process( + COMMAND "${_wolfssl_compliance_tooling_git}" -C "${SBOM_ROOT}" + log -1 --format=%ct + OUTPUT_VARIABLE _sde + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE _git_rc) + if(NOT _git_rc EQUAL 0) + set(_sde "") + endif() + endif() + endif() + if(NOT _sde STREQUAL "") + set(ENV{SOURCE_DATE_EPOCH} "${_sde}") + endif() + endif() +endif() + +math(EXPR _last "${CMAKE_ARGC} - 1") +set(_args "") +set(_seen_dd FALSE) +foreach(_i RANGE 0 ${_last}) + set(_a "${CMAKE_ARGV${_i}}") + if(_seen_dd) + # A raw semicolon would split the CMake list and break the argument. + string(REPLACE ";" "\\;" _safe "${_a}") + list(APPEND _args "${_safe}") + elseif(_a STREQUAL "--") + set(_seen_dd TRUE) + endif() +endforeach() + +if(NOT _args) + message(FATAL_ERROR "sbom-with-sde.cmake: no command after --") +endif() + +execute_process(COMMAND ${_args} RESULT_VARIABLE _rc) +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 0ea2a58..ed9abdc 100644 --- a/share/build/sbom.cmake +++ b/share/build/sbom.cmake @@ -4,7 +4,7 @@ # are copied and drifting across wolfMQTT, wolfTPM, and wolfBoot. It exposes ONE # function, wolfssl_compliance_tooling_add_sbom(), so each product describes itself in a few lines # and gets an `sbom` target that calls the same driver as the Make and autotools -# paths. +# paths. That call also adds `install-sbom` and `uninstall-sbom`. # # Include this file, then call the function: # @@ -50,6 +50,25 @@ # inferred from LICENSE. # LICENSE_TEXT Plain-text licence for a LicenseRef-* used in # LICENSE_OVERRIDE (required by SPDX 2.3). +# TV_OUT SPDX tag-value file, when a later step writes one. +# INSTALL_DIR Doc directory for install-sbom. Default: +# CMAKE_INSTALL_FULL_DOCDIR, else +# /share/doc/. +# INSTALL_DEPENDS Target that produces the files. Default: +# TARGET_NAME. wolfTPM builds the tag-value file in a +# later target named sbom, so that product passes +# NO_INSTALL here and calls wolfssl_compliance_tooling_add_sbom_install() +# after that target exists. +# NO_INSTALL Do not create install-sbom / uninstall-sbom. +# INSTALL_TARGET Name of the install target (default: install-sbom). +# UNINSTALL_TARGET Name of the uninstall target (default: uninstall-sbom). +# A second call in the same tree sets both. +# +# wolfssl_compliance_tooling_add_sbom() also creates install-sbom and uninstall-sbom. +# install-sbom reads DESTDIR when the target runs. Output paths are the +# caller's CDX_OUT and SPDX_OUT, so a wolfBoot config tag is installed +# under that name. The tag-value file is installed only when TV_OUT is +# set and the file exists. uninstall-sbom removes the same paths. # # These mirror the SBOM_* variables of build/sbom.mk one for one; the two # fragments must accept the same product description, or the same product @@ -64,12 +83,13 @@ get_filename_component(_WOLFSSL_COMPLIANCE_TOOLING_DRIVER "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/../sbom-driver" ABSOLUTE) function(wolfssl_compliance_tooling_add_sbom) - set(_opts NO_ARTIFACT_HASH SOURCE_ONLY) + set(_opts NO_ARTIFACT_HASH SOURCE_ONLY NO_INSTALL) set(_one NAME TARGET_NAME VERSION VERSION_FILE VERSION_MACRO LICENSE SBOM_GEN GEN_SBOM HOSTCC ROOT LIB USER_SETTINGS OPTIONS_H SETTINGS_H DEP_WOLFSSL DEP_WOLFCRYPT DEP_OPENSSL CRYPTO_ONLY - CDX_OUT SPDX_OUT COMPONENT_TYPE - LICENSE_OVERRIDE LICENSE_TEXT) + CDX_OUT SPDX_OUT TV_OUT COMPONENT_TYPE + LICENSE_OVERRIDE LICENSE_TEXT + INSTALL_DIR INSTALL_DEPENDS INSTALL_TARGET UNINSTALL_TARGET) set(_multi TARGETS DEFS DEP_VERSION INCLUDE_DIRS) cmake_parse_arguments(SB "${_opts}" "${_one}" "${_multi}" ${ARGN}) @@ -170,7 +190,9 @@ function(wolfssl_compliance_tooling_add_sbom) endif() if(SB_VERSION) - list(APPEND _cmd --version ${SB_VERSION}) + # CMake before 3.24 parses options after -P and treats a separate + # "--version" as its own flag. One argument keeps the driver version. + list(APPEND _cmd --version=${SB_VERSION}) endif() if(SB_VERSION_FILE) list(APPEND _cmd --version-file ${SB_VERSION_FILE}) @@ -187,17 +209,21 @@ function(wolfssl_compliance_tooling_add_sbom) if(SB_LICENSE_TEXT) list(APPEND _cmd --license-text ${SB_LICENSE_TEXT}) endif() - if(SB_DEP_WOLFSSL) - list(APPEND _cmd --dep-wolfssl ${SB_DEP_WOLFSSL}) + # cmake_parse_arguments leaves an omitted keyword undefined. An + # unquoted name then compares as the literal "SB_DEP_*", which is not + # empty, and the flag is appended with no value. Quote the expansion. + # "no" stays a real value and is forwarded. + if(NOT "${SB_DEP_WOLFSSL}" STREQUAL "") + list(APPEND _cmd --dep-wolfssl "${SB_DEP_WOLFSSL}") endif() - if(SB_DEP_WOLFCRYPT) - list(APPEND _cmd --dep-wolfcrypt ${SB_DEP_WOLFCRYPT}) + if(NOT "${SB_DEP_WOLFCRYPT}" STREQUAL "") + list(APPEND _cmd --dep-wolfcrypt "${SB_DEP_WOLFCRYPT}") endif() - if(SB_DEP_OPENSSL) - list(APPEND _cmd --dep-openssl ${SB_DEP_OPENSSL}) + if(NOT "${SB_DEP_OPENSSL}" STREQUAL "") + list(APPEND _cmd --dep-openssl "${SB_DEP_OPENSSL}") endif() - if(SB_CRYPTO_ONLY) - list(APPEND _cmd --crypto-only ${SB_CRYPTO_ONLY}) + if(NOT "${SB_CRYPTO_ONLY}" STREQUAL "") + list(APPEND _cmd --crypto-only "${SB_CRYPTO_ONLY}") endif() foreach(_dv IN LISTS SB_DEP_VERSION) list(APPEND _cmd --dep-version ${_dv}) @@ -205,6 +231,18 @@ function(wolfssl_compliance_tooling_add_sbom) if(SB_SBOM_GEN) list(APPEND _cmd --gen-sbom ${SB_SBOM_GEN}) endif() + # A literal version makes the output path known here. Pass it through so + # install-sbom copies the same file the driver writes. A version read + # from a header stays a build-time name; the install script reads that + # header with the same macro. + if(NOT SB_CDX_OUT AND SB_VERSION) + set(SB_CDX_OUT + "${CMAKE_CURRENT_BINARY_DIR}/${SB_NAME}-${SB_VERSION}.cdx.json") + endif() + if(NOT SB_SPDX_OUT AND SB_VERSION) + set(SB_SPDX_OUT + "${CMAKE_CURRENT_BINARY_DIR}/${SB_NAME}-${SB_VERSION}.spdx.json") + endif() if(SB_CDX_OUT) list(APPEND _cmd --cdx-out ${SB_CDX_OUT}) endif() @@ -212,9 +250,107 @@ function(wolfssl_compliance_tooling_add_sbom) list(APPEND _cmd --spdx-out ${SB_SPDX_OUT}) endif() + # 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 ${_cmd} + 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)") + + if(NOT SB_NO_INSTALL) + set(_install_depends ${SB_TARGET_NAME}) + if(SB_INSTALL_DEPENDS) + set(_install_depends ${SB_INSTALL_DEPENDS}) + endif() + wolfssl_compliance_tooling_add_sbom_install( + NAME ${SB_NAME} + DEPENDS ${_install_depends} + CDX "${SB_CDX_OUT}" + SPDX "${SB_SPDX_OUT}" + TV "${SB_TV_OUT}" + VERSION "${SB_VERSION}" + VERSION_FILE "${SB_VERSION_FILE}" + VERSION_MACRO "${SB_VERSION_MACRO}" + BINDIR "${CMAKE_CURRENT_BINARY_DIR}" + INSTALL_DIR "${SB_INSTALL_DIR}" + INSTALL_TARGET "${SB_INSTALL_TARGET}" + UNINSTALL_TARGET "${SB_UNINSTALL_TARGET}") + endif() +endfunction() + +# Install and uninstall the files from one wolfssl_compliance_tooling_add_sbom() call. +# Call this directly when the files are produced by a target that does not +# exist yet inside wolfssl_compliance_tooling_add_sbom(), as wolfTPM does: the public sbom +# 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) + cmake_parse_arguments(SI "" "${_one}" "" ${ARGN}) + + if(NOT SI_NAME) + message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom_install: NAME is required") + endif() + if(NOT SI_DEPENDS) + message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom_install: DEPENDS is required") + endif() + if(NOT SI_INSTALL_TARGET) + set(SI_INSTALL_TARGET install-sbom) + endif() + if(NOT SI_UNINSTALL_TARGET) + set(SI_UNINSTALL_TARGET uninstall-sbom) + endif() + if(TARGET ${SI_INSTALL_TARGET}) + message(FATAL_ERROR + "wolfssl_compliance_tooling_add_sbom_install: target ${SI_INSTALL_TARGET} already exists. " + "Pass INSTALL_TARGET for a second SBOM.") + endif() + + if(SI_INSTALL_DIR) + set(_docdir "${SI_INSTALL_DIR}") + elseif(CMAKE_INSTALL_FULL_DOCDIR) + set(_docdir "${CMAKE_INSTALL_FULL_DOCDIR}") + else() + set(_docdir "${CMAKE_INSTALL_PREFIX}/share/doc/${SI_NAME}") + endif() + if(NOT IS_ABSOLUTE "${_docdir}") + set(_docdir "${CMAKE_INSTALL_PREFIX}/${_docdir}") + endif() + + add_custom_target(${SI_INSTALL_TARGET} + COMMAND ${CMAKE_COMMAND} + -DWOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR=${_docdir} + -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_NAME=${SI_NAME} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION=${SI_VERSION} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE=${SI_VERSION_FILE} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO=${SI_VERSION_MACRO} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR=${SI_BINDIR} + -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/install-sbom.cmake" + VERBATIM + COMMENT "Installing ${SI_NAME} SBOM to ${_docdir}") + add_dependencies(${SI_INSTALL_TARGET} ${SI_DEPENDS}) + + add_custom_target(${SI_UNINSTALL_TARGET} + COMMAND ${CMAKE_COMMAND} + -DWOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR=${_docdir} + -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_NAME=${SI_NAME} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION=${SI_VERSION} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE=${SI_VERSION_FILE} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO=${SI_VERSION_MACRO} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR=${SI_BINDIR} + -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/uninstall-sbom.cmake" + VERBATIM + COMMENT "Uninstalling ${SI_NAME} SBOM from ${_docdir}") endfunction() diff --git a/share/build/uninstall-sbom.cmake b/share/build/uninstall-sbom.cmake new file mode 100644 index 0000000..0354c6d --- /dev/null +++ b/share/build/uninstall-sbom.cmake @@ -0,0 +1,30 @@ +# cmake -P script: remove SBOM artifacts installed by install-sbom.cmake. +# DESTDIR is read from the environment when this script runs. +# file(REMOVE) does nothing when a file is already absent. + +if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR OR WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR STREQUAL "") + message(FATAL_ERROR "uninstall-sbom.cmake: WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR is required") +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/sbom-paths.cmake") +wolfssl_compliance_tooling_sbom_resolve_outputs(_cdx _spdx _tv) + +if(DEFINED ENV{DESTDIR}) + set(_destdir "$ENV{DESTDIR}") +else() + set(_destdir "") +endif() +set(_dest "${_destdir}${WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR}") + +get_filename_component(_cdx_name "${_cdx}" NAME) +get_filename_component(_spdx_name "${_spdx}" NAME) +set(_remove + "${_dest}/${_cdx_name}" + "${_dest}/${_spdx_name}") +if(NOT _tv STREQUAL "") + get_filename_component(_tv_name "${_tv}" NAME) + list(APPEND _remove "${_dest}/${_tv_name}") +endif() + +file(REMOVE ${_remove}) +message(STATUS "Uninstalled SBOM from ${_dest}") diff --git a/share/frontends/compdb_sbom.py b/share/frontends/compdb_sbom.py index 34f427a..804366c 100755 --- a/share/frontends/compdb_sbom.py +++ b/share/frontends/compdb_sbom.py @@ -51,7 +51,7 @@ def entry_tokens(entry): def macro_name(d): """Macro name of a -D/-U operand: the text before the first '=' or '(', so a function-like 'F(a)=a' is keyed as 'F'.""" - return re.split(r'[=(]', d, 1)[0] + return re.split(r'[=(]', d, maxsplit=1)[0] def extract_defines(tokens): diff --git a/tests/test_low_findings.py b/tests/test_low_findings.py index 2bb4b79..77c99f5 100644 --- a/tests/test_low_findings.py +++ b/tests/test_low_findings.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Regression tests for the low-severity review-findings bundle (gpex.20).""" +import importlib.util import json import os import stat @@ -15,12 +16,18 @@ GEN_SBOM = os.path.join(SHARE, "gen-sbom") VALIDATE = os.path.join(SHARE, "validate_sbom.py") -gs = SourceFileLoader("gen_sbom", GEN_SBOM).load_module() -drv = SourceFileLoader("sbom_driver", os.path.join(SHARE, "sbom-driver.py") - ).load_module() -compdb = SourceFileLoader( - "compdb_sbom", os.path.join(SHARE, "frontends", "compdb_sbom.py") -).load_module() +def _load(name, path): + loader = SourceFileLoader(name, path) + module = importlib.util.module_from_spec( + importlib.util.spec_from_loader(name, loader)) + loader.exec_module(module) + return module + + +gs = _load("gen_sbom", GEN_SBOM) +drv = _load("sbom_driver", os.path.join(SHARE, "sbom-driver.py")) +compdb = _load("compdb_sbom", + os.path.join(SHARE, "frontends", "compdb_sbom.py")) class TestAtomicWrite(unittest.TestCase): @@ -35,9 +42,11 @@ def test_writes_content_and_leaves_no_temp(self): def test_dev_stdout_redirected_to_file(self): # `--cdx-out /dev/stdout > out.json`: /dev/stdout is a symlink to the # redirected file and must be written through, not replaced. - code = ("from importlib.machinery import SourceFileLoader as L; " - "L('g', %r).load_module().write_json_atomic({'a': 1}, " - "'/dev/stdout')" % GEN_SBOM) + code = ("import importlib.util as u; " + "from importlib.machinery import SourceFileLoader as L; " + "l = L('g', %r); m = u.module_from_spec(" + "u.spec_from_loader('g', l)); l.exec_module(m); " + "m.write_json_atomic({'a': 1}, '/dev/stdout')" % GEN_SBOM) with tempfile.TemporaryDirectory() as d: out = os.path.join(d, "out.json") with open(out, "w") as f: diff --git a/tests/test_sbom_cmake_install.py b/tests/test_sbom_cmake_install.py new file mode 100644 index 0000000..240a7dd --- /dev/null +++ b/tests/test_sbom_cmake_install.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""CMake install-sbom / uninstall-sbom and build-time SOURCE_DATE_EPOCH.""" + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BUILD = ROOT / "share" / "build" +CMAKE = shutil.which("cmake") + + +def run(cmd, env=None, cwd=None): + return subprocess.run( + cmd, cwd=cwd, env=env, text=True, capture_output=True, check=False) + + +class InstallScriptTests(unittest.TestCase): + def setUp(self): + if not CMAKE: + self.skipTest("cmake is not installed") + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + + def _install(self, extra, destdir=None): + env = os.environ.copy() + if destdir is None: + env.pop("DESTDIR", None) + else: + env["DESTDIR"] = destdir + cmd = [CMAKE, *extra, "-P", str(BUILD / "install-sbom.cmake")] + return run(cmd, env=env) + + def test_destdir_and_tag_value_and_config_tag_name(self): + # wolfBoot writes a config tag into the file name. The install copies + # that name. A missing tag-value file is skipped. + cdx = self.dir / "wolfboot-stm32h7-1.2.3.cdx.json" + spdx = self.dir / "wolfboot-stm32h7-1.2.3.spdx.json" + stale = self.dir / "wolfboot-stm32h7-1.2.3.spdx" + cdx.write_text("{}\n") + spdx.write_text("{}\n") + # A leftover tag-value file must stay out of the install when TV + # was not passed. + stale.write_text("stale\n") + stage = self.dir / "stage" + # The doc dir is the absolute install path. DESTDIR is prepended. + doc = Path("/usr/local/share/doc/wolfboot") + result = self._install([ + f"-DWOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR={doc}", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX={cdx}", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX={spdx}", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV=", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=wolfboot", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR=" + str(self.dir), + ], destdir=str(stage)) + self.assertEqual(result.returncode, 0, result.stderr) + installed = stage / "usr" / "local" / "share" / "doc" / "wolfboot" + self.assertTrue((installed / cdx.name).is_file()) + self.assertTrue((installed / spdx.name).is_file()) + self.assertFalse((installed / stale.name).exists()) + + tv = self.dir / "wolfboot-stm32h7-1.2.3.spdx" + tv.write_text("SPDX\n") + result = self._install([ + f"-DWOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR={doc}", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX={cdx}", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX={spdx}", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV={tv}", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=wolfboot", + ], destdir=str(stage)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((installed / tv.name).is_file()) + + env = os.environ.copy() + env["DESTDIR"] = str(stage) + result = run([ + CMAKE, + f"-DWOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR={doc}", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX={cdx}", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX={spdx}", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV={tv}", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=wolfboot", + "-P", str(BUILD / "uninstall-sbom.cmake"), + ], env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((installed / cdx.name).exists()) + self.assertFalse((installed / spdx.name).exists()) + self.assertFalse((installed / tv.name).exists()) + + def test_version_header_names_match_the_driver_default(self): + header = self.dir / "version.h" + header.write_text('#define LIBWOLFTPM_VERSION_STRING "3.4.5"\n') + bindir = self.dir / "build" + bindir.mkdir() + cdx = bindir / "wolftpm-3.4.5.cdx.json" + spdx = bindir / "wolftpm-3.4.5.spdx.json" + cdx.write_text("{}\n") + spdx.write_text("{}\n") + doc = self.dir / "doc" + result = self._install([ + f"-DWOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR={doc}", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX=", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX=", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV=", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=wolftpm", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION=", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE={header}", + "-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO=LIBWOLFTPM_VERSION_STRING", + f"-DWOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR={bindir}", + ]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((doc / cdx.name).is_file()) + self.assertTrue((doc / spdx.name).is_file()) + + +class SdeScriptTests(unittest.TestCase): + def setUp(self): + if not CMAKE: + self.skipTest("cmake is not installed") + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + + def _print_sde(self, root, env): + code = "import os; print(os.environ.get('SOURCE_DATE_EPOCH',''))" + return run([ + CMAKE, f"-DSBOM_ROOT={root}", + "-P", str(BUILD / "sbom-with-sde.cmake"), + "--", os.sys.executable, "-c", code, + ], env=env) + + def test_build_time_value_wins_over_git(self): + env = os.environ.copy() + env["SOURCE_DATE_EPOCH"] = "111" + result = self._print_sde(self.dir, env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "111") + + def test_empty_value_uses_git_commit_time(self): + git = shutil.which("git") + if not git: + self.skipTest("git is not installed") + run([git, "init"], cwd=self.dir) + run([git, "config", "user.email", "sbom@example.invalid"], cwd=self.dir) + run([git, "config", "user.name", "sbom"], cwd=self.dir) + (self.dir / "README").write_text("x\n") + run([git, "add", "README"], cwd=self.dir) + run([git, "commit", "-m", "init"], cwd=self.dir) + stamp = run([git, "log", "-1", "--format=%ct"], cwd=self.dir) + self.assertEqual(stamp.returncode, 0, stamp.stderr) + env = os.environ.copy() + env.pop("SOURCE_DATE_EPOCH", None) + result = self._print_sde(self.dir, env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), stamp.stdout.strip()) + + def test_nested_directory_does_not_use_the_outer_commit(self): + # A release tarball unpacked inside another checkout must not + # take that checkout's commit time. + git = shutil.which("git") + if not git: + self.skipTest("git is not installed") + run([git, "init"], cwd=self.dir) + run([git, "config", "user.email", "sbom@example.invalid"], cwd=self.dir) + run([git, "config", "user.name", "sbom"], cwd=self.dir) + (self.dir / "README").write_text("x\n") + run([git, "add", "README"], cwd=self.dir) + run([git, "commit", "-m", "init"], cwd=self.dir) + nested = self.dir / "unpacked" + nested.mkdir() + env = os.environ.copy() + env.pop("SOURCE_DATE_EPOCH", None) + result = self._print_sde(nested, env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "") + + +class HelperConfigureTests(unittest.TestCase): + def setUp(self): + if not CMAKE: + self.skipTest("cmake is not installed") + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + + def _configure(self, body): + src = self.dir / "src" + src.mkdir() + (src / "CMakeLists.txt").write_text(body) + (src / "opts.h").write_text("#define DEMO 1\n") + (src / "LICENSE").write_text("demo\n") + build = self.dir / "build" + result = run([ + CMAKE, "-S", str(src), "-B", str(build), "-G", "Unix Makefiles", + ]) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + return build + + def _build_text(self, build): + parts = [] + for path in build.rglob("*"): + if path.suffix in {".make", ".cmake"} or path.name == "Makefile": + parts.append(path.read_text(errors="replace")) + return "\n".join(parts) + + def test_default_call_records_install_targets(self): + build = self._configure(f""" +cmake_minimum_required(VERSION 3.16) +project(sbomtest NONE) +include("{BUILD / "sbom.cmake"}") +wolfssl_compliance_tooling_add_sbom( + NAME demo + VERSION 1.2.3 + LIB ${{CMAKE_CURRENT_BINARY_DIR}}/libdemo.a + OPTIONS_H ${{CMAKE_CURRENT_SOURCE_DIR}}/opts.h + LICENSE ${{CMAKE_CURRENT_SOURCE_DIR}}/LICENSE + INSTALL_DIR ${{CMAKE_CURRENT_BINARY_DIR}}/doc) +""") + text = self._build_text(build) + self.assertNotIn("--dep-libz", text) + self.assertNotIn("--document-namespace", text) + self.assertIn("sbom-with-sde.cmake", text) + # CMake before 3.24 treats a separate "--version" after -P as its + # own flag. The driver version stays one argument. + self.assertIn("--version=1.2.3", text) + self.assertNotIn("--version 1.2.3", text) + self.assertIn("install-sbom", text) + self.assertIn("uninstall-sbom", text) + self.assertIn("demo-1.2.3.cdx.json", text) + # Omitted dependency options must not appear. An unquoted CMake + # guard would emit a bare --dep-wolfssl. + self.assertNotIn("--dep-wolfssl", text) + self.assertNotIn("--dep-openssl", text) + self.assertNotIn("--crypto-only", text) + + def test_wolftpm_style_install_depends_on_the_public_target(self): + # The helper target is not the public sbom target. install-sbom + # must wait for the target that writes the tag-value file. + build = self._configure(f""" +cmake_minimum_required(VERSION 3.16) +project(sbomtest NONE) +include("{BUILD / "sbom.cmake"}") +add_custom_target(sbom COMMAND ${{CMAKE_COMMAND}} -E echo public-sbom) +wolfssl_compliance_tooling_add_sbom( + NAME wolftpm + VERSION 3.4.5 + TARGET_NAME wolftpm-sbom-gen + NO_INSTALL + LIB ${{CMAKE_CURRENT_BINARY_DIR}}/libwolftpm.a + OPTIONS_H ${{CMAKE_CURRENT_SOURCE_DIR}}/opts.h + LICENSE ${{CMAKE_CURRENT_SOURCE_DIR}}/LICENSE + CDX_OUT ${{CMAKE_CURRENT_BINARY_DIR}}/wolftpm-3.4.5.cdx.json + SPDX_OUT ${{CMAKE_CURRENT_BINARY_DIR}}/wolftpm-3.4.5.spdx.json) +wolfssl_compliance_tooling_add_sbom_install( + NAME wolftpm + DEPENDS sbom + CDX ${{CMAKE_CURRENT_BINARY_DIR}}/wolftpm-3.4.5.cdx.json + SPDX ${{CMAKE_CURRENT_BINARY_DIR}}/wolftpm-3.4.5.spdx.json + TV ${{CMAKE_CURRENT_BINARY_DIR}}/wolftpm-3.4.5.spdx + INSTALL_DIR ${{CMAKE_CURRENT_BINARY_DIR}}/doc) +""") + text = self._build_text(build) + self.assertIn("wolftpm-3.4.5.spdx", text) + # Makefile2 holds the add_dependencies edge. A match against + # install-sbom: CMakeFiles/install-sbom is the target's own rule. + makefile2 = (build / "CMakeFiles" / "Makefile2").read_text() + self.assertIn( + "CMakeFiles/install-sbom.dir/all: CMakeFiles/sbom.dir/all", + makefile2) + + def test_source_only_sbom_target_builds(self): + # A string search for sbom-with-sde.cmake still passes when the + # directory prefix is empty. Building the target runs that script. + src = self.dir / "src" + src.mkdir() + (src / "x.c").write_text("int x(void) { return 1; }\n") + (src / "LICENSE").write_text("demo\n") + (src / "CMakeLists.txt").write_text(f""" +cmake_minimum_required(VERSION 3.16) +project(sbomtest C) +add_library(x STATIC x.c) +include("{BUILD / "sbom.cmake"}") +wolfssl_compliance_tooling_add_sbom( + NAME t + VERSION 1 + TARGETS x + SOURCE_ONLY + ROOT ${{CMAKE_CURRENT_SOURCE_DIR}} + LICENSE ${{CMAKE_CURRENT_SOURCE_DIR}}/LICENSE) +""") + build = self.dir / "build" + configured = run([ + CMAKE, "-S", str(src), "-B", str(build), "-G", "Unix Makefiles", + ]) + self.assertEqual(configured.returncode, 0, + configured.stderr + configured.stdout) + built = run([CMAKE, "--build", str(build), "--target", "sbom"]) + self.assertEqual(built.returncode, 0, built.stderr + built.stdout) + self.assertTrue((build / "t-1.cdx.json").is_file()) + self.assertTrue((build / "t-1.spdx.json").is_file()) + + def test_second_call_keeps_its_install_target_name(self): + build = self._configure(f""" +cmake_minimum_required(VERSION 3.16) +project(sbomtest NONE) +include("{BUILD / "sbom.cmake"}") +wolfssl_compliance_tooling_add_sbom( + NAME demo + VERSION 1 + LIB ${{CMAKE_CURRENT_BINARY_DIR}}/libdemo.a + LICENSE ${{CMAKE_CURRENT_SOURCE_DIR}}/LICENSE) +wolfssl_compliance_tooling_add_sbom( + NAME extra + VERSION 1 + TARGET_NAME sbom-extra + INSTALL_TARGET install-sbom-extra + UNINSTALL_TARGET uninstall-sbom-extra + LIB ${{CMAKE_CURRENT_BINARY_DIR}}/libextra.a + LICENSE ${{CMAKE_CURRENT_SOURCE_DIR}}/LICENSE) +""") + text = self._build_text(build) + self.assertIn("install-sbom-extra", text) + self.assertIn("uninstall-sbom-extra", text) + + +if __name__ == "__main__": + unittest.main()