Repository: randombit/botan (cpp)

Issue:
Upstream a CMake Find Module
CMake provides so-called ["find modules"](https://cmake.org/cmake/help/latest/manual/cmake-developer.7.html#find-modules) in order to make it easier for downstream users of software packages to find header files, libraries to link against, etc. CMake ships find modules for [many standard software packages](https://gitlab.kitware.com/cmake/cmake/-/tree/master/Modules), e.g., Boost, OpenSSL, curl and many more. We have been writing our own find module for Botan 2.x and would now have to write our own find module for Botan 3.x when switching. Others are doing the same, e.g., [KeePassXC](https://github.com/keepassxreboot/keepassxc/tree/develop/cmake), [rnp](https://github.com/rnpgp/rnp/blob/master/cmake/Modules/FindBotan2.cmake) and more. We even link to rnp's FindBotan2.cmake from our [wiki](https://github.com/randombit/botan/wiki/Adding-Botan-to-your-build).

Can we just upstream one of these to the CMake project to be included with the CMake distribution? That would make it really easy for Botan users on CMake to integrate Botan into their builds and we get rid of all these CMake script duplicates.

Pull request description:
Add cmake package config
Adds a CMake [package configuration](https://cmake.org/cmake/help/latest/manual/cmake-packages.7.html#config-file-packages) for the library, so users of Botan using CMake to link against Botan don't need to invoke pkg-config or write a CMake find module (https://github.com/rnpgp/rnp/issues/2121). Unlike suggested in #3490, we don't write a find module, as package configuration files now seem to be the [recommended way](https://discourse.cmake.org/t/botan-find-module/9101/4). The package configuration files are installed with Botan and can then be used as follows:

```CMake
# find any Botan version
find_package(Botan REQUIRED)

# find a minimum Botan version (>= 3.3.0)
find_package(Botan 3.3.0 REQUIRED)

# should be able to use ranges, too
find_package(Botan 3.0.0...3.3.0 REQUIRED)

# even check if certain modules are installed
find_package(Botan 3.3.0 REQUIRED COMPONENTS rsa ecdsa kyber)

# finally link with Botan
target_link_libraries(my_target PRIVATE Botan::Botan)
```

Also adds tests to the CI. Tests are provided in `src/scripts/ci/cmake_tests` using an example CMake project.

Closes #3490.

Patch:
diff --git a/.gitignore b/.gitignore
index 169aa6ae5..f0b64ff48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,4 @@
 /Makefile
-CMakeLists.txt*
 build.ninja
 .ninja_log
 libbotan*.so.*
diff --git a/configure.py b/configure.py
index 3af0bd1e6..f8c5ba08b 100755
--- a/configure.py
+++ b/configure.py
@@ -2315,6 +2315,9 @@ def create_template_vars(source_paths, build_paths, options, modules, cc, arch,
         if osinfo.soname_pattern_patch is not None:
             variables['soname_patch'] = osinfo.soname_pattern_patch.format(**variables)
 
+        if options.os == 'windows':
+            variables['implib_name'] = variables['static_lib_name']
+
         variables['lib_link_cmd'] = variables['lib_link_cmd'].format(**variables)
 
     for var in ['exe_link_cmd']:
@@ -3342,6 +3345,10 @@ def do_io_for_build(cc, arch, osinfo, using_mods, info_modules, build_paths, sou
     write_template(in_build_dir('build.h'), in_build_data('buildh.in'))
     write_template(in_build_dir('botan.doxy'), in_build_data('botan.doxy.in'))
 
+    robust_makedirs(in_build_dir("cmake"))
+    write_template(in_build_dir('cmake/botan-config.cmake'), in_build_data('botan-config.cmake.in'))
+    write_template(in_build_dir('cmake/botan-config-version.cmake'), in_build_data('botan-config-version.cmake.in'))
+
     if 'botan_pkgconfig' in template_vars:
         write_template(template_vars['botan_pkgconfig'], in_build_data('botan.pc.in'))
 
diff --git a/src/build-data/botan-config-version.cmake.in b/src/build-data/botan-config-version.cmake.in
new file mode 100644
index 000000000..890468d3a
--- /dev/null
+++ b/src/build-data/botan-config-version.cmake.in
@@ -0,0 +1,16 @@
+set(PACKAGE_VERSION %{version})
+
+# Botan follows semver:
+# * the requested version should be less or equal to the installed version, however
+# * the requested major version must fit exactly.
+
+if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION)
+  set(PACKAGE_VERSION_COMPATIBLE FALSE)
+elseif(PACKAGE_FIND_VERSION_MAJOR VERSION_LESS %{version_major})
+  set(PACKAGE_VERSION_COMPATIBLE FALSE)
+else()
+  set(PACKAGE_VERSION_COMPATIBLE TRUE)
+  if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION)
+    set(PACKAGE_VERSION_EXACT TRUE)
+  endif()
+endif()
diff --git a/src/build-data/botan-config.cmake.in b/src/build-data/botan-config.cmake.in
new file mode 100644
index 000000000..8d14c4e77
--- /dev/null
+++ b/src/build-data/botan-config.cmake.in
@@ -0,0 +1,116 @@
+#.rst:
+# botan-config.cmake
+# -----------
+#
+# Find the botan library.
+#
+# This CMake configuration file, installed as part of the Botan build,
+# provides support for find_package(Botan).
+#
+# Required version(s) can be passed as usual:
+# find_package(Botan 3.3.0 REQUIRED)
+#
+# COMPONENTS and OPTIONAL_COMPONENTS can be used to specify Botan
+# modules that must or should be enabled in the Botan build:
+# find_package(Botan 3.3.0 COMPONENTS rsa ecdsa)
+#
+# IMPORTED Targets
+# ^^^^^^^^^^^^^^^^
+#
+# This module defines :prop_tgt:`IMPORTED` targets:
+#
+# ``Botan::Botan``
+#   The botan shared library, if found.
+# ``Botan::Botan-static``
+#   The botan static library, if found.
+#
+# Result variables
+# ^^^^^^^^^^^^^^^^
+#
+# This module defines the following variables:
+#
+# ::
+#
+#   Botan_FOUND          - true if the headers and library were found
+#   Botan_VERSION        - library version that was found, if any
+#
+
+set(_Botan_supported_components
+%{for mod_list}
+%{i}
+%{endfor}
+)
+
+unset(${CMAKE_FIND_PACKAGE_NAME}_FOUND)
+unset(_Botan_missing_required_modules)
+
+foreach(_comp IN LISTS ${CMAKE_FIND_PACKAGE_NAME}_FIND_COMPONENTS)
+  if (NOT _comp IN_LIST _Botan_supported_components)
+    set(${CMAKE_FIND_PACKAGE_NAME}_${_comp}_FOUND False)
+    if(${CMAKE_FIND_PACKAGE_NAME}_FIND_REQUIRED_${_comp})
+      list(APPEND _Botan_missing_required_modules ${_comp})
+    endif()
+  else()
+    set(${CMAKE_FIND_PACKAGE_NAME}_${_comp}_FOUND True)
+  endif()
+endforeach()
+
+if(_Botan_missing_required_modules)
+  set(${CMAKE_FIND_PACKAGE_NAME}_FOUND False)
+  list(JOIN _Botan_missing_required_modules ", " _missing_modules)
+  set(${CMAKE_FIND_PACKAGE_NAME}_NOT_FOUND_MESSAGE "Unsupported module(s): ${_missing_modules}")
+endif()
+
+if(DEFINED ${CMAKE_FIND_PACKAGE_NAME}_FOUND AND NOT ${${CMAKE_FIND_PACKAGE_NAME}_FOUND})
+  return()
+endif()
+
+# botan-config.cmake lives in "${_Botan_PREFIX}/lib/cmake/Botan-X": traverse up to $_Botan_PREFIX
+set(_Botan_PREFIX "${CMAKE_CURRENT_LIST_DIR}")
+get_filename_component(_Botan_PREFIX "${_Botan_PREFIX}" DIRECTORY)
+get_filename_component(_Botan_PREFIX "${_Botan_PREFIX}" DIRECTORY)
+get_filename_component(_Botan_PREFIX "${_Botan_PREFIX}" DIRECTORY)
+
+%{if build_static_lib}
+if(NOT TARGET Botan::Botan-static)
+  add_library(Botan::Botan-static STATIC IMPORTED)
+  set_target_properties(Botan::Botan-static
+    PROPERTIES
+      IMPORTED_LOCATION                 "${_Botan_PREFIX}/lib/%{static_lib_name}"
+      INTERFACE_INCLUDE_DIRECTORIES     "${_Botan_PREFIX}/include/botan-%{version_major}"
+      IMPORTED_LINK_INTERFACE_LANGUAGES "CXX"
+      INTERFACE_LINK_OPTIONS            "SHELL:%{cxx_abi_flags}")
+endif()
+%{endif}
+
+%{if implib_name}
+set(_Botan_implib     "${_Botan_PREFIX}/lib/%{implib_name}")
+set(_Botan_shared_lib "${_Botan_PREFIX}/bin/%{shared_lib_name}")
+%{endif}
+%{unless implib_name}
+set(_Botan_implib "")
+%{endif}
+
+%{if build_shared_lib}
+if(NOT TARGET Botan::Botan)
+  if(NOT DEFINED _Botan_shared_lib)
+    set(_Botan_shared_lib "${_Botan_PREFIX}/lib/%{shared_lib_name}")
+  endif()
+
+  add_library(Botan::Botan SHARED IMPORTED)
+  set_target_properties(Botan::Botan
+    PROPERTIES
+      IMPORTED_LOCATION             "${_Botan_shared_lib}"
+      IMPORTED_IMPLIB               "${_Botan_implib}"
+      INTERFACE_INCLUDE_DIRECTORIES "${_Botan_PREFIX}/include/botan-%{version_major}"
+      INTERFACE_LINK_OPTIONS        "SHELL:%{cxx_abi_flags}")
+  set_property(TARGET Botan::Botan APPEND PROPERTY IMPORTED_CONFIGURATIONS NOCONFIG)
+  set_target_properties(Botan::Botan
+    PROPERTIES
+      IMPORTED_LOCATION_NOCONFIG "${_Botan_PREFIX}/lib/%{shared_lib_name}"
+      IMPORTED_SONAME_NOCONFIG   "%{shared_lib_name}"
+      IMPORTED_IMPLIB_NOCONFIG   "${_Botan_implib}")
+endif()
+%{endif}
+
+set(${CMAKE_FIND_PACKAGE_NAME}_FOUND True)
diff --git a/src/scripts/ci_check_install.py b/src/scripts/ci_check_install.py
index e691bd05a..dd4d88e01 100755
--- a/src/scripts/ci_check_install.py
+++ b/src/scripts/ci_check_install.py
@@ -6,6 +6,7 @@ Botan CI check installation script
 This script is used to validate the results of `make install`
 
 (C) 2020 Jack Lloyd, René Meusel, Hannes Rantzsch
+(C) 2023 René Meusel
 
 Botan is released under the Simplified BSD License (see license.txt)
 """
@@ -14,6 +15,7 @@ import os
 import sys
 import json
 import re
+import subprocess
 
 def verify_library(build_config):
     lib_dir = build_config['libdir']
@@ -75,6 +77,34 @@ def verify_includes(build_config):
 
     return True
 
+def verify_cmake_package(build_config):
+    if build_config['os'] not in ['windows', 'linux', 'macos']:
+        return True # skip (e.g. for mingw)
+
+    cmake_build_dir = os.path.join(build_config['abs_root_dir'], build_config['build_dir'], 'cmake_test')
+    cmake_test_dir = os.path.join(build_config['abs_root_dir'], "src", "scripts", "ci", "cmake_tests")
+
+    def cmake_preset():
+        if build_config['os'] == 'windows':
+            return 'windows_x86_64' if build_config['arch'] == 'x86_64' else 'windows_x86'
+        return 'unix'
+
+    def test_target():
+        return 'test' if build_config['os'] != 'windows' else 'RUN_TESTS'
+
+    try:
+        subprocess.run(["cmake", "--preset", cmake_preset(),
+                                 "-B", cmake_build_dir,
+                                 "-S", cmake_test_dir,
+                                 "-DCMAKE_PREFIX_PATH=%s" % build_config['prefix']], check=True)
+        subprocess.run(["cmake", "--build", cmake_build_dir, "--config", "Release"], check=True)
+        subprocess.run(["cmake", "--build", cmake_build_dir, "--config", "Release", "--target", test_target()], check=True)
+    except RuntimeError as e:
+        print("Using the CMake package failed: %s" % str(e))
+        return False
+
+    return True
+
 def main(args=None):
     if args is None:
         args = sys.argv
@@ -98,6 +128,9 @@ def main(args=None):
     if not verify_library(build_config):
         return 1
 
+    if not verify_cmake_package(build_config):
+        return 1
+
     return 0
 
 if __name__ == '__main__':
diff --git a/src/scripts/install.py b/src/scripts/install.py
index f4aee90b8..81ba766f9 100755
--- a/src/scripts/install.py
+++ b/src/scripts/install.py
@@ -146,6 +146,7 @@ def main(args):
     lib_dir = cfg['libdir']
     target_include_dir = cfg['installed_include_dir']
     pkgconfig_dir = 'pkgconfig'
+    cmake_dir = 'cmake'
 
     prefix = cfg['prefix']
 
@@ -162,7 +163,7 @@ def main(args):
         copy_file(full_header_path,
                   prepend_destdir(os.path.join(target_include_dir, header)))
 
-    if build_static_lib or target_os == 'windows':
+    if build_static_lib:
         static_lib = cfg['static_lib_name']
         copy_file(os.path.join(out_dir, static_lib),
                   prepend_destdir(os.path.join(lib_dir, os.path.basename(static_lib))))
@@ -171,8 +172,11 @@ def main(args):
         if target_os == "windows":
             libname = cfg['libname']
             soname_base = libname + '.dll'
+            implib = cfg['implib_name']
             copy_executable(os.path.join(out_dir, soname_base),
                             prepend_destdir(os.path.join(bin_dir, soname_base)))
+            copy_file(os.path.join(out_dir, implib),
+                      prepend_destdir(os.path.join(lib_dir, os.path.basename(implib))))
         elif target_os == "mingw":
             shared_lib_name = cfg['shared_lib_name']
             copy_executable(os.path.join(out_dir, shared_lib_name),
@@ -206,6 +210,13 @@ def main(args):
         copy_file(cfg['botan_pkgconfig'],
                   prepend_destdir(os.path.join(pkgconfig_dir, os.path.basename(cfg['botan_pkgconfig']))))
 
+    cmake_dir = os.path.join(prefix, lib_dir, cmake_dir, 'Botan-%s' % cfg["version"])
+    makedirs(prepend_destdir(cmake_dir))
+    copy_file('build/cmake/botan-config.cmake',
+                  prepend_destdir(os.path.join(cmake_dir, 'botan-config.cmake')))
+    copy_file('build/cmake/botan-config-version.cmake',
+                  prepend_destdir(os.path.join(cmake_dir, 'botan-config-version.cmake')))
+
     if 'ffi' in cfg['mod_list'] and cfg['build_shared_lib'] is True and cfg['install_python_module'] is True:
         for ver in cfg['python_version'].split(','):
             py_lib_path = os.path.join(lib_dir, 'python%s' % (ver), 'site-packages')


Test patch:
diff --git a/src/scripts/ci/cmake_tests/CMakeLists.txt b/src/scripts/ci/cmake_tests/CMakeLists.txt
new file mode 100644
index 000000000..e1a546a26
--- /dev/null
+++ b/src/scripts/ci/cmake_tests/CMakeLists.txt
@@ -0,0 +1,128 @@
+cmake_minimum_required(VERSION 3.24)
+
+project(Botan_CMake_Tests CXX)
+
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED On)
+set(CMAKE_CXX_EXTENSIONS Off)
+
+# test #1: find any Botan version and link dynamically
+find_package(Botan REQUIRED)
+if(NOT Botan_FOUND)
+  message(FATAL_ERROR "Test #1 failed: Failed to find Botan with any version")
+endif()
+
+if(TARGET Botan::Botan)
+  add_executable(botan_version_test main.cpp)
+  target_link_libraries(botan_version_test Botan::Botan)
+
+  if(WIN32)
+    add_custom_command(TARGET botan_version_test POST_BUILD
+                       COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_RUNTIME_DLLS:botan_version_test> $<TARGET_FILE_DIR:botan_version_test>
+                       COMMAND_EXPAND_LISTS)
+  endif()
+
+  add_test(NAME "Using shared library"
+           COMMAND botan_version_test
+           PASS_REGULAR_EXPRESSION "${Botan_VERSION}")
+endif()
+
+unset(Botan_FOUND)
+
+# test #2: link statically
+find_package(Botan REQUIRED)
+if(TARGET Botan::Botan-static)
+  add_executable(botan_version_test_static main.cpp)
+  target_link_libraries(botan_version_test_static Botan::Botan-static)
+
+  add_test(NAME "Using static library"
+           COMMAND botan_version_test_static
+           PASS_REGULAR_EXPRESSION "${Botan_VERSION}")
+endif()
+
+unset(Botan_FOUND)
+
+# test #3: find Botan version installed by version number
+find_package(Botan ${Botan_VERSION} REQUIRED)
+if(NOT Botan_FOUND)
+  message(FATAL_ERROR "Failed to find Botan with version ${Botan_VERSION}")
+endif()
+
+unset(Botan_FOUND)
+
+# test #4: find Botan with specific modules
+find_package(Botan ${Botan_VERSION} COMPONENTS ecdsa rsa REQUIRED)
+if(NOT Botan_ecdsa_FOUND OR NOT Botan_rsa_FOUND)
+  message(FATAL_ERROR "Package did not specify that the (optional) components were found: ecdsa, rsa")
+endif()
+
+if(NOT Botan_FOUND)
+  message(FATAL_ERROR "Failed to find Botan with version ${Botan_VERSION} containing modules: ecdsa, rsa")
+endif()
+
+unset(Botan_FOUND)
+
+# test #5: fail if required module is not included
+find_package(Botan ${Botan_VERSION} COMPONENTS tpm QUIET)
+if(Botan_FOUND)
+  message(FATAL_ERROR "Found Botan with version ${Botan_VERSION}: Found module tpm but expected it to be absent")
+endif()
+
+unset(Botan_FOUND)
+
+# test #6: do not fail if optional module is not included
+find_package(Botan ${Botan_VERSION} OPTIONAL_COMPONENTS tpm REQUIRED)
+if(NOT Botan_FOUND)
+  message(FATAL_ERROR "Failed to find Botan with version ${Botan_VERSION}, despite missing optional modules: tpm")
+endif()
+if(NOT DEFINED Botan_tpm_FOUND OR ${Botan_tpm_FOUND})
+  message(FATAL_ERROR "Failed to notify that the optional component was not found")
+endif()
+
+unset(Botan_FOUND)
+
+# test #7: fail with some specific (but unavailable) component and fall-back to generic
+find_package(Botan ${Botan_VERSION} COMPONENTS nonexistent unobtainium QUIET)
+if(NOT Botan_FOUND)
+  # try again without requiring specific components
+  find_package(Botan ${Botan_VERSION})
+else()
+  message(FATAL_ERROR "Found Botan regardless of the non-existent component requirement")
+endif()
+if(NOT Botan_FOUND)
+  message(FATAL_ERROR "Failed to find generic Botan as a second attempt")
+endif()
+
+unset(Botan_FOUND)
+
+# test #8: try to find a future version
+math(EXPR Botan_FUTURE_MAJOR_VERSION "${Botan_VERSION_MAJOR} + 1")
+find_package(Botan ${Botan_FUTURE_MAJOR_VERSION} QUIET)
+if(Botan_FOUND)
+  message(FATAL_ERROR "Found a future major version that doesn't exist yet: ${Botan_FUTURE_MAJOR_VERSION}")
+endif()
+
+unset(Botan_FOUND)
+
+# test #9: try to find an older version (that is not compatible with the installed one)
+math(EXPR Botan_PAST_MAJOR_VERSION "${Botan_VERSION_MAJOR} - 1")
+find_package(Botan ${Botan_PAST_MAJOR_VERSION} QUIET)
+if(Botan_FOUND)
+  message(FATAL_ERROR "Found a legacy major version that doesn't exist yet: ${Botan_PAST_MAJOR_VERSION}")
+endif()
+
+unset(Botan_FOUND)
+
+# test #10: try to find Botan with a different case-sensitive spelling
+find_package(botan COMPONENTS rsa REQUIRED)
+if(NOT botan_FOUND)
+  message(FATAL_ERROR "Failed to find Botan with a different case-spelling")
+endif()
+if(NOT botan_rsa_FOUND)
+  message(FATAL_ERROR "Failed to find Botan component with a different case-spelling")
+endif()
+
+unset(Botan_FOUND)
+unset(botan_FOUND)
+
+enable_testing()
diff --git a/src/scripts/ci/cmake_tests/CMakePresets.json b/src/scripts/ci/cmake_tests/CMakePresets.json
new file mode 100644
index 000000000..68b0ef539
--- /dev/null
+++ b/src/scripts/ci/cmake_tests/CMakePresets.json
@@ -0,0 +1,21 @@
+{
+    "version": 5,
+    "cmakeMinimumRequired": {
+        "major": 3,
+        "minor": 24,
+        "patch": 0
+    },
+    "configurePresets": [
+        {
+            "name": "windows_x86_64",
+            "architecture": "x64"
+        },
+        {
+            "name": "windows_x86",
+            "architecture": "Win32"
+        },
+        {
+            "name": "unix"
+        }
+    ]
+}
diff --git a/src/scripts/ci/cmake_tests/main.cpp b/src/scripts/ci/cmake_tests/main.cpp
new file mode 100644
index 000000000..632dc92d1
--- /dev/null
+++ b/src/scripts/ci/cmake_tests/main.cpp
@@ -0,0 +1,7 @@
+#include <botan/version.h>
+#include <iostream>
+
+int main() {
+   std::cout << Botan::short_version_string() << std::endl;
+   return 0;
+}