From b99c95a3e500fdcc584845549b4351cb1532881f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 22:46:23 -0500 Subject: [PATCH 001/148] build(classic): add pinned LLVM toolchain and native coverage opt-in --- CMakeLists.txt | 24 ++-- CMakePresets.json | 1 + client/AGENTS.md | 2 +- client/CMakeLists.txt | 24 ++-- client/CMakePresets.json | 1 + client/README.md | 9 +- cmake/toolchains/llvm.cmake | 34 +++++ libatrinik/AGENTS.md | 2 +- libatrinik/CMakeLists.txt | 24 ++-- libatrinik/CMakePresets.json | 1 + libatrinik/README.md | 16 ++- libatrinik/pathfinding/CMakeLists.txt | 15 ++- server/AGENTS.md | 2 +- server/CMakeLists.txt | 24 ++-- server/CMakePresets.json | 1 + server/README.md | 9 +- server/tools/admin_shutdown_coverage.sh | 32 +++-- tools/ci/llvm_coverage.py | 158 ++++++++++++++++++++++++ tools/ci/run_gpu_coverage.sh | 25 +++- tools/ci/run_linux_check.sh | 54 ++++++-- tools/tests/test_llvm_coverage.py | 144 +++++++++++++++++++++ 21 files changed, 530 insertions(+), 72 deletions(-) create mode 100644 cmake/toolchains/llvm.cmake create mode 100644 tools/ci/llvm_coverage.py create mode 100644 tools/tests/test_llvm_coverage.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 71e189295..780a535a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,7 +12,7 @@ option(ATRINIK_BUILD_CLIENT "Build the classic client" ON) option(ATRINIK_BUILD_SERVER "Build the classic server" ON) option(ENABLE_WARNING_ERRORS "Treat compiler warnings as errors" ON) option(ENABLE_SANITIZERS "Enable address and undefined-behavior sanitizers" OFF) -option(ENABLE_COVERAGE "Enable GCC coverage instrumentation" OFF) +option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) option(ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS "Precompile the server global header" ON) option(ENABLE_PYTHON_PLUGIN "Build the server Python plugin" ON) @@ -50,14 +50,22 @@ function(atrinik_configure_target target) -fsanitize=address,undefined -fno-omit-frame-pointer) endif () if (ENABLE_COVERAGE) - if (NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") - message(FATAL_ERROR "ENABLE_COVERAGE requires GCC") - endif () - target_compile_options(${target} PRIVATE - --coverage -fprofile-abs-path -O0 -g) get_target_property(target_type ${target} TYPE) - if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") - target_link_options(${target} PRIVATE --coverage) + if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) + target_compile_options(${target} PRIVATE + -fprofile-instr-generate -fcoverage-mapping -O0 -g) + if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") + target_link_options(${target} PRIVATE -fprofile-instr-generate) + endif () + elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") + # Transitional support for the previous immutable producer image. + target_compile_options(${target} PRIVATE + --coverage -fprofile-abs-path -O0 -g) + if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") + target_link_options(${target} PRIVATE --coverage) + endif () + else () + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") endif () endif () endfunction() diff --git a/CMakePresets.json b/CMakePresets.json index bfe9c28e6..18c80a094 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -58,6 +58,7 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/integrated-linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, { diff --git a/client/AGENTS.md b/client/AGENTS.md index 0fa61c2e8..068d442f2 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -100,7 +100,7 @@ all native changes with `./atrinik build client --profile PROFILE --test` from the workspace. Use the workspace topology lifecycle for live checks. - For substantial native logic changes, also run the `linux-coverage` preset - and gcovr summary documented in `README.md`; keep source/test exclusions + and native LLVM LCOV export documented in `README.md`; keep source/test exclusions intentional. - Commits and pull-request titles use Conventional Commits. Classic uses one repository-wide release line; keep client release assets coherent with it. diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 87940765d..23c91d46b 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -168,7 +168,7 @@ atrinik_find_root_document(ATRINIK_ATTRIBUTIONS_FILE ATTRIBUTIONS.md) option(ENABLE_WARNING_ERRORS "Treat compiler warnings as errors" ON) option(ENABLE_SANITIZERS "Enable address and undefined-behavior sanitizers" OFF) -option(ENABLE_COVERAGE "Enable GCC coverage instrumentation" OFF) +option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) set(PACKAGE_TYPE "none" CACHE STRING "Package type (none, appimage, deb, rpm, nsis, or zip)") set(ATRINIK_DISCORD_APPLICATION_ID_FILE "" CACHE FILEPATH "Optional file containing the public Discord Application ID") @@ -233,14 +233,22 @@ function(atrinik_configure_target target) -fsanitize=address,undefined -fno-omit-frame-pointer) endif () if (ENABLE_COVERAGE) - if (NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") - message(FATAL_ERROR "ENABLE_COVERAGE requires GCC") - endif () - target_compile_options(${target} PRIVATE - --coverage -fprofile-abs-path -O0 -g) get_target_property(target_type ${target} TYPE) - if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") - target_link_options(${target} PRIVATE --coverage) + if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) + target_compile_options(${target} PRIVATE + -fprofile-instr-generate -fcoverage-mapping -O0 -g) + if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") + target_link_options(${target} PRIVATE -fprofile-instr-generate) + endif () + elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") + # Transitional support for the previous immutable producer image. + target_compile_options(${target} PRIVATE + --coverage -fprofile-abs-path -O0 -g) + if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") + target_link_options(${target} PRIVATE --coverage) + endif () + else () + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") endif () endif () endfunction() diff --git a/client/CMakePresets.json b/client/CMakePresets.json index 6fccdfa98..5487ae40d 100644 --- a/client/CMakePresets.json +++ b/client/CMakePresets.json @@ -55,6 +55,7 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, { diff --git a/client/README.md b/client/README.md index 45d89efaa..c44c34515 100644 --- a/client/README.md +++ b/client/README.md @@ -23,11 +23,14 @@ The executable is written to build/linux-debug/atrinik. - To collect line, function, and branch coverage from the native tests: - $ cmake --preset linux-coverage + To collect line, function, and branch coverage from the native tests in a full + Classic checkout, use the pinned LLVM 23.1.3 toolchain and native profiles: + $ cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" $ cmake --build --preset linux-coverage + $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage $ ctest --preset linux-coverage - $ gcovr --root . --filter 'src/' --exclude 'src/tests/' --print-summary + $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ + --source-root .. --component client --output coverage.lcov The client requires SDL 3.4 or newer, SDL3_image 3.2 or newer, SDL3_ttf 3.2 or newer, and SDL3_mixer 3.2.4 or newer. The supported mixer contract contains diff --git a/cmake/toolchains/llvm.cmake b/cmake/toolchains/llvm.cmake new file mode 100644 index 000000000..f99eef298 --- /dev/null +++ b/cmake/toolchains/llvm.cmake @@ -0,0 +1,34 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +# Native Linux LLVM cohort. Cross Windows builds use LLVM-MinGW's toolchain. +# Opt in with -DCMAKE_TOOLCHAIN_FILE=/path/to/classic/cmake/toolchains/llvm.cmake. +if (NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR "This LLVM toolchain requires a native Linux host") +endif () + +find_program(CMAKE_C_COMPILER NAMES clang REQUIRED) +find_program(CMAKE_CXX_COMPILER NAMES clang++ REQUIRED) +find_program(CMAKE_AR NAMES llvm-ar REQUIRED) +find_program(CMAKE_RANLIB NAMES llvm-ranlib REQUIRED) +find_program(CMAKE_LINKER NAMES ld.lld REQUIRED) + +foreach (tool CMAKE_C_COMPILER CMAKE_CXX_COMPILER CMAKE_AR CMAKE_RANLIB CMAKE_LINKER) + execute_process(COMMAND "${${tool}}" --version + OUTPUT_VARIABLE tool_version ERROR_VARIABLE tool_error + RESULT_VARIABLE tool_result) + if (NOT tool_result EQUAL 0 OR + NOT tool_version MATCHES "[Vv]ersion 23\\.1\\.3([^0-9.]|$)" AND + NOT tool_version MATCHES "LLD 23\\.1\\.3([^0-9.]|$)") + message(FATAL_ERROR "${tool} must be LLVM 23.1.3: ${tool_version}${tool_error}") + endif () +endforeach () + +set(CMAKE_C_COMPILER_AR "${CMAKE_AR}") +set(CMAKE_CXX_COMPILER_AR "${CMAKE_AR}") +set(CMAKE_C_COMPILER_RANLIB "${CMAKE_RANLIB}") +set(CMAKE_CXX_COMPILER_RANLIB "${CMAKE_RANLIB}") +# Keep CMake's build-type optimization defaults; do not enable LTO or PGO. +set(CMAKE_EXE_LINKER_FLAGS_INIT "--rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld") +set(CMAKE_SHARED_LINKER_FLAGS_INIT "--rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld") +set(CMAKE_MODULE_LINKER_FLAGS_INIT "--rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld") diff --git a/libatrinik/AGENTS.md b/libatrinik/AGENTS.md index 920fa9464..c837b5792 100644 --- a/libatrinik/AGENTS.md +++ b/libatrinik/AGENTS.md @@ -35,7 +35,7 @@ install/package consumption, and downstream client/server builds through the workspace when a public API changes. - For substantial native logic changes, also run the `linux-coverage` preset - and gcovr summary documented in `README.md`; include both core and standalone + and native LLVM LCOV export documented in `README.md`; include both core and standalone pathfinding coverage without counting test or build output as source. - Commits and pull-request titles use Conventional Commits. Classic uses one repository-wide release line. diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index d45b0d8e3..fdbfcfeda 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -22,7 +22,7 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) include(CMakePackageConfigHelpers) option(ENABLE_WARNING_ERRORS "Treat compiler warnings as errors" ON) - option(ENABLE_COVERAGE "Enable GCC coverage instrumentation" OFF) + option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) option(ENABLE_SANITIZERS "Enable address and undefined-behavior sanitizers" OFF) option(BUILD_FUZZING "Build bounded libFuzzer protocol targets" OFF) @@ -44,14 +44,22 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) endif () endif () if (ENABLE_COVERAGE) - if (NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") - message(FATAL_ERROR "ENABLE_COVERAGE requires GCC") - endif () - target_compile_options(${target} PRIVATE - --coverage -fprofile-abs-path -O0 -g) get_target_property(target_type ${target} TYPE) - if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") - target_link_options(${target} PRIVATE --coverage) + if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) + target_compile_options(${target} PRIVATE + -fprofile-instr-generate -fcoverage-mapping -O0 -g) + if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") + target_link_options(${target} PRIVATE -fprofile-instr-generate) + endif () + elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") + # Transitional support for the previous immutable producer image. + target_compile_options(${target} PRIVATE + --coverage -fprofile-abs-path -O0 -g) + if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") + target_link_options(${target} PRIVATE --coverage) + endif () + else () + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") endif () endif () if (ENABLE_SANITIZERS) diff --git a/libatrinik/CMakePresets.json b/libatrinik/CMakePresets.json index 64d553139..e7bbb7c48 100644 --- a/libatrinik/CMakePresets.json +++ b/libatrinik/CMakePresets.json @@ -31,6 +31,7 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, { diff --git a/libatrinik/README.md b/libatrinik/README.md index 9cb30bbe2..86422df2e 100644 --- a/libatrinik/README.md +++ b/libatrinik/README.md @@ -19,16 +19,24 @@ ctest --test-dir build --output-on-failure cmake --install build --prefix /path/to/prefix ``` -To collect line, function, and branch coverage from the native tests: +To collect line, function, and branch coverage from core and standalone +pathfinding tests in a full Classic checkout, use the pinned LLVM 23.1.3 tools: ```sh -cmake --preset linux-coverage +cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" cmake --build --preset linux-coverage +python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage ctest --preset linux-coverage -gcovr --root . --filter '.*\.c$' --exclude '.*/tests/.*' \ - --exclude '.*/build/.*' --print-summary +python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ + --source-root .. --component libatrinik --output coverage.lcov ``` +The helper requires matching Clang, llvm-profdata and llvm-cov 23.1.3. +CTest writes separate `%m-%p.profraw` files for processes and shared libraries; +LCOV includes production C sources and excludes tests, build output and upstream +dependency trees. Missing or empty profiles fail the export. Run `prepare` only +before a fresh test pass; retain those profiles when adding runtime coverage. + To run the standalone suite with address and undefined-behavior sanitizers: ```sh diff --git a/libatrinik/pathfinding/CMakeLists.txt b/libatrinik/pathfinding/CMakeLists.txt index c894fb30d..eee8b1e1e 100644 --- a/libatrinik/pathfinding/CMakeLists.txt +++ b/libatrinik/pathfinding/CMakeLists.txt @@ -16,16 +16,21 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) project(atrinik-pathfinding VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) include(CTest) include(CMakePackageConfigHelpers) - option(ENABLE_COVERAGE "Enable GCC coverage instrumentation" OFF) + option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) set(ATRINIK_PATHFINDING_STANDALONE true) endif () if (ENABLE_COVERAGE) - if (NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") - message(FATAL_ERROR "ENABLE_COVERAGE requires GCC") + if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) + add_compile_options(-fprofile-instr-generate -fcoverage-mapping -O0 -g) + add_link_options(-fprofile-instr-generate) + elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") + # Transitional support for the previous immutable producer image. + add_compile_options(--coverage -fprofile-abs-path -O0 -g) + add_link_options(--coverage) + else () + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") endif () - add_compile_options(--coverage -fprofile-abs-path -O0 -g) - add_link_options(--coverage) endif () add_library(atrinik-pathfinding STATIC pathfinding.c) diff --git a/server/AGENTS.md b/server/AGENTS.md index 4cdf326e6..14f4c6e01 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -102,7 +102,7 @@ through `./atrinik build server --profile PROFILE --test`; runtime checks use exact `topology show`/`up`/`ps`/`logs`/`down` with isolated state/scenario. - For substantial native logic, run the documented `linux-coverage` preset and - gcovr summary; pull-request CI also runs `linux-sanitizers`. + native LLVM LCOV export; pull-request CI also runs `linux-sanitizers`. - Classic has one release line. Preserve source, Windows package, checksum, and server-image contracts. Scoped source packages embed matching protocol/ libatrinik under `dependencies/` and select those repository-owned inputs diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 20478397e..a574e6fb3 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -46,7 +46,7 @@ atrinik_find_root_document(ATRINIK_ATTRIBUTIONS_FILE ATTRIBUTIONS.md) option(ENABLE_WARNING_ERRORS "Treat compiler warnings as errors" ON) option(ENABLE_SANITIZERS "Enable address and undefined-behavior sanitizers" OFF) -option(ENABLE_COVERAGE "Enable GCC coverage instrumentation" OFF) +option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) if (ATRINIK_INTEGRATED_BUILD) option(ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS "Precompile the server global header for the core object library" ON) @@ -88,14 +88,22 @@ function(atrinik_configure_target target) -fsanitize=address,undefined -fno-omit-frame-pointer) endif () if (ENABLE_COVERAGE) - if (NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") - message(FATAL_ERROR "ENABLE_COVERAGE requires GCC") - endif () - target_compile_options(${target} PRIVATE - --coverage -fprofile-abs-path -fprofile-update=atomic -O0 -g) get_target_property(target_type ${target} TYPE) - if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") - target_link_options(${target} PRIVATE --coverage) + if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) + target_compile_options(${target} PRIVATE + -fprofile-instr-generate -fcoverage-mapping -O0 -g) + if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") + target_link_options(${target} PRIVATE -fprofile-instr-generate) + endif () + elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") + # Transitional support for the previous immutable producer image. + target_compile_options(${target} PRIVATE + --coverage -fprofile-abs-path -fprofile-update=atomic -O0 -g) + if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") + target_link_options(${target} PRIVATE --coverage) + endif () + else () + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") endif () endif () endfunction() diff --git a/server/CMakePresets.json b/server/CMakePresets.json index 63eb4c9ff..0fd48b874 100644 --- a/server/CMakePresets.json +++ b/server/CMakePresets.json @@ -64,6 +64,7 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true}, "execution": {"jobs": 4} }, diff --git a/server/README.md b/server/README.md index a1d54de8f..b274c05dc 100644 --- a/server/README.md +++ b/server/README.md @@ -33,11 +33,14 @@ `build/PRESET/server-test-runtimes/` for failure diagnosis. The complete resource and cleanup contract is documented in `doc/TESTING.md`. - To collect line, function, and branch coverage from the native tests: - $ cmake --preset linux-coverage + To collect line, function, and branch coverage from the native tests in a full + Classic checkout, use the pinned LLVM 23.1.3 toolchain and native profiles: + $ cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" $ cmake --build --preset linux-coverage + $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage $ ctest --preset linux-coverage - $ gcovr --root . --filter 'src/' --exclude 'src/tests/' --print-summary + $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ + --source-root .. --component server --output coverage.lcov The server's simulation, monotonic, UTC wall, round-generation, and authored world-time domains are documented in doc/SERVER_CLOCKS.md. New deadlines must diff --git a/server/tools/admin_shutdown_coverage.sh b/server/tools/admin_shutdown_coverage.sh index 5dfba157e..5e79619d8 100755 --- a/server/tools/admin_shutdown_coverage.sh +++ b/server/tools/admin_shutdown_coverage.sh @@ -3,15 +3,31 @@ # SPDX-License-Identifier: GPL-2.0-or-later # Run only in the isolated CI worker: sources are read-only, and both writable -# directories are disposable tmpfs mounts. stdout is exclusively Cobertura XML. +# directories are disposable tmpfs mounts. LLVM mode writes only LCOV to stdout. set -euo pipefail mkdir /tmp/admin-shutdown-coverage cd /tmp/admin-shutdown-coverage -gcc -std=c17 -Wall -Wextra -Werror --coverage -DATRINIK_TESTING \ - -shared -fPIC -I/workspace/server/src/include \ - /workspace/server/src/server/admin_shutdown.c -o admin_shutdown.so -python3 /workspace/server/src/tests/admin_shutdown_socket.py \ - ./admin_shutdown.so /run/atrinik-admin-tests >&2 -gcovr --root /workspace --filter '/workspace/server/src/server/admin_shutdown\.c$' \ - --xml - . +if [[ ${ATRINIK_LLVM_TOOLCHAIN:-0} == 1 ]]; then + export LLVM_PROFILE_FILE="$(python3 /workspace/tools/ci/llvm_coverage.py prepare \ + --build-dir /tmp/admin-shutdown-coverage)" + clang -std=c17 -Wall -Wextra -Werror \ + -fprofile-instr-generate -fcoverage-mapping -DATRINIK_TESTING \ + --rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld \ + -shared -fPIC -I/workspace/server/src/include \ + /workspace/server/src/server/admin_shutdown.c -o admin_shutdown.so + python3 /workspace/server/src/tests/admin_shutdown_socket.py \ + ./admin_shutdown.so /run/atrinik-admin-tests >&2 + python3 /workspace/tools/ci/llvm_coverage.py export \ + --build-dir /tmp/admin-shutdown-coverage --source-root /workspace \ + --component server --compiler clang --output - +else + # Transitional validation for the previously released immutable image. + gcc -std=c17 -Wall -Wextra -Werror --coverage -DATRINIK_TESTING \ + -shared -fPIC -I/workspace/server/src/include \ + /workspace/server/src/server/admin_shutdown.c -o admin_shutdown.so + python3 /workspace/server/src/tests/admin_shutdown_socket.py \ + ./admin_shutdown.so /run/atrinik-admin-tests >&2 + gcovr --root /workspace --filter '/workspace/server/src/server/admin_shutdown\.c$' \ + --xml - . +fi diff --git a/tools/ci/llvm_coverage.py b/tools/ci/llvm_coverage.py new file mode 100644 index 000000000..3d4534ccb --- /dev/null +++ b/tools/ci/llvm_coverage.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Export native LLVM coverage for the complete Classic binary graph.""" +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import argparse +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + +LLVM_VERSION = "23.1.3" +PROFILE_DIRECTORY = "llvm-profiles" + + +def run(command): + result = subprocess.run(command, check=True, text=True, capture_output=True) + # llvm-cov can exit successfully while warning about mismatched functions + # or stale profiles. Such output is incomplete acceptance evidence. + if result.stderr.strip(): + raise ValueError(f"{command[0]} reported diagnostics: {result.stderr.strip()}") + return result.stdout + + +def verify_tool(tool): + version = run([tool, "--version"]) + if not re.search(r"(?:[Vv]ersion |LLD )" + re.escape(LLVM_VERSION) + r"(?![\d.])", version): + raise ValueError(f"{tool} must be LLVM {LLVM_VERSION}") + + +def profile_directory(build_dir): + # Never follow a symlink when cleaning task-owned profile output. + if build_dir.is_symlink() or (build_dir / PROFILE_DIRECTORY).is_symlink(): + raise ValueError("coverage build/profile directory must not be a symlink") + if not build_dir.is_dir(): + raise ValueError(f"coverage build directory is unavailable: {build_dir}") + return build_dir / PROFILE_DIRECTORY + + +def prepare(build_dir): + directory = profile_directory(build_dir) + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir() + return str(directory.resolve() / "%m-%p.profraw") + + +def coverage_objects(build_dir): + objects = [] + for path in sorted(build_dir.rglob("*")): + if path.is_symlink() or not path.is_file(): + continue + relative = path.relative_to(build_dir) + if "CMakeFiles" in relative.parts or PROFILE_DIRECTORY in relative.parts: + continue + # Executables and plugins both carry coverage maps; static libraries + # are represented by their linked consumers. ELF magic prevents source, + # archives, scripts or generated files from being mistaken for objects. + with path.open("rb") as stream: + if stream.read(4) != b"\x7fELF": + continue + if b"__llvm_covmap" in stream.read(): + objects.append(path) + if not objects: + raise ValueError("no instrumented executables or shared plugins found") + return objects + + +def filter_lcov(report, source_root, component): + root = source_root.resolve() + prefixes = [root / component] if component != "all" else [ + root / "client", root / "server", root / "libatrinik"] + records = [] + for record in report.split("end_of_record"): + match = re.search(r"^SF:(.+)$", record, re.MULTILINE) + if not match: + continue + path = Path(match.group(1)) + if not path.is_absolute(): + path = root / path + path = path.resolve() + for prefix in prefixes: + if not path.is_relative_to(prefix): + continue + relative = path.relative_to(prefix) + # Production C only. Tests, generated build/dependency trees and + # bundled upstream sources never contribute to Classic coverage. + excluded = {"tests", "build", "dependencies", "_deps", "third_party", "third-party"} + if path.suffix == ".c" and not excluded.intersection(relative.parts): + if prefix.name == "libatrinik" or relative.parts[0] == "src": + records.append(record.strip() + "\nend_of_record\n") + break + if not records: + raise ValueError("coverage export contains no intended production C sources") + return "".join(records) + + +def export(build_dir, source_root, component, output, compiler=None): + if output != "-": + # A failed collection must never leave an older report to be uploaded. + Path(output).unlink(missing_ok=True) + directory = profile_directory(build_dir) + profiles = sorted(directory.glob("*.profraw")) + if not profiles or any(path.is_symlink() or path.stat().st_size == 0 for path in profiles): + raise ValueError("missing, empty or symlinked LLVM raw profiles") + if compiler is None: + cache = build_dir / "CMakeCache.txt" + match = re.search(r"^CMAKE_C_COMPILER:(?:FILEPATH|STRING)=(.+)$", cache.read_text(), re.MULTILINE) + if not match: + raise ValueError("coverage build has no recorded C compiler") + compiler = match.group(1) + profdata = os.environ.get("LLVM_PROFDATA", "llvm-profdata") + cov = os.environ.get("LLVM_COV", "llvm-cov") + for tool in (compiler, profdata, cov): + verify_tool(tool) + objects = coverage_objects(build_dir) + merged = directory / "coverage.profdata" + run([profdata, "merge", "-sparse", "-o", str(merged), *map(str, profiles)]) + arguments = [cov, "export", "-format=lcov", f"-instr-profile={merged}", str(objects[0])] + for object_path in objects[1:]: + arguments += ["-object", str(object_path)] + report = filter_lcov(run(arguments), source_root, component) + if output == "-": + sys.stdout.write(report) + else: + Path(output).write_text(report) + print(f"LLVM coverage: {len(profiles)} profiles, {len(objects)} objects, {component} -> {output}", file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + reset = commands.add_parser("prepare", help="clear only this build's raw profiles before its tests") + reset.add_argument("--build-dir", required=True, type=Path) + emit = commands.add_parser("export", help="merge profiles and export filtered LCOV") + emit.add_argument("--build-dir", required=True, type=Path) + emit.add_argument("--source-root", required=True, type=Path) + emit.add_argument("--component", required=True, choices=("client", "server", "libatrinik", "all")) + emit.add_argument("--output", required=True) + emit.add_argument("--compiler", help="exact compiler when the build does not use CMake") + args = parser.parse_args() + try: + if args.command == "prepare": + print(prepare(args.build_dir)) + else: + export(args.build_dir, args.source_root, args.component, args.output, args.compiler) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"LLVM coverage failed: {error}", file=sys.stderr) + if isinstance(error, subprocess.CalledProcessError) and error.stderr: + print(error.stderr, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ci/run_gpu_coverage.sh b/tools/ci/run_gpu_coverage.sh index c7f822b52..25050c8b4 100755 --- a/tools/ci/run_gpu_coverage.sh +++ b/tools/ci/run_gpu_coverage.sh @@ -39,6 +39,7 @@ docker run --rm \ --network none \ --env ATRINIK_BENCHMARK_REVISION="${expected_revision}" \ --env ATRINIK_BENCHMARK_DIRTY=false \ + --env ATRINIK_LLVM_TOOLCHAIN="${ATRINIK_LLVM_TOOLCHAIN:-0}" \ --volume "${source_root}:/workspace" \ --workdir /workspace \ "${build_image}" \ @@ -54,11 +55,20 @@ docker run --rm \ python3 client/tools/dependencies.py verify python3 client/tools/verify_gpu_fixture_provenance.py cd client + if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then + set -- -DCMAKE_TOOLCHAIN_FILE=/workspace/cmake/toolchains/llvm.cmake + else + set -- + fi cmake --preset linux-coverage \ + "$@" \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL=/workspace/protocol \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK=/workspace/libatrinik \ -DATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders cmake --build --preset linux-coverage --parallel "$(nproc)" + if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then + python3 /workspace/tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage + fi ctest --preset linux-coverage --output-on-failure --no-tests=error -LE performance ' @@ -91,6 +101,7 @@ docker run --rm \ --env SDL_VIDEODRIVER=x11 \ --env VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json \ --env VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json \ + --env LLVM_PROFILE_FILE="${compiled_home}/build/linux-coverage/llvm-profiles/%m-%p.profraw" \ --volume "${source_root}:${compiled_root}" \ --workdir "${compiled_home}" \ "${gpu_image}" \ @@ -116,7 +127,15 @@ docker run --rm \ --network none \ --volume "${source_root}:${compiled_root}" \ --workdir "${compiled_root}/client" \ + --env ATRINIK_LLVM_TOOLCHAIN="${ATRINIK_LLVM_TOOLCHAIN:-0}" \ "${build_image}" \ - gcovr --root . --filter src/ --exclude src/tests/ \ - --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ - --print-summary --xml coverage.xml + sh -c ' + if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then + python3 ../tools/ci/llvm_coverage.py export \ + --build-dir build/linux-coverage --source-root .. \ + --component client --output coverage.lcov + else + gcovr --root . --filter src/ --exclude src/tests/ \ + --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ + --print-summary --xml coverage.xml + fi' diff --git a/tools/ci/run_linux_check.sh b/tools/ci/run_linux_check.sh index 2c5460152..c9ced006a 100755 --- a/tools/ci/run_linux_check.sh +++ b/tools/ci/run_linux_check.sh @@ -33,12 +33,48 @@ test -w "${CCACHE_DIR}" test "$(stat --format='%a' "${CCACHE_DIR}")" = 1777 test "$(jq -r '.target' /usr/local/share/atrinik/classic-toolchain.json)" = classic-final test "$(jq -r '.platform' /usr/local/share/atrinik/classic-toolchain.json)" = linux/amd64 -test "$(jq -r '.tools.gcc' /usr/local/share/atrinik/classic-toolchain.json)" = 15.2.0 +llvm_toolchain=${ATRINIK_LLVM_TOOLCHAIN:-0} +if [[ ${llvm_toolchain} == 1 ]]; then + test "$(jq -r '.tools.clang' /usr/local/share/atrinik/classic-toolchain.json)" = 23.1.3 + export CC=clang CXX=clang++ +else + # Transitional validation for the previously released immutable image. + test "$(jq -r '.tools.gcc' /usr/local/share/atrinik/classic-toolchain.json)" = 15.2.0 +fi launcher=( -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache ) +if [[ ${llvm_toolchain} == 1 ]]; then + launcher+=("-DCMAKE_TOOLCHAIN_FILE=${source_root}/cmake/toolchains/llvm.cmake") +fi + +# These functions run in the owning module directory. CTest's coverage preset +# supplies %m-%p profile names, separating parallel processes and shared plugins. +prepare_coverage() { + if [[ ${llvm_toolchain} == 1 ]]; then + python3 "${source_root}/tools/ci/llvm_coverage.py" prepare --build-dir build/linux-coverage + fi +} +export_coverage() { + local module=$1 + if [[ ${llvm_toolchain} == 1 ]]; then + python3 "${source_root}/tools/ci/llvm_coverage.py" export \ + --build-dir build/linux-coverage --source-root "${source_root}" \ + --component "${module}" --output coverage.lcov + elif [[ ${module} == libatrinik ]]; then + gcovr --root . --filter '.*\.c$' --exclude '.*/tests/.*' \ + --exclude '.*/build/.*' \ + --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ + --print-summary --xml coverage.xml + else + gcovr --root . --filter 'src/' --exclude 'src/tests/' \ + --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ + --print-summary --xml coverage.xml + fi +} + sibling_sources=( -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${source_root}/protocol" -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${source_root}/libatrinik" @@ -93,11 +129,9 @@ case "${component}" in -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ "${launcher[@]}" cmake --build --preset linux-coverage --parallel "${jobs}" + prepare_coverage ctest --preset linux-coverage - gcovr --root . --filter '.*\.c$' --exclude '.*/tests/.*' \ - --exclude '.*/build/.*' \ - --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ - --print-summary --xml coverage.xml + export_coverage libatrinik cmake --preset linux-sanitizers \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ "${launcher[@]}" @@ -126,10 +160,9 @@ case "${component}" in -DENABLE_PRECOMPILED_HEADERS=OFF \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" cmake --build --preset linux-coverage --parallel "${jobs}" + prepare_coverage ctest --preset linux-coverage --parallel 4 -LE performance - gcovr --root . --filter 'src/' --exclude 'src/tests/' \ - --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ - --print-summary --xml coverage.xml + export_coverage server cmake --preset linux-release \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" cmake --build --preset linux-release --parallel "${jobs}" @@ -150,10 +183,9 @@ case "${component}" in cmake --preset linux-coverage \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" cmake --build --preset linux-coverage --parallel "${jobs}" + prepare_coverage ctest --preset linux-coverage -LE performance - gcovr --root . --filter 'src/' --exclude 'src/tests/' \ - --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ - --print-summary --xml coverage.xml + export_coverage client cmake --preset linux-release \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" cmake --build --preset linux-release --parallel "${jobs}" diff --git a/tools/tests/test_llvm_coverage.py b/tools/tests/test_llvm_coverage.py new file mode 100644 index 000000000..5fc32b5de --- /dev/null +++ b/tools/tests/test_llvm_coverage.py @@ -0,0 +1,144 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import contextlib +import importlib.util +import io +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "llvm_coverage", Path(__file__).resolve().parents[1] / "ci" / "llvm_coverage.py") +coverage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(coverage) + + +def record(path): + return f"SF:{path}\nFN:1,example\nFNDA:1,example\nDA:1,1\nBRDA:1,0,0,1\nend_of_record\n" + + +class NativeCoverageTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.build = self.root / "server" / "build" / "linux-coverage" + self.build.mkdir(parents=True) + + def fixture(self): + coverage.prepare(self.build) + (self.build / "llvm-profiles" / "binary-123.profraw").write_bytes(b"profile") + (self.build / "CMakeCache.txt").write_text("CMAKE_C_COMPILER:FILEPATH=/opt/llvm/bin/clang\n") + (self.build / "atrinik-server").write_bytes(b"\x7fELFbinary__llvm_covmap") + plugins = self.build / "plugins" + plugins.mkdir() + (plugins / "python.so").write_bytes(b"\x7fELFplugin__llvm_covmap") + + def test_prepare_clears_previous_profiles_and_separates_processes(self): + pattern = coverage.prepare(self.build) + profiles = self.build / "llvm-profiles" + (profiles / "stale.profraw").write_bytes(b"old") + (self.build / "keep").write_text("keep") + self.assertEqual(coverage.prepare(self.build), pattern) + self.assertTrue(pattern.endswith("/%m-%p.profraw")) + self.assertEqual(list(profiles.iterdir()), []) + self.assertEqual((self.build / "keep").read_text(), "keep") + + def test_prepare_rejects_symlinked_profile_directory(self): + other = self.root / "other" + other.mkdir() + (self.build / "llvm-profiles").symlink_to(other) + with self.assertRaisesRegex(ValueError, "symlink"): + coverage.prepare(self.build) + + def test_discovers_tests_and_plugins_but_excludes_probes_and_archives(self): + self.fixture() + (self.build / "test").write_bytes(b"\x7fELFtest__llvm_covmap") + (self.build / "libcore.a").write_bytes(b"!__llvm_covmap") + (self.build / "ordinary.so").write_bytes(b"\x7fELFplain") + probes = self.build / "CMakeFiles" + probes.mkdir() + (probes / "probe").write_bytes(b"\x7fELFprobe__llvm_covmap") + (self.build / "alias").symlink_to(self.build / "test") + self.assertEqual([str(p.relative_to(self.build)) for p in coverage.coverage_objects(self.build)], + ["atrinik-server", "plugins/python.so", "test"]) + + def test_filters_component_sources_and_retains_branch_function_counts(self): + paths = ["server/src/server/main.c", "server/src/plugins/python/python.c", + "server/src/tests/unit.c", "server/build/generated.c", + "server/src/third_party/upstream.c", "client/src/client/main.c", + "server/src/include/header.h", "server-other/src/main.c"] + report = "".join(record(self.root / path) for path in paths) + actual = coverage.filter_lcov(report, self.root, "server") + self.assertEqual(actual, record(self.root / paths[0]) + record(self.root / paths[1])) + + def test_library_includes_standalone_pathfinding_without_tests(self): + report = "".join(record(self.root / path) for path in + ["libatrinik/socket.c", "libatrinik/pathfinding/pathfinding.c", + "libatrinik/pathfinding/tests/test.c", "libatrinik/dependencies/source.c"]) + self.assertEqual(coverage.filter_lcov(report, self.root, "libatrinik"), + record(self.root / "libatrinik/socket.c") + + record(self.root / "libatrinik/pathfinding/pathfinding.c")) + + def test_no_profile_is_a_failure(self): + coverage.prepare(self.build) + output = self.root / "coverage.lcov" + output.write_text("stale report") + with self.assertRaisesRegex(ValueError, "missing"): + coverage.export(self.build, self.root, "server", str(output)) + self.assertFalse(output.exists()) + + def test_successful_tool_with_stale_profile_warning_is_a_failure(self): + result = subprocess.CompletedProcess(["llvm-cov"], 0, "partial report", "warning: mismatched data") + with patch.object(coverage.subprocess, "run", return_value=result): + with self.assertRaisesRegex(ValueError, "mismatched data"): + coverage.run(["llvm-cov"]) + + def test_empty_profile_is_a_failure(self): + self.fixture() + (self.build / "llvm-profiles" / "empty.profraw").touch() + with self.assertRaisesRegex(ValueError, "empty"): + coverage.export(self.build, self.root, "server", "-") + + def test_mismatched_version_is_a_failure(self): + with patch.object(coverage, "run", return_value="LLVM version 23.1.30"): + with self.assertRaisesRegex(ValueError, "23.1.3"): + coverage.verify_tool("llvm-cov") + + def test_export_validates_tools_merges_profiles_and_adds_plugin_objects(self): + self.fixture() + report = record(self.root / "server/src/server/main.c") + calls = [] + + def execute(command): + calls.append(command) + if "--version" in command: + return "LLVM version 23.1.3\n" + if command[1] == "export": + return report + return "" + + output = io.StringIO() + with patch.object(coverage, "run", side_effect=execute), contextlib.redirect_stdout(output): + coverage.export(self.build, self.root, "server", "-") + self.assertEqual(output.getvalue(), report) + self.assertEqual(len([call for call in calls if "--version" in call]), 3) + self.assertIn("-sparse", calls[3]) + self.assertIn("-object", calls[4]) + self.assertIn(str(self.build / "plugins/python.so"), calls[4]) + + def test_failed_merge_does_not_write_report(self): + self.fixture() + output = self.root / "coverage.lcov" + with patch.object(coverage, "verify_tool"), patch.object( + coverage, "run", side_effect=subprocess.CalledProcessError(1, "llvm-profdata")): + with self.assertRaises(subprocess.CalledProcessError): + coverage.export(self.build, self.root, "server", str(output)) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() From aae84f05dc25662eaf25eb110185f31efb936632 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 22:48:17 -0500 Subject: [PATCH 002/148] fix(build): preserve atomic LLVM coverage counters --- CMakeLists.txt | 2 +- client/CMakeLists.txt | 2 +- libatrinik/CMakeLists.txt | 2 +- libatrinik/pathfinding/CMakeLists.txt | 2 +- server/CMakeLists.txt | 2 +- server/doc/TESTING.md | 38 +++++++++++++++++++++---- server/tools/admin_shutdown_coverage.sh | 2 +- 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 780a535a6..ff743dcce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,7 +53,7 @@ function(atrinik_configure_target target) get_target_property(target_type ${target} TYPE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) target_compile_options(${target} PRIVATE - -fprofile-instr-generate -fcoverage-mapping -O0 -g) + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 23c91d46b..ac7e732bf 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -236,7 +236,7 @@ function(atrinik_configure_target target) get_target_property(target_type ${target} TYPE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) target_compile_options(${target} PRIVATE - -fprofile-instr-generate -fcoverage-mapping -O0 -g) + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index fdbfcfeda..6898b2959 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -47,7 +47,7 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) get_target_property(target_type ${target} TYPE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) target_compile_options(${target} PRIVATE - -fprofile-instr-generate -fcoverage-mapping -O0 -g) + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () diff --git a/libatrinik/pathfinding/CMakeLists.txt b/libatrinik/pathfinding/CMakeLists.txt index eee8b1e1e..cb76a00f1 100644 --- a/libatrinik/pathfinding/CMakeLists.txt +++ b/libatrinik/pathfinding/CMakeLists.txt @@ -22,7 +22,7 @@ endif () if (ENABLE_COVERAGE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) - add_compile_options(-fprofile-instr-generate -fcoverage-mapping -O0 -g) + add_compile_options(-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) add_link_options(-fprofile-instr-generate) elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") # Transitional support for the previous immutable producer image. diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index a574e6fb3..18f3e7cb9 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -91,7 +91,7 @@ function(atrinik_configure_target target) get_target_property(target_type ${target} TYPE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) target_compile_options(${target} PRIVATE - -fprofile-instr-generate -fcoverage-mapping -O0 -g) + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () diff --git a/server/doc/TESTING.md b/server/doc/TESTING.md index ace8a47a5..a5021b815 100644 --- a/server/doc/TESTING.md +++ b/server/doc/TESTING.md @@ -28,12 +28,38 @@ the complete process group, escalating to `SIGKILL` after five seconds when needed. Re-running a test reconstructs its private runtime from the unchanged seed; other tests and their retained artifacts are not touched. -GCC coverage keeps its normal object-adjacent `.gcda` files so gcovr needs no -relocation or lossy post-processing. GCC locks concurrent profile-file merges -on supported filesystems; coverage builds additionally use -`-fprofile-update=atomic`. Validation compares sequential and parallel test -counts and gcovr totals and stresses randomized four-way scheduling before the -parallel result is accepted. +LLVM 23.1.3 coverage instruments production code with +`-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic`. Atomic +counter updates preserve accurate counts when authentication and QUIC workers +execute instrumented functions concurrently. The same flags apply to shared +libatrinik code, the integrated build and standalone modules. + +From the server directory in a full Classic checkout: + +```sh +cmake --preset linux-coverage \ + -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" +cmake --build --preset linux-coverage +python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage +ctest --preset linux-coverage --parallel 4 -LE performance +python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ + --source-root .. --component server --output coverage.lcov +``` + +The coverage test preset sets `LLVM_PROFILE_FILE` to the build's isolated +`llvm-profiles/%m-%p.profraw` path. The binary signature and process ID separate +parallel processes and instrumented plugins. `prepare` clears only that build's +previous profiles before a fresh pass; additional runtime probes retain the +existing profiles. Matching Clang, llvm-profdata and llvm-cov merge those files +and export LCOV for production server C sources, including plugins, while +excluding tests and generated dependency/build output. Missing profiles, +version mismatches and LLVM diagnostics fail collection. Compare sequential and +parallel LCOV counts and stress randomized four-way scheduling before accepting +parallel coverage evidence. + +During the immutable producer transition only, the previous GCC image retains +object-adjacent `.gcda` profiles, atomic counters and gcovr export. New LLVM +coverage does not consume those files. Use `ctest --preset PRESET --schedule-random --repeat until-fail:COUNT` for a stress pass. A failure's working files are attributable at diff --git a/server/tools/admin_shutdown_coverage.sh b/server/tools/admin_shutdown_coverage.sh index 5e79619d8..b1602231c 100755 --- a/server/tools/admin_shutdown_coverage.sh +++ b/server/tools/admin_shutdown_coverage.sh @@ -12,7 +12,7 @@ if [[ ${ATRINIK_LLVM_TOOLCHAIN:-0} == 1 ]]; then export LLVM_PROFILE_FILE="$(python3 /workspace/tools/ci/llvm_coverage.py prepare \ --build-dir /tmp/admin-shutdown-coverage)" clang -std=c17 -Wall -Wextra -Werror \ - -fprofile-instr-generate -fcoverage-mapping -DATRINIK_TESTING \ + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -DATRINIK_TESTING \ --rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld \ -shared -fPIC -I/workspace/server/src/include \ /workspace/server/src/server/admin_shutdown.c -o admin_shutdown.so From e1a92214730174fa1f592c41bf4ffea918d3a9eb Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 22:49:47 -0500 Subject: [PATCH 003/148] build(windows): consume LLVM toolchains and stage verified DLL closure --- client/CMakeLists.txt | 27 ++++- client/INSTALL | 5 + .../cmake/verify_windows_client_sound.cmake | 37 +++--- client/tools/build-windows-package.sh | 18 +-- cmake/InstallWindowsRuntime.cmake | 105 ++++++++++++++++ docs/WINDOWS_TOOLCHAIN.md | 52 ++++++++ server/CMakeLists.txt | 14 ++- server/INSTALL | 5 + server/tools/build-windows-package.sh | 16 ++- tools/ci/stage_windows_runtime.py | 108 ++++++++++++++--- tools/release/package_sources.py | 2 + tools/tests/test_package_sources.py | 11 ++ tools/tests/test_stage_windows_runtime.py | 114 +++++++++++++++++- tools/tests/test_windows_trust_notices.py | 106 +++++++++++++++- 14 files changed, 551 insertions(+), 69 deletions(-) create mode 100644 cmake/InstallWindowsRuntime.cmake create mode 100644 docs/WINDOWS_TOOLCHAIN.md diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index ac7e732bf..f25e4802b 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -409,14 +409,23 @@ if (MINGW) endif () if (MINGW) - if (NOT CMAKE_OBJDUMP) + find_program(ATRINIK_LLVM_READOBJ NAMES llvm-readobj REQUIRED) + if (NOT ATRINIK_LLVM_READOBJ) message(FATAL_ERROR - "CMAKE_OBJDUMP is required to validate Windows client sound support") + "llvm-readobj is required to validate Windows client sound support") + endif () + + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tools/ci/stage_windows_runtime.py") + set(ATRINIK_RUNTIME_HELPER "${CMAKE_CURRENT_SOURCE_DIR}/tools/ci/stage_windows_runtime.py") + else () + set(ATRINIK_RUNTIME_HELPER "${CMAKE_CURRENT_SOURCE_DIR}/../tools/ci/stage_windows_runtime.py") endif () add_custom_command(TARGET ${EXECUTABLE} POST_BUILD COMMAND ${CMAKE_COMMAND} - "-DATRINIK_OBJDUMP=${CMAKE_OBJDUMP}" + "-DATRINIK_READOBJ=${ATRINIK_LLVM_READOBJ}" + "-DATRINIK_PYTHON=${Python3_EXECUTABLE}" + "-DATRINIK_RUNTIME_HELPER=${ATRINIK_RUNTIME_HELPER}" "-DATRINIK_CLIENT_EXECUTABLE=$" -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/verify_windows_client_sound.cmake" COMMENT "Verifying that the Windows client imports SDL3_mixer" @@ -1360,8 +1369,14 @@ if (WIN32 AND NOT UNIX) set(INSTALL_SUBDIR_SHARE ".") set(INSTALL_SUBDIR_DOC ".") + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/InstallWindowsRuntime.cmake") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/InstallWindowsRuntime.cmake") + else () + include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/InstallWindowsRuntime.cmake") + endif () + set(ATRINIK_WINDOWS_RUNTIME_DIR "" CACHE PATH - "Directory containing MinGW runtime DLLs for portable packages") + "Directory containing LLVM-MinGW runtime DLLs for portable packages") set(ATRINIK_CA_BUNDLE "${CMAKE_CURRENT_SOURCE_DIR}/ca-bundle.crt" CACHE FILEPATH "CA certificate bundle to install with the client") @@ -1373,8 +1388,8 @@ if (WIN32 AND NOT UNIX) "${ATRINIK_WINDOWS_RUNTIME_DIR}/*.dll") if (ATRINIK_WINDOWS_RUNTIME_DLLS) - install(FILES ${ATRINIK_WINDOWS_RUNTIME_DLLS} - DESTINATION ${INSTALL_SUBDIR_BIN}) + atrinik_install_windows_runtime("${INSTALL_SUBDIR_BIN}" + "$") # A c-ares runtime must carry the complete notice from its toolchain. set(ATRINIK_WINDOWS_CARES_LICENSE diff --git a/client/INSTALL b/client/INSTALL index 4cb32886d..b8e2f64ce 100644 --- a/client/INSTALL +++ b/client/INSTALL @@ -86,3 +86,8 @@ runtime DLLs include c-ares, packaging also requires its complete license at `ATRINIK_WINDOWS_RUNTIME_DIR/../share/licenses/c-ares/LICENSE.md`, or the explicit `ATRINIK_WINDOWS_CARES_LICENSE` override, and installs it as `c-ares-LICENSE.md` beside the DLLs. Toolchains without a c-ares DLL do not require that notice. + +Windows toolchain and recursive runtime packaging requirements are documented +in `../docs/WINDOWS_TOOLCHAIN.md`. Use the required neutral +`ATRINIK_WINDOWS_TOOLCHAIN_FILE`, `ATRINIK_WINDOWS_TARGET` and +`ATRINIK_WINDOWS_RUNTIME_DIR` producer variables with plain CMake. diff --git a/client/cmake/verify_windows_client_sound.cmake b/client/cmake/verify_windows_client_sound.cmake index 7617aa7a9..f43d2e37a 100644 --- a/client/cmake/verify_windows_client_sound.cmake +++ b/client/cmake/verify_windows_client_sound.cmake @@ -1,28 +1,19 @@ -if (NOT DEFINED ATRINIK_OBJDUMP OR ATRINIK_OBJDUMP STREQUAL "") - message(FATAL_ERROR "ATRINIK_OBJDUMP is required") -endif () - -if (NOT DEFINED ATRINIK_CLIENT_EXECUTABLE OR - NOT EXISTS "${ATRINIK_CLIENT_EXECUTABLE}") - message(FATAL_ERROR - "Windows client executable does not exist: ${ATRINIK_CLIENT_EXECUTABLE}") -endif () +foreach (required ATRINIK_READOBJ ATRINIK_PYTHON ATRINIK_RUNTIME_HELPER + ATRINIK_CLIENT_EXECUTABLE) + if (NOT DEFINED ${required} OR "${${required}}" STREQUAL "") + message(FATAL_ERROR "${required} is required") + endif () +endforeach () execute_process( - COMMAND "${ATRINIK_OBJDUMP}" -p "${ATRINIK_CLIENT_EXECUTABLE}" - RESULT_VARIABLE objdump_result - OUTPUT_VARIABLE objdump_output - ERROR_VARIABLE objdump_error) - -if (NOT "${objdump_result}" STREQUAL "0") + COMMAND "${ATRINIK_PYTHON}" "${ATRINIK_RUNTIME_HELPER}" + --readobj "${ATRINIK_READOBJ}" --inspect-only + --require-import SDL3_mixer.dll "${ATRINIK_CLIENT_EXECUTABLE}" + RESULT_VARIABLE inspector_result + OUTPUT_VARIABLE inspector_output + ERROR_VARIABLE inspector_error) +if (NOT "${inspector_result}" STREQUAL "0") message(FATAL_ERROR - "Could not inspect Windows client imports: ${objdump_error}") + "Could not verify Windows client sound imports: ${inspector_error}") endif () - -if (NOT objdump_output MATCHES - "DLL Name:[ \t]+[Ss][Dd][Ll]3_[Mm][Ii][Xx][Ee][Rr]\\.[Dd][Ll][Ll]") - message(FATAL_ERROR - "Windows client was built without an SDL3_mixer runtime import") -endif () - message(STATUS "Windows client sound import verified") diff --git a/client/tools/build-windows-package.sh b/client/tools/build-windows-package.sh index 037f20ccb..4c30c7f4e 100755 --- a/client/tools/build-windows-package.sh +++ b/client/tools/build-windows-package.sh @@ -8,15 +8,19 @@ if [[ ! ${version} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "ATRINIK_PACKAGE_VERSION must be MAJOR.MINOR.PATCH" >&2 exit 1 fi -for variable in MXE_TOOLCHAIN_FILE MXE_RUNTIME_DIR; do +for variable in ATRINIK_WINDOWS_TOOLCHAIN_FILE ATRINIK_WINDOWS_RUNTIME_DIR; do if [[ -z ${!variable:-} || ! -e ${!variable} ]]; then - echo "${variable} does not identify an installed MXE dependency" >&2 + echo "${variable} does not identify an installed Windows dependency" >&2 exit 1 fi done -mxe_cmake=${MXE_TARGET:-x86_64-w64-mingw32.shared}-cmake -command -v "${mxe_cmake}" >/dev/null +if [[ ${ATRINIK_WINDOWS_TARGET:-} != x86_64-w64-mingw32 ]]; then + echo "ATRINIK_WINDOWS_TARGET must be x86_64-w64-mingw32" >&2 + exit 1 +fi +command -v cmake >/dev/null +command -v llvm-readobj >/dev/null dependency_downloads=${ATRINIK_DEPENDENCY_DOWNLOADS:-} dependency_sync_arguments=() profile_sound=${ATRINIK_PROFILE_SOUND_DIR:-} @@ -80,13 +84,13 @@ if [[ -n ${repository_root} && -f ${repository_root}/protocol/CMakeLists.txt && ) fi -"${mxe_cmake}" -S . -B build/windows-release -G Ninja \ +cmake -S . -B build/windows-release -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_TOOLCHAIN_FILE="${MXE_TOOLCHAIN_FILE}" \ + -DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}" \ -DBUILD_TESTING=OFF \ -DPACKAGE_TYPE=zip \ -DATRINIK_PACKAGE_VERSION="${version}" \ - -DATRINIK_WINDOWS_RUNTIME_DIR="${MXE_RUNTIME_DIR}" \ + -DATRINIK_WINDOWS_RUNTIME_DIR="${ATRINIK_WINDOWS_RUNTIME_DIR}" \ "-DATRINIK_CA_BUNDLE=${PWD}/ca-bundle.crt" \ "-DATRINIK_DISCORD_APPLICATION_ID_FILE=${discord_config_file}" \ "${dependency_arguments[@]}" diff --git a/cmake/InstallWindowsRuntime.cmake b/cmake/InstallWindowsRuntime.cmake new file mode 100644 index 000000000..f03f14934 --- /dev/null +++ b/cmake/InstallWindowsRuntime.cmake @@ -0,0 +1,105 @@ +# Stage only the recursive PE import closure; targets retain their install owner. +function(atrinik_install_windows_runtime destination) + find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) + find_program(ATRINIK_LLVM_READOBJ NAMES llvm-readobj REQUIRED) + set(ATRINIK_WINDOWS_LLVM_LICENSE_DIR + "${ATRINIK_WINDOWS_RUNTIME_DIR}/../share/licenses/llvm-mingw" + CACHE PATH "LLVM-MinGW notices for compiler-rt, CRT and runtime DLLs") + foreach (notice LICENSE.TXT COPYING COPYING.MinGW-w64-runtime.txt + COPYING.MinGW-w64.txt COPYING.winpthreads.txt COPYING.winstorecompat.txt) + if (NOT EXISTS "${ATRINIK_WINDOWS_LLVM_LICENSE_DIR}/${notice}" OR + IS_DIRECTORY "${ATRINIK_WINDOWS_LLVM_LICENSE_DIR}/${notice}" OR + IS_SYMLINK "${ATRINIK_WINDOWS_LLVM_LICENSE_DIR}/${notice}") + message(FATAL_ERROR "LLVM-MinGW package notice is missing: ${notice}") + endif () + install(FILES "${ATRINIK_WINDOWS_LLVM_LICENSE_DIR}/${notice}" + DESTINATION "${destination}/licenses/llvm-mingw") + endforeach () + set(ATRINIK_WINDOWS_DEPENDENCY_INVENTORY_DIR + "${ATRINIK_WINDOWS_RUNTIME_DIR}/../share/atrinik/windows-dependencies" + CACHE PATH "Producer inventory for rebuilt Windows dependencies") + set(dependency_inventory "${ATRINIK_WINDOWS_DEPENDENCY_INVENTORY_DIR}/dependencies.json") + set(pe_inventory "${ATRINIK_WINDOWS_DEPENDENCY_INVENTORY_DIR}/pe-closure.json") + foreach (inventory "${dependency_inventory}" "${pe_inventory}") + if (NOT EXISTS "${inventory}" OR IS_DIRECTORY "${inventory}" OR IS_SYMLINK "${inventory}") + message(FATAL_ERROR "Windows dependency inventory is missing: ${inventory}") + endif () + endforeach () + file(READ "${dependency_inventory}" dependency_manifest) + string(JSON schema GET "${dependency_manifest}" schema_version) + if (NOT schema EQUAL 1) + message(FATAL_ERROR "Unsupported Windows dependency inventory schema") + endif () + string(JSON dependency_count LENGTH "${dependency_manifest}" dependencies) + if (dependency_count LESS 1 OR dependency_count GREATER 128) + message(FATAL_ERROR "Windows dependency inventory has an invalid size") + endif () + math(EXPR last_dependency "${dependency_count} - 1") + set(dependency_names) + foreach (index RANGE ${last_dependency}) + string(JSON name GET "${dependency_manifest}" dependencies ${index} name) + if (NOT name MATCHES "^[A-Za-z0-9_+.-]+$" OR name STREQUAL "." OR name STREQUAL ".." OR + name IN_LIST dependency_names) + message(FATAL_ERROR "Invalid or duplicate Windows dependency name: ${name}") + endif () + list(APPEND dependency_names "${name}") + string(JSON notice_count LENGTH "${dependency_manifest}" dependencies ${index} licenses) + if (notice_count LESS 1 OR notice_count GREATER 64) + message(FATAL_ERROR "Windows dependency has an invalid notice count: ${name}") + endif () + math(EXPR last_notice "${notice_count} - 1") + foreach (notice_index RANGE ${last_notice}) + string(JSON source_notice GET "${dependency_manifest}" dependencies ${index} licenses ${notice_index}) + if (NOT source_notice MATCHES "^[A-Za-z0-9_+./-]+$" OR source_notice MATCHES "(^/|(^|/)\\.\\.(/|$))") + message(FATAL_ERROR "Unsafe Windows dependency notice path: ${source_notice}") + endif () + string(REPLACE "/" "_" notice "${source_notice}") + set(notice_file "${ATRINIK_WINDOWS_RUNTIME_DIR}/../share/licenses/${name}/${notice}") + if (NOT EXISTS "${notice_file}" OR IS_DIRECTORY "${notice_file}" OR IS_SYMLINK "${notice_file}") + message(FATAL_ERROR "Windows dependency notice is missing: ${name}/${notice}") + endif () + install(FILES "${notice_file}" DESTINATION "${destination}/licenses/${name}") + endforeach () + endforeach () + install(FILES "${dependency_inventory}" DESTINATION "${destination}/licenses" + RENAME windows-dependencies.json) + install(FILES "${pe_inventory}" DESTINATION "${destination}/licenses" + RENAME windows-pe-closure.json) + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tools/ci/stage_windows_runtime.py") + set(runtime_helper "${CMAKE_CURRENT_SOURCE_DIR}/tools/ci/stage_windows_runtime.py") + else () + set(runtime_helper "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../tools/ci/stage_windows_runtime.py") + endif () + if (NOT EXISTS "${runtime_helper}") + message(FATAL_ERROR "Windows runtime closure helper is missing: ${runtime_helper}") + endif () + set(runtime_arguments "--runtime-dir \"${ATRINIK_WINDOWS_RUNTIME_DIR}\"") + if (ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR) + string(APPEND runtime_arguments + " --runtime-dir \"${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}\"") + # Preserve the externally built Python extension modules, and inspect + # their dependencies without replacing their runtime ownership. + file(GLOB python_modules "${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}/*.pyd" + "${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}/*.dll") + list(APPEND ARGN ${python_modules}) + endif () + foreach (binary IN LISTS ARGN) + string(APPEND runtime_arguments " \"${binary}\"") + endforeach () + set(stage "${CMAKE_CURRENT_BINARY_DIR}/windows-runtime-stage") + set(script "${CMAKE_CURRENT_BINARY_DIR}/install-windows-runtime-$.cmake") + file(GENERATE OUTPUT "${script}" CONTENT " +file(LOCK \"${CMAKE_CURRENT_BINARY_DIR}/windows-runtime.lock\" GUARD PROCESS TIMEOUT 30) +file(REMOVE_RECURSE \"${stage}\") +execute_process(COMMAND \"${Python3_EXECUTABLE}\" \"${runtime_helper}\" + --readobj \"${ATRINIK_LLVM_READOBJ}\" --dlls-only --output-dir \"${stage}\" + ${runtime_arguments} + RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error) +if (NOT result EQUAL 0) + message(FATAL_ERROR \"Windows runtime closure failed: \${error}\") +endif () +file(INSTALL DESTINATION \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${destination}\" + TYPE DIRECTORY FILES \"${stage}/\") +") + install(SCRIPT "${script}") +endfunction() diff --git a/docs/WINDOWS_TOOLCHAIN.md b/docs/WINDOWS_TOOLCHAIN.md new file mode 100644 index 000000000..faa4ad5e6 --- /dev/null +++ b/docs/WINDOWS_TOOLCHAIN.md @@ -0,0 +1,52 @@ +# Classic Windows toolchain + +The Windows producer and native qualification cohort use LLVM-MinGW +`20261006` with LLVM `23.1.3`, UCRT, compiler-rt, LLD and the LLVM archive, +resource, import-library and inspection utilities. Target dependencies are +rebuilt in a fresh prefix with that cohort; do not reuse MXE/GCC object files, +static libraries or runtime directories. Image digests remain separately +qualified release inputs. + +Cross-build consumers use plain CMake with these required producer variables: + +```sh +export ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake +export ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 +export ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin +export ATRINIK_PACKAGE_VERSION=MAJOR.MINOR.PATCH +``` + +The producer exposes LLVM tools from `/opt/llvm-mingw/bin`. CMake, Ninja and +host Python remain host tools; pkg-config selects the freshly built Windows +dependency prefix. From the owning module directory, run +`tools/build-windows-package.sh OUTPUT_DIRECTORY`. Server packaging additionally +requires `ATRINIK_WINDOWS_PYTHON_INCLUDE_DIR`, +`ATRINIK_WINDOWS_PYTHON_LIBRARY` and `ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR`, and +builds the host worldmaker separately before cross-compiling the server. +Dependency, shader, sound and source-profile qualification inputs keep their +existing verification contracts. + +Packages stage the recursive DLL import closure with +`tools/ci/stage_windows_runtime.py --readobj llvm-readobj`. The inspector parses +LLVM COFF ordinary and delayed import blocks, requires x86-64 PE format, and +rejects unresolved imports, unsafe DLL names, duplicate runtime names and GCC +runtime DLLs. UCRT and Windows API-set DLLs are platform dependencies. The +installer owns the executable and server plugins; closure staging does not +replace those targets. The compatible external Python runtime retains its +own files and Microsoft runtime dependencies; every bundled Python DLL and +extension module is also inspected. Python API calls must retain the existing +allocation and CRT-resource ownership boundaries. + +Both packages retain the producer's `windows-dependencies.json` and +`windows-pe-closure.json` below `licenses/`, every dependency notice listed by +that source inventory, and the complete LLVM-MinGW notices below +`licenses/llvm-mingw/`. Missing inventories, missing notices and unsafe notice +paths fail packaging. This includes compiler-rt and MinGW CRT notices even +when their code is linked statically. + +Native Windows qualification requires the matching LLVM-MinGW cohort and its +fresh dependency prefix, selected explicitly by a native toolchain file. A +cross-build, hosted smoke or fixture parser test does not prove D3D12 hardware, +gameplay or audible behavior. Keep production packages separate from test-only +conformance builds and retain the workspace's Windows GPU preflight and cleanup +requirements. diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 18f3e7cb9..90c3a67b8 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -707,8 +707,14 @@ if (MINGW AND ${PACKAGE_TYPE} STREQUAL "zip") "The portable Windows server package requires the Python plugin.") endif () + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/InstallWindowsRuntime.cmake") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/InstallWindowsRuntime.cmake") + else () + include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/InstallWindowsRuntime.cmake") + endif () + set(ATRINIK_WINDOWS_RUNTIME_DIR "" CACHE PATH - "Directory containing MinGW runtime DLLs") + "Directory containing LLVM-MinGW runtime DLLs") set(ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR "" CACHE PATH "Directory containing the embedded Windows Python runtime") set(ATRINIK_SERVER_PACKAGE_ROOT "" CACHE PATH @@ -743,13 +749,13 @@ if (MINGW AND ${PACKAGE_TYPE} STREQUAL "zip") file(GLOB ATRINIK_WINDOWS_RUNTIME_DLLS "${ATRINIK_WINDOWS_RUNTIME_DIR}/*.dll") - list(FILTER ATRINIK_WINDOWS_RUNTIME_DLLS EXCLUDE REGEX - "/libffi-8\\.dll$") if (NOT ATRINIK_WINDOWS_RUNTIME_DLLS) message(FATAL_ERROR "No DLLs found in ATRINIK_WINDOWS_RUNTIME_DIR: ${ATRINIK_WINDOWS_RUNTIME_DIR}") endif () - install(FILES ${ATRINIK_WINDOWS_RUNTIME_DLLS} DESTINATION server) + atrinik_install_windows_runtime(server + "$" + "$" "$") # A c-ares runtime must carry the complete notice from its toolchain. set(ATRINIK_WINDOWS_CARES_LICENSE diff --git a/server/INSTALL b/server/INSTALL index aa8f6e30c..847535888 100644 --- a/server/INSTALL +++ b/server/INSTALL @@ -46,3 +46,8 @@ also requires its complete license at `ATRINIK_WINDOWS_RUNTIME_DIR/../share/licenses/c-ares/LICENSE.md`, or the explicit `ATRINIK_WINDOWS_CARES_LICENSE` override, and installs it as `c-ares-LICENSE.md` beside the DLLs. Toolchains without a c-ares DLL do not require that notice. + +Windows toolchain and recursive runtime packaging requirements are documented +in `../docs/WINDOWS_TOOLCHAIN.md`. Use the required neutral +`ATRINIK_WINDOWS_TOOLCHAIN_FILE`, `ATRINIK_WINDOWS_TARGET` and +`ATRINIK_WINDOWS_RUNTIME_DIR` producer variables with plain CMake. diff --git a/server/tools/build-windows-package.sh b/server/tools/build-windows-package.sh index b477f2527..e54e277f9 100755 --- a/server/tools/build-windows-package.sh +++ b/server/tools/build-windows-package.sh @@ -17,7 +17,7 @@ if [[ -n ${qualification} || -n ${qualification_sha} ]]; then exit 1 fi fi -for variable in MXE_TOOLCHAIN_FILE MXE_RUNTIME_DIR \ +for variable in ATRINIK_WINDOWS_TOOLCHAIN_FILE ATRINIK_WINDOWS_RUNTIME_DIR \ ATRINIK_WINDOWS_PYTHON_INCLUDE_DIR ATRINIK_WINDOWS_PYTHON_LIBRARY \ ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR; do if [[ -z ${!variable:-} || ! -e ${!variable} ]]; then @@ -26,8 +26,12 @@ for variable in MXE_TOOLCHAIN_FILE MXE_RUNTIME_DIR \ fi done -mxe_cmake=${MXE_TARGET:-x86_64-w64-mingw32.shared}-cmake -command -v "${mxe_cmake}" >/dev/null +if [[ ${ATRINIK_WINDOWS_TARGET:-} != x86_64-w64-mingw32 ]]; then + echo "ATRINIK_WINDOWS_TARGET must be x86_64-w64-mingw32" >&2 + exit 1 +fi +command -v cmake >/dev/null +command -v llvm-readobj >/dev/null dependency_downloads=${ATRINIK_DEPENDENCY_DOWNLOADS:-} dependency_sync_arguments=() profile_content=${ATRINIK_PROFILE_CONTENT_DIR:-} @@ -168,13 +172,13 @@ cmake -E copy_directory "${region_assets}/client-maps" \ cmake -E remove_directory "${region_runtime}" cmake -E remove_directory "${region_assets}" -"${mxe_cmake}" -S . -B build/windows-release -G Ninja \ +cmake -S . -B build/windows-release -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_TOOLCHAIN_FILE="${MXE_TOOLCHAIN_FILE}" \ + -DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}" \ -DBUILD_TESTING=OFF \ -DPACKAGE_TYPE=zip \ -DATRINIK_PACKAGE_VERSION="${version}" \ - -DATRINIK_WINDOWS_RUNTIME_DIR="${MXE_RUNTIME_DIR}" \ + -DATRINIK_WINDOWS_RUNTIME_DIR="${ATRINIK_WINDOWS_RUNTIME_DIR}" \ -DATRINIK_WINDOWS_PYTHON_INCLUDE_DIR="${ATRINIK_WINDOWS_PYTHON_INCLUDE_DIR}" \ -DATRINIK_WINDOWS_PYTHON_LIBRARY="${ATRINIK_WINDOWS_PYTHON_LIBRARY}" \ -DATRINIK_WINDOWS_PYTHON_RUNTIME_DIR="${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}" \ diff --git a/tools/ci/stage_windows_runtime.py b/tools/ci/stage_windows_runtime.py index dab93b32d..3f8a3981e 100644 --- a/tools/ci/stage_windows_runtime.py +++ b/tools/ci/stage_windows_runtime.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stage the bounded MinGW DLL closure for native Windows test executables.""" +"""Stage a bounded LLVM-MinGW DLL closure for Windows tests and packages.""" from __future__ import annotations @@ -12,26 +12,48 @@ from typing import Callable, Iterable -DLL_NAME = re.compile(r"^\s*DLL Name:\s*(\S+)\s*$", re.IGNORECASE | re.MULTILINE) +DLL_NAME = re.compile(r"[A-Za-z0-9_+.-]+\.dll", re.IGNORECASE) +GCC_RUNTIME = re.compile( + r"(?:libgcc_s|libstdc\+\+|libssp|libgomp|libatomic|libquadmath)[^/]*\.dll", + re.IGNORECASE, +) SYSTEM_DLLS = { "advapi32.dll", "bcrypt.dll", + "cfgmgr32.dll", + "comctl32.dll", + "comdlg32.dll", "crypt32.dll", + "d3d12.dll", + "d3dcompiler_47.dll", "dnsapi.dll", + "dwmapi.dll", + "dxgi.dll", "gdi32.dll", + "hid.dll", "imagehlp.dll", + "imm32.dll", "iphlpapi.dll", "kernel32.dll", "msvcrt.dll", + "mswsock.dll", "ncrypt.dll", "normaliz.dll", "ole32.dll", + "oleaut32.dll", + "powrprof.dll", + "psapi.dll", "rpcrt4.dll", "secur32.dll", + "setupapi.dll", "shell32.dll", + "shlwapi.dll", "user32.dll", + "ucrtbase.dll", + "uxtheme.dll", "version.dll", "winmm.dll", + "wintrust.dll", "wldap32.dll", "ws2_32.dll", "wsock32.dll", @@ -43,9 +65,34 @@ class StageError(RuntimeError): """Raised when a Windows runtime closure cannot be proven complete.""" -def imported_dlls(objdump: str, binary: Path) -> set[str]: +def parse_imports(output: str) -> set[str]: + """Parse LLVM COFF import blocks, including delay-loaded DLLs.""" + if not re.search(r"^Format:\s+COFF-x86-64\s*$", output, re.MULTILINE): + raise StageError("LLVM inspector did not identify an x86-64 COFF image") + imports: set[str] = set() + block = None + for line in output.splitlines(): + stripped = line.strip() + if stripped in {"Import {", "DelayImport {"}: + if block is not None: + raise StageError("nested LLVM COFF import block") + block = [] + elif block is not None: + if stripped == "}": + if len(block) != 1 or DLL_NAME.fullmatch(block[0]) is None: + raise StageError("LLVM COFF import block has an invalid DLL name") + imports.add(block[0]) + block = None + elif stripped.startswith("Name:"): + block.append(stripped.removeprefix("Name:").strip()) + if block is not None: + raise StageError("unterminated LLVM COFF import block") + return imports + + +def imported_dlls(readobj: str, binary: Path) -> set[str]: result = subprocess.run( - [objdump, "-p", str(binary)], + [readobj, "--coff-imports", str(binary)], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -56,7 +103,7 @@ def imported_dlls(objdump: str, binary: Path) -> set[str]: raise StageError( f"cannot inspect {binary}: {result.stderr.strip() or result.returncode}" ) - return {match.group(1) for match in DLL_NAME.finditer(result.stdout)} + return parse_imports(result.stdout) def is_system_dll(name: str) -> bool: @@ -76,6 +123,8 @@ def runtime_index(runtime_dir: Path) -> dict[str, Path]: if candidate.is_symlink() or not candidate.is_file() or candidate.suffix.lower() != ".dll": continue normalized = candidate.name.lower() + if GCC_RUNTIME.fullmatch(normalized): + raise StageError(f"GCC runtime DLL in LLVM runtime directory: {candidate.name}") if normalized in index: raise StageError(f"duplicate case-insensitive runtime DLL: {candidate.name}") index[normalized] = candidate @@ -84,11 +133,19 @@ def runtime_index(runtime_dir: Path) -> dict[str, Path]: def stage_runtime( binaries: Iterable[Path], - runtime_dir: Path, + runtime_dir: Path | Iterable[Path], output_dir: Path, inspector: Callable[[Path], set[str]], + *, + dlls_only: bool = False, ) -> list[str]: - sources = runtime_index(runtime_dir) + sources: dict[str, Path] = {} + runtime_dirs = [runtime_dir] if isinstance(runtime_dir, Path) else runtime_dir + for directory in runtime_dirs: + for name, source in runtime_index(directory).items(): + if name in sources and sources[name] != source: + raise StageError(f"duplicate case-insensitive runtime DLL: {source.name}") + sources[name] = source if output_dir.exists(): if output_dir.is_symlink() or not output_dir.is_dir(): raise StageError(f"output directory is not a normal directory: {output_dir}") @@ -99,22 +156,25 @@ def stage_runtime( queue: list[Path] = [] staged: dict[str, Path] = {} - def stage(source: Path) -> None: + def stage(source: Path, copy: bool = True) -> None: if source.is_symlink() or not source.is_file(): raise StageError(f"runtime input is not a normal file: {source}") normalized = source.name.lower() + if GCC_RUNTIME.fullmatch(normalized): + raise StageError(f"GCC runtime DLL in LLVM closure: {source.name}") previous = staged.get(normalized) if previous is not None: if previous != source: raise StageError(f"duplicate staged filename: {source.name}") return - destination = output_dir / source.name - shutil.copyfile(source, destination) + if copy: + destination = output_dir / source.name + shutil.copyfile(source, destination) staged[normalized] = source queue.append(source) for binary in binaries: - stage(binary) + stage(binary, copy=not dlls_only) position = 0 runtime_count = 0 @@ -122,7 +182,11 @@ def stage(source: Path) -> None: binary = queue[position] position += 1 for dependency in sorted(inspector(binary), key=str.lower): + if DLL_NAME.fullmatch(dependency) is None: + raise StageError(f"invalid imported DLL name for {binary.name}: {dependency}") normalized = dependency.lower() + if GCC_RUNTIME.fullmatch(normalized): + raise StageError(f"GCC runtime DLL in LLVM closure: {dependency}") if is_system_dll(dependency) or normalized in staged: continue source = sources.get(normalized) @@ -138,17 +202,31 @@ def stage(source: Path) -> None: def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--objdump", required=True) - parser.add_argument("--runtime-dir", type=Path, required=True) - parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--readobj", required=True) + parser.add_argument("--runtime-dir", type=Path, action="append", default=[]) + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--dlls-only", action="store_true") + parser.add_argument("--inspect-only", action="store_true") + parser.add_argument("--require-import", action="append", default=[]) parser.add_argument("binaries", nargs="+", type=Path) arguments = parser.parse_args() try: + if arguments.require_import or arguments.inspect_only: + for binary in arguments.binaries: + imports = {name.lower() for name in imported_dlls(arguments.readobj, binary)} + for required in arguments.require_import: + if required.lower() not in imports: + raise StageError(f"{binary.name} does not import required DLL: {required}") + if arguments.inspect_only: + return 0 + if not arguments.runtime_dir or arguments.output_dir is None: + raise StageError("staging requires --runtime-dir and --output-dir") staged = stage_runtime( arguments.binaries, arguments.runtime_dir, arguments.output_dir, - lambda binary: imported_dlls(arguments.objdump, binary), + lambda binary: imported_dlls(arguments.readobj, binary), + dlls_only=arguments.dlls_only, ) except (OSError, StageError, subprocess.SubprocessError) as error: parser.exit(1, f"Windows runtime staging failed: {error}\n") diff --git a/tools/release/package_sources.py b/tools/release/package_sources.py index 841723a4c..64f29a6af 100755 --- a/tools/release/package_sources.py +++ b/tools/release/package_sources.py @@ -27,6 +27,8 @@ "LICENSE.md", "ATTRIBUTIONS.md", "cmake/AtrinikVersion.cmake", + "cmake/InstallWindowsRuntime.cmake", + "tools/ci/stage_windows_runtime.py", ) PROVENANCE_PREFIX = "docs/history/" diff --git a/tools/tests/test_package_sources.py b/tools/tests/test_package_sources.py index e1fb54f71..11cc8312a 100644 --- a/tools/tests/test_package_sources.py +++ b/tools/tests/test_package_sources.py @@ -32,6 +32,8 @@ def setUp(self) -> None: "LICENSE.md": "license\n", "ATTRIBUTIONS.md": "notices\n", "cmake/AtrinikVersion.cmake": "# version contract\n", + "cmake/InstallWindowsRuntime.cmake": "# Windows runtime closure\n", + "tools/ci/stage_windows_runtime.py": "# LLVM inspector\n", "docs/history/imports.json": "{}\n", "client/.releaserc.json": "{}\n", "client/main.c": "int main(void) { return 0; }\n", @@ -98,6 +100,8 @@ def test_scoped_archive_contains_shared_evidence_but_not_other_modules(self) -> self.assertIn( "atrinik-classic-client-5.6.0/cmake/AtrinikVersion.cmake", names ) + self.assertIn("atrinik-classic-client-5.6.0/cmake/InstallWindowsRuntime.cmake", names) + self.assertIn("atrinik-classic-client-5.6.0/tools/ci/stage_windows_runtime.py", names) self.assertIn( "atrinik-classic-client-5.6.0/PROVENANCE/history/imports.json", names ) @@ -125,6 +129,13 @@ def test_unsafe_source_path_is_rejected(self) -> None: with self.assertRaisesRegex(package_sources.PackageError, "unsafe archive path"): package_sources.selected_name("root", "../escape", "package") + def test_standalone_native_packages_retain_windows_closure_tools(self) -> None: + for scope in ("client", "server"): + for path in ("cmake/InstallWindowsRuntime.cmake", "tools/ci/stage_windows_runtime.py"): + with self.subTest(scope=scope, path=path): + self.assertEqual(package_sources.selected_name(scope, path, "package"), + f"package/{path}") + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_stage_windows_runtime.py b/tools/tests/test_stage_windows_runtime.py index 468c9883d..f7c084cb4 100644 --- a/tools/tests/test_stage_windows_runtime.py +++ b/tools/tests/test_stage_windows_runtime.py @@ -4,6 +4,8 @@ from pathlib import Path import tempfile import unittest +from unittest.mock import patch +import subprocess MODULE_PATH = Path(__file__).resolve().parents[1] / "ci" / "stage_windows_runtime.py" @@ -27,7 +29,7 @@ def test_recursive_closure_is_case_insensitive_and_excludes_system_dlls(self) -> imports = { "path.exe": {"imagehlp.dll", "KERNEL32.dll", "LIBCRYPTO.DLL"}, "libcrypto.dll": {"zlib1.dll", "api-ms-win-core-file-l1-1-0.dll"}, - "zlib1.dll": {"msvcrt.dll"}, + "zlib1.dll": {"ucrtbase.dll"}, } staged = stage_windows_runtime.stage_runtime( @@ -74,13 +76,117 @@ def test_nonempty_output_directory_fails_closed(self) -> None: [executable], runtime, output, lambda binary: set() ) - def test_objdump_parser_accepts_pe_import_table_spacing(self) -> None: - output = "\tDLL Name: KERNEL32.dll\n DLL Name: libcrypto-4-x64.dll\n" + def test_llvm_parser_accepts_normal_and_delayed_imports(self) -> None: + output = """File: fixture.exe +Format: COFF-x86-64 +Arch: x86_64 +AddressSize: 64bit +Import { + Name: KERNEL32.dll + ImportLookupTableRVA: 0x17290 + Symbol: GetLastError (627) +} +DelayImport { + Name: libcrypto-4-x64.dll + Attributes: 0x1 + Symbol: SSL_connect (0) +} +""" self.assertEqual( - {match.group(1) for match in stage_windows_runtime.DLL_NAME.finditer(output)}, + stage_windows_runtime.parse_imports(output), {"KERNEL32.dll", "libcrypto-4-x64.dll"}, ) + def test_llvm_parser_rejects_unrecognized_and_malformed_output(self) -> None: + for output in ("DLL Name: fixture.dll", "Format: elf64-x86-64\n", + "Format: COFF-i386\n", "Format: COFF-x86-64\nImport {\n", + "Format: COFF-x86-64\nImport {\nName: ../bad.dll\n}\n", + "Format: COFF-x86-64\nImport {\n}\n", + "Format: COFF-x86-64\nImport {\nName: a.dll\nName: b.dll\n}\n"): + with self.subTest(output=output), self.assertRaises(stage_windows_runtime.StageError): + stage_windows_runtime.parse_imports(output) + + def test_no_imports_is_valid_only_with_known_pe_format(self) -> None: + self.assertEqual(stage_windows_runtime.parse_imports("Format: COFF-x86-64\n"), set()) + + def test_inspector_uses_llvm_coff_import_mode_and_propagates_failure(self) -> None: + success = subprocess.CompletedProcess([], 0, "Format: COFF-x86-64\n", "") + with patch.object(stage_windows_runtime.subprocess, "run", return_value=success) as run: + self.assertEqual(stage_windows_runtime.imported_dlls("llvm-readobj", Path("fixture.exe")), set()) + self.assertEqual(run.call_args.args[0], ["llvm-readobj", "--coff-imports", "fixture.exe"]) + failure = subprocess.CompletedProcess([], 1, "", "invalid PE") + with patch.object(stage_windows_runtime.subprocess, "run", return_value=failure): + with self.assertRaisesRegex(stage_windows_runtime.StageError, "invalid PE"): + stage_windows_runtime.imported_dlls("llvm-readobj", Path("fixture.exe")) + + def test_system_ucrt_and_external_python_crt_are_not_staged(self) -> None: + for name in ("ucrtbase.dll", "api-ms-win-crt-runtime-l1-1-0.dll", "msvcrt.dll"): + self.assertTrue(stage_windows_runtime.is_system_dll(name)) + + def test_gcc_runtime_in_imports_or_unused_prefix_fails(self) -> None: + for name in ("libgcc_s_seh-1.dll", "libstdc++-6.dll", "libssp-0.dll", + "libgomp-1.dll", "libatomic-1.dll", "libquadmath-0.dll"): + for unused in (False, True): + with self.subTest(name=name, unused=unused), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime = root / "runtime" + runtime.mkdir() + executable = root / "path.exe" + executable.write_bytes(b"exe") + if unused: + (runtime / name).write_bytes(b"stale") + with self.assertRaisesRegex(stage_windows_runtime.StageError, "GCC runtime DLL"): + stage_windows_runtime.stage_runtime( + [executable], runtime, root / "output", + lambda binary: set() if unused else {name}) + + def test_dlls_only_preserves_root_plugin_ownership_and_closes_external_python(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime, python = root / "runtime", root / "python" + runtime.mkdir() + python.mkdir() + executable, plugin = root / "server.exe", root / "plugin_python.dll" + executable.write_bytes(b"exe") + plugin.write_bytes(b"plugin") + (python / "python314.dll").write_bytes(b"python") + (runtime / "libcurl.dll").write_bytes(b"curl") + (runtime / "unused.dll").write_bytes(b"unused") + imports = {"server.exe": {"plugin_python.dll", "libcurl.dll"}, + "plugin_python.dll": {"python314.dll"}, + "python314.dll": {"VCRUNTIME140.dll"}, + "VCRUNTIME140.dll": {"ucrtbase.dll"}, "libcurl.dll": set()} + (python / "VCRUNTIME140.dll").write_bytes(b"external runtime") + staged = stage_windows_runtime.stage_runtime( + [executable, plugin], [runtime, python], root / "output", + lambda binary: imports[binary.name], dlls_only=True) + self.assertEqual(staged, ["libcurl.dll", "python314.dll", "VCRUNTIME140.dll"]) + + def test_import_name_cannot_escape_runtime_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime = root / "runtime" + runtime.mkdir() + executable = root / "path.exe" + executable.write_bytes(b"exe") + with self.assertRaisesRegex(stage_windows_runtime.StageError, "invalid imported DLL name"): + stage_windows_runtime.stage_runtime( + [executable], runtime, root / "output", lambda binary: {"../escape.dll"}) + + def test_duplicate_case_insensitive_dll_across_runtime_roots_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runtime, python = root / "runtime", root / "python" + runtime.mkdir() + python.mkdir() + (runtime / "zlib1.dll").write_bytes(b"one") + (python / "ZLIB1.dll").write_bytes(b"two") + executable = root / "path.exe" + executable.write_bytes(b"exe") + with self.assertRaisesRegex(stage_windows_runtime.StageError, "duplicate case-insensitive"): + stage_windows_runtime.stage_runtime( + [executable], [runtime, python], root / "output", lambda binary: set()) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_windows_trust_notices.py b/tools/tests/test_windows_trust_notices.py index f181b4f95..9ba8f5626 100644 --- a/tools/tests/test_windows_trust_notices.py +++ b/tools/tests/test_windows_trust_notices.py @@ -1,8 +1,10 @@ """Execute the production CMake runtime install rules without native builds.""" import os +import json from pathlib import Path import subprocess +import sys import tempfile import unittest import zipfile @@ -18,12 +20,28 @@ def fixture(self, module, cares=True, notice=True): runtime = root / "toolchain/bin" runtime.mkdir(parents=True) (runtime / "libcurl-4.dll").write_bytes(b"fixture curl") + (runtime / "unused.dll").write_bytes(b"unused dependency") if cares: (runtime / "libcares-2.dll").write_bytes(b"fixture c-ares") license_path = root / "toolchain/share/licenses/c-ares/LICENSE.md" license_path.parent.mkdir(parents=True) if notice: license_path.write_bytes(b"fixture complete MIT notice\n") + llvm_notices = root / "toolchain/share/licenses/llvm-mingw" + llvm_notices.mkdir(parents=True) + for name in ("LICENSE.TXT", "COPYING", "COPYING.MinGW-w64-runtime.txt", + "COPYING.MinGW-w64.txt", "COPYING.winpthreads.txt", "COPYING.winstorecompat.txt"): + (llvm_notices / name).write_bytes(b"fixture complete LLVM-MinGW notice\n") + inventories = root / "toolchain/share/atrinik/windows-dependencies" + inventories.mkdir(parents=True) + (inventories / "dependencies.json").write_text(json.dumps({ + "schema_version": 1, "dependencies": [{"name": "fixture-curl", "version": "1", + "licenses": ["COPYING", "docs/NOTICE"]}]})) + (inventories / "pe-closure.json").write_text('{"libcurl-4.dll": []}') + notices = root / "toolchain/share/licenses/fixture-curl" + notices.mkdir() + for name in ("COPYING", "docs_NOTICE"): + (notices / name).write_bytes(b"fixture dependency notice\n") source = (ROOT / module / "CMakeLists.txt").read_text() if module == "client": start = source.index(" if (ATRINIK_WINDOWS_RUNTIME_DIR)", source.index("# Installer.")) @@ -49,8 +67,26 @@ def fixture(self, module, cares=True, notice=True): (root / "ca-bundle.crt").write_bytes(ca.read_bytes()) system_ca = root / "system-ca.crt" system_ca.write_bytes(b"different system CA fixture") + inspector = root / "llvm-readobj" + inspector.write_text('#!/usr/bin/env python3\nimport pathlib, sys\n' + 'binary = pathlib.Path(sys.argv[-1])\n' + 'print("Format: COFF-x86-64")\n' + 'if binary.suffix == ".exe":\n' + ' print("Import {\\n Name: libcurl-4.dll\\n}")\n' + 'elif binary.name == "libcurl-4.dll" and ' + '(binary.parent / "libcares-2.dll").exists():\n' + ' print("Import {\\n Name: libcares-2.dll\\n}")\n') + inspector.chmod(0o755) + (root / "fixture.exe").write_bytes(b"fixture executable") cmake = ('cmake_minimum_required(VERSION 3.21)\nproject(trust_fixture NONE)\n' 'set(INSTALL_SUBDIR_BIN ".")\nset(PACKAGE_TYPE zip)\n' + f'include("{ROOT}/cmake/InstallWindowsRuntime.cmake")\n' + f'set(ATRINIK_LLVM_READOBJ "{inspector}")\n' + 'set(EXECUTABLE fixture-server)\n' + 'foreach(target atrinik fixture-server plugin_arena plugin_python)\n' + ' add_executable(${target} IMPORTED)\n' + f' set_target_properties(${{target}} PROPERTIES IMPORTED_LOCATION "{root}/fixture.exe")\n' + 'endforeach()\n' f'set(ATRINIK_LICENSE_FILE "{root}/LICENSE.md")\n' f'set(ATRINIK_ATTRIBUTIONS_FILE "{root}/ATTRIBUTIONS.md")\n' f'set(ATRINIK_WINDOWS_RUNTIME_DIR "{runtime}")\n' + rules + @@ -83,6 +119,12 @@ def test_both_packages_include_exact_source_ca_and_notice(self): self.assertNotEqual(archive.read(bundles[0]), system_ca.read_bytes()) cares_dlls = [n for n in names if n.endswith("/libcares-2.dll")] self.assertEqual(len(cares_dlls), 1) + self.assertFalse(any(n.endswith("/unused.dll") for n in names)) + llvm_license = next(n for n in names if n.endswith("/licenses/llvm-mingw/LICENSE.TXT")) + self.assertEqual(archive.read(llvm_license), b"fixture complete LLVM-MinGW notice\n") + dependency_notice = next(n for n in names if n.endswith("/licenses/fixture-curl/docs_NOTICE")) + self.assertEqual(archive.read(dependency_notice), b"fixture dependency notice\n") + self.assertTrue(any(n.endswith("/licenses/windows-dependencies.json") for n in names)) self.assertEqual(Path(notices[0]).parent, Path(cares_dlls[0]).parent) def test_cares_missing_or_directory_notice_fails_closed(self): @@ -131,12 +173,12 @@ def test_client_helper_passes_authored_ca_even_with_system_ca(self): (client / "tools/dependencies.py").write_text("") bin_path = root / "bin" bin_path.mkdir() - for command in ("python3", "git"): + for command in ("python3", "git", "llvm-readobj"): script = bin_path / command script.write_text("#!/bin/sh\nexit 0\n") script.chmod(0o755) capture = root / "configure-args" - script = bin_path / "fixture-cmake" + script = bin_path / "cmake" script.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE"\nexit 23\n') script.chmod(0o755) toolchain = root / "toolchain.cmake" @@ -146,8 +188,8 @@ def test_client_helper_passes_authored_ca_even_with_system_ca(self): shader = root / "shaders" shader.mkdir() env = dict(os.environ, PATH=f"{bin_path}:{os.environ['PATH']}", CAPTURE=str(capture), - ATRINIK_PACKAGE_VERSION="5.1.0", MXE_TARGET="fixture", MXE_TOOLCHAIN_FILE=str(toolchain), - MXE_RUNTIME_DIR=str(runtime), ATRINIK_GPU_SHADER_DIRECTORY=str(shader)) + ATRINIK_PACKAGE_VERSION="5.1.0", ATRINIK_WINDOWS_TARGET="x86_64-w64-mingw32", ATRINIK_WINDOWS_TOOLCHAIN_FILE=str(toolchain), + ATRINIK_WINDOWS_RUNTIME_DIR=str(runtime), ATRINIK_GPU_SHADER_DIRECTORY=str(shader)) result = subprocess.run(["bash", str(helper)], cwd=client, env=env, capture_output=True, text=True) self.assertEqual(result.returncode, 23, result.stdout + result.stderr) arguments = capture.read_text().splitlines() @@ -163,6 +205,62 @@ def test_standalone_client_ca_override_remains_available(self): name = next(n for n in archive.namelist() if n.endswith("/ca-bundle.crt")) self.assertEqual(archive.read(name), system_ca.read_bytes()) + def test_unused_gcc_runtime_prefix_fails_package_install(self): + for module in ("client", "server"): + with self.subTest(module=module): + root, result, _, _ = self.fixture(module, cares=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + (root / "toolchain/bin/libgcc_s_seh-1.dll").write_bytes(b"stale GCC") + result = subprocess.run( + ["cmake", "--install", str(root / "build"), "--prefix", str(root / "installed")], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("GCC runtime DLL", " ".join((result.stdout + result.stderr).split())) + + def test_client_sound_verifier_uses_the_same_llvm_parser(self): + root, result, _, _ = self.fixture("client", cares=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + inspector = root / "llvm-readobj" + arguments = ["cmake", f"-DATRINIK_READOBJ={inspector}", + f"-DATRINIK_PYTHON={sys.executable}", + f"-DATRINIK_RUNTIME_HELPER={ROOT}/tools/ci/stage_windows_runtime.py", + f"-DATRINIK_CLIENT_EXECUTABLE={root}/fixture.exe", "-P", + str(ROOT / "client/cmake/verify_windows_client_sound.cmake")] + result = subprocess.run(arguments, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not import required DLL", result.stdout + result.stderr) + inspector.write_text('#!/usr/bin/env python3\n' + 'print("Format: COFF-x86-64\\nImport {\\n Name: SDL3_mixer.dll\\n}")\n') + result = subprocess.run(arguments, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("Windows client sound import verified", result.stdout + result.stderr) + + def test_missing_llvm_crt_notice_fails_configuration(self): + root, result, _, _ = self.fixture("client", cares=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + (root / "toolchain/share/licenses/llvm-mingw/COPYING.MinGW-w64-runtime.txt").unlink() + result = subprocess.run(["cmake", "-S", str(root), "-B", str(root / "build")], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("LLVM-MinGW package notice is missing", result.stdout + result.stderr) + + def test_dependency_inventory_rejects_missing_notices_and_unsafe_paths(self): + cases = [ + {"schema_version": 2, "dependencies": [{"name": "fixture-curl", "licenses": ["COPYING"]}]}, + {"schema_version": 1, "dependencies": []}, + {"schema_version": 1, "dependencies": [{"name": "../escape", "licenses": ["COPYING"]}]}, + {"schema_version": 1, "dependencies": [{"name": "fixture-curl", "licenses": ["../COPYING"]}]}, + {"schema_version": 1, "dependencies": [{"name": "fixture-curl", "licenses": ["missing"]}]}, + ] + for manifest in cases: + with self.subTest(manifest=manifest): + root, result, _, _ = self.fixture("client", cares=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + (root / "toolchain/share/atrinik/windows-dependencies/dependencies.json").write_text(json.dumps(manifest)) + result = subprocess.run(["cmake", "-S", str(root), "-B", str(root / "build")], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + if __name__ == "__main__": unittest.main() From 6d84267056bc6e711a1dff9763b31ea3c484300c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 22:50:23 -0500 Subject: [PATCH 004/148] build(release): retain LLVM tools in standalone source archives --- tools/release/package_sources.py | 2 ++ tools/tests/test_package_sources.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/release/package_sources.py b/tools/release/package_sources.py index 64f29a6af..6a77f3b9c 100755 --- a/tools/release/package_sources.py +++ b/tools/release/package_sources.py @@ -28,6 +28,8 @@ "ATTRIBUTIONS.md", "cmake/AtrinikVersion.cmake", "cmake/InstallWindowsRuntime.cmake", + "cmake/toolchains/llvm.cmake", + "tools/ci/llvm_coverage.py", "tools/ci/stage_windows_runtime.py", ) PROVENANCE_PREFIX = "docs/history/" diff --git a/tools/tests/test_package_sources.py b/tools/tests/test_package_sources.py index 11cc8312a..611677f42 100644 --- a/tools/tests/test_package_sources.py +++ b/tools/tests/test_package_sources.py @@ -33,6 +33,8 @@ def setUp(self) -> None: "ATTRIBUTIONS.md": "notices\n", "cmake/AtrinikVersion.cmake": "# version contract\n", "cmake/InstallWindowsRuntime.cmake": "# Windows runtime closure\n", + "cmake/toolchains/llvm.cmake": "# LLVM toolchain\n", + "tools/ci/llvm_coverage.py": "# LLVM coverage conversion\n", "tools/ci/stage_windows_runtime.py": "# LLVM inspector\n", "docs/history/imports.json": "{}\n", "client/.releaserc.json": "{}\n", @@ -102,6 +104,8 @@ def test_scoped_archive_contains_shared_evidence_but_not_other_modules(self) -> ) self.assertIn("atrinik-classic-client-5.6.0/cmake/InstallWindowsRuntime.cmake", names) self.assertIn("atrinik-classic-client-5.6.0/tools/ci/stage_windows_runtime.py", names) + self.assertIn("atrinik-classic-client-5.6.0/cmake/toolchains/llvm.cmake", names) + self.assertIn("atrinik-classic-client-5.6.0/tools/ci/llvm_coverage.py", names) self.assertIn( "atrinik-classic-client-5.6.0/PROVENANCE/history/imports.json", names ) @@ -131,7 +135,8 @@ def test_unsafe_source_path_is_rejected(self) -> None: def test_standalone_native_packages_retain_windows_closure_tools(self) -> None: for scope in ("client", "server"): - for path in ("cmake/InstallWindowsRuntime.cmake", "tools/ci/stage_windows_runtime.py"): + for path in ("cmake/InstallWindowsRuntime.cmake", "tools/ci/stage_windows_runtime.py", + "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py"): with self.subTest(scope=scope, path=path): self.assertEqual(package_sources.selected_name(scope, path, "package"), f"package/{path}") From 0862ca586a1f578df79f1c1b4246a51920d877a2 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 22:47:29 -0500 Subject: [PATCH 005/148] build(ci): migrate classic validation consumers to LLVM --- .github/workflows/build-release-candidate.yml | 12 +- .github/workflows/check.yml | 70 ++++++++--- .../workflows/daily-client-performance.yml | 5 +- .github/workflows/gpu-qualification.yml | 112 +++++++++++++++++- .github/workflows/pr-benchmarks.yml | 10 +- CONTRIBUTING.md | 9 +- docs/CI-LINUX-IMAGE.md | 32 +++++ docs/DAILY_CLIENT_PERFORMANCE.md | 5 + docs/PR_BENCHMARKS.md | 5 + tools/ci/classify_changes.py | 3 +- tools/tests/test_classify_changes.py | 12 ++ tools/tests/test_workflow_contracts.py | 57 +++++++-- 12 files changed, 292 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 3f3688f7f..d666f3065 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -51,7 +51,7 @@ permissions: env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 - WINDOWS_BUILD_CACHE_EPOCH: 1.16.0-4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 + WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-1.16.0-4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 concurrency: group: >- @@ -604,7 +604,10 @@ jobs: --env CCACHE_DIR=/workspace/client/build/ccache \ --env CCACHE_MAXSIZE=500M \ --env CCACHE_TEMPDIR=/tmp/atrinik-client-ccache \ - --env PATH=/opt/mxe/.ccache/bin:/opt/mxe/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + --env ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake \ + --env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 \ + --env ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin \ + --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/client \ ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ @@ -667,7 +670,10 @@ jobs: --env CCACHE_DIR=/workspace/server/build/ccache \ --env CCACHE_MAXSIZE=500M \ --env CCACHE_TEMPDIR=/tmp/atrinik-server-ccache \ - --env PATH=/opt/mxe/.ccache/bin:/opt/mxe/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + --env ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake \ + --env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 \ + --env ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin \ + --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/server \ ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3f7d2bc5f..ac9a08bc3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -29,8 +29,8 @@ env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 CLASSIC_GPU_COVERAGE_IMAGE: ghcr.io/atrinik/linux-build:1.16.0@sha256:89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e - CLASSIC_LINUX_CCACHE_COMPILER: gcc-15.2.0 - CLASSIC_LINUX_CCACHE_EPOCH: "1" + CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt + CLASSIC_LINUX_CCACHE_EPOCH: "2" jobs: gpu-shaders: @@ -260,6 +260,8 @@ jobs: --epoch "${CLASSIC_LINUX_CCACHE_EPOCH}" \ --material .github/workflows/check.yml \ --material tools/ci/run_linux_check.sh \ + --material cmake/toolchains/llvm.cmake \ + --material tools/ci/llvm_coverage.py \ --material protocol/CMakeLists.txt \ --material libatrinik/CMakeLists.txt \ --material libatrinik/CMakePresets.json \ @@ -280,6 +282,7 @@ jobs: docker run --rm \ --user "$(id -u):$(id -g)" \ --network none \ + --env ATRINIK_LLVM_TOOLCHAIN=1 \ --env CCACHE_DIR=/cache/ccache \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ @@ -312,7 +315,7 @@ jobs: with: disable_search: true fail_ci_if_error: false - files: libatrinik/coverage.xml + files: libatrinik/coverage.lcov flags: libatrinik use_oidc: true - name: Validate editor packaging helper @@ -382,7 +385,10 @@ jobs: --env ATRINIK_BENCHMARK_DIRTY=false \ --env ATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders \ --env ATRINIK_DISCORD_APPLICATION_ID_FILE=/workspace/build/discord-test-application-id \ - --env PATH=/opt/mxe/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + --env ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake \ + --env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 \ + --env ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin \ + --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace \ ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ @@ -392,7 +398,10 @@ jobs: pcpnatpmp_mingw_source=build/libpcpnatpmp-mingw-source test ! -e "${pcpnatpmp_mingw_source}" cmake -E copy_directory "${pcpnatpmp_source}" "${pcpnatpmp_mingw_source}" - x86_64-w64-mingw32.shared-cmake \ + cmake \ + -DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}" \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -S libatrinik \ -B libatrinik/build/windows-tests \ -G Ninja \ @@ -428,7 +437,10 @@ jobs: -DSOURCE_DIR=/workspace/build/libpcpnatpmp-mingw-source \ -DPATCH_FILE=/workspace/server/cmake/patches/libpcpnatpmp-mingw.patch \ -P /workspace/server/cmake/apply_patch_idempotent.cmake - x86_64-w64-mingw32.shared-cmake \ + cmake \ + -DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}" \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -S . \ -B build/windows-integrated \ -G Ninja \ @@ -441,7 +453,10 @@ jobs: -DFETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP=/workspace/build/libpcpnatpmp-mingw-source cmake --build build/windows-integrated \ --target atrinik atrinik-server --parallel "$(nproc)" - x86_64-w64-mingw32.shared-cmake \ + cmake \ + -DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}" \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -S client \ -B client/build/windows-tests \ -G Ninja \ @@ -457,8 +472,8 @@ jobs: stage=libatrinik/build/windows-test-bundle cmake -E remove_directory "${stage}" python3 tools/ci/stage_windows_runtime.py \ - --objdump x86_64-w64-mingw32.shared-objdump \ - --runtime-dir "${MXE_RUNTIME_DIR}" \ + --readobj llvm-readobj \ + --runtime-dir "${ATRINIK_WINDOWS_RUNTIME_DIR}" \ --output-dir "${stage}" \ libatrinik/build/windows-tests/libatrinik-path.exe \ libatrinik/build/windows-tests/libatrinik-rendezvous.exe \ @@ -483,13 +498,19 @@ jobs: --env CCACHE_MAXSIZE=250M \ --env GH_TOKEN= \ --env GITHUB_TOKEN= \ - --env PATH=/opt/mxe/.ccache/bin:/opt/mxe/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + --env ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake \ + --env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 \ + --env ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin \ + --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace \ ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ bash -c ' set -euo pipefail - x86_64-w64-mingw32.shared-cmake \ + cmake \ + -DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}" \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -S server \ -B server/build/windows-pr-server \ -G Ninja \ @@ -517,7 +538,10 @@ jobs: --env CCACHE_DIR=/workspace/server/build/ccache \ --env CCACHE_MAXSIZE=500M \ --env CCACHE_TEMPDIR=/tmp/atrinik-server-package-ccache \ - --env PATH=/opt/mxe/.ccache/bin:/opt/mxe/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + --env ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake \ + --env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 \ + --env ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin \ + --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/server \ ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ @@ -783,6 +807,8 @@ jobs: --epoch "${CLASSIC_LINUX_CCACHE_EPOCH}" \ --material .github/workflows/check.yml \ --material tools/ci/run_linux_check.sh \ + --material cmake/toolchains/llvm.cmake \ + --material tools/ci/llvm_coverage.py \ --material server/CMakeLists.txt \ --material server/CMakePresets.json \ --github-output "${GITHUB_OUTPUT}" @@ -802,6 +828,7 @@ jobs: docker run --rm \ --user "$(id -u):$(id -g)" \ --network none \ + --env ATRINIK_LLVM_TOOLCHAIN=1 \ --env CCACHE_DIR=/cache/ccache \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ @@ -818,6 +845,7 @@ jobs: if [[ ${fixture_uid} -ne 0 ]]; then fixture_name=nonroot; fi docker run --rm \ --user "${fixture_uid}:${fixture_uid}" \ + --env ATRINIK_LLVM_TOOLCHAIN=1 \ --network none --read-only --cap-drop ALL \ --security-opt no-new-privileges \ --cpus 1 --memory 512m --memory-swap 512m \ @@ -829,7 +857,7 @@ jobs: --mount "type=bind,source=${GITHUB_WORKSPACE}/server/tools/admin_shutdown_coverage.sh,target=/workspace/server/tools/admin_shutdown_coverage.sh,readonly" \ "${CLASSIC_LINUX_IMAGE}" \ bash /workspace/server/tools/admin_shutdown_coverage.sh \ - > "server/coverage-admin-${fixture_name}.xml" + > "server/coverage-admin-${fixture_name}.lcov" done - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: @@ -845,7 +873,7 @@ jobs: override_commit: ${{ env.COVERAGE_SHA }} disable_search: true fail_ci_if_error: false - files: server/coverage.xml,server/coverage-admin-root.xml,server/coverage-admin-nonroot.xml + files: server/coverage.lcov,server/coverage-admin-root.lcov,server/coverage-admin-nonroot.lcov flags: server use_oidc: true @@ -887,6 +915,8 @@ jobs: --epoch "${CLASSIC_LINUX_CCACHE_EPOCH}" \ --material .github/workflows/check.yml \ --material tools/ci/run_linux_check.sh \ + --material cmake/toolchains/llvm.cmake \ + --material tools/ci/llvm_coverage.py \ --material tools/ci/run_gpu_coverage.sh \ --material client/CMakeLists.txt \ --material client/CMakePresets.json \ @@ -907,6 +937,7 @@ jobs: docker run --rm \ --user "$(id -u):$(id -g)" \ --network none \ + --env ATRINIK_LLVM_TOOLCHAIN=1 \ --env CCACHE_DIR=/cache/ccache \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ @@ -933,6 +964,8 @@ jobs: echo "::notice::GPU coverage image is not publicly pullable; hardware qualification remains required" fi - name: Exercise GPU renderer coverage + env: + ATRINIK_LLVM_TOOLCHAIN: "1" if: steps.gpu-coverage-image-public.outputs.available == 'true' run: | tools/ci/run_gpu_coverage.sh \ @@ -947,7 +980,7 @@ jobs: override_commit: ${{ env.COVERAGE_SHA }} disable_search: true fail_ci_if_error: false - files: client/coverage.xml + files: client/coverage.lcov flags: client-unit use_oidc: true - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 @@ -994,6 +1027,8 @@ jobs: docker pull "${CLASSIC_LINUX_IMAGE}" docker pull "${CLASSIC_GPU_COVERAGE_IMAGE}" - name: Build and exercise complete GPU coverage + env: + ATRINIK_LLVM_TOOLCHAIN: "1" run: | tools/ci/run_gpu_coverage.sh \ "${GITHUB_WORKSPACE}" \ @@ -1007,7 +1042,7 @@ jobs: override_commit: ${{ env.COVERAGE_SHA }} disable_search: true fail_ci_if_error: false - files: client/coverage.xml + files: client/coverage.lcov flags: client-gpu use_oidc: true @@ -1049,6 +1084,8 @@ jobs: --epoch "${CLASSIC_LINUX_CCACHE_EPOCH}" \ --material .github/workflows/check.yml \ --material tools/ci/run_linux_check.sh \ + --material cmake/toolchains/llvm.cmake \ + --material tools/ci/llvm_coverage.py \ --material CMakeLists.txt \ --material CMakePresets.json \ --material protocol/CMakeLists.txt \ @@ -1072,6 +1109,7 @@ jobs: docker run --rm \ --user "$(id -u):$(id -g)" \ --network none \ + --env ATRINIK_LLVM_TOOLCHAIN=1 \ --env CCACHE_DIR=/cache/ccache \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ diff --git a/.github/workflows/daily-client-performance.yml b/.github/workflows/daily-client-performance.yml index a7887d6eb..db3e3b7d7 100644 --- a/.github/workflows/daily-client-performance.yml +++ b/.github/workflows/daily-client-performance.yml @@ -24,8 +24,8 @@ concurrency: env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 - CLASSIC_LINUX_CCACHE_COMPILER: gcc-15.2.0 - CLASSIC_LINUX_CCACHE_EPOCH: "1" + CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt + CLASSIC_LINUX_CCACHE_EPOCH: "2" FINAL_BENCHMARK_DATA_COMMIT: bab40ecefefa5b6052d42eab6390c504b9482e81 PAGES_URL: https://atrinik.github.io/classic/ @@ -117,6 +117,7 @@ jobs: --env ATRINIK_BENCHMARK_DIRTY \ --env ATRINIK_MOVEMENT_EVENT_NAME \ --env ATRINIK_MOVEMENT_MATRIX \ + --env ATRINIK_LLVM_TOOLCHAIN=1 \ --env CCACHE_DIR=/cache/ccache \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ diff --git a/.github/workflows/gpu-qualification.yml b/.github/workflows/gpu-qualification.yml index 64c9c3c65..53458f0ce 100644 --- a/.github/workflows/gpu-qualification.yml +++ b/.github/workflows/gpu-qualification.yml @@ -11,6 +11,10 @@ concurrency: group: classic-gpu-qualification-${{ github.ref }} cancel-in-progress: false +env: + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + CLASSIC_WINDOWS_IMAGE: ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 + jobs: shaders: name: Build qualification shader cohort @@ -39,9 +43,69 @@ jobs: if-no-files-found: error retention-days: 1 + windows-bundle: + name: Build matching Windows qualification bundle + needs: shaders + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Bind clean source and acquire locked client inputs + run: | + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + test -z "$(git status --short)" + python3 client/tools/dependencies.py sync --root client --refresh + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: qualification-gpu-shaders + path: build/gpu-shaders + - name: Pull pinned LLVM Windows producer anonymously + run: docker pull "${CLASSIC_WINDOWS_IMAGE}" + - name: Cross-build and stage the exact qualification runtime + run: | + docker run --rm --network none \ + --user "$(id -u):$(id -g)" \ + --env HOME=/tmp/classic-home \ + --env ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake \ + --env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 \ + --env ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin \ + --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + --env ATRINIK_BENCHMARK_REVISION="${GITHUB_SHA}" \ + --env ATRINIK_BENCHMARK_DIRTY=false \ + --volume "${GITHUB_WORKSPACE}:/workspace" --workdir /workspace \ + "${CLASSIC_WINDOWS_IMAGE}" bash -c ' + set -euo pipefail + cmake -S client -B client/build/gpu-qualification -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}" \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON \ + -DATRINIK_GPU_CONFORMANCE_REQUIRED=ON -DPACKAGE_TYPE=none \ + -DATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders + cmake --build client/build/gpu-qualification --config Release \ + --target atrinik client-gpu-renderer-integration-tests --parallel "$(nproc)" + python3 tools/ci/stage_windows_runtime.py \ + --readobj llvm-readobj --runtime-dir "${ATRINIK_WINDOWS_RUNTIME_DIR}" \ + --output-dir build/windows-gpu-qualification \ + client/build/gpu-qualification/atrinik.exe \ + client/build/gpu-qualification/client-gpu-renderer-integration-tests.exe + cmake -E copy_directory client/sound build/windows-gpu-qualification/sound + printf "%s\n" "${ATRINIK_BENCHMARK_REVISION}" \ + > build/windows-gpu-qualification/source-revision + clang --version > build/windows-gpu-qualification/compiler.txt + sha256sum build/gpu-shaders/SHA256SUMS \ + > build/windows-gpu-qualification/shader-manifest.sha256 + ' + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: windows-gpu-qualification-${{ github.sha }} + path: build/windows-gpu-qualification + if-no-files-found: error + retention-days: 1 + qualify: name: ${{ matrix.backend }} / ${{ matrix.hardware }} - needs: shaders + needs: [shaders, windows-bundle] strategy: fail-fast: false matrix: @@ -83,16 +147,52 @@ jobs: test -z "$(git status --porcelain=v1 --untracked-files=normal)" echo "ATRINIK_BENCHMARK_REVISION=${revision}" >>"${GITHUB_ENV}" echo "ATRINIK_BENCHMARK_DIRTY=false" >>"${GITHUB_ENV}" + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: runner.os == 'Windows' + with: + name: windows-gpu-qualification-${{ github.sha }} + path: client/build/gpu-qualification + - name: Verify exact Windows qualification bundle + if: runner.os == 'Windows' + shell: bash + run: | + test "$(cat client/build/gpu-qualification/source-revision)" = "${GITHUB_SHA}" + grep -F 'clang version 23.1.3' client/build/gpu-qualification/compiler.txt + sha256sum --check client/build/gpu-qualification/shader-manifest.sha256 + cmake -E copy_directory client/build/gpu-qualification/sound client/sound - name: Configure clean Release qualification build + if: runner.os != 'Windows' shell: bash env: ATRINIK_QUALIFICATION_SHADER_DIRECTORY: ${{ github.workspace }}/build/gpu-shaders - run: >- - cmake -S client -B client/build/gpu-qualification -G Ninja - -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON - -DATRINIK_GPU_CONFORMANCE_REQUIRED=ON -DPACKAGE_TYPE=none - "-DATRINIK_GPU_SHADER_DIRECTORY=${ATRINIK_QUALIFICATION_SHADER_DIRECTORY}" + run: | + set -euo pipefail + if test "${RUNNER_OS}" = Linux; then + python3 client/tools/dependencies.py sync --root client --refresh + docker pull "${CLASSIC_LINUX_IMAGE}" + docker run --rm --network none --user "$(id -u):$(id -g)" \ + --env HOME=/tmp/classic-home \ + --env ATRINIK_BENCHMARK_REVISION --env ATRINIK_BENCHMARK_DIRTY \ + --volume "${GITHUB_WORKSPACE}:/workspace" --workdir /workspace \ + "${CLASSIC_LINUX_IMAGE}" bash -c ' + set -euo pipefail + cmake -S client -B client/build/gpu-qualification -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=/workspace/cmake/toolchains/llvm.cmake \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON \ + -DATRINIK_GPU_CONFORMANCE_REQUIRED=ON -DPACKAGE_TYPE=none \ + -DATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders + cmake --build client/build/gpu-qualification --config Release \ + --target atrinik client-gpu-renderer-integration-tests --parallel "$(nproc)" + ' + else + cmake -S client -B client/build/gpu-qualification -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="${GITHUB_WORKSPACE}/cmake/toolchains/llvm.cmake" \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON \ + -DATRINIK_GPU_CONFORMANCE_REQUIRED=ON -DPACKAGE_TYPE=none \ + "-DATRINIK_GPU_SHADER_DIRECTORY=${ATRINIK_QUALIFICATION_SHADER_DIRECTORY}" + fi - name: Build production client and GPU harness + if: runner.os == 'macOS' shell: bash run: >- cmake --build client/build/gpu-qualification --config Release diff --git a/.github/workflows/pr-benchmarks.yml b/.github/workflows/pr-benchmarks.yml index bb888016f..d41ff3fdc 100644 --- a/.github/workflows/pr-benchmarks.yml +++ b/.github/workflows/pr-benchmarks.yml @@ -15,8 +15,8 @@ concurrency: env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 - CLASSIC_LINUX_CCACHE_COMPILER: gcc-15.2.0 - CLASSIC_LINUX_CCACHE_EPOCH: "1" + CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt + CLASSIC_LINUX_CCACHE_EPOCH: "2" jobs: changes: @@ -183,6 +183,8 @@ jobs: --epoch "${CLASSIC_LINUX_CCACHE_EPOCH}" \ --material .github/workflows/pr-benchmarks.yml \ --material tools/ci/run_linux_check.sh \ + --material cmake/toolchains/llvm.cmake \ + --material tools/ci/llvm_coverage.py \ --material client/tools/benchmark_lighting_regression.py \ --material client/tools/benchmark_movement_regression.py \ --material client/tools/movement_benchmark_schema.py \ @@ -207,6 +209,7 @@ jobs: docker run --rm \ --user "$(id -u):$(id -g)" \ --network none \ + --env ATRINIK_LLVM_TOOLCHAIN=1 \ --env CCACHE_DIR=/cache/ccache \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ @@ -273,6 +276,8 @@ jobs: --epoch "${CLASSIC_LINUX_CCACHE_EPOCH}" \ --material .github/workflows/pr-benchmarks.yml \ --material tools/ci/run_linux_check.sh \ + --material cmake/toolchains/llvm.cmake \ + --material tools/ci/llvm_coverage.py \ --material server/CMakeLists.txt \ --material server/CMakePresets.json \ --github-output "${GITHUB_OUTPUT}" @@ -292,6 +297,7 @@ jobs: docker run --rm \ --user "$(id -u):$(id -g)" \ --network none \ + --env ATRINIK_LLVM_TOOLCHAIN=1 \ --env CCACHE_DIR=/cache/ccache \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d167a8164..047c4d8e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,8 +118,11 @@ Linux Check compiles protocol/libatrinik, server, and client inside the exact digest-pinned `ghcr.io/atrinik/classic-build` image declared in `.github/workflows/check.yml`. Each job restores only its own ccache directory, uses explicit C and C++ CMake launchers, and normalizes workspace paths through -`CCACHE_BASEDIR`. Cache keys include the runner platform, trust scope, GCC and -image identities, relevant configuration files, and a manual epoch. Pull +`CCACHE_BASEDIR`. Native consumers select the exact LLVM toolchain explicitly; +native coverage uses LLVM profile instrumentation and LCOV reports. Python +coverage remains XML. Cache keys include the runner platform, trust scope, +Clang 23.1.3, LLD, compiler-rt and image identities, relevant configuration +files, and a manual epoch. Pull request and merge-group entries never share a write key with `main`; only the ccache directory is persisted, never a CMake build tree. @@ -128,7 +131,7 @@ candidate. Use that candidate digest only for pre-merge consumer evidence. Once the devcontainer change is released, verify the release image's embedded inventory and source coordinates, then replace both image and digest constants with its versioned digest before making the Classic pull request ready. Update -the GCC identity if needed, and increment `CLASSIC_LINUX_CCACHE_EPOCH` for any +the compiler identity if needed, and increment `CLASSIC_LINUX_CCACHE_EPOCH` for any cache contract change not represented by the hashed configuration inputs. Run the cache-key unit tests, then use cold and repeated Check runs to inspect the raw ccache artifacts and image timing summary before accepting the pin. diff --git a/docs/CI-LINUX-IMAGE.md b/docs/CI-LINUX-IMAGE.md index 06bb3e09d..840fb9625 100644 --- a/docs/CI-LINUX-IMAGE.md +++ b/docs/CI-LINUX-IMAGE.md @@ -204,3 +204,35 @@ DOCKER_CONFIG="$(mktemp -d)" docker buildx imagetools inspect \ Artifact names contain the run attempt. Attempt 3 provides `linux-{core,client,server}-*-3`; attempt 4 provides the corresponding `-4` artifacts. Preserve their complete TSV files when recalculating this ledger. + +## LLVM 23.1.3 consumer migration + +Current native Check and benchmark consumers select Clang 23.1.3, LLD and +compiler-rt explicitly through `ATRINIK_LLVM_TOOLCHAIN=1`. The compiler cache +identity is `clang-23.1.3-lld-compiler-rt`; epoch 2 separates this contract from +the historical GCC caches. Release optimization and sanitizer/test selection +remain unchanged. Native coverage is generated by `llvm-profdata` and +`llvm-cov` as `libatrinik/coverage.lcov`, `server/coverage.lcov` and +`client/coverage.lcov`. The isolated admin fixtures produce +`server/coverage-admin-root.lcov` and `server/coverage-admin-nonroot.lcov`. +Python tool coverage remains `tools/coverage.xml`. Coverage generation fails +if the exact LLVM tools, instrumented objects or profile data are absent; +optional Codecov reporting does not weaken those native checks. + +The existing immutable 1.16.0 provider image pins are retained while the new +LLVM producer is qualified and published. Those historical images do not +satisfy the LLVM consumer contract. Before readiness, replace every Linux, +GPU coverage and Windows provider pin with a verified released digest, record +its inventory and source coordinates, and run the full Check and release +rehearsal. Never infer a release digest from a candidate tag or reuse the +historical GCC timing and cache results as LLVM acceptance evidence. + +Windows GPU qualification builds its Release client and harness on a Linux +host in the pinned LLVM-MinGW producer. The source-qualified bundle includes +the recursive DLL closure, locked sound inputs, source revision, compiler +identity and shader manifest digest. Windows hardware runners verify that +bundle and execute the existing qualification scenarios without rebuilding +against host compiler or SDL libraries. Linux qualification builds in the +pinned Linux producer; macOS uses the explicit exact LLVM toolchain. Actual +hardware qualification remains acceptance evidence separate from hosted +cross-build and smoke tests. diff --git a/docs/DAILY_CLIENT_PERFORMANCE.md b/docs/DAILY_CLIENT_PERFORMANCE.md index be777ed2a..736892ac8 100644 --- a/docs/DAILY_CLIENT_PERFORMANCE.md +++ b/docs/DAILY_CLIENT_PERFORMANCE.md @@ -177,3 +177,8 @@ python3 -m unittest discover -s tools/tests -p 'test_*.py' actionlint .github/workflows/daily-client-performance.yml git diff --check ``` + +Native benchmark builds use the same explicit Clang 23.1.3, LLD and +compiler-rt toolchain as Check and retain their existing Release optimization. +LLVM and historical GCC observations belong to distinct environment cohorts; +compare timings only within the compatible compiler and image cohort. diff --git a/docs/PR_BENCHMARKS.md b/docs/PR_BENCHMARKS.md index 64c284536..08d74aaae 100644 --- a/docs/PR_BENCHMARKS.md +++ b/docs/PR_BENCHMARKS.md @@ -62,3 +62,8 @@ bash -n tools/ci/run_linux_check.sh shellcheck tools/ci/run_linux_check.sh actionlint .github/workflows/check.yml .github/workflows/pr-benchmarks.yml ``` + +Native benchmark builds use the same explicit Clang 23.1.3, LLD and +compiler-rt toolchain as Check and retain their existing Release optimization. +LLVM and historical GCC observations belong to distinct environment cohorts; +compare timings only within the compatible compiler and image cohort. diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index 53f6675e3..617f066de 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -23,6 +23,7 @@ "CMakeLists.txt", "CMakePresets.json", "codecov.yml", + "tools/tests/test_llvm_coverage.py", "LICENSE.md", "docs/history/component-release-map.json", "docs/history/release-tags.json", @@ -41,7 +42,7 @@ "server/", "tools/ci/", ) -WINDOWS_TEST_PATHS = {"CMakeLists.txt"} +WINDOWS_TEST_PATHS = {"CMakeLists.txt", "cmake/InstallWindowsRuntime.cmake"} BENCHMARK_CONTRACT_PATHS = { ".github/workflows/pr-benchmarks.yml", "tools/ci/classify_changes.py", diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index 3e92fe4b2..9ab7122c3 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -99,6 +99,18 @@ def test_shared_native_change_selects_both_components(self) -> None: self.assertFalse(result["codeql_server"]) self.assertTrue(result["codeql_core_cpp"]) + def test_llvm_contract_changes_select_both_native_consumers(self) -> None: + for path in ("cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", + "tools/tests/test_llvm_coverage.py"): + with self.subTest(path=path): + result = classify_changes.classify([path]) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + result = classify_changes.classify(["cmake/InstallWindowsRuntime.cmake"]) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + self.assertTrue(result["windows"]) + def test_codecov_configuration_selects_both_coverage_components(self) -> None: result = classify_changes.classify(["codecov.yml"]) self.assertTrue(result["client"]) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 7499d8e40..342641b06 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -38,6 +38,42 @@ def test_final_published_provider_pins_and_installed_library_lane(self) -> None: self.assertNotIn(forbidden, lane) self.assertNotIn("apt-get", sources) + def test_native_consumers_require_llvm_and_native_lcov(self) -> None: + for name in ("check.yml", "pr-benchmarks.yml", "daily-client-performance.yml"): + workflow = self.text(name) + with self.subTest(workflow=name): + self.assertIn("CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt", workflow) + self.assertIn('CLASSIC_LINUX_CCACHE_EPOCH: "2"', workflow) + # Every cached native workload selects LLVM inside its container. + self.assertEqual(workflow.count("--env ATRINIK_LLVM_TOOLCHAIN=1"), + workflow.count("--env CCACHE_DIR=/cache/ccache") + + (1 if name == "check.yml" else 0)) + self.assertNotIn("gcc-15.2.0", workflow) + if name != "daily-client-performance.yml": + self.assertEqual(workflow.count("--material cmake/toolchains/llvm.cmake"), + workflow.count("--material tools/ci/run_linux_check.sh")) + self.assertEqual(workflow.count("--material tools/ci/llvm_coverage.py"), + workflow.count("--material tools/ci/run_linux_check.sh")) + check = self.text("check.yml") + for report in ("libatrinik/coverage.lcov", "client/coverage.lcov", + "server/coverage.lcov", "server/coverage-admin-root.lcov", + "server/coverage-admin-nonroot.lcov"): + self.assertIn(report, check) + self.assertIn("files: tools/coverage.xml", check) + self.assertEqual(check.count('ATRINIK_LLVM_TOOLCHAIN: "1"'), 2) + qualify = self.text("gpu-qualification.yml") + self.assertIn('-DCMAKE_TOOLCHAIN_FILE=/workspace/cmake/toolchains/llvm.cmake', qualify) + self.assertIn("-DCMAKE_BUILD_TYPE=Release", qualify) + self.assertIn("name: Build matching Windows qualification bundle", qualify) + self.assertIn("needs: [shaders, windows-bundle]", qualify) + self.assertIn("--readobj llvm-readobj", qualify) + self.assertIn("--runtime-dir \"${ATRINIK_WINDOWS_RUNTIME_DIR}\"", qualify) + self.assertIn('test "$(cat client/build/gpu-qualification/source-revision)" = "${GITHUB_SHA}"', qualify) + self.assertIn("sha256sum --check client/build/gpu-qualification/shader-manifest.sha256", qualify) + self.assertIn("windows-gpu-qualification-${{ github.sha }}", qualify) + self.assertIn("if: runner.os == 'macOS'", qualify) + self.assertNotIn("matching native LLVM-MinGW toolchain required", qualify) + def test_windows_publisher_uses_the_staged_ctest_fixture(self) -> None: workflow = self.text("check.yml") jobs = dict( @@ -1137,7 +1173,7 @@ def test_windows_packages_persist_toolchain_bound_compiler_caches(self) -> None: digest = "4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69" image = f"ghcr.io/atrinik/windows-build:1.16.0@sha256:{digest}" self.assertEqual( - candidate.count(f"WINDOWS_BUILD_CACHE_EPOCH: 1.16.0-{digest}"), 1 + candidate.count(f"WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-1.16.0-{digest}"), 1 ) self.assertEqual(candidate.count(image), 4) self.assertNotIn("ghcr.io/atrinik/windows-build:1.0.5", candidate) @@ -1158,12 +1194,12 @@ def test_windows_packages_persist_toolchain_bound_compiler_caches(self) -> None: f"--env CCACHE_TEMPDIR=/tmp/atrinik-{component}-ccache", job ) self.assertIn( - "--env PATH=/opt/mxe/.ccache/bin:/opt/mxe/usr/bin:", job + "--env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:", job ) self.assertEqual(candidate.count("ccache --zero-stats"), 2) self.assertEqual(candidate.count("ccache --show-stats"), 2) self.assertEqual(candidate.count("--env CCACHE_MAXSIZE=500M"), 2) - self.assertEqual(candidate.count("--env PATH=/opt/mxe/.ccache/bin:"), 2) + self.assertEqual(candidate.count("--env PATH=/opt/llvm-mingw/bin:"), 2) def test_discord_application_id_is_release_only_package_data(self) -> None: package = self.text("package-release.yml") @@ -1234,8 +1270,8 @@ def test_core_uploads_exercised_python_release_tool_coverage(self) -> None: libatrinik_upload = core[core.index("- name: Upload libatrinik coverage") :] self.assertIn("files: tools/coverage.xml", tools_upload) self.assertIn("flags: release-tools", tools_upload) - self.assertNotIn("libatrinik/coverage.xml", tools_upload) - self.assertIn("files: libatrinik/coverage.xml", libatrinik_upload) + self.assertNotIn("libatrinik/coverage.lcov", tools_upload) + self.assertIn("files: libatrinik/coverage.lcov", libatrinik_upload) self.assertIn("flags: libatrinik", libatrinik_upload) self.assertNotIn("tools/coverage.xml", libatrinik_upload) @@ -1299,7 +1335,7 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) server_build, ) self.assertLess( - server_build.index("x86_64-w64-mingw32.shared-cmake"), + server_build.index("-DCMAKE_TOOLCHAIN_FILE="), server_build.index("cmake --build server/build/windows-pr-server"), ) self.assertIn( @@ -1307,6 +1343,13 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) ) self.assertIn("--env CCACHE_MAXSIZE=250M", build) self.assertNotIn("--env CCACHE_DIR=/opt/mxe", build) + self.assertNotIn("/opt/mxe", build) + self.assertNotIn("x86_64-w64-mingw32.shared-cmake", build) + self.assertEqual(build.count("--env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32"), 3) + self.assertEqual(build.count('-DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}"'), 4) + self.assertEqual(build.count("-DCMAKE_C_COMPILER_LAUNCHER=ccache"), 4) + self.assertIn("--readobj llvm-readobj", build) + self.assertIn('--runtime-dir "${ATRINIK_WINDOWS_RUNTIME_DIR}"', build) self.assertIn("-DBUILD_TESTING=ON", build) self.assertIn("python3 client/tools/dependencies.py sync --root client", build) self.assertIn("python3 server/tools/dependencies.py sync --root server", build) @@ -2034,7 +2077,7 @@ def test_codecov_reporting_is_informational_without_weakening_native_coverage( workflow.index(" integrated:\n name: Integrated client/server graph") ] self.assertIn("tools/ci/run_gpu_coverage.sh", gpu_coverage) - self.assertIn("files: client/coverage.xml", gpu_coverage) + self.assertIn("files: client/coverage.lcov", gpu_coverage) self.assertIn("flags: client-gpu", gpu_coverage) native_coverage = gpu_coverage.split( " - name: Upload GPU-complete client coverage", 1 From f1f9bdaba488f821cfd5627a8bc331eb17ca8316 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 22:59:34 -0500 Subject: [PATCH 006/148] fix(ci): bind GPU qualification to supported Linux runtime --- .github/workflows/check.yml | 1 + .github/workflows/gpu-qualification.yml | 48 ++++++--- docs/CI-LINUX-IMAGE.md | 18 ++++ tools/ci/classify_changes.py | 1 + tools/ci/run_gpu_qualification.py | 101 ++++++++++++++++++ tools/tests/test_classify_changes.py | 3 +- tools/tests/test_gpu_qualification_runtime.py | 85 +++++++++++++++ tools/tests/test_workflow_contracts.py | 3 + 8 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 tools/ci/run_gpu_qualification.py create mode 100644 tools/tests/test_gpu_qualification_runtime.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ac9a08bc3..d89c64b77 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -855,6 +855,7 @@ jobs: --mount "type=bind,source=${GITHUB_WORKSPACE}/server/src/include/admin_shutdown.h,target=/workspace/server/src/include/admin_shutdown.h,readonly" \ --mount "type=bind,source=${GITHUB_WORKSPACE}/server/src/tests/admin_shutdown_socket.py,target=/workspace/server/src/tests/admin_shutdown_socket.py,readonly" \ --mount "type=bind,source=${GITHUB_WORKSPACE}/server/tools/admin_shutdown_coverage.sh,target=/workspace/server/tools/admin_shutdown_coverage.sh,readonly" \ + --mount "type=bind,source=${GITHUB_WORKSPACE}/tools/ci/llvm_coverage.py,target=/workspace/tools/ci/llvm_coverage.py,readonly" \ "${CLASSIC_LINUX_IMAGE}" \ bash /workspace/server/tools/admin_shutdown_coverage.sh \ > "server/coverage-admin-${fixture_name}.lcov" diff --git a/.github/workflows/gpu-qualification.yml b/.github/workflows/gpu-qualification.yml index 53458f0ce..bbc1757fd 100644 --- a/.github/workflows/gpu-qualification.yml +++ b/.github/workflows/gpu-qualification.yml @@ -13,6 +13,7 @@ concurrency: env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + CLASSIC_LINUX_RUNTIME_IMAGE: ghcr.io/atrinik/linux-build:1.16.0@sha256:89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e CLASSIC_WINDOWS_IMAGE: ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 jobs: @@ -168,8 +169,11 @@ jobs: run: | set -euo pipefail if test "${RUNNER_OS}" = Linux; then + python3 tools/ci/run_gpu_qualification.py \ + --source-root "${GITHUB_WORKSPACE}" --check-runtime python3 client/tools/dependencies.py sync --root client --refresh docker pull "${CLASSIC_LINUX_IMAGE}" + docker pull "${CLASSIC_LINUX_RUNTIME_IMAGE}" docker run --rm --network none --user "$(id -u):$(id -g)" \ --env HOME=/tmp/classic-home \ --env ATRINIK_BENCHMARK_REVISION --env ATRINIK_BENCHMARK_DIRTY \ @@ -207,9 +211,14 @@ jobs: ATRINIK_GPU_CONFORMANCE_QUALIFIED_HARDWARE: "1" working-directory: client run: | - executable=build/gpu-qualification/client-gpu-renderer-integration-tests - if test "${RUNNER_OS}" = Windows; then executable="${executable}.exe"; fi - "${executable}" + executable=(build/gpu-qualification/client-gpu-renderer-integration-tests) + if test "${RUNNER_OS}" = Windows; then + executable=(build/gpu-qualification/client-gpu-renderer-integration-tests.exe) + elif test "${RUNNER_OS}" = Linux; then + executable=(python3 ../tools/ci/run_gpu_qualification.py + --source-root "${GITHUB_WORKSPACE}" -- client-gpu-renderer-integration-tests) + fi + "${executable[@]}" - name: Run three fresh processes for every stress row shell: bash env: @@ -218,8 +227,13 @@ jobs: ATRINIK_GPU_CONFORMANCE_QUALIFIED_HARDWARE: "1" working-directory: client run: | - executable=build/gpu-qualification/atrinik - if test "${RUNNER_OS}" = Windows; then executable="${executable}.exe"; fi + executable=(build/gpu-qualification/atrinik) + if test "${RUNNER_OS}" = Windows; then + executable=(build/gpu-qualification/atrinik.exe) + elif test "${RUNNER_OS}" = Linux; then + executable=(python3 ../tools/ci/run_gpu_qualification.py + --source-root "${GITHUB_WORKSPACE}" -- atrinik) + fi mkdir -p ../build/gpu-qualification for workload in \ dense-17x17-five-depth-1080p \ @@ -242,7 +256,7 @@ jobs: for run in 1 2 3; do output="../build/gpu-qualification/${workload}-${run}.jsonl" ATRINIK_GPU_CONFORMANCE_OUTPUT="${output}" \ - "${executable}" --gpu-player-view-benchmark \ + "${executable[@]}" --gpu-player-view-benchmark \ "src/tests/fixtures/player_view/${fixture}.xml" "${workload}" done done @@ -261,9 +275,14 @@ jobs: ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY: ../build/gpu-qualification/review working-directory: client run: | - executable=build/gpu-qualification/atrinik - if test "${RUNNER_OS}" = Windows; then executable="${executable}.exe"; fi - "${executable}" --gpu-player-view-lifecycle \ + executable=(build/gpu-qualification/atrinik) + if test "${RUNNER_OS}" = Windows; then + executable=(build/gpu-qualification/atrinik.exe) + elif test "${RUNNER_OS}" = Linux; then + executable=(python3 ../tools/ci/run_gpu_qualification.py + --source-root "${GITHUB_WORKSPACE}" -- atrinik) + fi + "${executable[@]}" --gpu-player-view-lifecycle \ src/tests/fixtures/player_view/brynknot-movement.xml - name: Validate bounded recovery artifact collection shell: bash @@ -279,14 +298,19 @@ jobs: ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY: ../build/gpu-qualification/review working-directory: client run: | - executable=build/gpu-qualification/atrinik - if test "${RUNNER_OS}" = Windows; then executable="${executable}.exe"; fi + executable=(build/gpu-qualification/atrinik) + if test "${RUNNER_OS}" = Windows; then + executable=(build/gpu-qualification/atrinik.exe) + elif test "${RUNNER_OS}" = Linux; then + executable=(python3 ../tools/ci/run_gpu_qualification.py + --source-root "${GITHUB_WORKSPACE}" -- atrinik) + fi for fixture in smooth radial-light-smooth exit-cues \ living-outline-translucent living-outline-retained-fow timed-light \ map-overlay-linked-depth map-overlay-widget-state map-overlay-elevated \ visibility-fade-centered brynknot-movement \ gpu-qualification-town-25x25 gpu-ui-closure; do - "${executable}" --gpu-player-view \ + "${executable[@]}" --gpu-player-view \ "src/tests/fixtures/player_view/${fixture}.xml" \ >>"../build/gpu-qualification/production-fixtures.jsonl" done diff --git a/docs/CI-LINUX-IMAGE.md b/docs/CI-LINUX-IMAGE.md index 840fb9625..bc66bc124 100644 --- a/docs/CI-LINUX-IMAGE.md +++ b/docs/CI-LINUX-IMAGE.md @@ -236,3 +236,21 @@ against host compiler or SDL libraries. Linux qualification builds in the pinned Linux producer; macOS uses the explicit exact LLVM toolchain. Actual hardware qualification remains acceptance evidence separate from hosted cross-build and smoke tests. + +Linux hardware qualification runs each fresh client/harness process in the +qualified immutable `linux-build` runtime from the same LLVM release cohort +as the `classic-build` producer. The broad runtime includes the Vulkan loader +and Mesa drivers omitted by the slim producer. `tools/ci/run_gpu_qualification.py` +obtains the active X11 and audio session plus assigned GPU additions from +`atrinik_workspace.linux_platform` and passes its successful JSON argument +array directly to Docker. The runner supplies an assigned, verified wrapper +checkout as `ATRINIK_QUALIFICATION_WORKSPACE_ROOT`, +`ATRINIK_QUALIFICATION_GPU_KIND` (`mesa` or `nvidia`), and, for Mesa, its actual +`ATRINIK_QUALIFICATION_RENDER_DEVICE`. `DISPLAY`, private `XAUTHORITY` and +`XDG_RUNTIME_DIR` identify the assigned live desktop/audio session. The helper +requires those inputs and checks them again for every process; it never selects +a default render node or changes X-server access. Source fixtures and artifacts +retain their `/workspace` build paths, while each container gets temporary +private client configuration. Runner provisioning, current resource admission +and the bounded hardware reservation remain coordinator prerequisites. A +capability report supplies no authority and no passing hardware evidence. diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index 617f066de..f46c57cd3 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -24,6 +24,7 @@ "CMakePresets.json", "codecov.yml", "tools/tests/test_llvm_coverage.py", + "tools/tests/test_gpu_qualification_runtime.py", "LICENSE.md", "docs/history/component-release-map.json", "docs/history/release-tags.json", diff --git a/tools/ci/run_gpu_qualification.py b/tools/ci/run_gpu_qualification.py new file mode 100644 index 000000000..aff8aa7b9 --- /dev/null +++ b/tools/ci/run_gpu_qualification.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Run one Linux GPU qualification process in its pinned compatible runtime. + +The assigned runner supplies the workspace module and actual desktop/GPU +coordinates. Its capability report grants neither resource authority nor +hardware qualification; admission remains with the coordinator. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys + + +FORWARDED_ENVIRONMENT = ( + "ATRINIK_BENCHMARK_REVISION", "ATRINIK_BENCHMARK_DIRTY", + "ATRINIK_GPU_CONFORMANCE_DRIVER", "ATRINIK_GPU_CONFORMANCE_HARDWARE_TIER", + "ATRINIK_GPU_CONFORMANCE_QUALIFIED_HARDWARE", "ATRINIK_GPU_CONFORMANCE_OUTPUT", + "ATRINIK_GPU_CONFORMANCE_LIFECYCLE_OUTPUT", + "ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY", +) + + +def desktop_arguments(environment: dict[str, str]) -> list[str]: + workspace = Path(environment["ATRINIK_QUALIFICATION_WORKSPACE_ROOT"]) + if not workspace.is_absolute() or not (workspace / "atrinik_workspace/linux_platform.py").is_file(): + raise ValueError("qualified runner must supply its verified workspace module") + gpu = environment["ATRINIK_QUALIFICATION_GPU_KIND"] + if gpu not in ("mesa", "nvidia"): + raise ValueError("assigned GPU kind must be mesa or nvidia") + command = [sys.executable, "-m", "atrinik_workspace.linux_platform", + "--desktop", "x11", "--gpu", gpu, "--audio"] + if gpu == "mesa": + command += ["--render-device", environment["ATRINIK_QUALIFICATION_RENDER_DEVICE"]] + # Check completion before parsing: failed session probes produce no report. + result = subprocess.run(command, cwd=workspace, env=environment, + check=True, capture_output=True, text=True, timeout=30) + report = json.loads(result.stdout) + arguments = report.get("docker_arguments") + if (report.get("schema_version") != 1 or + report.get("delivery_authority") is not False or + report.get("hardware_qualified") is not False or + not isinstance(arguments, list) or not arguments or + any(not isinstance(value, str) or "\x00" in value for value in arguments)): + raise ValueError("invalid desktop capability report") + return arguments + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--check-runtime", action="store_true") + parser.add_argument("command", nargs=argparse.REMAINDER) + options = parser.parse_args(argv) + try: + environment = dict(os.environ) + root = options.source_root.resolve(strict=True) + if any(character in str(root) for character in ",\n\r"): + raise ValueError("ambiguous source mount") + image = environment["CLASSIC_LINUX_RUNTIME_IMAGE"] + if not re.fullmatch(r"ghcr\.io/atrinik/linux-build:[^\s@]+@sha256:[0-9a-f]{64}", image): + raise ValueError("qualification requires the immutable qualified runtime image") + capabilities = desktop_arguments(environment) + if options.check_runtime: + if options.command: + raise ValueError("runtime check takes no executable") + subprocess.run(["docker", "info", "--format", "{{.ServerVersion}}"], + check=True, capture_output=True, text=True, timeout=30) + return 0 + command = options.command + if command and command[0] == "--": + command = command[1:] + if not command or command[0] not in ("atrinik", "client-gpu-renderer-integration-tests"): + raise ValueError("unsupported qualification executable") + executable = root / "client/build/gpu-qualification" / command[0] + if not executable.is_file() or not os.access(executable, os.X_OK): + raise ValueError("missing qualification executable") + arguments = ["docker", "run", "--rm", "--network", "none", + "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--tmpfs", "/tmp:rw,nosuid,nodev,mode=1777", *capabilities, + "--mount", f"type=bind,source={root},target=/workspace", + "--workdir", "/workspace/client", "--env", "HOME=/tmp/classic-home", + "--env", "ATRINIK_CONFIG_DIR=/tmp/classic-home/config"] + for name in FORWARDED_ENVIRONMENT: + if name in environment: + arguments += ["--env", f"{name}={environment[name]}"] + arguments += [image, "bash", "-c", 'mkdir -p "$HOME"; exec "$@"', "--", + "/workspace/client/build/gpu-qualification/" + command[0], *command[1:]] + return subprocess.run(arguments, check=False).returncode + except (KeyError, ValueError, OSError, subprocess.SubprocessError) as error: + print(f"GPU qualification runtime: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index 9ab7122c3..e3868dd37 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -101,7 +101,8 @@ def test_shared_native_change_selects_both_components(self) -> None: def test_llvm_contract_changes_select_both_native_consumers(self) -> None: for path in ("cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", - "tools/tests/test_llvm_coverage.py"): + "tools/tests/test_llvm_coverage.py", + "tools/tests/test_gpu_qualification_runtime.py"): with self.subTest(path=path): result = classify_changes.classify([path]) self.assertTrue(result["client"]) diff --git a/tools/tests/test_gpu_qualification_runtime.py b/tools/tests/test_gpu_qualification_runtime.py new file mode 100644 index 000000000..5f482326d --- /dev/null +++ b/tools/tests/test_gpu_qualification_runtime.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from tools.ci import run_gpu_qualification as runtime + + +class QualificationRuntimeTests(unittest.TestCase): + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + (self.root / "atrinik_workspace").mkdir() + (self.root / "atrinik_workspace/linux_platform.py").touch() + executable = self.root / "client/build/gpu-qualification/atrinik" + executable.parent.mkdir(parents=True) + executable.touch(mode=0o700) + self.environment = { + "ATRINIK_QUALIFICATION_WORKSPACE_ROOT": str(self.root), + "ATRINIK_QUALIFICATION_GPU_KIND": "mesa", + "ATRINIK_QUALIFICATION_RENDER_DEVICE": "/dev/dri/renderD140", + "CLASSIC_LINUX_RUNTIME_IMAGE": "ghcr.io/atrinik/linux-build:qualified@sha256:" + "a" * 64, + "ATRINIK_GPU_CONFORMANCE_OUTPUT": "../build/gpu-qualification/row.jsonl", + "GITHUB_TOKEN": "must-not-enter-container", + } + self.report = { + "schema_version": 1, "delivery_authority": False, "hardware_qualified": False, + "docker_arguments": ["--user", "123:456", "--device", "/dev/dri/renderD140"], + } + + def test_actual_session_arguments_and_only_explicit_environment_are_forwarded(self) -> None: + with patch.dict(os.environ, self.environment, clear=True), patch.object( + runtime.subprocess, "run", + side_effect=[SimpleNamespace(stdout=json.dumps(self.report)), SimpleNamespace(returncode=7)], + ) as run: + result = runtime.main(["--source-root", str(self.root), "--", "atrinik", "--gpu-player-view"]) + self.assertEqual(result, 7) + probe = run.call_args_list[0].args[0] + self.assertIn("atrinik_workspace.linux_platform", probe) + self.assertEqual(probe[-2:], ["--render-device", "/dev/dri/renderD140"]) + arguments = run.call_args_list[1].args[0] + self.assertIn("/dev/dri/renderD140", arguments) + self.assertIn("ATRINIK_GPU_CONFORMANCE_OUTPUT=../build/gpu-qualification/row.jsonl", arguments) + self.assertIn("ATRINIK_CONFIG_DIR=/tmp/classic-home/config", arguments) + self.assertNotIn("GITHUB_TOKEN=must-not-enter-container", arguments) + self.assertFalse(run.call_args_list[1].kwargs.get("shell", False)) + + def test_failed_session_probe_never_launches_docker(self) -> None: + with patch.dict(os.environ, self.environment, clear=True), patch.object( + runtime.subprocess, "run", side_effect=subprocess.CalledProcessError(2, "probe"), + ) as run: + self.assertEqual(runtime.main(["--source-root", str(self.root), "--", "atrinik"]), 2) + self.assertEqual(run.call_count, 1) + + def test_report_cannot_claim_authority_or_hardware_acceptance(self) -> None: + for field in ("delivery_authority", "hardware_qualified"): + report = {**self.report, field: True} + with self.subTest(field=field), patch.dict(os.environ, self.environment, clear=True), patch.object( + runtime.subprocess, "run", return_value=SimpleNamespace(stdout=json.dumps(report)), + ) as run: + self.assertEqual(runtime.main(["--source-root", str(self.root), "--", "atrinik"]), 2) + self.assertEqual(run.call_count, 1) + + def test_missing_assigned_render_device_has_no_default(self) -> None: + del self.environment["ATRINIK_QUALIFICATION_RENDER_DEVICE"] + with patch.dict(os.environ, self.environment, clear=True), patch.object(runtime.subprocess, "run") as run: + self.assertEqual(runtime.main(["--source-root", str(self.root), "--", "atrinik"]), 2) + run.assert_not_called() + + def test_floating_image_is_rejected_before_any_probe(self) -> None: + self.environment["CLASSIC_LINUX_RUNTIME_IMAGE"] = "ghcr.io/atrinik/linux-build:latest" + with patch.dict(os.environ, self.environment, clear=True), patch.object(runtime.subprocess, "run") as run: + self.assertEqual(runtime.main(["--source-root", str(self.root), "--", "atrinik"]), 2) + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 342641b06..0ee1d6088 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -73,6 +73,9 @@ def test_native_consumers_require_llvm_and_native_lcov(self) -> None: self.assertIn("windows-gpu-qualification-${{ github.sha }}", qualify) self.assertIn("if: runner.os == 'macOS'", qualify) self.assertNotIn("matching native LLVM-MinGW toolchain required", qualify) + self.assertIn("--source-root \"${GITHUB_WORKSPACE}\" --check-runtime", qualify) + self.assertEqual(qualify.count("python3 ../tools/ci/run_gpu_qualification.py"), 4) + self.assertIn("tools/ci/llvm_coverage.py,target=/workspace/tools/ci/llvm_coverage.py,readonly", check) def test_windows_publisher_uses_the_staged_ctest_fixture(self) -> None: workflow = self.text("check.yml") From 185fbbc589bafccdb7e9f4a74834f5dfe87c5b87 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 22:59:56 -0500 Subject: [PATCH 007/148] build(classic): require LLVM tools and native coverage by default --- AGENTS.md | 6 ++ CMakeLists.txt | 20 ++-- CONTRIBUTING.md | 10 +- client/CMakeLists.txt | 46 ++++----- cmake/AtrinikLLVM.cmake | 43 ++++++++ libatrinik/CMakeLists.txt | 32 +++--- libatrinik/pathfinding/CMakeLists.txt | 15 +-- libatrinik/pathfinding/tests/CMakeLists.txt | 2 +- server/CMakeLists.txt | 22 ++-- server/doc/TESTING.md | 4 - server/tools/admin_shutdown_coverage.sh | 37 +++---- tools/ci/llvm_coverage.py | 14 ++- tools/ci/run_gpu_coverage.sh | 26 +---- tools/ci/run_linux_check.sh | 38 ++----- tools/tests/test_llvm_coverage.py | 7 ++ tools/tests/test_llvm_toolchain.py | 109 ++++++++++++++++++++ 16 files changed, 274 insertions(+), 157 deletions(-) create mode 100644 cmake/AtrinikLLVM.cmake create mode 100644 tools/tests/test_llvm_toolchain.py diff --git a/AGENTS.md b/AGENTS.md index 15c49b2a1..b64babd27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,12 @@ - The root CMake project is the integrated client/server graph and must add protocol and libatrinik exactly once. Keep component CMake entry points, presets, source packaging, and installed consumers independently functional. +- Native C17 builds require Clang 23.1.3, LLD and compiler-rt, with matching + libunwind on Linux or the LLVM-MinGW UCRT toolchain on Windows. Integrated + and standalone CMake entry points validate that cohort. Preserve build-type + optimization and explicit portable CPU flags; do not enable LTO or PGO. + Native coverage uses atomic LLVM counters and binary/process-specific raw + profiles, exported to LCOV with matching llvm-profdata and llvm-cov. - All Atrinik-authored source in this monorepo, including `protocol/`, is GPL-2.0-or-later under the single root `LICENSE.md`. Preserve every compatible third-party asset/code notice, attribution, imported commit map, retired-ref diff --git a/CMakeLists.txt b/CMakeLists.txt index ff743dcce..cbd7a3671 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,9 +1,12 @@ cmake_minimum_required(VERSION 3.21) include(cmake/AtrinikVersion.cmake) +include(cmake/AtrinikLLVM.cmake) atrinik_resolve_version(ATRINIK_SOURCE_VERSION) +atrinik_initialize_llvm_toolchain() project(atrinik-classic VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) +atrinik_require_llvm() atrinik_initialize_version_metadata() include(CTest) @@ -12,7 +15,7 @@ option(ATRINIK_BUILD_CLIENT "Build the classic client" ON) option(ATRINIK_BUILD_SERVER "Build the classic server" ON) option(ENABLE_WARNING_ERRORS "Treat compiler warnings as errors" ON) option(ENABLE_SANITIZERS "Enable address and undefined-behavior sanitizers" OFF) -option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) +option(ENABLE_COVERAGE "Enable native LLVM coverage instrumentation" OFF) option(ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS "Precompile the server global header" ON) option(ENABLE_PYTHON_PLUGIN "Build the server Python plugin" ON) @@ -33,7 +36,7 @@ endif () function(atrinik_configure_target target) target_compile_features(${target} PRIVATE c_std_17) target_compile_definitions(${target} PRIVATE _GNU_SOURCE) - if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + if (CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_options(${target} PRIVATE -Wall -Wextra -Wno-unused-parameter -fno-common) if (ENABLE_WARNING_ERRORS) @@ -41,8 +44,8 @@ function(atrinik_configure_target target) endif () endif () if (ENABLE_SANITIZERS) - if (MINGW OR NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") - message(FATAL_ERROR "ENABLE_SANITIZERS requires native GCC or Clang") + if (MINGW OR NOT CMAKE_C_COMPILER_ID STREQUAL "Clang") + message(FATAL_ERROR "ENABLE_SANITIZERS requires native Clang") endif () target_compile_options(${target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) @@ -57,15 +60,8 @@ function(atrinik_configure_target target) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () - elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") - # Transitional support for the previous immutable producer image. - target_compile_options(${target} PRIVATE - --coverage -fprofile-abs-path -O0 -g) - if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") - target_link_options(${target} PRIVATE --coverage) - endif () else () - message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang") endif () endif () endfunction() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 047c4d8e8..0a680e1eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,6 +114,14 @@ packages. Producers verify their checkout and name uploaded artifacts with that revision; consumers select the matching revision before verifying locked inputs. Push, merge-group, and manual runs build one cohort for the event revision. +Native Classic builds require Clang 23.1.3, LLD, compiler-rt and LLVM archive +tools. Linux links also select matching libunwind; Windows uses the pinned +LLVM-MinGW UCRT toolchain. The integrated and standalone CMake entry points +select the native Linux toolchain by default and validate explicit toolchains +after compiler detection. Keep existing debug/release optimization and portable +CPU flags; do not enable LTO or PGO. Native test coverage uses LLVM source +profiles and LCOV, with atomic counters and isolated binary/process filenames. + Linux Check compiles protocol/libatrinik, server, and client inside the exact digest-pinned `ghcr.io/atrinik/classic-build` image declared in `.github/workflows/check.yml`. Each job restores only its own ccache directory, @@ -131,7 +139,7 @@ candidate. Use that candidate digest only for pre-merge consumer evidence. Once the devcontainer change is released, verify the release image's embedded inventory and source coordinates, then replace both image and digest constants with its versioned digest before making the Classic pull request ready. Update -the compiler identity if needed, and increment `CLASSIC_LINUX_CCACHE_EPOCH` for any +the LLVM identity if needed, and increment `CLASSIC_LINUX_CCACHE_EPOCH` for any cache contract change not represented by the hashed configuration inputs. Run the cache-key unit tests, then use cold and repeated Check runs to inspect the raw ccache artifacts and image timing summary before accepting the pin. diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index f25e4802b..e06f90546 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -12,11 +12,16 @@ if (NOT ATRINIK_INTEGRATED_BUILD) "${ATRINIK_COMPONENT_SOURCE_DIR}/../cmake/AtrinikVersion.cmake") endif () include("${ATRINIK_VERSION_MODULE}") + get_filename_component(ATRINIK_CMAKE_MODULE_DIR "${ATRINIK_VERSION_MODULE}" DIRECTORY) + include("${ATRINIK_CMAKE_MODULE_DIR}/AtrinikLLVM.cmake") + unset(ATRINIK_CMAKE_MODULE_DIR) unset(ATRINIK_VERSION_MODULE) unset(ATRINIK_COMPONENT_CMAKE_FILE) unset(ATRINIK_COMPONENT_SOURCE_DIR) atrinik_resolve_version(ATRINIK_SOURCE_VERSION) + atrinik_initialize_llvm_toolchain() project(atrinik-client VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) + atrinik_require_llvm() atrinik_initialize_version_metadata() endif () @@ -168,7 +173,7 @@ atrinik_find_root_document(ATRINIK_ATTRIBUTIONS_FILE ATTRIBUTIONS.md) option(ENABLE_WARNING_ERRORS "Treat compiler warnings as errors" ON) option(ENABLE_SANITIZERS "Enable address and undefined-behavior sanitizers" OFF) -option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) +option(ENABLE_COVERAGE "Enable native LLVM coverage instrumentation" OFF) set(PACKAGE_TYPE "none" CACHE STRING "Package type (none, appimage, deb, rpm, nsis, or zip)") set(ATRINIK_DISCORD_APPLICATION_ID_FILE "" CACHE FILEPATH "Optional file containing the public Discord Application ID") @@ -216,7 +221,7 @@ function(atrinik_configure_target target) target_compile_features(${target} PRIVATE c_std_17) target_compile_definitions(${target} PRIVATE _GNU_SOURCE) atrinik_apply_version_metadata(${target}) - if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + if (CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_options(${target} PRIVATE -Wall -Wextra -Wno-unused-parameter -fno-common) if (ENABLE_WARNING_ERRORS) @@ -224,8 +229,8 @@ function(atrinik_configure_target target) endif () endif () if (ENABLE_SANITIZERS) - if (MINGW OR NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") - message(FATAL_ERROR "ENABLE_SANITIZERS requires native GCC or Clang") + if (MINGW OR NOT CMAKE_C_COMPILER_ID STREQUAL "Clang") + message(FATAL_ERROR "ENABLE_SANITIZERS requires native Clang") endif () target_compile_options(${target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) @@ -240,15 +245,8 @@ function(atrinik_configure_target target) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () - elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") - # Transitional support for the previous immutable producer image. - target_compile_options(${target} PRIVATE - --coverage -fprofile-abs-path -O0 -g) - if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") - target_link_options(${target} PRIVATE --coverage) - endif () else () - message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang") endif () endif () endfunction() @@ -974,10 +972,10 @@ if (BUILD_TESTING) atrinik_configure_target(client-compound-packet-tests) # Keep optimized switch tables separate when discarding unrelated handlers. target_compile_options(client-compound-packet-tests PRIVATE - $<$:-ffunction-sections> - $<$:-fdata-sections>) + $<$:-ffunction-sections> + $<$:-fdata-sections>) target_link_options(client-compound-packet-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:-Wl,--gc-sections>) target_compile_definitions(client-compound-packet-tests PRIVATE SDL_DISABLE_OLD_NAMES) target_include_directories(client-compound-packet-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include @@ -995,10 +993,10 @@ if (BUILD_TESTING) src/tests/book_edit.c src/client/book_edit_packet.c src/client/book_edit_model.c src/gui/toolkit/text_input.c) atrinik_configure_target(client-book-edit-tests) target_compile_options(client-book-edit-tests PRIVATE - $<$:-ffunction-sections> - $<$:-fdata-sections>) + $<$:-ffunction-sections> + $<$:-fdata-sections>) target_link_options(client-book-edit-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:-Wl,--gc-sections>) target_compile_definitions(client-book-edit-tests PRIVATE SDL_DISABLE_OLD_NAMES) target_include_directories(client-book-edit-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include @@ -1176,9 +1174,9 @@ if (BUILD_TESTING) atrinik_configure_target(client-item-drag-tests) target_compile_definitions(client-item-drag-tests PRIVATE SDL_DISABLE_OLD_NAMES) target_compile_options(client-item-drag-tests PRIVATE - $<$:-ffunction-sections>) + $<$:-ffunction-sections>) target_link_options(client-item-drag-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:-Wl,--gc-sections>) target_include_directories(client-item-drag-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include ${CMAKE_CURRENT_SOURCE_DIR}/src/include @@ -1192,9 +1190,9 @@ if (BUILD_TESTING) atrinik_configure_target(client-notification-tests) target_compile_definitions(client-notification-tests PRIVATE SDL_DISABLE_OLD_NAMES) target_compile_options(client-notification-tests PRIVATE - $<$:-ffunction-sections>) + $<$:-ffunction-sections>) target_link_options(client-notification-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:-Wl,--gc-sections>) target_include_directories(client-notification-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include ${CMAKE_CURRENT_SOURCE_DIR}/src/include @@ -1225,9 +1223,9 @@ if (BUILD_TESTING) ATRINIK_RENDER_PROFILER_TESTING SDL_DISABLE_OLD_NAMES) target_compile_options(client-render-profiler-tests PRIVATE - $<$:-ffunction-sections>) + $<$:-ffunction-sections>) target_link_options(client-render-profiler-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:-Wl,--gc-sections>) target_include_directories(client-render-profiler-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include ${CMAKE_CURRENT_SOURCE_DIR}/src/include diff --git a/cmake/AtrinikLLVM.cmake b/cmake/AtrinikLLVM.cmake new file mode 100644 index 000000000..0e6249638 --- /dev/null +++ b/cmake/AtrinikLLVM.cmake @@ -0,0 +1,43 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +include_guard(GLOBAL) + +# Call before project() so standalone and integrated graphs choose the same +# compiler. Explicit portable and LLVM-MinGW toolchains retain their target +# platform, sysroot and architecture settings. +function(atrinik_initialize_llvm_toolchain) + if (NOT CMAKE_TOOLCHAIN_FILE AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux" AND + NOT CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(CMAKE_TOOLCHAIN_FILE + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/toolchains/llvm.cmake" CACHE FILEPATH + "Pinned native LLVM toolchain") + endif () +endfunction() + +# Call after project(). Compile/link flags below preserve the selected build +# type's optimization and explicit portable CPU baseline; no LTO or PGO. +function(atrinik_require_llvm) + if (NOT CMAKE_C_COMPILER_ID STREQUAL "Clang" OR + NOT CMAKE_C_COMPILER_VERSION VERSION_EQUAL "23.1.3") + message(FATAL_ERROR "Classic native builds require Clang 23.1.3") + endif () + if (CMAKE_CXX_COMPILER_LOADED AND + (NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR + NOT CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL "23.1.3")) + message(FATAL_ERROR "Classic C++ build dependencies require Clang 23.1.3") + endif () + foreach (tool CMAKE_AR CMAKE_RANLIB CMAKE_LINKER) + execute_process(COMMAND "${${tool}}" --version + OUTPUT_VARIABLE tool_version ERROR_VARIABLE tool_error + RESULT_VARIABLE tool_result) + if (NOT tool_result EQUAL 0 OR + NOT tool_version MATCHES "LLVM version 23\\.1\\.3([^0-9.]|$)" AND + NOT tool_version MATCHES "LLD 23\\.1\\.3([^0-9.]|$)") + message(FATAL_ERROR "${tool} must be LLVM 23.1.3: ${tool_version}${tool_error}") + endif () + endforeach () + if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_link_options(--rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld) + endif () +endfunction() diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index 6898b2959..914fa45e3 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -9,20 +9,25 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) get_filename_component(component_dir "${component_file}" DIRECTORY) if (EXISTS "${component_dir}/cmake/AtrinikVersion.cmake") include("${component_dir}/cmake/AtrinikVersion.cmake") + include("${component_dir}/cmake/AtrinikLLVM.cmake") elseif (EXISTS "${component_dir}/../cmake/AtrinikVersion.cmake") include("${component_dir}/../cmake/AtrinikVersion.cmake") + include("${component_dir}/../cmake/AtrinikLLVM.cmake") else () include("${component_dir}/../../cmake/AtrinikVersion.cmake") + include("${component_dir}/../../cmake/AtrinikLLVM.cmake") endif () atrinik_resolve_version(ATRINIK_SOURCE_VERSION) + atrinik_initialize_llvm_toolchain() project(libatrinik VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) + atrinik_require_llvm() include(CTest) include(CMakePackageConfigHelpers) option(ENABLE_WARNING_ERRORS "Treat compiler warnings as errors" ON) - option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) + option(ENABLE_COVERAGE "Enable native LLVM coverage instrumentation" OFF) option(ENABLE_SANITIZERS "Enable address and undefined-behavior sanitizers" OFF) option(BUILD_FUZZING "Build bounded libFuzzer protocol targets" OFF) @@ -33,7 +38,7 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) function(atrinik_configure_target target) target_compile_features(${target} PRIVATE c_std_17) target_compile_definitions(${target} PRIVATE _GNU_SOURCE) - if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + if (CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_options(${target} PRIVATE -Wall -Wextra @@ -51,20 +56,13 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () - elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") - # Transitional support for the previous immutable producer image. - target_compile_options(${target} PRIVATE - --coverage -fprofile-abs-path -O0 -g) - if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") - target_link_options(${target} PRIVATE --coverage) - endif () else () - message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang") endif () endif () if (ENABLE_SANITIZERS) - if (MINGW OR NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") - message(FATAL_ERROR "ENABLE_SANITIZERS requires native GCC or Clang") + if (MINGW OR NOT CMAKE_C_COMPILER_ID STREQUAL "Clang") + message(FATAL_ERROR "ENABLE_SANITIZERS requires native Clang") endif () target_compile_options(${target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) @@ -526,7 +524,7 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR add_executable(libatrinik-access-code tests/access_code.c) atrinik_configure_target(libatrinik-access-code) target_link_libraries(libatrinik-access-code PRIVATE Atrinik::Core) - if (UNIX AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + if (UNIX AND CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_definitions(libatrinik-access-code PRIVATE ACCESS_CODE_TEST_ENTROPY) target_link_options(libatrinik-access-code PRIVATE "-Wl,--wrap=RAND_priv_bytes") endif () @@ -540,7 +538,7 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR add_executable(libatrinik-sensitive-packet tests/sensitive_packet.c) atrinik_configure_target(libatrinik-sensitive-packet) target_link_libraries(libatrinik-sensitive-packet PRIVATE Atrinik::Core) - if (UNIX AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + if (UNIX AND CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_definitions(libatrinik-sensitive-packet PRIVATE PACKET_TEST_FREE) target_link_options(libatrinik-sensitive-packet PRIVATE "-Wl,--wrap=free") endif () @@ -585,7 +583,7 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR target_link_libraries(libatrinik-path PRIVATE Atrinik::Core) if (MINGW) target_link_options(libatrinik-path PRIVATE "-Wl,--wrap=path_windows_token_user") - elseif (UNIX AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + elseif (UNIX AND CMAKE_C_COMPILER_ID STREQUAL "Clang") target_link_options(libatrinik-path PRIVATE "-Wl,--wrap=fsync" "-Wl,--wrap=close") endif () add_test(NAME libatrinik-path COMMAND libatrinik-path) @@ -613,7 +611,7 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR target_link_libraries(libatrinik-socket-asset PRIVATE Atrinik::Core) add_test(NAME libatrinik-socket-asset COMMAND libatrinik-socket-asset) - if (UNIX AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + if (UNIX AND CMAKE_C_COMPILER_ID STREQUAL "Clang") add_executable(libatrinik-socket-websocket tests/socket_websocket.c) atrinik_configure_target(libatrinik-socket-websocket) target_link_options(libatrinik-socket-websocket PRIVATE @@ -641,7 +639,7 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR add_executable(libatrinik-curl tests/curl.c) atrinik_configure_target(libatrinik-curl) target_link_libraries(libatrinik-curl PRIVATE Atrinik::Core Threads::Threads) - if (CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + if (CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_definitions(libatrinik-curl PRIVATE ATRINIK_TEST_CURL_CLEANUP) target_link_options(libatrinik-curl PRIVATE "-Wl,--wrap=curl_easy_cleanup") endif () diff --git a/libatrinik/pathfinding/CMakeLists.txt b/libatrinik/pathfinding/CMakeLists.txt index cb76a00f1..4cb8e5f45 100644 --- a/libatrinik/pathfinding/CMakeLists.txt +++ b/libatrinik/pathfinding/CMakeLists.txt @@ -6,17 +6,22 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) get_filename_component(component_dir "${component_file}" DIRECTORY) if (EXISTS "${component_dir}/../cmake/AtrinikVersion.cmake") include("${component_dir}/../cmake/AtrinikVersion.cmake") + include("${component_dir}/../cmake/AtrinikLLVM.cmake") elseif (EXISTS "${component_dir}/../../cmake/AtrinikVersion.cmake") include("${component_dir}/../../cmake/AtrinikVersion.cmake") + include("${component_dir}/../../cmake/AtrinikLLVM.cmake") else () include("${component_dir}/../../../cmake/AtrinikVersion.cmake") + include("${component_dir}/../../../cmake/AtrinikLLVM.cmake") endif () atrinik_resolve_version(ATRINIK_SOURCE_VERSION) + atrinik_initialize_llvm_toolchain() project(atrinik-pathfinding VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) + atrinik_require_llvm() include(CTest) include(CMakePackageConfigHelpers) - option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) + option(ENABLE_COVERAGE "Enable native LLVM coverage instrumentation" OFF) set(ATRINIK_PATHFINDING_STANDALONE true) endif () @@ -24,12 +29,8 @@ if (ENABLE_COVERAGE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) add_compile_options(-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) add_link_options(-fprofile-instr-generate) - elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") - # Transitional support for the previous immutable producer image. - add_compile_options(--coverage -fprofile-abs-path -O0 -g) - add_link_options(--coverage) else () - message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang") endif () endif () @@ -45,7 +46,7 @@ target_include_directories(atrinik-pathfinding $ $) -if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") +if (CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_options(atrinik-pathfinding PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wshadow -fno-common) if (NOT DEFINED ENABLE_WARNING_ERRORS OR ENABLE_WARNING_ERRORS) diff --git a/libatrinik/pathfinding/tests/CMakeLists.txt b/libatrinik/pathfinding/tests/CMakeLists.txt index 3f498bf45..13252fc42 100644 --- a/libatrinik/pathfinding/tests/CMakeLists.txt +++ b/libatrinik/pathfinding/tests/CMakeLists.txt @@ -1,6 +1,6 @@ add_executable(atrinik-pathfinding-tests test_pathfinding.c) target_link_libraries(atrinik-pathfinding-tests PRIVATE Atrinik::Pathfinding) -if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") +if (CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_options(atrinik-pathfinding-tests PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wshadow) if (NOT DEFINED ENABLE_WARNING_ERRORS OR ENABLE_WARNING_ERRORS) diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 90c3a67b8..7f7d2e9a4 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -12,11 +12,16 @@ if (NOT ATRINIK_INTEGRATED_BUILD) "${ATRINIK_COMPONENT_SOURCE_DIR}/../cmake/AtrinikVersion.cmake") endif () include("${ATRINIK_VERSION_MODULE}") + get_filename_component(ATRINIK_CMAKE_MODULE_DIR "${ATRINIK_VERSION_MODULE}" DIRECTORY) + include("${ATRINIK_CMAKE_MODULE_DIR}/AtrinikLLVM.cmake") + unset(ATRINIK_CMAKE_MODULE_DIR) unset(ATRINIK_VERSION_MODULE) unset(ATRINIK_COMPONENT_CMAKE_FILE) unset(ATRINIK_COMPONENT_SOURCE_DIR) atrinik_resolve_version(ATRINIK_SOURCE_VERSION) + atrinik_initialize_llvm_toolchain() project(atrinik-server VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) + atrinik_require_llvm() atrinik_initialize_version_metadata() endif () @@ -46,7 +51,7 @@ atrinik_find_root_document(ATRINIK_ATTRIBUTIONS_FILE ATTRIBUTIONS.md) option(ENABLE_WARNING_ERRORS "Treat compiler warnings as errors" ON) option(ENABLE_SANITIZERS "Enable address and undefined-behavior sanitizers" OFF) -option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) +option(ENABLE_COVERAGE "Enable native LLVM coverage instrumentation" OFF) if (ATRINIK_INTEGRATED_BUILD) option(ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS "Precompile the server global header for the core object library" ON) @@ -71,7 +76,7 @@ function(atrinik_configure_target target) target_compile_features(${target} PRIVATE c_std_17) target_compile_definitions(${target} PRIVATE _GNU_SOURCE) atrinik_apply_version_metadata(${target}) - if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + if (CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_options(${target} PRIVATE -Wall -Wextra -Wno-unused-parameter -fno-common) if (ENABLE_WARNING_ERRORS) @@ -79,8 +84,8 @@ function(atrinik_configure_target target) endif () endif () if (ENABLE_SANITIZERS) - if (MINGW OR NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") - message(FATAL_ERROR "ENABLE_SANITIZERS requires native GCC or Clang") + if (MINGW OR NOT CMAKE_C_COMPILER_ID STREQUAL "Clang") + message(FATAL_ERROR "ENABLE_SANITIZERS requires native Clang") endif () target_compile_options(${target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) @@ -95,15 +100,8 @@ function(atrinik_configure_target target) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () - elseif (CMAKE_C_COMPILER_ID STREQUAL "GNU") - # Transitional support for the previous immutable producer image. - target_compile_options(${target} PRIVATE - --coverage -fprofile-abs-path -fprofile-update=atomic -O0 -g) - if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") - target_link_options(${target} PRIVATE --coverage) - endif () else () - message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang or GCC") + message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang") endif () endif () endfunction() diff --git a/server/doc/TESTING.md b/server/doc/TESTING.md index a5021b815..089b11549 100644 --- a/server/doc/TESTING.md +++ b/server/doc/TESTING.md @@ -57,10 +57,6 @@ version mismatches and LLVM diagnostics fail collection. Compare sequential and parallel LCOV counts and stress randomized four-way scheduling before accepting parallel coverage evidence. -During the immutable producer transition only, the previous GCC image retains -object-adjacent `.gcda` profiles, atomic counters and gcovr export. New LLVM -coverage does not consume those files. - Use `ctest --preset PRESET --schedule-random --repeat until-fail:COUNT` for a stress pass. A failure's working files are attributable at `build/PRESET/server-test-runtimes/TEST-NAME`; CTest stdout/stderr remains in diff --git a/server/tools/admin_shutdown_coverage.sh b/server/tools/admin_shutdown_coverage.sh index b1602231c..da7b23b2f 100755 --- a/server/tools/admin_shutdown_coverage.sh +++ b/server/tools/admin_shutdown_coverage.sh @@ -3,31 +3,20 @@ # SPDX-License-Identifier: GPL-2.0-or-later # Run only in the isolated CI worker: sources are read-only, and both writable -# directories are disposable tmpfs mounts. LLVM mode writes only LCOV to stdout. +# directories are disposable tmpfs mounts. stdout contains only LCOV. set -euo pipefail mkdir /tmp/admin-shutdown-coverage cd /tmp/admin-shutdown-coverage -if [[ ${ATRINIK_LLVM_TOOLCHAIN:-0} == 1 ]]; then - export LLVM_PROFILE_FILE="$(python3 /workspace/tools/ci/llvm_coverage.py prepare \ - --build-dir /tmp/admin-shutdown-coverage)" - clang -std=c17 -Wall -Wextra -Werror \ - -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -DATRINIK_TESTING \ - --rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld \ - -shared -fPIC -I/workspace/server/src/include \ - /workspace/server/src/server/admin_shutdown.c -o admin_shutdown.so - python3 /workspace/server/src/tests/admin_shutdown_socket.py \ - ./admin_shutdown.so /run/atrinik-admin-tests >&2 - python3 /workspace/tools/ci/llvm_coverage.py export \ - --build-dir /tmp/admin-shutdown-coverage --source-root /workspace \ - --component server --compiler clang --output - -else - # Transitional validation for the previously released immutable image. - gcc -std=c17 -Wall -Wextra -Werror --coverage -DATRINIK_TESTING \ - -shared -fPIC -I/workspace/server/src/include \ - /workspace/server/src/server/admin_shutdown.c -o admin_shutdown.so - python3 /workspace/server/src/tests/admin_shutdown_socket.py \ - ./admin_shutdown.so /run/atrinik-admin-tests >&2 - gcovr --root /workspace --filter '/workspace/server/src/server/admin_shutdown\.c$' \ - --xml - . -fi +export LLVM_PROFILE_FILE="$(python3 /workspace/tools/ci/llvm_coverage.py prepare \ + --build-dir /tmp/admin-shutdown-coverage)" +clang -std=c17 -Wall -Wextra -Werror \ + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -DATRINIK_TESTING \ + --rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld \ + -shared -fPIC -I/workspace/server/src/include \ + /workspace/server/src/server/admin_shutdown.c -o admin_shutdown.so +python3 /workspace/server/src/tests/admin_shutdown_socket.py \ + ./admin_shutdown.so /run/atrinik-admin-tests >&2 +python3 /workspace/tools/ci/llvm_coverage.py export \ + --build-dir /tmp/admin-shutdown-coverage --source-root /workspace \ + --component server --compiler clang --output - diff --git a/tools/ci/llvm_coverage.py b/tools/ci/llvm_coverage.py index 3d4534ccb..24c0badec 100644 --- a/tools/ci/llvm_coverage.py +++ b/tools/ci/llvm_coverage.py @@ -70,8 +70,14 @@ def coverage_objects(build_dir): def filter_lcov(report, source_root, component): root = source_root.resolve() - prefixes = [root / component] if component != "all" else [ - root / "client", root / "server", root / "libatrinik"] + modules = ("client", "server", "libatrinik") if component == "all" else (component,) + prefixes = [] + for module in modules: + prefix = root / module + # Scoped source archives retain their module layout at archive root. + if component != "all" and not prefix.is_dir(): + prefix = root + prefixes.append((module, prefix)) records = [] for record in report.split("end_of_record"): match = re.search(r"^SF:(.+)$", record, re.MULTILINE) @@ -81,7 +87,7 @@ def filter_lcov(report, source_root, component): if not path.is_absolute(): path = root / path path = path.resolve() - for prefix in prefixes: + for module, prefix in prefixes: if not path.is_relative_to(prefix): continue relative = path.relative_to(prefix) @@ -89,7 +95,7 @@ def filter_lcov(report, source_root, component): # bundled upstream sources never contribute to Classic coverage. excluded = {"tests", "build", "dependencies", "_deps", "third_party", "third-party"} if path.suffix == ".c" and not excluded.intersection(relative.parts): - if prefix.name == "libatrinik" or relative.parts[0] == "src": + if module == "libatrinik" or relative.parts[0] == "src": records.append(record.strip() + "\nend_of_record\n") break if not records: diff --git a/tools/ci/run_gpu_coverage.sh b/tools/ci/run_gpu_coverage.sh index 25050c8b4..b4d0a5b92 100755 --- a/tools/ci/run_gpu_coverage.sh +++ b/tools/ci/run_gpu_coverage.sh @@ -39,7 +39,6 @@ docker run --rm \ --network none \ --env ATRINIK_BENCHMARK_REVISION="${expected_revision}" \ --env ATRINIK_BENCHMARK_DIRTY=false \ - --env ATRINIK_LLVM_TOOLCHAIN="${ATRINIK_LLVM_TOOLCHAIN:-0}" \ --volume "${source_root}:/workspace" \ --workdir /workspace \ "${build_image}" \ @@ -55,20 +54,13 @@ docker run --rm \ python3 client/tools/dependencies.py verify python3 client/tools/verify_gpu_fixture_provenance.py cd client - if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then - set -- -DCMAKE_TOOLCHAIN_FILE=/workspace/cmake/toolchains/llvm.cmake - else - set -- - fi cmake --preset linux-coverage \ - "$@" \ + -DCMAKE_TOOLCHAIN_FILE=/workspace/cmake/toolchains/llvm.cmake \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL=/workspace/protocol \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK=/workspace/libatrinik \ -DATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders cmake --build --preset linux-coverage --parallel "$(nproc)" - if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then - python3 /workspace/tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage - fi + python3 /workspace/tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage ctest --preset linux-coverage --output-on-failure --no-tests=error -LE performance ' @@ -127,15 +119,7 @@ docker run --rm \ --network none \ --volume "${source_root}:${compiled_root}" \ --workdir "${compiled_root}/client" \ - --env ATRINIK_LLVM_TOOLCHAIN="${ATRINIK_LLVM_TOOLCHAIN:-0}" \ "${build_image}" \ - sh -c ' - if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then - python3 ../tools/ci/llvm_coverage.py export \ - --build-dir build/linux-coverage --source-root .. \ - --component client --output coverage.lcov - else - gcovr --root . --filter src/ --exclude src/tests/ \ - --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ - --print-summary --xml coverage.xml - fi' + python3 ../tools/ci/llvm_coverage.py export \ + --build-dir build/linux-coverage --source-root .. \ + --component client --output coverage.lcov diff --git a/tools/ci/run_linux_check.sh b/tools/ci/run_linux_check.sh index c9ced006a..62f4432f1 100755 --- a/tools/ci/run_linux_check.sh +++ b/tools/ci/run_linux_check.sh @@ -33,46 +33,24 @@ test -w "${CCACHE_DIR}" test "$(stat --format='%a' "${CCACHE_DIR}")" = 1777 test "$(jq -r '.target' /usr/local/share/atrinik/classic-toolchain.json)" = classic-final test "$(jq -r '.platform' /usr/local/share/atrinik/classic-toolchain.json)" = linux/amd64 -llvm_toolchain=${ATRINIK_LLVM_TOOLCHAIN:-0} -if [[ ${llvm_toolchain} == 1 ]]; then - test "$(jq -r '.tools.clang' /usr/local/share/atrinik/classic-toolchain.json)" = 23.1.3 - export CC=clang CXX=clang++ -else - # Transitional validation for the previously released immutable image. - test "$(jq -r '.tools.gcc' /usr/local/share/atrinik/classic-toolchain.json)" = 15.2.0 -fi +test "$(jq -r '.tools.clang' /usr/local/share/atrinik/classic-toolchain.json)" = 23.1.3 +export CC=clang CXX=clang++ launcher=( -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + "-DCMAKE_TOOLCHAIN_FILE=${source_root}/cmake/toolchains/llvm.cmake" ) -if [[ ${llvm_toolchain} == 1 ]]; then - launcher+=("-DCMAKE_TOOLCHAIN_FILE=${source_root}/cmake/toolchains/llvm.cmake") -fi -# These functions run in the owning module directory. CTest's coverage preset -# supplies %m-%p profile names, separating parallel processes and shared plugins. +# CTest supplies binary/process-specific profiles for parallel tests and plugins. prepare_coverage() { - if [[ ${llvm_toolchain} == 1 ]]; then - python3 "${source_root}/tools/ci/llvm_coverage.py" prepare --build-dir build/linux-coverage - fi + python3 "${source_root}/tools/ci/llvm_coverage.py" prepare --build-dir build/linux-coverage } export_coverage() { local module=$1 - if [[ ${llvm_toolchain} == 1 ]]; then - python3 "${source_root}/tools/ci/llvm_coverage.py" export \ - --build-dir build/linux-coverage --source-root "${source_root}" \ - --component "${module}" --output coverage.lcov - elif [[ ${module} == libatrinik ]]; then - gcovr --root . --filter '.*\.c$' --exclude '.*/tests/.*' \ - --exclude '.*/build/.*' \ - --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ - --print-summary --xml coverage.xml - else - gcovr --root . --filter 'src/' --exclude 'src/tests/' \ - --gcov-ignore-parse-errors negative_hits.warn_once_per_file \ - --print-summary --xml coverage.xml - fi + python3 "${source_root}/tools/ci/llvm_coverage.py" export \ + --build-dir build/linux-coverage --source-root "${source_root}" \ + --component "${module}" --output coverage.lcov } sibling_sources=( diff --git a/tools/tests/test_llvm_coverage.py b/tools/tests/test_llvm_coverage.py index 5fc32b5de..ca1feb0fe 100644 --- a/tools/tests/test_llvm_coverage.py +++ b/tools/tests/test_llvm_coverage.py @@ -76,6 +76,7 @@ def test_filters_component_sources_and_retains_branch_function_counts(self): self.assertEqual(actual, record(self.root / paths[0]) + record(self.root / paths[1])) def test_library_includes_standalone_pathfinding_without_tests(self): + (self.root / "libatrinik").mkdir() report = "".join(record(self.root / path) for path in ["libatrinik/socket.c", "libatrinik/pathfinding/pathfinding.c", "libatrinik/pathfinding/tests/test.c", "libatrinik/dependencies/source.c"]) @@ -83,6 +84,12 @@ def test_library_includes_standalone_pathfinding_without_tests(self): record(self.root / "libatrinik/socket.c") + record(self.root / "libatrinik/pathfinding/pathfinding.c")) + def test_scoped_source_archive_uses_module_root_layout(self): + scoped = self.root / "atrinik-classic-libatrinik-5.1.0" + scoped.mkdir() + report = record(scoped / "socket.c") + record(scoped / "pathfinding/pathfinding.c") + self.assertEqual(coverage.filter_lcov(report, scoped, "libatrinik"), report) + def test_no_profile_is_a_failure(self): coverage.prepare(self.build) output = self.root / "coverage.lcov" diff --git a/tools/tests/test_llvm_toolchain.py b/tools/tests/test_llvm_toolchain.py new file mode 100644 index 000000000..aef5b9af3 --- /dev/null +++ b/tools/tests/test_llvm_toolchain.py @@ -0,0 +1,109 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +MODULE = ROOT / "cmake" / "AtrinikLLVM.cmake" +TOOLCHAIN = ROOT / "cmake" / "toolchains" / "llvm.cmake" + + +@unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") +class LLVMToolchainTests(unittest.TestCase): + """Exercise selection/rejection as CMake scripts, without compiling code.""" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.capture = self.root / "options.txt" + self.tool = self.root / "llvm-tool" + self.tool.write_text("#!/bin/sh\nprintf 'LLVM version 23.1.3\\n'\n") + self.tool.chmod(0o755) + + def script(self, body): + path = self.root / "test.cmake" + path.write_text(f'include("{MODULE}")\n' + body) + return subprocess.run([shutil.which("cmake"), "-P", str(path)], capture_output=True, text=True) + + def settings(self, platform="Linux", compiler="Clang", version="23.1.3"): + return f''' +set(CMAKE_C_COMPILER_ID "{compiler}") +set(CMAKE_C_COMPILER_VERSION "{version}") +set(CMAKE_SYSTEM_NAME "{platform}") +set(CMAKE_AR "{self.tool}") +set(CMAKE_RANLIB "{self.tool}") +set(CMAKE_LINKER "{self.tool}") +function(add_link_options) + file(WRITE "{self.capture}" "${{ARGV}}") +endfunction() +''' + + def test_native_default_selects_pinned_toolchain_from_module_directory(self): + result = self.script(f''' +set(CMAKE_HOST_SYSTEM_NAME Linux) +atrinik_initialize_llvm_toolchain() +file(WRITE "{self.capture}" "${{CMAKE_TOOLCHAIN_FILE}}") +''') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.capture.read_text(), str(TOOLCHAIN)) + + def test_explicit_portable_toolchain_and_cpu_flags_are_preserved(self): + result = self.script(self.settings() + f''' +set(CMAKE_TOOLCHAIN_FILE portable.cmake) +set(CMAKE_C_FLAGS "-march=x86-64 -mtune=generic") +atrinik_initialize_llvm_toolchain() +atrinik_require_llvm() +if (NOT CMAKE_TOOLCHAIN_FILE STREQUAL "portable.cmake" OR + NOT CMAKE_C_FLAGS STREQUAL "-march=x86-64 -mtune=generic") + message(FATAL_ERROR "portable selection changed") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.capture.read_text(), "--rtlib=compiler-rt;--unwindlib=libunwind;-fuse-ld=lld") + + def test_windows_toolchain_avoids_native_linux_runtime_flags(self): + result = self.script(self.settings("Windows") + ''' +set(CMAKE_TOOLCHAIN_FILE llvm-mingw.cmake) +atrinik_initialize_llvm_toolchain() +atrinik_require_llvm() +if (NOT CMAKE_TOOLCHAIN_FILE STREQUAL "llvm-mingw.cmake") + message(FATAL_ERROR "Windows selection changed") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.capture.exists()) + + def test_gcc_and_mismatched_clang_are_rejected(self): + for compiler, version in (("GNU", "15.2.0"), ("Clang", "23.1.2"), ("Clang", "23.1.30")): + with self.subTest(compiler=compiler, version=version): + result = self.script(self.settings(compiler=compiler, version=version) + "atrinik_require_llvm()\n") + self.assertNotEqual(result.returncode, 0) + self.assertIn("require Clang 23.1.3", result.stderr) + + def test_mismatched_archive_tools_are_rejected(self): + self.tool.write_text("#!/bin/sh\nprintf 'GNU ar 2.45\\n'\n") + result = self.script(self.settings() + "atrinik_require_llvm()\n") + self.assertNotEqual(result.returncode, 0) + self.assertIn("CMAKE_AR must be LLVM 23.1.3", result.stderr) + + def test_missing_llvm_fails_toolchain_selection(self): + empty = self.root / "empty" + empty.mkdir() + result = self.script(f''' +set(CMAKE_HOST_SYSTEM_NAME Linux) +set(CMAKE_FIND_ROOT_PATH "{empty}") +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM ONLY) +include("{TOOLCHAIN}") +''') + self.assertNotEqual(result.returncode, 0) + self.assertIn("CMAKE_C_COMPILER", result.stderr) + + +if __name__ == "__main__": + unittest.main() From c196b0d8ee8533b1f6d216b2b29f0f3ec8d26693 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:01:37 -0500 Subject: [PATCH 008/148] build(client): require LLVM producer for dependency helper --- client/tools/install-linux-ci-dependencies.sh | 33 ++++++++++++------- docs/BUILD_ACCELERATION.md | 20 +++++------ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/client/tools/install-linux-ci-dependencies.sh b/client/tools/install-linux-ci-dependencies.sh index 26f6960b5..0400045f1 100755 --- a/client/tools/install-linux-ci-dependencies.sh +++ b/client/tools/install-linux-ci-dependencies.sh @@ -8,22 +8,32 @@ if [[ $# -ne 1 ]]; then fi prefix=$(realpath -m "$1") -apt_prefix=() -if ((EUID != 0)); then - apt_prefix=(sudo) +# Compiler and system dependencies belong to the immutable Classic producer. +# Installing distribution compiler packages here would create another cohort. +python3 - <<'PY' +import json +from pathlib import Path + +inventory = json.loads(Path('/usr/local/share/atrinik/classic-toolchain.json').read_text()) +if (inventory.get('target') != 'classic-final' or + inventory.get('platform') != 'linux/amd64' or + inventory.get('tools', {}).get('clang') != '23.1.3'): + raise SystemExit('run this helper inside the pinned LLVM 23.1.3 Classic producer') +PY +script_directory=$(realpath "$(dirname "${BASH_SOURCE[0]}")") +toolchain=${script_directory}/../../cmake/toolchains/llvm.cmake +if [[ ! -f ${toolchain} ]]; then + # Scoped client archives place tools/ and cmake/ beside each other. + toolchain=${script_directory}/../cmake/toolchains/llvm.cmake fi -"${apt_prefix[@]}" apt-get update -qq -"${apt_prefix[@]}" env DEBIAN_FRONTEND=noninteractive \ - apt-get install -y -qq --no-install-recommends \ - build-essential ca-certificates cmake curl gcovr libcurl4-openssl-dev libidn2-dev libopus-dev \ - libopusfile-dev libsdl3-dev libsdl3-image-dev libsdl3-ttf-dev libssl-dev libxml2-dev ninja-build \ - pkgconf python3 zlib1g-dev +test -f "${toolchain}" +export CC=clang CXX=clang++ export CMAKE_PREFIX_PATH=${prefix} export LD_LIBRARY_PATH=${prefix}/lib export PKG_CONFIG_PATH=${prefix}/lib/pkgconfig -if [[ -f ${prefix}/.atrinik-sdl-mixer-2 ]]; then +if [[ -f ${prefix}/.atrinik-sdl-mixer-llvm-23.1.3 ]]; then exit 0 fi @@ -46,6 +56,7 @@ build_project() { cmake -S "${work}/${source}" -B "${work}/${source}-build" \ -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE="${toolchain}" \ -DCMAKE_INSTALL_PREFIX="${prefix}" \ "$@" cmake --build "${work}/${source}-build" --parallel @@ -76,4 +87,4 @@ pkg-config --atleast-version=3.4.0 sdl3 pkg-config --atleast-version=3.2.0 sdl3-image pkg-config --atleast-version=3.2.0 sdl3-ttf pkg-config --exact-version=3.2.4 sdl3-mixer -touch "${prefix}/.atrinik-sdl-mixer-2" +touch "${prefix}/.atrinik-sdl-mixer-llvm-23.1.3" diff --git a/docs/BUILD_ACCELERATION.md b/docs/BUILD_ACCELERATION.md index c46a48b7b..3f84d129f 100644 --- a/docs/BUILD_ACCELERATION.md +++ b/docs/BUILD_ACCELERATION.md @@ -22,9 +22,9 @@ option enables unity builds. ## Reproduce the benchmark -Configure one build tree per compiler and mode from the Classic repository +Configure one build tree per mode from the Classic repository root. The client has one conventional build path; the server commands below -show the GCC Debug pair that can be compared with and without its PCH. +show the LLVM 23.1.3 Debug pair that can be compared with and without its PCH. ```sh cmake -S client -B client/build/debug-conventional -G Ninja \ @@ -33,30 +33,30 @@ cmake -S client -B client/build/debug-conventional -G Ninja \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="$PWD/libatrinik" cmake --build client/build/debug-conventional --target atrinik --parallel 16 -cmake -S server -B server/build/benchmark-gcc-debug-conventional -G Ninja \ +cmake -S server -B server/build/benchmark-llvm-debug-conventional -G Ninja \ -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON \ -DENABLE_PRECOMPILED_HEADERS=OFF \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="$PWD/protocol" \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="$PWD/libatrinik" -cmake -S server -B server/build/benchmark-gcc-debug-pch -G Ninja \ +cmake -S server -B server/build/benchmark-llvm-debug-pch -G Ninja \ -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON \ -DENABLE_PRECOMPILED_HEADERS=ON \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="$PWD/protocol" \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="$PWD/libatrinik" python3 tools/benchmark_build_acceleration.py \ - --build-dir server/build/benchmark-gcc-debug-conventional \ + --build-dir server/build/benchmark-llvm-debug-conventional \ --target atrinik-server --source server/src/server/main.c \ --header server/src/include/global.h --jobs 16 --runs 3 \ - --output server/build/benchmark-gcc-debug-conventional.json + --output server/build/benchmark-llvm-debug-conventional.json python3 tools/benchmark_build_acceleration.py \ - --build-dir server/build/benchmark-gcc-debug-pch \ + --build-dir server/build/benchmark-llvm-debug-pch \ --target atrinik-server --source server/src/server/main.c \ --header server/src/include/global.h --jobs 16 --runs 3 \ - --output server/build/benchmark-gcc-debug-pch.json + --output server/build/benchmark-llvm-debug-pch.json ``` -For the server's Clang pair, set `CC=clang CXX=clang++` while configuring -parallel build directories. The script cleans the configured tree before each +The default native CMake toolchain selects Clang, LLD, compiler-rt and libunwind +from the pinned LLVM producer. The script cleans the configured tree before each run, measures an immediate warm build, advances and restores the representative source mtime, then advances and restores the common-header mtime. It records elapsed seconds, aggregate process-group peak RSS on Linux, rebuilt object/PCH From b2b7ab9e6c0d597311c9d9321b66a823f25fa9c9 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:03:28 -0500 Subject: [PATCH 009/148] fix(windows): bind package DLLs and Python snapshots to producer receipts --- cmake/InstallWindowsRuntime.cmake | 16 +- docs/WINDOWS_TOOLCHAIN.md | 12 + server/CMakeLists.txt | 3 +- tools/ci/stage_windows_runtime.py | 326 ++++++++++++++++++---- tools/release/package_sources.py | 1 + tools/tests/test_package_sources.py | 4 +- tools/tests/test_windows_trust_notices.py | 94 ++++++- 7 files changed, 382 insertions(+), 74 deletions(-) diff --git a/cmake/InstallWindowsRuntime.cmake b/cmake/InstallWindowsRuntime.cmake index f03f14934..ecbcdba81 100644 --- a/cmake/InstallWindowsRuntime.cmake +++ b/cmake/InstallWindowsRuntime.cmake @@ -12,8 +12,6 @@ function(atrinik_install_windows_runtime destination) IS_SYMLINK "${ATRINIK_WINDOWS_LLVM_LICENSE_DIR}/${notice}") message(FATAL_ERROR "LLVM-MinGW package notice is missing: ${notice}") endif () - install(FILES "${ATRINIK_WINDOWS_LLVM_LICENSE_DIR}/${notice}" - DESTINATION "${destination}/licenses/llvm-mingw") endforeach () set(ATRINIK_WINDOWS_DEPENDENCY_INVENTORY_DIR "${ATRINIK_WINDOWS_RUNTIME_DIR}/../share/atrinik/windows-dependencies" @@ -58,13 +56,8 @@ function(atrinik_install_windows_runtime destination) if (NOT EXISTS "${notice_file}" OR IS_DIRECTORY "${notice_file}" OR IS_SYMLINK "${notice_file}") message(FATAL_ERROR "Windows dependency notice is missing: ${name}/${notice}") endif () - install(FILES "${notice_file}" DESTINATION "${destination}/licenses/${name}") endforeach () endforeach () - install(FILES "${dependency_inventory}" DESTINATION "${destination}/licenses" - RENAME windows-dependencies.json) - install(FILES "${pe_inventory}" DESTINATION "${destination}/licenses" - RENAME windows-pe-closure.json) if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tools/ci/stage_windows_runtime.py") set(runtime_helper "${CMAKE_CURRENT_SOURCE_DIR}/tools/ci/stage_windows_runtime.py") else () @@ -73,15 +66,10 @@ function(atrinik_install_windows_runtime destination) if (NOT EXISTS "${runtime_helper}") message(FATAL_ERROR "Windows runtime closure helper is missing: ${runtime_helper}") endif () - set(runtime_arguments "--runtime-dir \"${ATRINIK_WINDOWS_RUNTIME_DIR}\"") + set(runtime_arguments "--runtime-dir \"${ATRINIK_WINDOWS_RUNTIME_DIR}\" --producer-inventory \"${pe_inventory}\"") if (ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR) string(APPEND runtime_arguments - " --runtime-dir \"${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}\"") - # Preserve the externally built Python extension modules, and inspect - # their dependencies without replacing their runtime ownership. - file(GLOB python_modules "${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}/*.pyd" - "${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}/*.dll") - list(APPEND ARGN ${python_modules}) + " --python-runtime \"${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}\" --python-inventory \"${ATRINIK_WINDOWS_DEPENDENCY_INVENTORY_DIR}/python-runtime.json\"") endif () foreach (binary IN LISTS ARGN) string(APPEND runtime_arguments " \"${binary}\"") diff --git a/docs/WINDOWS_TOOLCHAIN.md b/docs/WINDOWS_TOOLCHAIN.md index faa4ad5e6..2adec2ac5 100644 --- a/docs/WINDOWS_TOOLCHAIN.md +++ b/docs/WINDOWS_TOOLCHAIN.md @@ -37,6 +37,18 @@ own files and Microsoft runtime dependencies; every bundled Python DLL and extension module is also inspected. Python API calls must retain the existing allocation and CRT-resource ownership boundaries. +The stage helper requires the producer's schema-2 `pe-closure.json`, containing +each DLL's SHA-256, source SHA-256, owner and import list, plus hashed notices +for every owner. Its default location is the selected runtime prefix's +`share/atrinik/windows-dependencies/` directory. Every staged DLL must match +that receipt and its complete notice ownership. Inspection runs against the +copied package bytes. `--producer-inventory` can select an explicit receipt. +Server staging additionally uses `--python-runtime` and the producer's +schema-1 `python-runtime.json` receipt of every relative file and its SHA-256. +At install time it snapshots the entire recursive Python tree, rejects file +additions, removals, changed bytes and symlinks, and inspects every copied DLL, +extension and executable. The installer ships this validated snapshot. + Both packages retain the producer's `windows-dependencies.json` and `windows-pe-closure.json` below `licenses/`, every dependency notice listed by that source inventory, and the complete LLVM-MinGW notices below diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 7f7d2e9a4..39fb9b018 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -775,8 +775,7 @@ if (MINGW AND ${PACKAGE_TYPE} STREQUAL "zip") endif () endforeach () - install(DIRECTORY "${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}/" - DESTINATION server) + # The runtime helper snapshots and verifies every Python file at install. install(DIRECTORY "${ATRINIK_SERVER_PACKAGE_ROOT}/maps" DESTINATION server PATTERN ".git" EXCLUDE) diff --git a/tools/ci/stage_windows_runtime.py b/tools/ci/stage_windows_runtime.py index 3f8a3981e..91ba44394 100644 --- a/tools/ci/stage_windows_runtime.py +++ b/tools/ci/stage_windows_runtime.py @@ -5,10 +5,13 @@ import argparse import json -from pathlib import Path +import hashlib +import os +from pathlib import Path, PurePosixPath import re -import shutil import subprocess +import stat +import tempfile from typing import Callable, Iterable @@ -131,6 +134,165 @@ def runtime_index(runtime_dir: Path) -> dict[str, Path]: return index + +def read_inventory(path: Path) -> dict: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 4 * 1024 * 1024: + raise StageError(f"invalid Windows inventory file: {path}") + + def unique_keys(pairs): + result = {} + for key, value in pairs: + if key in result: + raise StageError(f"duplicate Windows inventory key: {key}") + result[key] = value + return result + + try: + result = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_keys) + except (ValueError, UnicodeError) as error: + raise StageError(f"invalid Windows inventory JSON: {path}: {error}") from error + if not isinstance(result, dict): + raise StageError(f"Windows inventory must be an object: {path}") + return result + + +def safe_relative_path(name: str) -> Path: + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_+./-]+", name): + raise StageError(f"unsafe Windows inventory path: {name}") + path = PurePosixPath(name) + if path.is_absolute() or any(part in {".", ".."} for part in name.split("/")): + raise StageError(f"unsafe Windows inventory path: {name}") + return Path(*path.parts) + + +def copy_snapshot(source: Path, destination: Path, expected: str | None = None, + *, max_bytes: int | None = None) -> str: + """Copy one opened regular file, then bind inspection to those copied bytes.""" + descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + digest = hashlib.sha256() + copied_bytes = 0 + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise StageError(f"runtime input is not a normal file: {source}") + destination.parent.mkdir(parents=True, exist_ok=True) + with os.fdopen(descriptor, "rb", closefd=False) as source_file, destination.open("xb") as output: + for chunk in iter(lambda: source_file.read(1024 * 1024), b""): + copied_bytes += len(chunk) + if max_bytes is not None and copied_bytes > max_bytes: + raise StageError(f"Windows inventory is unexpectedly large: {source.name}") + output.write(chunk) + digest.update(chunk) + finally: + os.close(descriptor) + value = digest.hexdigest() + if expected is not None and value != expected: + raise StageError(f"runtime bytes differ from producer inventory: {source.name}") + return value + + +def hash_record(value, name: str) -> str: + if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None: + raise StageError(f"invalid producer SHA-256 for {name}") + return value + + +def producer_inventory(path: Path, runtime_dir: Path) -> tuple[dict[str, dict], dict[str, tuple[Path, str]]]: + manifest = read_inventory(path) + if manifest.get("schema_version") != 2 or not isinstance(manifest.get("dlls"), dict): + raise StageError("unsupported producer PE inventory schema") + dependencies = read_inventory(path.parent / "dependencies.json") + if dependencies.get("schema_version") != 1 or not isinstance(dependencies.get("dependencies"), list): + raise StageError("unsupported producer dependency inventory schema") + notices = manifest.get("notices") + if not isinstance(notices, dict) or not notices: + raise StageError("producer PE inventory has no notice ownership registry") + notice_files = {} + for owner, owner_notices in notices.items(): + if not isinstance(owner, str) or re.fullmatch(r"[a-z0-9][a-z0-9_-]*", owner) is None: + raise StageError(f"invalid producer notice owner: {owner}") + if not isinstance(owner_notices, dict) or not owner_notices: + raise StageError(f"producer owner has no notices: {owner}") + for name, digest in owner_notices.items(): + relative = safe_relative_path(name) + if relative.parts[0] != owner or len(relative.parts) < 2 or name in notice_files: + raise StageError(f"producer notice has incorrect ownership: {name}") + source = runtime_dir.parent / "share/licenses" / relative + if source.is_symlink() or not source.is_file(): + raise StageError(f"producer owner notice is missing: {name}") + notice_files[name] = (source, hash_record(digest, name)) + llvm_notices = {"llvm-mingw/" + name for name in ("LICENSE.TXT", "COPYING", "COPYING.MinGW-w64-runtime.txt", + "COPYING.MinGW-w64.txt", "COPYING.winpthreads.txt", "COPYING.winstorecompat.txt")} + if not llvm_notices.issubset(notice_files): + raise StageError("producer inventory omits required LLVM-MinGW notices") + owners = set(notices) + for dependency in dependencies["dependencies"]: + if not isinstance(dependency, dict) or not isinstance(dependency.get("name"), str): + raise StageError("invalid producer dependency owner") + notices = dependency.get("licenses") + if not isinstance(notices, list) or not notices: + raise StageError(f"producer owner has no notices: {dependency['name']}") + safe_relative_path(dependency["name"]) + for notice in notices: + safe_relative_path(notice) + notice_file = runtime_dir.parent / "share/licenses" / dependency["name"] / notice.replace("/", "_") + if notice_file.is_symlink() or not notice_file.is_file(): + raise StageError(f"producer owner notice is missing: {dependency['name']}/{notice}") + records = {} + if len(manifest["dlls"]) > MAX_RUNTIME_DLLS: + raise StageError("producer PE inventory is unexpectedly large") + for name, record in manifest["dlls"].items(): + if DLL_NAME.fullmatch(name) is None or name.lower() in records or not isinstance(record, dict): + raise StageError(f"invalid producer DLL record: {name}") + if record.get("owner") not in owners: + raise StageError(f"producer DLL has no notice owner: {name}") + hash_record(record.get("sha256"), name) + hash_record(record.get("source_sha256"), f"{name} source") + imports = record.get("imports") + if not isinstance(imports, list) or any(not isinstance(item, str) or DLL_NAME.fullmatch(item) is None for item in imports): + raise StageError(f"invalid producer imports: {name}") + records[name.lower()] = {**record, "imports": [item.lower() for item in imports]} + return records, notice_files + + +def snapshot_python(runtime_dir: Path, inventory_path: Path, output_dir: Path) -> list[Path]: + if runtime_dir.is_symlink() or not runtime_dir.is_dir(): + raise StageError(f"Python runtime is not a normal directory: {runtime_dir}") + manifest = read_inventory(inventory_path) + files = manifest.get("files") + if manifest.get("schema_version") != 1 or not isinstance(files, dict) or not 1 <= len(files) <= 10000: + raise StageError("invalid Python runtime inventory") + actual = {} + windows_paths = set() + for source in runtime_dir.rglob("*"): + relative = source.relative_to(runtime_dir).as_posix() + if source.is_symlink() or not (source.is_file() or source.is_dir()): + raise StageError(f"invalid Python runtime entry: {relative}") + if source.is_file(): + if relative.lower() in windows_paths: + raise StageError(f"duplicate case-insensitive Python runtime path: {relative}") + windows_paths.add(relative.lower()) + actual[relative] = source + if set(actual) != set(files): + raise StageError("Python runtime files differ from producer inventory") + binaries = [] + names = set() + for name, source in sorted(actual.items()): + relative = safe_relative_path(name) + record = files[name] + expected = hash_record(record.get("sha256") if isinstance(record, dict) else record, name) + destination = output_dir / relative + if GCC_RUNTIME.fullmatch(source.name): + raise StageError(f"GCC runtime DLL in Python runtime: {name}") + copy_snapshot(source, destination, expected) + if source.suffix.lower() in {".dll", ".pyd", ".exe"}: + normalized = source.name.lower() + if normalized in names: + raise StageError(f"duplicate Python PE filename: {source.name}") + names.add(normalized) + binaries.append(destination) + return binaries + + def stage_runtime( binaries: Iterable[Path], runtime_dir: Path | Iterable[Path], @@ -138,9 +300,13 @@ def stage_runtime( inspector: Callable[[Path], set[str]], *, dlls_only: bool = False, + producer_records: dict[str, dict] | None = None, + python_runtime: Path | None = None, + python_inventory_path: Path | None = None, + producer_notices: dict[str, tuple[Path, str]] | None = None, ) -> list[str]: sources: dict[str, Path] = {} - runtime_dirs = [runtime_dir] if isinstance(runtime_dir, Path) else runtime_dir + runtime_dirs = [runtime_dir] if isinstance(runtime_dir, Path) else list(runtime_dir) for directory in runtime_dirs: for name, source in runtime_index(directory).items(): if name in sources and sources[name] != source: @@ -153,51 +319,81 @@ def stage_runtime( raise StageError(f"output directory is not empty: {output_dir}") else: output_dir.mkdir(parents=True) - queue: list[Path] = [] + if producer_notices is not None: + for name, (source, digest) in producer_notices.items(): + copy_snapshot(source, output_dir / "licenses" / safe_relative_path(name), digest) + python_binaries = [] + if python_runtime is not None: + if python_inventory_path is None: + raise StageError("Python runtime requires a producer file inventory") + python_binaries = snapshot_python(python_runtime, python_inventory_path, output_dir) + for binary in python_binaries: + if binary.suffix.lower() == ".dll": + name = binary.name.lower() + if name in sources: + raise StageError(f"duplicate case-insensitive runtime DLL: {binary.name}") + sources[name] = binary + queue: list[tuple[Path, Path, dict | None]] = [] staged: dict[str, Path] = {} - def stage(source: Path, copy: bool = True) -> None: - if source.is_symlink() or not source.is_file(): - raise StageError(f"runtime input is not a normal file: {source}") - normalized = source.name.lower() - if GCC_RUNTIME.fullmatch(normalized): - raise StageError(f"GCC runtime DLL in LLVM closure: {source.name}") - previous = staged.get(normalized) - if previous is not None: - if previous != source: - raise StageError(f"duplicate staged filename: {source.name}") - return - if copy: - destination = output_dir / source.name - shutil.copyfile(source, destination) - staged[normalized] = source - queue.append(source) - - for binary in binaries: - stage(binary, copy=not dlls_only) - - position = 0 - runtime_count = 0 - while position < len(queue): - binary = queue[position] - position += 1 - for dependency in sorted(inspector(binary), key=str.lower): - if DLL_NAME.fullmatch(dependency) is None: - raise StageError(f"invalid imported DLL name for {binary.name}: {dependency}") - normalized = dependency.lower() + with tempfile.TemporaryDirectory(prefix="atrinik-pe-inspection-") as temporary: + inspection_dir = Path(temporary) + + def stage(source: Path, copy: bool = True, external: bool = False) -> None: + if source.is_symlink() or not source.is_file(): + raise StageError(f"runtime input is not a normal file: {source}") + normalized = source.name.lower() if GCC_RUNTIME.fullmatch(normalized): - raise StageError(f"GCC runtime DLL in LLVM closure: {dependency}") - if is_system_dll(dependency) or normalized in staged: - continue - source = sources.get(normalized) - if source is None: - raise StageError(f"unresolved runtime DLL for {binary.name}: {dependency}") - runtime_count += 1 - if runtime_count > MAX_RUNTIME_DLLS: - raise StageError("Windows runtime dependency closure is unexpectedly large") - stage(source) + raise StageError(f"GCC runtime DLL in LLVM closure: {source.name}") + previous = staged.get(normalized) + if previous is not None: + if previous != source: + raise StageError(f"duplicate staged filename: {source.name}") + return + record = None + if producer_records is not None and source in sources.values() and not external: + record = producer_records.get(normalized) + if record is None: + raise StageError(f"runtime DLL has no producer ownership record: {source.name}") + if external: + inspection = source + else: + inspection = output_dir / source.name if copy else inspection_dir / source.name + copy_snapshot(source, inspection, record["sha256"] if record else None) + staged[normalized] = source + queue.append((source, inspection, record)) - return sorted((path.name for path in output_dir.iterdir()), key=str.lower) + for binary in binaries: + stage(binary, copy=not dlls_only) + for binary in python_binaries: + stage(binary, copy=False, external=True) + + position = 0 + runtime_count = 0 + while position < len(queue): + original, binary, record = queue[position] + position += 1 + imports = inspector(binary) + if record is not None and {name.lower() for name in imports} != set(record["imports"]): + raise StageError(f"runtime imports differ from producer inventory: {original.name}") + for dependency in sorted(imports, key=str.lower): + if DLL_NAME.fullmatch(dependency) is None: + raise StageError(f"invalid imported DLL name for {binary.name}: {dependency}") + normalized = dependency.lower() + if GCC_RUNTIME.fullmatch(normalized): + raise StageError(f"GCC runtime DLL in LLVM closure: {dependency}") + if is_system_dll(dependency) or normalized in staged: + continue + source = sources.get(normalized) + if source is None: + raise StageError(f"unresolved runtime DLL for {binary.name}: {dependency}") + runtime_count += 1 + if runtime_count > MAX_RUNTIME_DLLS: + raise StageError("Windows runtime dependency closure is unexpectedly large") + stage(source) + + return sorted((path.relative_to(output_dir).as_posix() for path in output_dir.rglob("*") + if path.is_file()), key=str.lower) def main() -> int: @@ -205,6 +401,9 @@ def main() -> int: parser.add_argument("--readobj", required=True) parser.add_argument("--runtime-dir", type=Path, action="append", default=[]) parser.add_argument("--output-dir", type=Path) + parser.add_argument("--producer-inventory", type=Path) + parser.add_argument("--python-runtime", type=Path) + parser.add_argument("--python-inventory", type=Path) parser.add_argument("--dlls-only", action="store_true") parser.add_argument("--inspect-only", action="store_true") parser.add_argument("--require-import", action="append", default=[]) @@ -221,13 +420,38 @@ def main() -> int: return 0 if not arguments.runtime_dir or arguments.output_dir is None: raise StageError("staging requires --runtime-dir and --output-dir") - staged = stage_runtime( - arguments.binaries, - arguments.runtime_dir, - arguments.output_dir, - lambda binary: imported_dlls(arguments.readobj, binary), - dlls_only=arguments.dlls_only, - ) + inventory_path = arguments.producer_inventory or ( + arguments.runtime_dir[0].parent / "share/atrinik/windows-dependencies/pe-closure.json") + with tempfile.TemporaryDirectory(prefix="atrinik-pe-inventory-") as temporary: + snapshot_dir = Path(temporary) + frozen_inventory = snapshot_dir / "pe-closure.json" + frozen_dependencies = snapshot_dir / "dependencies.json" + copy_snapshot(inventory_path, frozen_inventory, max_bytes=4 * 1024 * 1024) + copy_snapshot(inventory_path.parent / "dependencies.json", frozen_dependencies, max_bytes=4 * 1024 * 1024) + frozen_python = None + if arguments.python_runtime is not None: + frozen_python = snapshot_dir / "python-runtime.json" + copy_snapshot(arguments.python_inventory or inventory_path.parent / "python-runtime.json", frozen_python, + max_bytes=4 * 1024 * 1024) + records, notices = producer_inventory(frozen_inventory, arguments.runtime_dir[0]) + staged = stage_runtime( + arguments.binaries, + arguments.runtime_dir, + arguments.output_dir, + lambda binary: imported_dlls(arguments.readobj, binary), + dlls_only=arguments.dlls_only, + producer_records=records, + python_runtime=arguments.python_runtime, + python_inventory_path=frozen_python, + producer_notices=notices, + ) + metadata = {"windows-pe-closure.json": frozen_inventory, + "windows-dependencies.json": frozen_dependencies} + if frozen_python is not None: + metadata["windows-python-runtime.json"] = frozen_python + for name, source in metadata.items(): + copy_snapshot(source, arguments.output_dir / "licenses" / name) + staged.append("licenses/" + name) except (OSError, StageError, subprocess.SubprocessError) as error: parser.exit(1, f"Windows runtime staging failed: {error}\n") print(json.dumps({"staged": staged}, indent=2)) diff --git a/tools/release/package_sources.py b/tools/release/package_sources.py index 6a77f3b9c..8d56dc0f2 100755 --- a/tools/release/package_sources.py +++ b/tools/release/package_sources.py @@ -27,6 +27,7 @@ "LICENSE.md", "ATTRIBUTIONS.md", "cmake/AtrinikVersion.cmake", + "cmake/AtrinikLLVM.cmake", "cmake/InstallWindowsRuntime.cmake", "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", diff --git a/tools/tests/test_package_sources.py b/tools/tests/test_package_sources.py index 611677f42..0f42710bb 100644 --- a/tools/tests/test_package_sources.py +++ b/tools/tests/test_package_sources.py @@ -32,6 +32,7 @@ def setUp(self) -> None: "LICENSE.md": "license\n", "ATTRIBUTIONS.md": "notices\n", "cmake/AtrinikVersion.cmake": "# version contract\n", + "cmake/AtrinikLLVM.cmake": "# LLVM initialization contract\n", "cmake/InstallWindowsRuntime.cmake": "# Windows runtime closure\n", "cmake/toolchains/llvm.cmake": "# LLVM toolchain\n", "tools/ci/llvm_coverage.py": "# LLVM coverage conversion\n", @@ -105,6 +106,7 @@ def test_scoped_archive_contains_shared_evidence_but_not_other_modules(self) -> self.assertIn("atrinik-classic-client-5.6.0/cmake/InstallWindowsRuntime.cmake", names) self.assertIn("atrinik-classic-client-5.6.0/tools/ci/stage_windows_runtime.py", names) self.assertIn("atrinik-classic-client-5.6.0/cmake/toolchains/llvm.cmake", names) + self.assertIn("atrinik-classic-client-5.6.0/cmake/AtrinikLLVM.cmake", names) self.assertIn("atrinik-classic-client-5.6.0/tools/ci/llvm_coverage.py", names) self.assertIn( "atrinik-classic-client-5.6.0/PROVENANCE/history/imports.json", names @@ -136,7 +138,7 @@ def test_unsafe_source_path_is_rejected(self) -> None: def test_standalone_native_packages_retain_windows_closure_tools(self) -> None: for scope in ("client", "server"): for path in ("cmake/InstallWindowsRuntime.cmake", "tools/ci/stage_windows_runtime.py", - "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py"): + "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", "cmake/AtrinikLLVM.cmake"): with self.subTest(scope=scope, path=path): self.assertEqual(package_sources.selected_name(scope, path, "package"), f"package/{path}") diff --git a/tools/tests/test_windows_trust_notices.py b/tools/tests/test_windows_trust_notices.py index 9ba8f5626..6a2693dfc 100644 --- a/tools/tests/test_windows_trust_notices.py +++ b/tools/tests/test_windows_trust_notices.py @@ -2,6 +2,7 @@ import os import json +import hashlib from pathlib import Path import subprocess import sys @@ -13,7 +14,7 @@ class WindowsTrustNoticesTests(unittest.TestCase): - def fixture(self, module, cares=True, notice=True): + def fixture(self, module, cares=True, notice=True, python=False): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) root = Path(temporary.name) @@ -37,11 +38,29 @@ def fixture(self, module, cares=True, notice=True): (inventories / "dependencies.json").write_text(json.dumps({ "schema_version": 1, "dependencies": [{"name": "fixture-curl", "version": "1", "licenses": ["COPYING", "docs/NOTICE"]}]})) - (inventories / "pe-closure.json").write_text('{"libcurl-4.dll": []}') + dlls = {"libcurl-4.dll": {"sha256": hashlib.sha256(b"fixture curl").hexdigest(), + "owner": "fixture-curl", "source_sha256": "1" * 64, "imports": ["libcares-2.dll"] if cares else []}} + if cares: + dlls["libcares-2.dll"] = {"sha256": hashlib.sha256(b"fixture c-ares").hexdigest(), + "owner": "fixture-curl", "source_sha256": "1" * 64, "imports": []} + (inventories / "pe-closure.json").write_text(json.dumps({"schema_version": 2, "dlls": dlls, + "notices": {"llvm-mingw": {"llvm-mingw/" + path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in llvm_notices.iterdir()}, + "fixture-curl": {"fixture-curl/COPYING": hashlib.sha256(b"fixture dependency notice\n").hexdigest(), + "fixture-curl/docs_NOTICE": hashlib.sha256(b"fixture dependency notice\n").hexdigest()}}})) notices = root / "toolchain/share/licenses/fixture-curl" notices.mkdir() for name in ("COPYING", "docs_NOTICE"): (notices / name).write_bytes(b"fixture dependency notice\n") + python_runtime = root / "python" + if python: + python_runtime.mkdir() + python_files = {"python314.dll": b"fixture Python", "_ssl.pyd": b"fixture extension", + "python314.zip": b"fixture stdlib", "LICENSE.txt": b"Python notice"} + for name, data in python_files.items(): + (python_runtime / name).write_bytes(data) + (inventories / "python-runtime.json").write_text(json.dumps({"schema_version": 1, + "files": {name: {"sha256": hashlib.sha256(data).hexdigest()} + for name, data in python_files.items()}})) source = (ROOT / module / "CMakeLists.txt").read_text() if module == "client": start = source.index(" if (ATRINIK_WINDOWS_RUNTIME_DIR)", source.index("# Installer.")) @@ -53,7 +72,7 @@ def fixture(self, module, cares=True, notice=True): rules = source[start:end] + "\n" + rules else: start = source.index(" file(GLOB ATRINIK_WINDOWS_RUNTIME_DLLS", source.index("set(ATRINIK_WINDOWS_RUNTIME_DIR")) - end = source.index(' install(DIRECTORY "${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}/"', start) + end = source.index(' install(DIRECTORY "${ATRINIK_SERVER_PACKAGE_ROOT}/maps"', start) rules = source[start:end] # Server always installs its authored bundle directly. start = source.index(" install(FILES\n README.md", end) @@ -73,8 +92,7 @@ def fixture(self, module, cares=True, notice=True): 'print("Format: COFF-x86-64")\n' 'if binary.suffix == ".exe":\n' ' print("Import {\\n Name: libcurl-4.dll\\n}")\n' - 'elif binary.name == "libcurl-4.dll" and ' - '(binary.parent / "libcares-2.dll").exists():\n' + f'elif binary.name == "libcurl-4.dll" and {cares!r}:\n' ' print("Import {\\n Name: libcares-2.dll\\n}")\n') inspector.chmod(0o755) (root / "fixture.exe").write_bytes(b"fixture executable") @@ -89,7 +107,8 @@ def fixture(self, module, cares=True, notice=True): 'endforeach()\n' f'set(ATRINIK_LICENSE_FILE "{root}/LICENSE.md")\n' f'set(ATRINIK_ATTRIBUTIONS_FILE "{root}/ATTRIBUTIONS.md")\n' - f'set(ATRINIK_WINDOWS_RUNTIME_DIR "{runtime}")\n' + rules + + f'set(ATRINIK_WINDOWS_RUNTIME_DIR "{runtime}")\n' + + (f'set(ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR "{python_runtime}")\n' if python else "") + rules + '\nset(CPACK_GENERATOR ZIP)\nset(CPACK_PACKAGE_FILE_NAME fixture)\ninclude(CPack)\n') (root / "CMakeLists.txt").write_text(cmake) arguments = ["cmake", "-S", str(root), "-B", str(root / "build")] @@ -261,6 +280,69 @@ def test_dependency_inventory_rejects_missing_notices_and_unsafe_paths(self): capture_output=True, text=True) self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + def install_result(self, root): + return subprocess.run(["cmake", "--install", str(root / "build"), "--prefix", str(root / "installed")], + capture_output=True, text=True) + + def test_pe_inventory_rejects_malformed_unowned_or_changed_dlls(self): + for damage in ("malformed", "unowned", "bytes", "imports", "owner", "notice"): + with self.subTest(damage=damage): + root, result, _, _ = self.fixture("client", cares=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + inventory = root / "toolchain/share/atrinik/windows-dependencies/pe-closure.json" + if damage == "malformed": + inventory.write_text("not JSON") + elif damage == "unowned": + (root / "toolchain/bin/unowned.dll").write_bytes(b"unowned") + (root / "llvm-readobj").write_text('#!/usr/bin/env python3\nimport pathlib,sys\n' + 'print("Format: COFF-x86-64")\n' + 'if pathlib.Path(sys.argv[-1]).suffix == ".exe":\n' + ' print("Import {\\nName: unowned.dll\\n}")\n') + elif damage == "bytes": + (root / "toolchain/bin/libcurl-4.dll").write_bytes(b"changed bytes") + elif damage == "notice": + (root / "toolchain/share/licenses/fixture-curl/COPYING").write_bytes(b"changed notice") + else: + manifest = json.loads(inventory.read_text()) + if damage == "imports": + manifest["dlls"]["libcurl-4.dll"]["imports"] = ["kernel32.dll"] + else: + manifest["dlls"]["libcurl-4.dll"]["owner"] = "unowned" + inventory.write_text(json.dumps(manifest)) + result = self.install_result(root) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse((root / "installed/unowned.dll").exists()) + + def test_python_snapshot_ships_only_receipt_checked_files(self): + root, result, _, _ = self.fixture("server", cares=False, python=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + with self.package(root, "server") as archive: + for filename in ("python314.dll", "_ssl.pyd", "python314.zip", "LICENSE.txt"): + name = next(n for n in archive.namelist() if n.endswith("/server/" + filename)) + self.assertEqual(archive.read(name), (root / "python" / filename).read_bytes()) + self.assertTrue(any(n.endswith("/licenses/windows-python-runtime.json") for n in archive.namelist())) + + def test_python_late_additions_nested_gcc_and_changed_bytes_cannot_ship(self): + for damage in ("late-pyd", "nested-gcc", "changed", "symlink"): + with self.subTest(damage=damage): + root, result, _, _ = self.fixture("server", cares=False, python=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + runtime = root / "python" + if damage == "late-pyd": + (runtime / "late.pyd").write_bytes(b"invalid PE") + elif damage == "nested-gcc": + (runtime / "nested").mkdir() + (runtime / "nested/libgcc_s_seh-1.dll").write_bytes(b"GCC") + elif damage == "changed": + (runtime / "_ssl.pyd").write_bytes(b"changed bytes") + else: + (runtime / "_ssl.pyd").unlink() + (runtime / "_ssl.pyd").symlink_to(runtime / "python314.dll") + result = self.install_result(root) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse((root / "installed/server/late.pyd").exists()) + self.assertFalse((root / "installed/server/nested/libgcc_s_seh-1.dll").exists()) + if __name__ == "__main__": unittest.main() From 46811249f6d39170319d5ea5e17631c337902753 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:04:10 -0500 Subject: [PATCH 010/148] test(client): add exact Windows font dependency comparison --- client/CMakeLists.txt | 7 + client/src/tests/font_render.c | 188 ++++++++++++++++++++++ tools/ci/compare_font_render.py | 131 +++++++++++++++ tools/ci/font-render-probe/CMakeLists.txt | 12 ++ tools/ci/font-render-probe/README.md | 35 ++++ tools/tests/test_compare_font_render.py | 129 +++++++++++++++ 6 files changed, 502 insertions(+) create mode 100644 client/src/tests/font_render.c create mode 100644 tools/ci/compare_font_render.py create mode 100644 tools/ci/font-render-probe/CMakeLists.txt create mode 100644 tools/ci/font-render-probe/README.md create mode 100644 tools/tests/test_compare_font_render.py diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index e06f90546..f90949cd6 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -433,6 +433,13 @@ endif () if (BUILD_TESTING) set(ATRINIK_PYTHON3_EXECUTABLE "${Python3_EXECUTABLE}") + # Headless dependency equivalence probe, also built independently by the + # pinned Windows baseline producer (tools/ci/font-render-probe). + add_executable(client-font-render-probe src/tests/font_render.c) + atrinik_configure_target(client-font-render-probe) + target_link_libraries(client-font-render-probe PRIVATE + SDL3::SDL3 SDL3_ttf::SDL3_ttf) + set(ATRINIK_GPU_FIXTURE_PROVENANCE_COMMAND "${ATRINIK_PYTHON3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/verify_gpu_fixture_provenance.py") diff --git a/client/src/tests/font_render.c b/client/src/tests/font_render.c new file mode 100644 index 000000000..47d955d3d --- /dev/null +++ b/client/src/tests/font_render.c @@ -0,0 +1,188 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + * Headless, bounded SDL_ttf equivalence evidence; no window or GPU is created. + */ +#include +#include +#include +#include + +static const char *fonts[] = { + "arial.ttf", "courier.otf", "fanwood.otf", "logisoso.ttf", + "mono.ttf", "pecita.otf", "sans.ttf", "serif.ttf" +}; +static const int points[] = {11, 16, 24}; +static const int styles[] = {TTF_STYLE_NORMAL, TTF_STYLE_BOLD, TTF_STYLE_ITALIC, + TTF_STYLE_BOLD | TTF_STYLE_ITALIC, TTF_STYLE_UNDERLINE}; +static const char *style_names[] = {"normal", "bold", "italic", "bold-italic", "underline"}; +static const char *samples[] = {"M", "g", "\xc3\xa9", "AV", "ffi", + "Caf\xc3\xa9", "Cafe\xcc\x81"}; +static const Uint32 glyphs[] = {'M', 'g', 0xe9, 'A', 'V', 'f', 'i', 'e', 0x301}; + +static void fail(const char *message) { + fprintf(stderr, "font probe: %s: %s\n", message, SDL_GetError()); + exit(EXIT_FAILURE); +} + +static FILE *output(const char *directory, const char *name) { + char path[4096]; + int length = snprintf(path, sizeof(path), "%s/%s", directory, name); + if (length < 0 || (size_t)length >= sizeof(path)) { + fail("output path too long"); + } + /* Exclusive creation preserves earlier evidence on failed/repeated runs. */ + FILE *file = fopen(path, "wbx"); + if (file == NULL) { + fail("cannot exclusively create output"); + } + return file; +} + +static void canonical_pixel(SDL_Surface *surface, int x, int y, Uint8 rgba[4]) { + if (!SDL_ReadSurfacePixel(surface, x, y, &rgba[0], &rgba[1], &rgba[2], &rgba[3])) { + fail("pixel read failed"); + } + if (rgba[3] == 0) { + rgba[0] = rgba[1] = rgba[2] = 0; + } +} + +static void check_color_key_conversion(void) { + SDL_Surface *indexed = SDL_CreateSurface(2, 1, SDL_PIXELFORMAT_INDEX8); + if (indexed == NULL) { + fail("color key fixture allocation failed"); + } + SDL_Palette *palette = SDL_CreateSurfacePalette(indexed); + SDL_Color colors[] = {{43, 90, 172, 255}, {212, 165, 83, 255}}; + if (palette == NULL || !SDL_SetPaletteColors(palette, colors, 0, 2) || + !SDL_SetSurfaceColorKey(indexed, true, 0)) { + fail("color key fixture setup failed"); + } + ((Uint8 *)indexed->pixels)[0] = 0; + ((Uint8 *)indexed->pixels)[1] = 1; + SDL_Surface *converted = SDL_ConvertSurface(indexed, SDL_PIXELFORMAT_RGBA32); + if (converted == NULL) { + fail("color key fixture conversion failed"); + } + Uint8 rgba[4]; + canonical_pixel(converted, 0, 0, rgba); + if (rgba[0] || rgba[1] || rgba[2] || rgba[3]) { + fail("color key fixture lost transparency"); + } + canonical_pixel(converted, 1, 0, rgba); + if (rgba[0] != 212 || rgba[1] != 165 || rgba[2] != 83 || rgba[3] != 255) { + fail("color key fixture changed foreground"); + } + SDL_DestroySurface(converted); + SDL_DestroySurface(indexed); +} + +static void write_pixels(SDL_Surface *surface, const char *directory, int id) { + /* SDL_ttf solid surfaces use a palette color key, not palette alpha. + * Conversion applies that key before serializing actual RGBA coverage. */ + SDL_Surface *rgba_surface = SDL_ConvertSurface(surface, SDL_PIXELFORMAT_RGBA32); + if (rgba_surface == NULL) { + fail("RGBA conversion failed"); + } + char name[32]; + snprintf(name, sizeof(name), "case%04d.rgba", id); + FILE *file = output(directory, name); + for (int y = 0; y < surface->h; y++) { + for (int x = 0; x < surface->w; x++) { + Uint8 rgba[4]; + canonical_pixel(rgba_surface, x, y, rgba); + if (fwrite(rgba, 1, sizeof(rgba), file) != sizeof(rgba)) { + fail("pixel write failed"); + } + } + } + if (fclose(file) != 0) { + fail("pixel close failed"); + } + SDL_DestroySurface(rgba_surface); +} + +int main(int argc, char **argv) { + if (argc != 3) { + fprintf(stderr, "usage: %s FONT_DIR OUTPUT_DIR\n", argv[0]); + return EXIT_FAILURE; + } + if (!SDL_Init(0) || !TTF_Init()) { + fail("initialization failed"); + } + check_color_key_conversion(); + FILE *manifest = output(argv[2], "manifest.json"); + fprintf(manifest, "{\"schema\":1,\"cases\":[\n"); + int id = 0; + size_t total = 0; + for (size_t f = 0; f < SDL_arraysize(fonts); f++) { + char path[4096]; + int length = snprintf(path, sizeof(path), "%s/%s", argv[1], fonts[f]); + if (length < 0 || (size_t)length >= sizeof(path)) { + fail("font path too long"); + } + for (size_t p = 0; p < SDL_arraysize(points); p++) { + TTF_Font *font = TTF_OpenFont(path, (float)points[p]); + if (font == NULL) { + fail("cannot open shipped font"); + } + for (size_t s = 0; s < SDL_arraysize(styles); s++) { + TTF_SetFontStyle(font, styles[s]); + for (int mode = 0; mode < 2; mode++) { + for (size_t t = 0; t < SDL_arraysize(samples); t++, id++) { + int width, height; + if (!TTF_GetStringSize(font, samples[t], 0, &width, &height)) { + fail("string size failed"); + } + SDL_Color color = {212, 165, 83, 255}; + SDL_Surface *surface = mode == 0 + ? TTF_RenderText_Solid(font, samples[t], 0, color) + : TTF_RenderText_Blended(font, samples[t], 0, color); + if (surface == NULL) { + fail("render failed"); + } + if (surface->w <= 0 || surface->h <= 0 || + surface->w > 1024 || surface->h > 1024) { + fail("surface dimension bound exceeded"); + } + total += (size_t)surface->w * (size_t)surface->h * 4; + if (total > 128 * 1024 * 1024) { + fail("total pixel bound exceeded"); + } + write_pixels(surface, argv[2], id); + fprintf(manifest, + "%s{\"id\":%d,\"font\":\"%s\",\"point\":%d," + "\"style\":\"%s\",\"mode\":\"%s\",\"sample\":%zu," + "\"text_size\":[%d,%d],\"surface_size\":[%d,%d]," + "\"font_metrics\":[%d,%d,%d,%d],\"glyphs\":[", + id == 0 ? "" : ",\n", id, fonts[f], points[p], style_names[s], + mode == 0 ? "solid" : "blended", t, width, height, + surface->w, surface->h, TTF_GetFontHeight(font), + TTF_GetFontAscent(font), TTF_GetFontDescent(font), TTF_GetFontLineSkip(font)); + for (size_t g = 0; g < SDL_arraysize(glyphs); g++) { + int minx = 0, maxx = 0, miny = 0, maxy = 0, advance = 0; + bool available = TTF_FontHasGlyph(font, glyphs[g]); + if (available && !TTF_GetGlyphMetrics(font, glyphs[g], + &minx, &maxx, &miny, &maxy, &advance)) { + fail("glyph metrics failed"); + } + fprintf(manifest, "%s[%u,%d,%d,%d,%d,%d,%d]", + g == 0 ? "" : ",", (unsigned)glyphs[g], available ? 1 : 0, + minx, maxx, miny, maxy, advance); + } + fprintf(manifest, "]}"); + SDL_DestroySurface(surface); + } + } + } + TTF_CloseFont(font); + } + } + fprintf(manifest, "\n]}\n"); + if (ferror(manifest) || fclose(manifest) != 0) { + fail("manifest write failed"); + } + TTF_Quit(); + SDL_Quit(); + return EXIT_SUCCESS; +} diff --git a/tools/ci/compare_font_render.py b/tools/ci/compare_font_render.py new file mode 100644 index 000000000..ac05804fb --- /dev/null +++ b/tools/ci/compare_font_render.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Compare complete, bounded headless font evidence without tolerances. + +Copyright 2026 The Atrinik Project +SPDX-License-Identifier: GPL-2.0-or-later +""" +from __future__ import annotations + +import argparse +import itertools +import json +import os +from pathlib import Path + +FONTS = ("arial.ttf", "courier.otf", "fanwood.otf", "logisoso.ttf", + "mono.ttf", "pecita.otf", "sans.ttf", "serif.ttf") +POINTS = (11, 16, 24) +STYLES = ("normal", "bold", "italic", "bold-italic", "underline") +MODES = ("solid", "blended") +GLYPHS = (77, 103, 233, 65, 86, 102, 105, 101, 769) +CASES = tuple(itertools.product(FONTS, POINTS, STYLES, MODES, range(7))) +MAX_MANIFEST = 2 * 1024 * 1024 +MAX_PIXELS = 128 * 1024 * 1024 +FIELDS = {"id", "font", "point", "style", "mode", "sample", "text_size", + "surface_size", "font_metrics", "glyphs"} + + +class EvidenceError(ValueError): + pass + + +def require(condition: bool, message: str) -> None: + if not condition: + raise EvidenceError(message) + + +def unique_object(pairs: list[tuple[str, object]]) -> dict: + result = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON field: {key}") + result[key] = value + return result + + +def integers(value: object, count: int, name: str) -> list[int]: + require(isinstance(value, list) and len(value) == count, f"invalid {name}") + require(all(type(item) is int and -65536 <= item <= 65536 for item in value), + f"invalid {name} integers") + return value + + +def read_file(path: Path, maximum: int) -> bytes: + require(not path.is_symlink() and path.is_file(), f"not a regular evidence file: {path}") + require(path.stat().st_size <= maximum, f"evidence file too large: {path}") + with path.open("rb") as stream: + data = stream.read(maximum + 1) + require(len(data) <= maximum, f"evidence file grew beyond bound: {path}") + return data + + +def load_manifest(directory: Path) -> dict: + require(not directory.is_symlink() and directory.is_dir(), f"invalid evidence directory: {directory}") + manifest = json.loads(read_file(directory / "manifest.json", MAX_MANIFEST), + object_pairs_hook=unique_object) + require(isinstance(manifest, dict) and set(manifest) == {"schema", "cases"}, + "invalid manifest fields") + require(type(manifest["schema"]) is int and manifest["schema"] == 1, "invalid schema") + cases = manifest["cases"] + require(isinstance(cases, list) and len(cases) == len(CASES), "incomplete case matrix") + total = 0 + for index, (case, identity) in enumerate(zip(cases, CASES)): + require(isinstance(case, dict) and set(case) == FIELDS, f"invalid case fields: {index}") + require(type(case["id"]) is int and case["id"] == index, f"invalid case ID: {index}") + require(type(case["point"]) is int and type(case["sample"]) is int, + f"invalid case identity types: {index}") + require(tuple(case[key] for key in ("font", "point", "style", "mode", "sample")) == identity, + f"invalid case identity: {index}") + integers(case["text_size"], 2, "text size") + width, height = integers(case["surface_size"], 2, "surface size") + require(0 < width <= 1024 and 0 < height <= 1024, f"surface bound: {index}") + total += width * height * 4 + require(total <= MAX_PIXELS, "total pixel bound") + integers(case["font_metrics"], 4, "font metrics") + require(isinstance(case["glyphs"], list) and len(case["glyphs"]) == len(GLYPHS), + f"invalid glyph matrix: {index}") + for glyph, codepoint in zip(case["glyphs"], GLYPHS): + integers(glyph, 7, "glyph metrics") + require(glyph[0] == codepoint and glyph[1] in (0, 1), f"invalid glyph identity: {index}") + require(glyph[1] or glyph[2:] == [0] * 5, f"missing glyph has metrics: {index}") + expected = {"manifest.json"} | {f"case{index:04d}.rgba" for index in range(len(CASES))} + actual = set() + with os.scandir(directory) as entries: + for entry in entries: + require(entry.name in expected, "missing or unexpected evidence files") + actual.add(entry.name) + require(actual == expected, "missing or unexpected evidence files") + return manifest + + +def compare(baseline: Path, candidate: Path) -> int: + before, after = load_manifest(baseline), load_manifest(candidate) + require(before == after, "font metadata differs") + for case in before["cases"]: + index = case["id"] + width, height = case["surface_size"] + size = width * height * 4 + name = f"case{index:04d}.rgba" + left, right = read_file(baseline / name, size), read_file(candidate / name, size) + require(len(left) == size and len(right) == size, f"pixel byte count differs: {index}") + for pixels in (left, right): + require(all(pixels[offset + 3] or pixels[offset:offset + 3] == b"\0\0\0" + for offset in range(0, size, 4)), f"noncanonical transparent pixel: {index}") + require(left == right, f"font pixels differ: {index}") + return len(CASES) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("baseline", type=Path) + parser.add_argument("candidate", type=Path) + args = parser.parse_args() + try: + count = compare(args.baseline, args.candidate) + except (OSError, ValueError, RecursionError) as error: + parser.exit(1, f"font equivalence failed: {error}\n") + print(f"font equivalence: {count} cases, exact metadata and RGBA pixels") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/font-render-probe/CMakeLists.txt b/tools/ci/font-render-probe/CMakeLists.txt new file mode 100644 index 000000000..93c56ebd2 --- /dev/null +++ b/tools/ci/font-render-probe/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.21) +project(atrinik-font-render-probe LANGUAGES C) + +# Deliberately independent of the application's compiler policy: compile this +# exact source with both the pinned MXE baseline and the LLVM candidate. +find_package(SDL3 3.4 CONFIG REQUIRED) +find_package(SDL3_ttf 3.2 CONFIG REQUIRED) +add_executable(client-font-render-probe + ../../../client/src/tests/font_render.c) +set_target_properties(client-font-render-probe PROPERTIES + C_STANDARD 17 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) +target_link_libraries(client-font-render-probe PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf) diff --git a/tools/ci/font-render-probe/README.md b/tools/ci/font-render-probe/README.md new file mode 100644 index 000000000..b39e066fa --- /dev/null +++ b/tools/ci/font-render-probe/README.md @@ -0,0 +1,35 @@ +# Windows font dependency equivalence + +This standalone C17 build compiles the same `client/src/tests/font_render.c` +source with the original pinned MXE producer and the LLVM candidate. It needs +only SDL3 and SDL3_ttf; it does not configure the game, shaders or compiler policy. +Pass each producer's existing toolchain file to CMake and build +`client-font-render-probe`. The integrated client exposes that target whenever +`BUILD_TESTING` is enabled, including MinGW. + +Stage each executable and its own complete DLL closure in separate directories. +Use the same checked-out `client/fonts` directory and the same native Windows +runner for both invocations, with distinct empty output directories: + +```text +baseline/client-font-render-probe.exe FONT_DIR BASELINE_OUTPUT_DIR +candidate/client-font-render-probe.exe FONT_DIR CANDIDATE_OUTPUT_DIR +python tools/ci/compare_font_render.py BASELINE_OUTPUT_DIR CANDIDATE_OUTPUT_DIR +``` + +The probe creates no window or GPU device and exclusively creates its outputs. +It fails on unavailable fonts, render/metric errors, existing output files or +exceeded bounds. Its 1,680 stable cases cover eight shipped fonts, 11/16/24 point, +normal/bold/italic/bold-italic/underline, solid/blended and seven UTF-8 samples +(`M`, `g`, `é`, `AV`, `ffi`, composed `Café`, decomposed `Café`). Nine glyph +records retain availability, bounds and advance. Each case also records text +dimensions, surface dimensions, height, ascent, descent and line skip. + +Schema 1 contains ordered metadata in `manifest.json` and tightly packed +row-major RGBA8 `caseNNNN.rgba` files. RGB bytes for zero-alpha pixels are zero. +Surfaces are limited to 1,024 by 1,024, aggregate pixels to 128 MiB and metadata +to 2 MiB. The comparator requires the entire closed matrix and exact metadata +and pixel equality, with no tolerance or invented baseline metrics. Probe +outputs, producer/source identities and staged dependency inventories should +be retained as CI evidence, including on failure. Python fixture tests check +the comparator contract; they do not establish actual rendering equivalence. diff --git a/tools/tests/test_compare_font_render.py b/tools/tests/test_compare_font_render.py new file mode 100644 index 000000000..a77128f80 --- /dev/null +++ b/tools/tests/test_compare_font_render.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Failure and bounds checks for exact font evidence comparison.""" +from __future__ import annotations + +import copy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +SPEC = importlib.util.spec_from_file_location( + "compare_font_render", Path(__file__).parents[1] / "ci" / "compare_font_render.py") +probe = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(probe) + + +class FontEvidenceTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory() + cls.baseline = Path(cls.temporary.name) / "baseline" + cls.candidate = Path(cls.temporary.name) / "candidate" + cls.manifest = {"schema": 1, "cases": []} + for index, identity in enumerate(probe.CASES): + cls.manifest["cases"].append(dict( + zip(("id", "font", "point", "style", "mode", "sample"), (index, *identity)), + text_size=[1, 1], surface_size=[1, 1], font_metrics=[1, 1, -1, 1], + glyphs=[[codepoint, 1, 0, 1, -1, 1, 1] for codepoint in probe.GLYPHS])) + for directory in (cls.baseline, cls.candidate): + directory.mkdir() + (directory / "manifest.json").write_text(json.dumps(cls.manifest)) + for index in range(len(probe.CASES)): + (directory / f"case{index:04d}.rgba").write_bytes(b"\xd4\xa5\x53\xff") + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def tearDown(self): + (self.candidate / "manifest.json").write_text(json.dumps(self.manifest)) + path = self.candidate / "case0000.rgba" + if path.is_symlink(): + path.unlink() + path.write_bytes(b"\xd4\xa5\x53\xff") + (self.candidate / "unexpected").unlink(missing_ok=True) + + def mutate(self, change): + manifest = copy.deepcopy(self.manifest) + change(manifest) + (self.candidate / "manifest.json").write_text(json.dumps(manifest)) + + def rejects(self, message): + with self.assertRaisesRegex(probe.EvidenceError, message): + probe.compare(self.baseline, self.candidate) + + def test_complete_exact_comparison(self): + self.assertEqual(probe.compare(self.baseline, self.candidate), 1680) + + def test_metric_difference(self): + self.mutate(lambda m: m["cases"][0]["glyphs"][0].__setitem__(6, 2)) + self.rejects("metadata differs") + + def test_pixel_difference(self): + (self.candidate / "case0000.rgba").write_bytes(b"\xd5\xa5\x53\xff") + self.rejects("pixels differ") + + def test_noncanonical_transparent_rgb(self): + (self.candidate / "case0000.rgba").write_bytes(b"\x01\0\0\0") + self.rejects("noncanonical") + + def test_short_pixel_output(self): + (self.candidate / "case0000.rgba").write_bytes(b"\0") + self.rejects("byte count") + + def test_oversized_pixel_output(self): + (self.candidate / "case0000.rgba").write_bytes(b"\0" * 5) + self.rejects("too large") + + def test_missing_case(self): + self.mutate(lambda m: m["cases"].pop()) + self.rejects("incomplete") + + def test_changed_case_identity(self): + self.mutate(lambda m: m["cases"][0].__setitem__("font", "other.ttf")) + self.rejects("identity") + + def test_dimension_bound(self): + self.mutate(lambda m: m["cases"][0].__setitem__("surface_size", [1025, 1])) + self.rejects("surface bound") + + def test_total_byte_bound(self): + self.mutate(lambda m: [case.__setitem__("surface_size", [1024, 1024]) + for case in m["cases"]]) + self.rejects("total pixel bound") + + def test_boolean_is_not_integer(self): + self.mutate(lambda m: m["cases"][0].__setitem__("id", False)) + self.rejects("case ID") + + def test_unknown_fields(self): + self.mutate(lambda m: m["cases"][0].__setitem__("extra", 1)) + self.rejects("case fields") + + def test_duplicate_json_field(self): + (self.candidate / "manifest.json").write_text('{"schema":1,"schema":1,"cases":[]}') + self.rejects("duplicate JSON") + + def test_manifest_byte_bound(self): + (self.candidate / "manifest.json").write_bytes(b" " * (probe.MAX_MANIFEST + 1)) + self.rejects("too large") + + def test_unexpected_output(self): + (self.candidate / "unexpected").touch() + self.rejects("unexpected evidence") + + def test_missing_output(self): + (self.candidate / "case0000.rgba").unlink() + self.rejects("missing or unexpected") + + def test_symlink_output(self): + path = self.candidate / "case0000.rgba" + path.unlink() + path.symlink_to(self.baseline / "case0000.rgba") + self.rejects("regular evidence") + + +if __name__ == "__main__": + unittest.main() From 3a225cb9df6d8ad137a75e1763b910e4f9eb92cd Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:05:48 -0500 Subject: [PATCH 011/148] test(ci): compare native Windows font rendering across toolchains --- .github/workflows/check.yml | 82 +++++++++++++++++++++++++- .github/workflows/pr-benchmarks.yml | 2 + docs/CI-LINUX-IMAGE.md | 12 ++++ tools/ci/classify_changes.py | 1 + tools/tests/test_classify_changes.py | 1 + tools/tests/test_workflow_contracts.py | 36 ++++++++++- 6 files changed, 130 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d89c64b77..2a55c08ad 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -29,6 +29,8 @@ env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 CLASSIC_GPU_COVERAGE_IMAGE: ghcr.io/atrinik/linux-build:1.16.0@sha256:89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e + # Historical baseline for the paired native Windows font comparison. + CLASSIC_WINDOWS_FONT_BASELINE_IMAGE: ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt CLASSIC_LINUX_CCACHE_EPOCH: "2" @@ -261,6 +263,7 @@ jobs: --material .github/workflows/check.yml \ --material tools/ci/run_linux_check.sh \ --material cmake/toolchains/llvm.cmake \ + --material cmake/AtrinikLLVM.cmake \ --material tools/ci/llvm_coverage.py \ --material protocol/CMakeLists.txt \ --material libatrinik/CMakeLists.txt \ @@ -372,6 +375,37 @@ jobs: build/discord-test-application-id - name: Pull pinned Windows build environment run: docker pull ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 + - name: Check out the immutable historical baseline staging helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: fd4943252ac872fce2995c286b10a0c6d249aba3 + path: build/font-baseline-source + persist-credentials: false + sparse-checkout: tools/ci/stage_windows_runtime.py + - name: Build the original pinned Windows font baseline + run: | + docker pull "${CLASSIC_WINDOWS_FONT_BASELINE_IMAGE}" + docker run --rm --network none --cpus 2 --memory 4g \ + --user "$(id -u):$(id -g)" \ + --env HOME=/tmp/classic-home \ + --env BASELINE_IMAGE="${CLASSIC_WINDOWS_FONT_BASELINE_IMAGE}" \ + --env PATH=/opt/mxe/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + --volume "${GITHUB_WORKSPACE}:/workspace" --workdir /workspace \ + "${CLASSIC_WINDOWS_FONT_BASELINE_IMAGE}" bash -c ' + set -euo pipefail + x86_64-w64-mingw32.shared-cmake -S tools/ci/font-render-probe \ + -B build/font-baseline -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake --build build/font-baseline --target client-font-render-probe --parallel 2 + test "$(git -C build/font-baseline-source rev-parse HEAD)" = fd4943252ac872fce2995c286b10a0c6d249aba3 + python3 build/font-baseline-source/tools/ci/stage_windows_runtime.py \ + --objdump x86_64-w64-mingw32.shared-objdump --runtime-dir "${MXE_RUNTIME_DIR}" \ + --output-dir build/font-baseline-bundle \ + build/font-baseline/client-font-render-probe.exe + sha256sum build/font-baseline-source/tools/ci/stage_windows_runtime.py \ + > build/font-baseline-bundle/staging-helper.sha256 + printf "%s\n" "${BASELINE_IMAGE}" > build/font-baseline-bundle/producer-image.txt + x86_64-w64-mingw32.shared-gcc --version > build/font-baseline-bundle/compiler.txt + ' - name: Cross-build and stage standalone tests run: | docker run --rm \ @@ -468,7 +502,7 @@ jobs: -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL=/workspace/protocol \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK=/workspace/libatrinik cmake --build client/build/windows-tests \ - --target client-rich-presence-tests --parallel "$(nproc)" + --target client-rich-presence-tests client-font-render-probe --parallel "$(nproc)" stage=libatrinik/build/windows-test-bundle cmake -E remove_directory "${stage}" python3 tools/ci/stage_windows_runtime.py \ @@ -484,6 +518,13 @@ jobs: libatrinik/build/windows-tests/libatrinik-socket-quic.exe \ libatrinik/build/windows-tests/libatrinik-stun.exe \ client/build/windows-tests/client-rich-presence-tests.exe + cmake -E copy_directory build/font-baseline-bundle "${stage}/font-baseline" + python3 tools/ci/stage_windows_runtime.py \ + --readobj llvm-readobj --runtime-dir "${ATRINIK_WINDOWS_RUNTIME_DIR}" \ + --output-dir "${stage}/font-candidate" \ + client/build/windows-tests/client-font-render-probe.exe + cmake -E copy_directory client/fonts "${stage}/font-inputs" + cmake -E copy tools/ci/compare_font_render.py "${stage}" cmake -E copy_directory libatrinik/tests/fixtures "${stage}/fixtures" cmake -E copy tools/ci/smoke_windows_server_package.ps1 "${stage}" cmake -E copy tools/ci/smoke_windows_review_bundle.ps1 "${stage}" @@ -774,6 +815,42 @@ jobs: & (Join-Path $bundle "smoke_windows_server_package.ps1") ` -Package $packages[0].FullName + - name: Require identical baseline and LLVM font rendering + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $bundle = (Resolve-Path "build/native-windows-tests").Path + $fonts = Join-Path $bundle "font-inputs" + $baseline = Join-Path $bundle "font-baseline-result" + $candidate = Join-Path $bundle "font-candidate-result" + New-Item -ItemType Directory -Path $baseline, $candidate | Out-Null + function Invoke-FontProbe($executable, $output) { + $process = Start-Process -FilePath $executable -PassThru -NoNewWindow ` + -ArgumentList @("`"$fonts`"", "`"$output`"") + if (-not $process.WaitForExit(120000)) { + $process.Kill($true) + $process.WaitForExit() + throw "Font probe exceeded its 120-second deadline" + } + if ($process.ExitCode -ne 0) { throw "Font probe failed: $($process.ExitCode)" } + } + Invoke-FontProbe (Join-Path $bundle "font-baseline/client-font-render-probe.exe") $baseline + Invoke-FontProbe (Join-Path $bundle "font-candidate/client-font-render-probe.exe") $candidate + python (Join-Path $bundle "compare_font_render.py") $baseline $candidate + if ($LASTEXITCODE -ne 0) { throw "LLVM font rendering differs: $LASTEXITCODE" } + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: always() + with: + name: native-windows-font-comparison-${{ env.COVERAGE_SHA }}-${{ github.run_attempt }} + path: | + build/native-windows-tests/font-baseline-result + build/native-windows-tests/font-candidate-result + build/native-windows-tests/font-baseline/staging-helper.sha256 + build/native-windows-tests/font-baseline/producer-image.txt + build/native-windows-tests/font-baseline/compiler.txt + if-no-files-found: error + retention-days: 14 + server: name: Server validation needs: [changes, dependency-inputs] @@ -808,6 +885,7 @@ jobs: --material .github/workflows/check.yml \ --material tools/ci/run_linux_check.sh \ --material cmake/toolchains/llvm.cmake \ + --material cmake/AtrinikLLVM.cmake \ --material tools/ci/llvm_coverage.py \ --material server/CMakeLists.txt \ --material server/CMakePresets.json \ @@ -917,6 +995,7 @@ jobs: --material .github/workflows/check.yml \ --material tools/ci/run_linux_check.sh \ --material cmake/toolchains/llvm.cmake \ + --material cmake/AtrinikLLVM.cmake \ --material tools/ci/llvm_coverage.py \ --material tools/ci/run_gpu_coverage.sh \ --material client/CMakeLists.txt \ @@ -1086,6 +1165,7 @@ jobs: --material .github/workflows/check.yml \ --material tools/ci/run_linux_check.sh \ --material cmake/toolchains/llvm.cmake \ + --material cmake/AtrinikLLVM.cmake \ --material tools/ci/llvm_coverage.py \ --material CMakeLists.txt \ --material CMakePresets.json \ diff --git a/.github/workflows/pr-benchmarks.yml b/.github/workflows/pr-benchmarks.yml index d41ff3fdc..d8809eb5b 100644 --- a/.github/workflows/pr-benchmarks.yml +++ b/.github/workflows/pr-benchmarks.yml @@ -184,6 +184,7 @@ jobs: --material .github/workflows/pr-benchmarks.yml \ --material tools/ci/run_linux_check.sh \ --material cmake/toolchains/llvm.cmake \ + --material cmake/AtrinikLLVM.cmake \ --material tools/ci/llvm_coverage.py \ --material client/tools/benchmark_lighting_regression.py \ --material client/tools/benchmark_movement_regression.py \ @@ -277,6 +278,7 @@ jobs: --material .github/workflows/pr-benchmarks.yml \ --material tools/ci/run_linux_check.sh \ --material cmake/toolchains/llvm.cmake \ + --material cmake/AtrinikLLVM.cmake \ --material tools/ci/llvm_coverage.py \ --material server/CMakeLists.txt \ --material server/CMakePresets.json \ diff --git a/docs/CI-LINUX-IMAGE.md b/docs/CI-LINUX-IMAGE.md index bc66bc124..80abf9ed6 100644 --- a/docs/CI-LINUX-IMAGE.md +++ b/docs/CI-LINUX-IMAGE.md @@ -254,3 +254,15 @@ retain their `/workspace` build paths, while each container gets temporary private client configuration. Runner provisioning, current resource admission and the bounded hardware reservation remain coordinator prerequisites. A capability report supplies no authority and no passing hardware evidence. + +The required native Windows font comparison builds the same headless SDL_ttf +probe with the historical immutable MXE producer and the new LLVM producer. +The historical lane uses `stage_windows_runtime.py` from Classic revision +`fd4943252ac872fce2995c286b10a0c6d249aba3` with that producer's original objdump +and runtime inventory. Its image coordinate, compiler output and staging-helper +SHA are retained alongside comparison artifacts. The production LLVM stager +keeps its strict producer-inventory and GCC-runtime exclusions. Baseline and +candidate DLL closures occupy separate directories and render the same eight +committed fonts on one Windows runner; `compare_font_render.py` rejects any +metadata or pixel mismatch. Keep `CLASSIC_WINDOWS_FONT_BASELINE_IMAGE` at its +original immutable coordinate when replacing the candidate image pins. diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index f46c57cd3..b7141e6bb 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -24,6 +24,7 @@ "CMakePresets.json", "codecov.yml", "tools/tests/test_llvm_coverage.py", + "tools/tests/test_llvm_toolchain.py", "tools/tests/test_gpu_qualification_runtime.py", "LICENSE.md", "docs/history/component-release-map.json", diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index e3868dd37..ee8e2a741 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -102,6 +102,7 @@ def test_shared_native_change_selects_both_components(self) -> None: def test_llvm_contract_changes_select_both_native_consumers(self) -> None: for path in ("cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", "tools/tests/test_llvm_coverage.py", + "tools/tests/test_llvm_toolchain.py", "tools/tests/test_gpu_qualification_runtime.py"): with self.subTest(path=path): result = classify_changes.classify([path]) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 0ee1d6088..d51b48ac5 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -77,6 +77,33 @@ def test_native_consumers_require_llvm_and_native_lcov(self) -> None: self.assertEqual(qualify.count("python3 ../tools/ci/run_gpu_qualification.py"), 4) self.assertIn("tools/ci/llvm_coverage.py,target=/workspace/tools/ci/llvm_coverage.py,readonly", check) + def test_windows_font_baseline_is_separate_and_native_comparison_is_required(self) -> None: + workflow = self.text("check.yml") + build = workflow[workflow.index(" windows-test-build:"):workflow.index(" windows-test:")] + native = workflow[workflow.index(" windows-test:"):workflow.index(" server:\n name: Server validation")] + baseline = build[build.index(" - name: Build the original pinned Windows font baseline"): + build.index(" - name: Cross-build and stage standalone tests")] + self.assertIn("CLASSIC_WINDOWS_FONT_BASELINE_IMAGE", baseline) + self.assertIn("x86_64-w64-mingw32.shared-cmake -S tools/ci/font-render-probe", baseline) + self.assertIn("client-rich-presence-tests client-font-render-probe", build) + self.assertIn('cmake -E copy_directory build/font-baseline-bundle "${stage}/font-baseline"', build) + self.assertIn("ref: fd4943252ac872fce2995c286b10a0c6d249aba3", build) + self.assertIn("python3 build/font-baseline-source/tools/ci/stage_windows_runtime.py", baseline) + self.assertIn("--objdump x86_64-w64-mingw32.shared-objdump", baseline) + self.assertIn("staging-helper.sha256", baseline) + self.assertIn('--output-dir "${stage}/font-candidate"', build) + self.assertIn('cmake -E copy_directory client/fonts "${stage}/font-inputs"', build) + self.assertIn("font-baseline/client-font-render-probe.exe", native) + self.assertIn("font-candidate/client-font-render-probe.exe", native) + self.assertIn("compare_font_render.py", native) + self.assertIn('throw "LLVM font rendering differs:', native) + self.assertIn("$process.WaitForExit(120000)", native) + self.assertIn("$process.Kill($true)", native) + self.assertIn("name: native-windows-font-comparison-", native) + comparison = native[native.index(" - name: Require identical baseline and LLVM font rendering"):] + self.assertNotIn("continue-on-error", comparison) + self.assertIn("if: always()", comparison) + def test_windows_publisher_uses_the_staged_ctest_fixture(self) -> None: workflow = self.text("check.yml") jobs = dict( @@ -1313,7 +1340,7 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) self.assertEqual(build.count(image), 4) self.assertEqual(build.count(digest), 4) self.assertNotIn("ghcr.io/atrinik/windows-build:1.0.5", build) - self.assertEqual(build.count("--network none"), 3) + self.assertEqual(build.count("--network none"), 4) self.assertIn("persist-credentials: false", build) self.assertIn("ref: ${{ env.COVERAGE_SHA }}", build) self.assertIn('test "$(git rev-parse HEAD)" = "${COVERAGE_SHA}"', build) @@ -1346,8 +1373,11 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) ) self.assertIn("--env CCACHE_MAXSIZE=250M", build) self.assertNotIn("--env CCACHE_DIR=/opt/mxe", build) - self.assertNotIn("/opt/mxe", build) - self.assertNotIn("x86_64-w64-mingw32.shared-cmake", build) + baseline_start = build.index(" - name: Build the original pinned Windows font baseline") + baseline_end = build.index(" - name: Cross-build and stage standalone tests", baseline_start) + candidate_build = build[:baseline_start] + build[baseline_end:] + self.assertNotIn("/opt/mxe", candidate_build) + self.assertNotIn("x86_64-w64-mingw32.shared-cmake", candidate_build) self.assertEqual(build.count("--env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32"), 3) self.assertEqual(build.count('-DCMAKE_TOOLCHAIN_FILE="${ATRINIK_WINDOWS_TOOLCHAIN_FILE}"'), 4) self.assertEqual(build.count("-DCMAKE_C_COMPILER_LAUNCHER=ccache"), 4) From 653712c3abde4bdd3ae5a034855db1c8a61827ab Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:10:00 -0500 Subject: [PATCH 012/148] test(build): isolate version probes from LLVM compiler setup --- tools/tests/test_version_metadata.py | 121 +++++++++++++++++---------- 1 file changed, 75 insertions(+), 46 deletions(-) diff --git a/tools/tests/test_version_metadata.py b/tools/tests/test_version_metadata.py index 110b9e340..7333d3c86 100644 --- a/tools/tests/test_version_metadata.py +++ b/tools/tests/test_version_metadata.py @@ -87,35 +87,66 @@ def build_and_outputs(self) -> tuple[str, str]: outputs.append(result.stdout.strip()) return outputs[0], outputs[1] + def copy_shared_cmake(self, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + shutil.copy2(MODULE, destination / MODULE.name) + shutil.copy2(ROOT / "cmake/AtrinikLLVM.cmake", destination / "AtrinikLLVM.cmake") + shutil.copytree(ROOT / "cmake/toolchains", destination / "toolchains", dirs_exist_ok=True) + def assert_component_version_probe(self, source: Path, build: Path, *, version: str = "9.8.7", explicit: bool = True) -> None: - probe = self.root / "component-version-probe.cmake" + # Evaluate the real entry point only through its authored project VERSION + # call. Script-mode interception avoids compiler/toolchain discovery; + # no product build can continue past this deliberate fatal boundary. + build.mkdir(parents=True, exist_ok=True) + probe = build / "component-version-probe.cmake" probe.write_text( - """ -if (NOT PROJECT_VERSION STREQUAL "9.8.7") - message(FATAL_ERROR "Unexpected project version: ${PROJECT_VERSION}") -endif () -message(FATAL_ERROR "ATRINIK_VERSION_PROBE_COMPLETED") -""".lstrip().replace("9.8.7", version), - encoding="utf-8", - ) + f''' +set(CMAKE_SOURCE_DIR "{source.as_posix()}") +set(CMAKE_CURRENT_SOURCE_DIR "{source.as_posix()}") +set(CMAKE_BINARY_DIR "{build.as_posix()}") +set(CMAKE_CURRENT_BINARY_DIR "{build.as_posix()}") +function(project) + cmake_parse_arguments(PROBE "" "VERSION" "" ${{ARGN}}) + if (NOT PROBE_VERSION STREQUAL "{version}") + message(FATAL_ERROR "Unexpected project version: ${{PROBE_VERSION}}") + endif () + message(FATAL_ERROR "ATRINIK_VERSION_PROBE_COMPLETED") +endfunction() +include("{(source / 'CMakeLists.txt').as_posix()}") +'''.lstrip(), encoding="utf-8") result = subprocess.run( - [ - "cmake", - "-S", - str(source), - "-B", - str(build), - *(["-DATRINIK_PACKAGE_VERSION=" + version] if explicit else []), - f"-DCMAKE_PROJECT_INCLUDE={probe}", - ], - check=False, - capture_output=True, - text=True, - ) + ["cmake", *(["-DATRINIK_PACKAGE_VERSION=" + version] if explicit else []), "-P", str(probe)], + check=False, capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) - self.assertIn( - "ATRINIK_VERSION_PROBE_COMPLETED", result.stdout + result.stderr - ) + self.assertIn("ATRINIK_VERSION_PROBE_COMPLETED", result.stdout + result.stderr) + + def require_native_llvm(self) -> None: + missing = [] + for name in ("clang", "clang++", "llvm-ar", "llvm-ranlib", "ld.lld"): + tool = shutil.which(name) + if tool is None: + missing.append(name) + continue + result = subprocess.run([tool, "--version"], capture_output=True, text=True) + if result.returncode or not re.search(r"(?:[Vv]ersion |LLD )23\.1\.3(?![\d.])", result.stdout): + missing.append(name + " 23.1.3") + if missing: + reason = "real native LLVM package test requires " + ", ".join(missing) + if os.environ.get("CI", "").lower() not in ("", "0", "false"): + self.fail(reason) + self.skipTest(reason) + + def test_native_package_requirement_fails_when_ci_lacks_llvm(self) -> None: + from unittest import mock + with mock.patch.dict(os.environ, {"CI": "true"}), mock.patch.object(shutil, "which", return_value=None): + with self.assertRaisesRegex(self.failureException, "real native LLVM package test requires"): + self.require_native_llvm() + + def test_native_package_requirement_reports_local_missing_llvm(self) -> None: + from unittest import mock + with mock.patch.dict(os.environ, {"CI": ""}), mock.patch.object(shutil, "which", return_value=None): + with self.assertRaisesRegex(unittest.SkipTest, "real native LLVM package test requires"): + self.require_native_llvm() def test_explicit_version_is_embedded_identically_for_both_consumers(self) -> None: result = self.configure( @@ -288,8 +319,7 @@ def test_real_protocol_unified_and_embedded_archive_versions(self) -> None: with self.subTest(layout=layout): package = self.root / layout package.mkdir() - (package / "cmake").mkdir() - shutil.copy2(MODULE, package / "cmake/AtrinikVersion.cmake") + self.copy_shared_cmake(package / "cmake") (package / "VERSION").write_text("5.68.0\n") if layout == "standalone": protocol = package @@ -298,12 +328,15 @@ def test_real_protocol_unified_and_embedded_archive_versions(self) -> None: shutil.copytree(ROOT / "protocol", protocol, dirs_exist_ok=True, ignore=shutil.ignore_patterns("build", "__pycache__")) if layout == "unified": - shutil.copy2(ROOT / "CMakeLists.txt", package / "CMakeLists.txt") - # Exercise actual root/protocol configuration and package-version - # generation without compiling unrelated gameplay dependencies. - for consumer in ("libatrinik", "client", "server"): - (package / consumer).mkdir() - (package / consumer / "CMakeLists.txt").write_text("") + # This fixture checks protocol package metadata, without + # entering the native game compiler/dependency graph. + (package / "CMakeLists.txt").write_text( + 'cmake_minimum_required(VERSION 3.21)\n' + 'include(cmake/AtrinikVersion.cmake)\n' + 'atrinik_resolve_version(ATRINIK_SOURCE_VERSION)\n' + 'project(protocol-version-probe VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES NONE)\n' + 'set(ATRINIK_INTEGRATED_BUILD ON)\n' + 'add_subdirectory(protocol)\n') configure_source = package else: (protocol / "VERSION").write_text("5.68.0\n") @@ -317,8 +350,7 @@ def test_real_protocol_unified_and_embedded_archive_versions(self) -> None: def test_physical_component_and_symlink_views_use_owner_tag_without_override(self) -> None: owner = self.root / "classic-owner" - (owner / "cmake").mkdir(parents=True) - shutil.copy2(MODULE, owner / "cmake/AtrinikVersion.cmake") + self.copy_shared_cmake(owner / "cmake") for component in ("client", "server", "protocol", "libatrinik"): (owner / component).mkdir() shutil.copy2(ROOT / component / "CMakeLists.txt", owner / component / "CMakeLists.txt") @@ -337,9 +369,9 @@ def test_physical_component_and_symlink_views_use_owner_tag_without_override(sel version="5.68.0", explicit=False) def test_pathfinding_physical_tag_untagged_and_installed_package(self) -> None: + self.require_native_llvm() owner = self.root / "classic-owner" - (owner / "cmake").mkdir(parents=True) - shutil.copy2(MODULE, owner / "cmake/AtrinikVersion.cmake") + self.copy_shared_cmake(owner / "cmake") shutil.copytree(ROOT / "libatrinik/pathfinding", owner / "libatrinik/pathfinding") self.initialize_git(owner) self.git(owner, "tag", "v5.68.0") @@ -369,14 +401,15 @@ def test_pathfinding_physical_tag_untagged_and_installed_package(self) -> None: 'add_executable(consumer main.c)\ntarget_link_libraries(consumer PRIVATE Atrinik::Pathfinding)\n') (consumer / "main.c").write_text('int main(void) { return 0; }\n') subprocess.run(["cmake", "-S", str(consumer), "-B", str(consumer / "build"), - "-DCMAKE_PREFIX_PATH=" + str(install)], check=True, capture_output=True) + "-DCMAKE_PREFIX_PATH=" + str(install), + "-DCMAKE_TOOLCHAIN_FILE=" + str(ROOT / "cmake/toolchains/llvm.cmake")], + check=True, capture_output=True) subprocess.run(["cmake", "--build", str(consumer / "build")], check=True, capture_output=True) def test_generated_archives_keep_owner_identity_and_pathfinding_package_version(self) -> None: owner = self.root / "owner" - (owner / "cmake").mkdir(parents=True) - shutil.copy2(MODULE, owner / "cmake/AtrinikVersion.cmake") + self.copy_shared_cmake(owner / "cmake") shutil.copytree(ROOT / "protocol", owner / "protocol", ignore=shutil.ignore_patterns("build", "__pycache__")) shutil.copytree(ROOT / "libatrinik/pathfinding", owner / "libatrinik/pathfinding") @@ -408,11 +441,7 @@ def test_generated_archives_keep_owner_identity_and_pathfinding_package_version( "pathfinding" if scope == "libatrinik" else "dependencies/libatrinik/pathfinding") build = self.root / (scope + "-pathfinding-build") - result = subprocess.run(["cmake", "-Werror=dev", "-S", str(pathfinding), "-B", str(build)], - capture_output=True, text=True) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertIn('set(PACKAGE_VERSION "5.68.0")', - (build / "AtrinikPathfindingConfigVersion.cmake").read_text()) + self.assert_component_version_probe(pathfinding, build, version="5.68.0", explicit=False) if scope in ("client", "server"): self.source = package result = self.configure() @@ -469,7 +498,7 @@ def test_component_version_module_resolution_supports_scoped_packages(self) -> N (package / "cmake" / entry.name).symlink_to( entry, target_is_directory=entry.is_dir() ) - shutil.copy2(MODULE, package / "cmake" / MODULE.name) + self.copy_shared_cmake(package / "cmake") self.assert_component_version_probe( package, self.root / f"{component}-package-build" ) From 18d562a03e4175eb6b31d81753bd4e5ed4b2745a Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:24:42 -0500 Subject: [PATCH 013/148] fix(ci): forward LLVM coverage selection and reports --- .github/workflows/check.yml | 18 +++++++++++++----- tools/tests/test_workflow_contracts.py | 10 ++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3f7d2bc5f..7af0b65aa 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -25,6 +25,8 @@ concurrency: cancel-in-progress: true env: + # Retain the released GCC producer until immutable LLVM images are qualified. + ATRINIK_LLVM_TOOLCHAIN: "0" COVERAGE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 @@ -281,6 +283,7 @@ jobs: --user "$(id -u):$(id -g)" \ --network none \ --env CCACHE_DIR=/cache/ccache \ + --env ATRINIK_LLVM_TOOLCHAIN \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ --volume "${GITHUB_WORKSPACE}:/workspace" \ @@ -312,7 +315,7 @@ jobs: with: disable_search: true fail_ci_if_error: false - files: libatrinik/coverage.xml + files: libatrinik/coverage.${{ env.ATRINIK_LLVM_TOOLCHAIN == '1' && 'lcov' || 'xml' }} flags: libatrinik use_oidc: true - name: Validate editor packaging helper @@ -803,6 +806,7 @@ jobs: --user "$(id -u):$(id -g)" \ --network none \ --env CCACHE_DIR=/cache/ccache \ + --env ATRINIK_LLVM_TOOLCHAIN \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ --volume "${GITHUB_WORKSPACE}:/workspace" \ @@ -826,10 +830,12 @@ jobs: --mount "type=bind,source=${GITHUB_WORKSPACE}/server/src/server/admin_shutdown.c,target=/workspace/server/src/server/admin_shutdown.c,readonly" \ --mount "type=bind,source=${GITHUB_WORKSPACE}/server/src/include/admin_shutdown.h,target=/workspace/server/src/include/admin_shutdown.h,readonly" \ --mount "type=bind,source=${GITHUB_WORKSPACE}/server/src/tests/admin_shutdown_socket.py,target=/workspace/server/src/tests/admin_shutdown_socket.py,readonly" \ + --env ATRINIK_LLVM_TOOLCHAIN \ + --mount "type=bind,source=${GITHUB_WORKSPACE}/tools/ci/llvm_coverage.py,target=/workspace/tools/ci/llvm_coverage.py,readonly" \ --mount "type=bind,source=${GITHUB_WORKSPACE}/server/tools/admin_shutdown_coverage.sh,target=/workspace/server/tools/admin_shutdown_coverage.sh,readonly" \ "${CLASSIC_LINUX_IMAGE}" \ bash /workspace/server/tools/admin_shutdown_coverage.sh \ - > "server/coverage-admin-${fixture_name}.xml" + > "server/coverage-admin-${fixture_name}.$([[ ${ATRINIK_LLVM_TOOLCHAIN} == 1 ]] && echo lcov || echo xml)" done - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: @@ -845,7 +851,7 @@ jobs: override_commit: ${{ env.COVERAGE_SHA }} disable_search: true fail_ci_if_error: false - files: server/coverage.xml,server/coverage-admin-root.xml,server/coverage-admin-nonroot.xml + files: server/coverage.${{ env.ATRINIK_LLVM_TOOLCHAIN == '1' && 'lcov' || 'xml' }},server/coverage-admin-root.${{ env.ATRINIK_LLVM_TOOLCHAIN == '1' && 'lcov' || 'xml' }},server/coverage-admin-nonroot.${{ env.ATRINIK_LLVM_TOOLCHAIN == '1' && 'lcov' || 'xml' }} flags: server use_oidc: true @@ -908,6 +914,7 @@ jobs: --user "$(id -u):$(id -g)" \ --network none \ --env CCACHE_DIR=/cache/ccache \ + --env ATRINIK_LLVM_TOOLCHAIN \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ --env CI \ @@ -947,7 +954,7 @@ jobs: override_commit: ${{ env.COVERAGE_SHA }} disable_search: true fail_ci_if_error: false - files: client/coverage.xml + files: client/coverage.${{ env.ATRINIK_LLVM_TOOLCHAIN == '1' && 'lcov' || 'xml' }} flags: client-unit use_oidc: true - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 @@ -1007,7 +1014,7 @@ jobs: override_commit: ${{ env.COVERAGE_SHA }} disable_search: true fail_ci_if_error: false - files: client/coverage.xml + files: client/coverage.${{ env.ATRINIK_LLVM_TOOLCHAIN == '1' && 'lcov' || 'xml' }} flags: client-gpu use_oidc: true @@ -1073,6 +1080,7 @@ jobs: --user "$(id -u):$(id -g)" \ --network none \ --env CCACHE_DIR=/cache/ccache \ + --env ATRINIK_LLVM_TOOLCHAIN \ --env CCACHE_MAXSIZE=500M \ --env HOME=/tmp/classic-home \ --volume "${GITHUB_WORKSPACE}:/workspace" \ diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 7499d8e40..e9b1b9e57 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -1235,7 +1235,10 @@ def test_core_uploads_exercised_python_release_tool_coverage(self) -> None: self.assertIn("files: tools/coverage.xml", tools_upload) self.assertIn("flags: release-tools", tools_upload) self.assertNotIn("libatrinik/coverage.xml", tools_upload) - self.assertIn("files: libatrinik/coverage.xml", libatrinik_upload) + self.assertIn( + "files: libatrinik/coverage.${{ env.ATRINIK_LLVM_TOOLCHAIN == '1' && 'lcov' || 'xml' }}", + libatrinik_upload, + ) self.assertIn("flags: libatrinik", libatrinik_upload) self.assertNotIn("tools/coverage.xml", libatrinik_upload) @@ -2034,7 +2037,10 @@ def test_codecov_reporting_is_informational_without_weakening_native_coverage( workflow.index(" integrated:\n name: Integrated client/server graph") ] self.assertIn("tools/ci/run_gpu_coverage.sh", gpu_coverage) - self.assertIn("files: client/coverage.xml", gpu_coverage) + self.assertIn( + "files: client/coverage.${{ env.ATRINIK_LLVM_TOOLCHAIN == '1' && 'lcov' || 'xml' }}", + gpu_coverage, + ) self.assertIn("flags: client-gpu", gpu_coverage) native_coverage = gpu_coverage.split( " - name: Upload GPU-complete client coverage", 1 From 5be6e36dedaa07cfdbf735c4cf17b9b3d97a5e54 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:18:37 -0500 Subject: [PATCH 014/148] fix(build): isolate LLVM coverage caches from GCC presets --- CMakePresets.json | 16 ++++- client/AGENTS.md | 2 +- client/CMakePresets.json | 16 ++++- client/README.md | 16 +++-- libatrinik/AGENTS.md | 2 +- libatrinik/CMakePresets.json | 16 ++++- libatrinik/README.md | 26 +++++-- libatrinik/pathfinding/CMakePresets.json | 27 ++++++++ server/AGENTS.md | 2 +- server/CMakePresets.json | 17 ++++- server/README.md | 14 ++-- server/doc/TESTING.md | 15 +++-- tools/ci/run_gpu_coverage.sh | 42 ++++++------ tools/ci/run_linux_check.sh | 26 ++++--- tools/tests/test_coverage_presets.py | 86 ++++++++++++++++++++++++ tools/tests/test_workflow_contracts.py | 18 +++-- 16 files changed, 275 insertions(+), 66 deletions(-) create mode 100644 libatrinik/pathfinding/CMakePresets.json create mode 100644 tools/tests/test_coverage_presets.py diff --git a/CMakePresets.json b/CMakePresets.json index 18c80a094..70dfa430b 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -29,6 +29,14 @@ "ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS": false } }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "binaryDir": "${sourceDir}/build/integrated-llvm-coverage", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/cmake/toolchains/llvm.cmake" + } + }, { "name": "linux-sanitizers", "inherits": "linux-debug", @@ -42,6 +50,7 @@ {"name": "linux-debug", "configurePreset": "linux-debug"}, {"name": "linux-release", "configurePreset": "linux-release"}, {"name": "linux-coverage", "configurePreset": "linux-coverage"}, + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"}, {"name": "linux-sanitizers", "configurePreset": "linux-sanitizers"} ], "testPresets": [ @@ -58,9 +67,14 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", - "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/integrated-linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/integrated-llvm-coverage/llvm-profiles/%m-%p.profraw"} + }, { "name": "linux-sanitizers", "configurePreset": "linux-sanitizers", diff --git a/client/AGENTS.md b/client/AGENTS.md index 068d442f2..3e1c8b56a 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -99,7 +99,7 @@ - Validate dependency changes with `python3 tools/dependencies.py verify` and all native changes with `./atrinik build client --profile PROFILE --test` from the workspace. Use the workspace topology lifecycle for live checks. -- For substantial native logic changes, also run the `linux-coverage` preset +- For substantial native logic changes, also run the `llvm-coverage` preset and native LLVM LCOV export documented in `README.md`; keep source/test exclusions intentional. - Commits and pull-request titles use Conventional Commits. Classic uses one diff --git a/client/CMakePresets.json b/client/CMakePresets.json index 5487ae40d..f8985d872 100644 --- a/client/CMakePresets.json +++ b/client/CMakePresets.json @@ -31,6 +31,14 @@ "ENABLE_COVERAGE": true } }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + } + }, { "name": "linux-sanitizers", "inherits": "linux-debug", @@ -44,6 +52,7 @@ {"name": "linux-debug", "configurePreset": "linux-debug"}, {"name": "linux-sanitizers", "configurePreset": "linux-sanitizers"}, {"name": "linux-coverage", "configurePreset": "linux-coverage"}, + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"}, {"name": "linux-release", "configurePreset": "linux-release"} ], "testPresets": [ @@ -55,9 +64,14 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", - "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"} + }, { "name": "linux-sanitizers", "configurePreset": "linux-sanitizers", diff --git a/client/README.md b/client/README.md index c44c34515..835e324ad 100644 --- a/client/README.md +++ b/client/README.md @@ -25,13 +25,17 @@ To collect line, function, and branch coverage from the native tests in a full Classic checkout, use the pinned LLVM 23.1.3 toolchain and native profiles: - $ cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" - $ cmake --build --preset linux-coverage - $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage - $ ctest --preset linux-coverage - $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ + $ cmake --preset llvm-coverage + $ cmake --build --preset llvm-coverage + $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage + $ ctest --preset llvm-coverage + $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ --source-root .. --component client --output coverage.lcov + The llvm-coverage preset selects the pinned toolchain and its own + build/llvm-coverage cache and profiles. Existing linux-coverage build trees + remain available for the previously released producer. + The client requires SDL 3.4 or newer, SDL3_image 3.2 or newer, SDL3_ttf 3.2 or newer, and SDL3_mixer 3.2.4 or newer. The supported mixer contract contains exactly WAV, STBVORBIS, OPUS, VOC, AIFF, AU, DRMP3, SINEWAVE, and RAW; MIDI and @@ -566,5 +570,5 @@ Book writing Focused regression validation is `ctest --test-dir BUILD -R client-book-edit --output-on-failure`; the normal client suite also exercises command dispatch and - retained popup/GPU behavior. Use the documented linux-coverage preset for native + retained popup/GPU behavior. Use the documented llvm-coverage preset for native coverage, and the workspace wrapper for integrated client/server execution. diff --git a/libatrinik/AGENTS.md b/libatrinik/AGENTS.md index c837b5792..09c25e00f 100644 --- a/libatrinik/AGENTS.md +++ b/libatrinik/AGENTS.md @@ -34,7 +34,7 @@ top-level CMake/CTest suite, the standalone `pathfinding/` build and tests, install/package consumption, and downstream client/server builds through the workspace when a public API changes. -- For substantial native logic changes, also run the `linux-coverage` preset +- For substantial native logic changes, also run the `llvm-coverage` preset and native LLVM LCOV export documented in `README.md`; include both core and standalone pathfinding coverage without counting test or build output as source. - Commits and pull-request titles use Conventional Commits. Classic uses one diff --git a/libatrinik/CMakePresets.json b/libatrinik/CMakePresets.json index e7bbb7c48..73ef2a90b 100644 --- a/libatrinik/CMakePresets.json +++ b/libatrinik/CMakePresets.json @@ -12,6 +12,14 @@ "ENABLE_COVERAGE": true } }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + } + }, { "name": "linux-sanitizers", "generator": "Ninja", @@ -25,15 +33,21 @@ ], "buildPresets": [ {"name": "linux-coverage", "configurePreset": "linux-coverage"}, + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"}, {"name": "linux-sanitizers", "configurePreset": "linux-sanitizers"} ], "testPresets": [ { "name": "linux-coverage", "configurePreset": "linux-coverage", - "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"} + }, { "name": "linux-sanitizers", "configurePreset": "linux-sanitizers", diff --git a/libatrinik/README.md b/libatrinik/README.md index 86422df2e..71a2c4aea 100644 --- a/libatrinik/README.md +++ b/libatrinik/README.md @@ -23,14 +23,18 @@ To collect line, function, and branch coverage from core and standalone pathfinding tests in a full Classic checkout, use the pinned LLVM 23.1.3 tools: ```sh -cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" -cmake --build --preset linux-coverage -python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage -ctest --preset linux-coverage -python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ +cmake --preset llvm-coverage +cmake --build --preset llvm-coverage +python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage +ctest --preset llvm-coverage +python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ --source-root .. --component libatrinik --output coverage.lcov ``` +The `llvm-coverage` preset selects the pinned toolchain and its own +`build/llvm-coverage` cache and profiles. Existing `linux-coverage` build trees +remain available for the previously released producer. + The helper requires matching Clang, llvm-profdata and llvm-cov 23.1.3. CTest writes separate `%m-%p.profraw` files for processes and shared libraries; LCOV includes production C sources and excludes tests, build output and upstream @@ -159,6 +163,18 @@ cmake --build build/pathfinding --parallel ctest --test-dir build/pathfinding --output-on-failure ``` +Standalone coverage in a full Classic checkout uses the pathfinding module's +own LLVM preset and profile directory. From `libatrinik/pathfinding/`: + +```sh +cmake --preset llvm-coverage +cmake --build --preset llvm-coverage +python3 ../../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage +ctest --preset llvm-coverage +python3 ../../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ + --source-root ../.. --component libatrinik --output coverage.lcov +``` + Installing that build provides an independent `AtrinikPathfinding` CMake package. Release consumers request the exact unified package version with `find_package(AtrinikPathfinding 5.6.0 EXACT CONFIG REQUIRED)`, diff --git a/libatrinik/pathfinding/CMakePresets.json b/libatrinik/pathfinding/CMakePresets.json new file mode 100644 index 000000000..64bbe21f6 --- /dev/null +++ b/libatrinik/pathfinding/CMakePresets.json @@ -0,0 +1,27 @@ +{ + "version": 3, + "configurePresets": [ + { + "name": "llvm-coverage", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "BUILD_TESTING": true, + "ENABLE_COVERAGE": true, + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../../cmake/toolchains/llvm.cmake" + } + } + ], + "buildPresets": [ + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"} + ], + "testPresets": [ + { + "name": "llvm-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"}, + "output": {"outputOnFailure": true} + } + ] +} diff --git a/server/AGENTS.md b/server/AGENTS.md index 14f4c6e01..fa78c1008 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -101,7 +101,7 @@ - Follow root formatting and treat warnings/sanitizers as defects. Build/test through `./atrinik build server --profile PROFILE --test`; runtime checks use exact `topology show`/`up`/`ps`/`logs`/`down` with isolated state/scenario. -- For substantial native logic, run the documented `linux-coverage` preset and +- For substantial native logic, run the documented `llvm-coverage` preset and native LLVM LCOV export; pull-request CI also runs `linux-sanitizers`. - Classic has one release line. Preserve source, Windows package, checksum, and server-image contracts. Scoped source packages embed matching protocol/ diff --git a/server/CMakePresets.json b/server/CMakePresets.json index 0fd48b874..8560bf2f2 100644 --- a/server/CMakePresets.json +++ b/server/CMakePresets.json @@ -33,6 +33,14 @@ "ENABLE_COVERAGE": true } }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + } + }, { "name": "linux-sanitizers", "inherits": "linux-debug", @@ -46,6 +54,7 @@ {"name": "linux-debug", "configurePreset": "linux-debug"}, {"name": "linux-sanitizers", "configurePreset": "linux-sanitizers"}, {"name": "linux-coverage", "configurePreset": "linux-coverage"}, + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"}, {"name": "linux-release", "configurePreset": "linux-release"} ], "testPresets": [ @@ -64,10 +73,16 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", - "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true}, "execution": {"jobs": 4} }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"}, + "execution": {"jobs": 4} + }, { "name": "linux-release", "configurePreset": "linux-release", diff --git a/server/README.md b/server/README.md index b274c05dc..a6c1b0157 100644 --- a/server/README.md +++ b/server/README.md @@ -35,13 +35,17 @@ To collect line, function, and branch coverage from the native tests in a full Classic checkout, use the pinned LLVM 23.1.3 toolchain and native profiles: - $ cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" - $ cmake --build --preset linux-coverage - $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage - $ ctest --preset linux-coverage - $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ + $ cmake --preset llvm-coverage + $ cmake --build --preset llvm-coverage + $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage + $ ctest --preset llvm-coverage + $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ --source-root .. --component server --output coverage.lcov + The llvm-coverage preset selects the pinned toolchain and its own + build/llvm-coverage cache and profiles. Existing linux-coverage build trees + remain available for the previously released producer. + The server's simulation, monotonic, UTC wall, round-generation, and authored world-time domains are documented in doc/SERVER_CLOCKS.md. New deadlines must use the typed server clock API described there. diff --git a/server/doc/TESTING.md b/server/doc/TESTING.md index a5021b815..f854058bf 100644 --- a/server/doc/TESTING.md +++ b/server/doc/TESTING.md @@ -37,16 +37,17 @@ libatrinik code, the integrated build and standalone modules. From the server directory in a full Classic checkout: ```sh -cmake --preset linux-coverage \ - -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" -cmake --build --preset linux-coverage -python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage -ctest --preset linux-coverage --parallel 4 -LE performance -python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ +cmake --preset llvm-coverage +cmake --build --preset llvm-coverage +python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage +ctest --preset llvm-coverage --parallel 4 -LE performance +python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ --source-root .. --component server --output coverage.lcov ``` -The coverage test preset sets `LLVM_PROFILE_FILE` to the build's isolated +The `llvm-coverage` preset selects a separate `build/llvm-coverage` cache so +existing compiler selections in `linux-coverage` trees remain intact. The +coverage test preset sets `LLVM_PROFILE_FILE` to the build's isolated `llvm-profiles/%m-%p.profraw` path. The binary signature and process ID separate parallel processes and instrumented plugins. `prepare` clears only that build's previous profiles before a fresh pass; additional runtime probes retain the diff --git a/tools/ci/run_gpu_coverage.sh b/tools/ci/run_gpu_coverage.sh index 25050c8b4..b80388e26 100755 --- a/tools/ci/run_gpu_coverage.sh +++ b/tools/ci/run_gpu_coverage.sh @@ -11,7 +11,13 @@ source_root=$(realpath "$1") build_image=$2 gpu_image=$3 client_root=${source_root}/client -coverage_build=${client_root}/build/linux-coverage +coverage_preset=linux-coverage +coverage_directory=build/linux-coverage +if [[ ${ATRINIK_LLVM_TOOLCHAIN:-0} == 1 ]]; then + coverage_preset=llvm-coverage + coverage_directory=build/llvm-coverage +fi +coverage_build=${client_root}/${coverage_directory} coverage_record=${coverage_build}/gpu-ui-closure.jsonl expected_revision=$(git -C "${source_root}" rev-parse --verify HEAD) @@ -40,6 +46,8 @@ docker run --rm \ --env ATRINIK_BENCHMARK_REVISION="${expected_revision}" \ --env ATRINIK_BENCHMARK_DIRTY=false \ --env ATRINIK_LLVM_TOOLCHAIN="${ATRINIK_LLVM_TOOLCHAIN:-0}" \ + --env ATRINIK_COVERAGE_PRESET="${coverage_preset}" \ + --env ATRINIK_COVERAGE_DIRECTORY="${coverage_directory}" \ --volume "${source_root}:/workspace" \ --workdir /workspace \ "${build_image}" \ @@ -55,21 +63,15 @@ docker run --rm \ python3 client/tools/dependencies.py verify python3 client/tools/verify_gpu_fixture_provenance.py cd client - if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then - set -- -DCMAKE_TOOLCHAIN_FILE=/workspace/cmake/toolchains/llvm.cmake - else - set -- - fi - cmake --preset linux-coverage \ - "$@" \ + cmake --preset "${ATRINIK_COVERAGE_PRESET}" \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL=/workspace/protocol \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK=/workspace/libatrinik \ -DATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders - cmake --build --preset linux-coverage --parallel "$(nproc)" + cmake --build --preset "${ATRINIK_COVERAGE_PRESET}" --parallel "$(nproc)" if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then - python3 /workspace/tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage + python3 /workspace/tools/ci/llvm_coverage.py prepare --build-dir "${ATRINIK_COVERAGE_DIRECTORY}" fi - ctest --preset linux-coverage --output-on-failure --no-tests=error -LE performance + ctest --preset "${ATRINIK_COVERAGE_PRESET}" --output-on-failure --no-tests=error -LE performance ' if [[ ! -x ${coverage_build}/client-gpu-renderer-integration-tests || @@ -97,23 +99,23 @@ docker run --rm \ --network none \ --env ATRINIK_GPU_CONFORMANCE_DRIVER=vulkan \ --env ATRINIK_GPU_CONFORMANCE_REQUIRED=1 \ - --env ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY=build/linux-coverage/review \ + --env ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY="${coverage_directory}/review" \ --env SDL_VIDEODRIVER=x11 \ --env VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json \ --env VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json \ - --env LLVM_PROFILE_FILE="${compiled_home}/build/linux-coverage/llvm-profiles/%m-%p.profraw" \ + --env LLVM_PROFILE_FILE="${compiled_home}/${coverage_directory}/llvm-profiles/%m-%p.profraw" \ --volume "${source_root}:${compiled_root}" \ --workdir "${compiled_home}" \ "${gpu_image}" \ sh -c "xvfb-run -a sh -c 'set -eu; \ - mkdir -p build/linux-coverage/review; \ - ./build/linux-coverage/client-gpu-renderer-integration-tests; \ - ./build/linux-coverage/atrinik --gpu-player-view \ + mkdir -p ${coverage_directory}/review; \ + ./${coverage_directory}/client-gpu-renderer-integration-tests; \ + ./${coverage_directory}/atrinik --gpu-player-view \ src/tests/fixtures/player_view/gpu-ui-closure.xml \ - > build/linux-coverage/gpu-ui-closure.jsonl; \ + > ${coverage_directory}/gpu-ui-closure.jsonl; \ if ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY= \ ATRINIK_GPU_CONFORMANCE_TEST_SUPPRESS_ROOT_GLYPH=intro_server_browser \ - ./build/linux-coverage/atrinik --gpu-player-view \ + ./${coverage_directory}/atrinik --gpu-player-view \ src/tests/fixtures/player_view/gpu-ui-closure.xml >/dev/null 2>&1; then \ echo GPU UI closure accepted a suppressed root glyph >&2; \ exit 1; \ @@ -128,11 +130,13 @@ docker run --rm \ --volume "${source_root}:${compiled_root}" \ --workdir "${compiled_root}/client" \ --env ATRINIK_LLVM_TOOLCHAIN="${ATRINIK_LLVM_TOOLCHAIN:-0}" \ + --env ATRINIK_COVERAGE_PRESET="${coverage_preset}" \ + --env ATRINIK_COVERAGE_DIRECTORY="${coverage_directory}" \ "${build_image}" \ sh -c ' if [ "${ATRINIK_LLVM_TOOLCHAIN}" = 1 ]; then python3 ../tools/ci/llvm_coverage.py export \ - --build-dir build/linux-coverage --source-root .. \ + --build-dir "${ATRINIK_COVERAGE_DIRECTORY}" --source-root .. \ --component client --output coverage.lcov else gcovr --root . --filter src/ --exclude src/tests/ \ diff --git a/tools/ci/run_linux_check.sh b/tools/ci/run_linux_check.sh index c9ced006a..4d6785d4f 100755 --- a/tools/ci/run_linux_check.sh +++ b/tools/ci/run_linux_check.sh @@ -34,9 +34,13 @@ test "$(stat --format='%a' "${CCACHE_DIR}")" = 1777 test "$(jq -r '.target' /usr/local/share/atrinik/classic-toolchain.json)" = classic-final test "$(jq -r '.platform' /usr/local/share/atrinik/classic-toolchain.json)" = linux/amd64 llvm_toolchain=${ATRINIK_LLVM_TOOLCHAIN:-0} +coverage_preset=linux-coverage +coverage_directory=build/linux-coverage if [[ ${llvm_toolchain} == 1 ]]; then test "$(jq -r '.tools.clang' /usr/local/share/atrinik/classic-toolchain.json)" = 23.1.3 export CC=clang CXX=clang++ + coverage_preset=llvm-coverage + coverage_directory=build/llvm-coverage else # Transitional validation for the previously released immutable image. test "$(jq -r '.tools.gcc' /usr/local/share/atrinik/classic-toolchain.json)" = 15.2.0 @@ -54,14 +58,14 @@ fi # supplies %m-%p profile names, separating parallel processes and shared plugins. prepare_coverage() { if [[ ${llvm_toolchain} == 1 ]]; then - python3 "${source_root}/tools/ci/llvm_coverage.py" prepare --build-dir build/linux-coverage + python3 "${source_root}/tools/ci/llvm_coverage.py" prepare --build-dir "${coverage_directory}" fi } export_coverage() { local module=$1 if [[ ${llvm_toolchain} == 1 ]]; then python3 "${source_root}/tools/ci/llvm_coverage.py" export \ - --build-dir build/linux-coverage --source-root "${source_root}" \ + --build-dir "${coverage_directory}" --source-root "${source_root}" \ --component "${module}" --output coverage.lcov elif [[ ${module} == libatrinik ]]; then gcovr --root . --filter '.*\.c$' --exclude '.*/tests/.*' \ @@ -125,12 +129,12 @@ case "${component}" in ctest --test-dir "${source_root}/protocol/build/check" --output-on-failure pushd "${source_root}/libatrinik" >/dev/null - cmake --preset linux-coverage \ + cmake --preset "${coverage_preset}" \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ "${launcher[@]}" - cmake --build --preset linux-coverage --parallel "${jobs}" + cmake --build --preset "${coverage_preset}" --parallel "${jobs}" prepare_coverage - ctest --preset linux-coverage + ctest --preset "${coverage_preset}" export_coverage libatrinik cmake --preset linux-sanitizers \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ @@ -156,12 +160,12 @@ case "${component}" in python3 tools/dependencies.py sync \ --cache "${dependency_downloads}" --refresh --offline python3 tools/dependencies.py verify - cmake --preset linux-coverage \ + cmake --preset "${coverage_preset}" \ -DENABLE_PRECOMPILED_HEADERS=OFF \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-coverage --parallel "${jobs}" + cmake --build --preset "${coverage_preset}" --parallel "${jobs}" prepare_coverage - ctest --preset linux-coverage --parallel 4 -LE performance + ctest --preset "${coverage_preset}" --parallel 4 -LE performance export_coverage server cmake --preset linux-release \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" @@ -180,11 +184,11 @@ case "${component}" in python3 tools/dependencies.py sync \ --cache "${dependency_downloads}" --refresh --offline python3 tools/dependencies.py verify - cmake --preset linux-coverage \ + cmake --preset "${coverage_preset}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-coverage --parallel "${jobs}" + cmake --build --preset "${coverage_preset}" --parallel "${jobs}" prepare_coverage - ctest --preset linux-coverage -LE performance + ctest --preset "${coverage_preset}" -LE performance export_coverage client cmake --preset linux-release \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" diff --git a/tools/tests/test_coverage_presets.py b/tools/tests/test_coverage_presets.py new file mode 100644 index 000000000..d027ac3b8 --- /dev/null +++ b/tools/tests/test_coverage_presets.py @@ -0,0 +1,86 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("llvm_coverage", ROOT / "tools/ci/llvm_coverage.py") +coverage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(coverage) +MODULES = (".", "client", "server", "libatrinik", "libatrinik/pathfinding") + + +def preset(section, name): + values = {entry["name"]: entry for entry in section} + selected = values[name] + inherited = selected.get("inherits") + if inherited is None: + return selected.copy() + result = preset(section, inherited) + for key, value in selected.items(): + if isinstance(value, dict): + result[key] = {**result.get(key, {}), **value} + else: + result[key] = value + return result + + +class CoveragePresetTests(unittest.TestCase): + def test_opt_in_has_matching_configure_build_and_test_presets(self): + for module in MODULES: + with self.subTest(module=module): + source = ROOT / module + data = json.loads((source / "CMakePresets.json").read_text()) + configured = preset(data["configurePresets"], "llvm-coverage") + built = preset(data["buildPresets"], "llvm-coverage") + tested = preset(data["testPresets"], "llvm-coverage") + self.assertTrue(configured["cacheVariables"]["ENABLE_COVERAGE"]) + self.assertEqual(configured["cacheVariables"]["CMAKE_BUILD_TYPE"], "Debug") + toolchain = configured["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"].replace("${sourceDir}", str(source)) + self.assertEqual(Path(toolchain).resolve(), ROOT / "cmake/toolchains/llvm.cmake") + self.assertEqual(built["configurePreset"], "llvm-coverage") + self.assertEqual(tested["configurePreset"], "llvm-coverage") + self.assertEqual(tested["environment"]["LLVM_PROFILE_FILE"], + configured["binaryDir"] + "/llvm-profiles/%m-%p.profraw") + + def test_native_profile_preparation_leaves_existing_gcc_cache_untouched(self): + for module in MODULES[:-1]: + with self.subTest(module=module), tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) + data = json.loads((ROOT / module / "CMakePresets.json").read_text()) + legacy = preset(data["configurePresets"], "linux-coverage") + native = preset(data["configurePresets"], "llvm-coverage") + legacy_dir = Path(legacy["binaryDir"].replace("${sourceDir}", str(source))) + native_dir = Path(native["binaryDir"].replace("${sourceDir}", str(source))) + self.assertNotEqual(legacy_dir, native_dir) + legacy_dir.mkdir(parents=True) + cache = legacy_dir / "CMakeCache.txt" + cache.write_text("CMAKE_C_COMPILER:FILEPATH=/usr/bin/gcc\n") + raw = legacy_dir / "old.gcda" + raw.write_bytes(b"retained GCC data") + native_dir.mkdir(parents=True) + pattern = coverage.prepare(native_dir) + self.assertEqual(pattern, str(native_dir / "llvm-profiles/%m-%p.profraw")) + self.assertEqual(cache.read_text(), "CMAKE_C_COMPILER:FILEPATH=/usr/bin/gcc\n") + self.assertEqual(raw.read_bytes(), b"retained GCC data") + self.assertNotIn("CMAKE_TOOLCHAIN_FILE", legacy.get("cacheVariables", {})) + self.assertNotIn("LLVM_PROFILE_FILE", preset(data["testPresets"], "linux-coverage").get("environment", {})) + + @unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") + def test_cmake_accepts_all_coverage_preset_files_without_compiler_probes(self): + for module in MODULES: + with self.subTest(module=module): + result = subprocess.run(["cmake", "--list-presets=all"], cwd=ROOT / module, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.count('"llvm-coverage"'), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index e9b1b9e57..3952d991d 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -884,14 +884,16 @@ def test_server_validation_exercises_release_ndebug(self) -> None: runner = (ROOT / "tools" / "ci" / "run_linux_check.sh").read_text( encoding="utf-8" ) + self.assertIn("coverage_preset=linux-coverage", runner) + self.assertIn("coverage_preset=llvm-coverage", runner) server = runner[runner.index(" server)") : runner.index(" client)")] - coverage = server.index("cmake --preset linux-coverage") + coverage = server.index('cmake --preset "${coverage_preset}"') release = server.index("cmake --preset linux-release") sanitizers = server.index("cmake --preset linux-sanitizers") self.assertLess(coverage, release) self.assertLess(release, sanitizers) self.assertIn("cmake --build --preset linux-release --parallel", server) - for preset in ("linux-coverage", "linux-release", "linux-sanitizers"): + for preset in ('"${coverage_preset}"', "linux-release", "linux-sanitizers"): self.assertIn(f"ctest --preset {preset} --parallel 4", server) self.assertIn(f"ctest --preset {preset} --parallel 4 -LE performance", server) @@ -905,6 +907,7 @@ def test_server_validation_exercises_release_ndebug(self) -> None: "linux-debug": 4, "linux-sanitizers": 4, "linux-coverage": 4, + "llvm-coverage": 4, "linux-release": 4, }, ) @@ -949,7 +952,7 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None client = runner[client_start : runner.index(" client-benchmark)", client_start)] for name, component in (("server", server), ("client", client)): with self.subTest(component=name): - coverage = component.index("cmake --preset linux-coverage") + coverage = component.index('cmake --preset "${coverage_preset}"') release = component.index("cmake --preset linux-release") sanitizers = component.index("cmake --preset linux-sanitizers") self.assertLess(coverage, release) @@ -961,7 +964,7 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None ) else: self.assertNotIn("-DENABLE_PRECOMPILED_HEADERS=OFF", component) - self.assertIn("ctest --preset linux-coverage", component) + self.assertIn('ctest --preset "${coverage_preset}"', component) self.assertIn("ctest --preset linux-release", component) self.assertIn("ctest --preset linux-sanitizers", component) @@ -1976,8 +1979,11 @@ def test_linux_checks_pin_image_and_isolate_compiler_caches(self) -> None: self.assertIn('--env ATRINIK_BENCHMARK_REVISION="${expected_revision}"', gpu_coverage) self.assertIn("verify_gpu_coverage_record.py", gpu_coverage) self.assertIn("ATRINIK_GPU_CONFORMANCE_TEST_SUPPRESS_ROOT_GLYPH", gpu_coverage) - self.assertIn("cmake --preset linux-coverage", gpu_coverage) - self.assertIn("ctest --preset linux-coverage", gpu_coverage) + self.assertIn("coverage_preset=linux-coverage", gpu_coverage) + self.assertIn("coverage_preset=llvm-coverage", gpu_coverage) + self.assertIn('--env ATRINIK_COVERAGE_DIRECTORY="${coverage_directory}"', gpu_coverage) + self.assertIn('cmake --preset "${ATRINIK_COVERAGE_PRESET}"', gpu_coverage) + self.assertIn('ctest --preset "${ATRINIK_COVERAGE_PRESET}"', gpu_coverage) self.assertIn("lvp_icd.json", gpu_coverage) self.assertIn("--network none", gpu_coverage) self.assertIn("tools/ci/run_gpu_coverage.sh", client) From 1e0090669603a7e13e9f61189c6ba79a6d8cd934 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:18:37 -0500 Subject: [PATCH 015/148] fix(build): isolate LLVM coverage caches from GCC presets --- CMakePresets.json | 16 ++++- client/AGENTS.md | 2 +- client/CMakePresets.json | 16 ++++- client/README.md | 16 +++-- libatrinik/AGENTS.md | 2 +- libatrinik/CMakePresets.json | 16 ++++- libatrinik/README.md | 26 +++++-- libatrinik/pathfinding/CMakePresets.json | 27 ++++++++ server/AGENTS.md | 2 +- server/CMakePresets.json | 17 ++++- server/README.md | 14 ++-- server/doc/TESTING.md | 15 +++-- tools/ci/run_gpu_coverage.sh | 26 +++---- tools/ci/run_linux_check.sh | 22 +++--- tools/tests/test_coverage_presets.py | 86 ++++++++++++++++++++++++ tools/tests/test_workflow_contracts.py | 18 +++-- 16 files changed, 261 insertions(+), 60 deletions(-) create mode 100644 libatrinik/pathfinding/CMakePresets.json create mode 100644 tools/tests/test_coverage_presets.py diff --git a/CMakePresets.json b/CMakePresets.json index 18c80a094..70dfa430b 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -29,6 +29,14 @@ "ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS": false } }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "binaryDir": "${sourceDir}/build/integrated-llvm-coverage", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/cmake/toolchains/llvm.cmake" + } + }, { "name": "linux-sanitizers", "inherits": "linux-debug", @@ -42,6 +50,7 @@ {"name": "linux-debug", "configurePreset": "linux-debug"}, {"name": "linux-release", "configurePreset": "linux-release"}, {"name": "linux-coverage", "configurePreset": "linux-coverage"}, + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"}, {"name": "linux-sanitizers", "configurePreset": "linux-sanitizers"} ], "testPresets": [ @@ -58,9 +67,14 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", - "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/integrated-linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/integrated-llvm-coverage/llvm-profiles/%m-%p.profraw"} + }, { "name": "linux-sanitizers", "configurePreset": "linux-sanitizers", diff --git a/client/AGENTS.md b/client/AGENTS.md index 068d442f2..3e1c8b56a 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -99,7 +99,7 @@ - Validate dependency changes with `python3 tools/dependencies.py verify` and all native changes with `./atrinik build client --profile PROFILE --test` from the workspace. Use the workspace topology lifecycle for live checks. -- For substantial native logic changes, also run the `linux-coverage` preset +- For substantial native logic changes, also run the `llvm-coverage` preset and native LLVM LCOV export documented in `README.md`; keep source/test exclusions intentional. - Commits and pull-request titles use Conventional Commits. Classic uses one diff --git a/client/CMakePresets.json b/client/CMakePresets.json index 5487ae40d..f8985d872 100644 --- a/client/CMakePresets.json +++ b/client/CMakePresets.json @@ -31,6 +31,14 @@ "ENABLE_COVERAGE": true } }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + } + }, { "name": "linux-sanitizers", "inherits": "linux-debug", @@ -44,6 +52,7 @@ {"name": "linux-debug", "configurePreset": "linux-debug"}, {"name": "linux-sanitizers", "configurePreset": "linux-sanitizers"}, {"name": "linux-coverage", "configurePreset": "linux-coverage"}, + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"}, {"name": "linux-release", "configurePreset": "linux-release"} ], "testPresets": [ @@ -55,9 +64,14 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", - "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"} + }, { "name": "linux-sanitizers", "configurePreset": "linux-sanitizers", diff --git a/client/README.md b/client/README.md index c44c34515..835e324ad 100644 --- a/client/README.md +++ b/client/README.md @@ -25,13 +25,17 @@ To collect line, function, and branch coverage from the native tests in a full Classic checkout, use the pinned LLVM 23.1.3 toolchain and native profiles: - $ cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" - $ cmake --build --preset linux-coverage - $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage - $ ctest --preset linux-coverage - $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ + $ cmake --preset llvm-coverage + $ cmake --build --preset llvm-coverage + $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage + $ ctest --preset llvm-coverage + $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ --source-root .. --component client --output coverage.lcov + The llvm-coverage preset selects the pinned toolchain and its own + build/llvm-coverage cache and profiles. Existing linux-coverage build trees + remain available for the previously released producer. + The client requires SDL 3.4 or newer, SDL3_image 3.2 or newer, SDL3_ttf 3.2 or newer, and SDL3_mixer 3.2.4 or newer. The supported mixer contract contains exactly WAV, STBVORBIS, OPUS, VOC, AIFF, AU, DRMP3, SINEWAVE, and RAW; MIDI and @@ -566,5 +570,5 @@ Book writing Focused regression validation is `ctest --test-dir BUILD -R client-book-edit --output-on-failure`; the normal client suite also exercises command dispatch and - retained popup/GPU behavior. Use the documented linux-coverage preset for native + retained popup/GPU behavior. Use the documented llvm-coverage preset for native coverage, and the workspace wrapper for integrated client/server execution. diff --git a/libatrinik/AGENTS.md b/libatrinik/AGENTS.md index c837b5792..09c25e00f 100644 --- a/libatrinik/AGENTS.md +++ b/libatrinik/AGENTS.md @@ -34,7 +34,7 @@ top-level CMake/CTest suite, the standalone `pathfinding/` build and tests, install/package consumption, and downstream client/server builds through the workspace when a public API changes. -- For substantial native logic changes, also run the `linux-coverage` preset +- For substantial native logic changes, also run the `llvm-coverage` preset and native LLVM LCOV export documented in `README.md`; include both core and standalone pathfinding coverage without counting test or build output as source. - Commits and pull-request titles use Conventional Commits. Classic uses one diff --git a/libatrinik/CMakePresets.json b/libatrinik/CMakePresets.json index e7bbb7c48..73ef2a90b 100644 --- a/libatrinik/CMakePresets.json +++ b/libatrinik/CMakePresets.json @@ -12,6 +12,14 @@ "ENABLE_COVERAGE": true } }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + } + }, { "name": "linux-sanitizers", "generator": "Ninja", @@ -25,15 +33,21 @@ ], "buildPresets": [ {"name": "linux-coverage", "configurePreset": "linux-coverage"}, + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"}, {"name": "linux-sanitizers", "configurePreset": "linux-sanitizers"} ], "testPresets": [ { "name": "linux-coverage", "configurePreset": "linux-coverage", - "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true} }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"} + }, { "name": "linux-sanitizers", "configurePreset": "linux-sanitizers", diff --git a/libatrinik/README.md b/libatrinik/README.md index 86422df2e..71a2c4aea 100644 --- a/libatrinik/README.md +++ b/libatrinik/README.md @@ -23,14 +23,18 @@ To collect line, function, and branch coverage from core and standalone pathfinding tests in a full Classic checkout, use the pinned LLVM 23.1.3 tools: ```sh -cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" -cmake --build --preset linux-coverage -python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage -ctest --preset linux-coverage -python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ +cmake --preset llvm-coverage +cmake --build --preset llvm-coverage +python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage +ctest --preset llvm-coverage +python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ --source-root .. --component libatrinik --output coverage.lcov ``` +The `llvm-coverage` preset selects the pinned toolchain and its own +`build/llvm-coverage` cache and profiles. Existing `linux-coverage` build trees +remain available for the previously released producer. + The helper requires matching Clang, llvm-profdata and llvm-cov 23.1.3. CTest writes separate `%m-%p.profraw` files for processes and shared libraries; LCOV includes production C sources and excludes tests, build output and upstream @@ -159,6 +163,18 @@ cmake --build build/pathfinding --parallel ctest --test-dir build/pathfinding --output-on-failure ``` +Standalone coverage in a full Classic checkout uses the pathfinding module's +own LLVM preset and profile directory. From `libatrinik/pathfinding/`: + +```sh +cmake --preset llvm-coverage +cmake --build --preset llvm-coverage +python3 ../../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage +ctest --preset llvm-coverage +python3 ../../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ + --source-root ../.. --component libatrinik --output coverage.lcov +``` + Installing that build provides an independent `AtrinikPathfinding` CMake package. Release consumers request the exact unified package version with `find_package(AtrinikPathfinding 5.6.0 EXACT CONFIG REQUIRED)`, diff --git a/libatrinik/pathfinding/CMakePresets.json b/libatrinik/pathfinding/CMakePresets.json new file mode 100644 index 000000000..64bbe21f6 --- /dev/null +++ b/libatrinik/pathfinding/CMakePresets.json @@ -0,0 +1,27 @@ +{ + "version": 3, + "configurePresets": [ + { + "name": "llvm-coverage", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "BUILD_TESTING": true, + "ENABLE_COVERAGE": true, + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../../cmake/toolchains/llvm.cmake" + } + } + ], + "buildPresets": [ + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"} + ], + "testPresets": [ + { + "name": "llvm-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"}, + "output": {"outputOnFailure": true} + } + ] +} diff --git a/server/AGENTS.md b/server/AGENTS.md index 14f4c6e01..fa78c1008 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -101,7 +101,7 @@ - Follow root formatting and treat warnings/sanitizers as defects. Build/test through `./atrinik build server --profile PROFILE --test`; runtime checks use exact `topology show`/`up`/`ps`/`logs`/`down` with isolated state/scenario. -- For substantial native logic, run the documented `linux-coverage` preset and +- For substantial native logic, run the documented `llvm-coverage` preset and native LLVM LCOV export; pull-request CI also runs `linux-sanitizers`. - Classic has one release line. Preserve source, Windows package, checksum, and server-image contracts. Scoped source packages embed matching protocol/ diff --git a/server/CMakePresets.json b/server/CMakePresets.json index 0fd48b874..8560bf2f2 100644 --- a/server/CMakePresets.json +++ b/server/CMakePresets.json @@ -33,6 +33,14 @@ "ENABLE_COVERAGE": true } }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + } + }, { "name": "linux-sanitizers", "inherits": "linux-debug", @@ -46,6 +54,7 @@ {"name": "linux-debug", "configurePreset": "linux-debug"}, {"name": "linux-sanitizers", "configurePreset": "linux-sanitizers"}, {"name": "linux-coverage", "configurePreset": "linux-coverage"}, + {"name": "llvm-coverage", "configurePreset": "llvm-coverage"}, {"name": "linux-release", "configurePreset": "linux-release"} ], "testPresets": [ @@ -64,10 +73,16 @@ { "name": "linux-coverage", "configurePreset": "linux-coverage", - "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/linux-coverage/llvm-profiles/%m-%p.profraw"}, "output": {"outputOnFailure": true}, "execution": {"jobs": 4} }, + { + "name": "llvm-coverage", + "inherits": "linux-coverage", + "configurePreset": "llvm-coverage", + "environment": {"LLVM_PROFILE_FILE": "${sourceDir}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"}, + "execution": {"jobs": 4} + }, { "name": "linux-release", "configurePreset": "linux-release", diff --git a/server/README.md b/server/README.md index b274c05dc..a6c1b0157 100644 --- a/server/README.md +++ b/server/README.md @@ -35,13 +35,17 @@ To collect line, function, and branch coverage from the native tests in a full Classic checkout, use the pinned LLVM 23.1.3 toolchain and native profiles: - $ cmake --preset linux-coverage -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" - $ cmake --build --preset linux-coverage - $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage - $ ctest --preset linux-coverage - $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ + $ cmake --preset llvm-coverage + $ cmake --build --preset llvm-coverage + $ python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage + $ ctest --preset llvm-coverage + $ python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ --source-root .. --component server --output coverage.lcov + The llvm-coverage preset selects the pinned toolchain and its own + build/llvm-coverage cache and profiles. Existing linux-coverage build trees + remain available for the previously released producer. + The server's simulation, monotonic, UTC wall, round-generation, and authored world-time domains are documented in doc/SERVER_CLOCKS.md. New deadlines must use the typed server clock API described there. diff --git a/server/doc/TESTING.md b/server/doc/TESTING.md index 089b11549..cd66e0ced 100644 --- a/server/doc/TESTING.md +++ b/server/doc/TESTING.md @@ -37,16 +37,17 @@ libatrinik code, the integrated build and standalone modules. From the server directory in a full Classic checkout: ```sh -cmake --preset linux-coverage \ - -DCMAKE_TOOLCHAIN_FILE="$PWD/../cmake/toolchains/llvm.cmake" -cmake --build --preset linux-coverage -python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage -ctest --preset linux-coverage --parallel 4 -LE performance -python3 ../tools/ci/llvm_coverage.py export --build-dir build/linux-coverage \ +cmake --preset llvm-coverage +cmake --build --preset llvm-coverage +python3 ../tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage +ctest --preset llvm-coverage --parallel 4 -LE performance +python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ --source-root .. --component server --output coverage.lcov ``` -The coverage test preset sets `LLVM_PROFILE_FILE` to the build's isolated +The `llvm-coverage` preset selects a separate `build/llvm-coverage` cache so +existing compiler selections in `linux-coverage` trees remain intact. The +coverage test preset sets `LLVM_PROFILE_FILE` to the build's isolated `llvm-profiles/%m-%p.profraw` path. The binary signature and process ID separate parallel processes and instrumented plugins. `prepare` clears only that build's previous profiles before a fresh pass; additional runtime probes retain the diff --git a/tools/ci/run_gpu_coverage.sh b/tools/ci/run_gpu_coverage.sh index b4d0a5b92..6a6b11c5f 100755 --- a/tools/ci/run_gpu_coverage.sh +++ b/tools/ci/run_gpu_coverage.sh @@ -11,7 +11,7 @@ source_root=$(realpath "$1") build_image=$2 gpu_image=$3 client_root=${source_root}/client -coverage_build=${client_root}/build/linux-coverage +coverage_build=${client_root}/build/llvm-coverage coverage_record=${coverage_build}/gpu-ui-closure.jsonl expected_revision=$(git -C "${source_root}" rev-parse --verify HEAD) @@ -54,14 +54,14 @@ docker run --rm \ python3 client/tools/dependencies.py verify python3 client/tools/verify_gpu_fixture_provenance.py cd client - cmake --preset linux-coverage \ + cmake --preset llvm-coverage \ -DCMAKE_TOOLCHAIN_FILE=/workspace/cmake/toolchains/llvm.cmake \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL=/workspace/protocol \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK=/workspace/libatrinik \ -DATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders - cmake --build --preset linux-coverage --parallel "$(nproc)" - python3 /workspace/tools/ci/llvm_coverage.py prepare --build-dir build/linux-coverage - ctest --preset linux-coverage --output-on-failure --no-tests=error -LE performance + cmake --build --preset llvm-coverage --parallel "$(nproc)" + python3 /workspace/tools/ci/llvm_coverage.py prepare --build-dir build/llvm-coverage + ctest --preset llvm-coverage --output-on-failure --no-tests=error -LE performance ' if [[ ! -x ${coverage_build}/client-gpu-renderer-integration-tests || @@ -89,23 +89,23 @@ docker run --rm \ --network none \ --env ATRINIK_GPU_CONFORMANCE_DRIVER=vulkan \ --env ATRINIK_GPU_CONFORMANCE_REQUIRED=1 \ - --env ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY=build/linux-coverage/review \ + --env ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY=build/llvm-coverage/review \ --env SDL_VIDEODRIVER=x11 \ --env VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json \ --env VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json \ - --env LLVM_PROFILE_FILE="${compiled_home}/build/linux-coverage/llvm-profiles/%m-%p.profraw" \ + --env LLVM_PROFILE_FILE="${compiled_home}/build/llvm-coverage/llvm-profiles/%m-%p.profraw" \ --volume "${source_root}:${compiled_root}" \ --workdir "${compiled_home}" \ "${gpu_image}" \ sh -c "xvfb-run -a sh -c 'set -eu; \ - mkdir -p build/linux-coverage/review; \ - ./build/linux-coverage/client-gpu-renderer-integration-tests; \ - ./build/linux-coverage/atrinik --gpu-player-view \ + mkdir -p build/llvm-coverage/review; \ + ./build/llvm-coverage/client-gpu-renderer-integration-tests; \ + ./build/llvm-coverage/atrinik --gpu-player-view \ src/tests/fixtures/player_view/gpu-ui-closure.xml \ - > build/linux-coverage/gpu-ui-closure.jsonl; \ + > build/llvm-coverage/gpu-ui-closure.jsonl; \ if ATRINIK_GPU_CONFORMANCE_REVIEW_DIRECTORY= \ ATRINIK_GPU_CONFORMANCE_TEST_SUPPRESS_ROOT_GLYPH=intro_server_browser \ - ./build/linux-coverage/atrinik --gpu-player-view \ + ./build/llvm-coverage/atrinik --gpu-player-view \ src/tests/fixtures/player_view/gpu-ui-closure.xml >/dev/null 2>&1; then \ echo GPU UI closure accepted a suppressed root glyph >&2; \ exit 1; \ @@ -121,5 +121,5 @@ docker run --rm \ --workdir "${compiled_root}/client" \ "${build_image}" \ python3 ../tools/ci/llvm_coverage.py export \ - --build-dir build/linux-coverage --source-root .. \ + --build-dir build/llvm-coverage --source-root .. \ --component client --output coverage.lcov diff --git a/tools/ci/run_linux_check.sh b/tools/ci/run_linux_check.sh index 62f4432f1..9b0cbd0bd 100755 --- a/tools/ci/run_linux_check.sh +++ b/tools/ci/run_linux_check.sh @@ -44,12 +44,12 @@ launcher=( # CTest supplies binary/process-specific profiles for parallel tests and plugins. prepare_coverage() { - python3 "${source_root}/tools/ci/llvm_coverage.py" prepare --build-dir build/linux-coverage + python3 "${source_root}/tools/ci/llvm_coverage.py" prepare --build-dir build/llvm-coverage } export_coverage() { local module=$1 python3 "${source_root}/tools/ci/llvm_coverage.py" export \ - --build-dir build/linux-coverage --source-root "${source_root}" \ + --build-dir build/llvm-coverage --source-root "${source_root}" \ --component "${module}" --output coverage.lcov } @@ -103,12 +103,12 @@ case "${component}" in ctest --test-dir "${source_root}/protocol/build/check" --output-on-failure pushd "${source_root}/libatrinik" >/dev/null - cmake --preset linux-coverage \ + cmake --preset llvm-coverage \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ "${launcher[@]}" - cmake --build --preset linux-coverage --parallel "${jobs}" + cmake --build --preset llvm-coverage --parallel "${jobs}" prepare_coverage - ctest --preset linux-coverage + ctest --preset llvm-coverage export_coverage libatrinik cmake --preset linux-sanitizers \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ @@ -134,12 +134,12 @@ case "${component}" in python3 tools/dependencies.py sync \ --cache "${dependency_downloads}" --refresh --offline python3 tools/dependencies.py verify - cmake --preset linux-coverage \ + cmake --preset llvm-coverage \ -DENABLE_PRECOMPILED_HEADERS=OFF \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-coverage --parallel "${jobs}" + cmake --build --preset llvm-coverage --parallel "${jobs}" prepare_coverage - ctest --preset linux-coverage --parallel 4 -LE performance + ctest --preset llvm-coverage --parallel 4 -LE performance export_coverage server cmake --preset linux-release \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" @@ -158,11 +158,11 @@ case "${component}" in python3 tools/dependencies.py sync \ --cache "${dependency_downloads}" --refresh --offline python3 tools/dependencies.py verify - cmake --preset linux-coverage \ + cmake --preset llvm-coverage \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-coverage --parallel "${jobs}" + cmake --build --preset llvm-coverage --parallel "${jobs}" prepare_coverage - ctest --preset linux-coverage -LE performance + ctest --preset llvm-coverage -LE performance export_coverage client cmake --preset linux-release \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" diff --git a/tools/tests/test_coverage_presets.py b/tools/tests/test_coverage_presets.py new file mode 100644 index 000000000..d027ac3b8 --- /dev/null +++ b/tools/tests/test_coverage_presets.py @@ -0,0 +1,86 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("llvm_coverage", ROOT / "tools/ci/llvm_coverage.py") +coverage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(coverage) +MODULES = (".", "client", "server", "libatrinik", "libatrinik/pathfinding") + + +def preset(section, name): + values = {entry["name"]: entry for entry in section} + selected = values[name] + inherited = selected.get("inherits") + if inherited is None: + return selected.copy() + result = preset(section, inherited) + for key, value in selected.items(): + if isinstance(value, dict): + result[key] = {**result.get(key, {}), **value} + else: + result[key] = value + return result + + +class CoveragePresetTests(unittest.TestCase): + def test_opt_in_has_matching_configure_build_and_test_presets(self): + for module in MODULES: + with self.subTest(module=module): + source = ROOT / module + data = json.loads((source / "CMakePresets.json").read_text()) + configured = preset(data["configurePresets"], "llvm-coverage") + built = preset(data["buildPresets"], "llvm-coverage") + tested = preset(data["testPresets"], "llvm-coverage") + self.assertTrue(configured["cacheVariables"]["ENABLE_COVERAGE"]) + self.assertEqual(configured["cacheVariables"]["CMAKE_BUILD_TYPE"], "Debug") + toolchain = configured["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"].replace("${sourceDir}", str(source)) + self.assertEqual(Path(toolchain).resolve(), ROOT / "cmake/toolchains/llvm.cmake") + self.assertEqual(built["configurePreset"], "llvm-coverage") + self.assertEqual(tested["configurePreset"], "llvm-coverage") + self.assertEqual(tested["environment"]["LLVM_PROFILE_FILE"], + configured["binaryDir"] + "/llvm-profiles/%m-%p.profraw") + + def test_native_profile_preparation_leaves_existing_gcc_cache_untouched(self): + for module in MODULES[:-1]: + with self.subTest(module=module), tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) + data = json.loads((ROOT / module / "CMakePresets.json").read_text()) + legacy = preset(data["configurePresets"], "linux-coverage") + native = preset(data["configurePresets"], "llvm-coverage") + legacy_dir = Path(legacy["binaryDir"].replace("${sourceDir}", str(source))) + native_dir = Path(native["binaryDir"].replace("${sourceDir}", str(source))) + self.assertNotEqual(legacy_dir, native_dir) + legacy_dir.mkdir(parents=True) + cache = legacy_dir / "CMakeCache.txt" + cache.write_text("CMAKE_C_COMPILER:FILEPATH=/usr/bin/gcc\n") + raw = legacy_dir / "old.gcda" + raw.write_bytes(b"retained GCC data") + native_dir.mkdir(parents=True) + pattern = coverage.prepare(native_dir) + self.assertEqual(pattern, str(native_dir / "llvm-profiles/%m-%p.profraw")) + self.assertEqual(cache.read_text(), "CMAKE_C_COMPILER:FILEPATH=/usr/bin/gcc\n") + self.assertEqual(raw.read_bytes(), b"retained GCC data") + self.assertNotIn("CMAKE_TOOLCHAIN_FILE", legacy.get("cacheVariables", {})) + self.assertNotIn("LLVM_PROFILE_FILE", preset(data["testPresets"], "linux-coverage").get("environment", {})) + + @unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") + def test_cmake_accepts_all_coverage_preset_files_without_compiler_probes(self): + for module in MODULES: + with self.subTest(module=module): + result = subprocess.run(["cmake", "--list-presets=all"], cwd=ROOT / module, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.count('"llvm-coverage"'), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index d51b48ac5..e03ea916b 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -950,14 +950,16 @@ def test_server_validation_exercises_release_ndebug(self) -> None: runner = (ROOT / "tools" / "ci" / "run_linux_check.sh").read_text( encoding="utf-8" ) + self.assertIn("coverage_preset=linux-coverage", runner) + self.assertIn("coverage_preset=llvm-coverage", runner) server = runner[runner.index(" server)") : runner.index(" client)")] - coverage = server.index("cmake --preset linux-coverage") + coverage = server.index('cmake --preset "${coverage_preset}"') release = server.index("cmake --preset linux-release") sanitizers = server.index("cmake --preset linux-sanitizers") self.assertLess(coverage, release) self.assertLess(release, sanitizers) self.assertIn("cmake --build --preset linux-release --parallel", server) - for preset in ("linux-coverage", "linux-release", "linux-sanitizers"): + for preset in ('"${coverage_preset}"', "linux-release", "linux-sanitizers"): self.assertIn(f"ctest --preset {preset} --parallel 4", server) self.assertIn(f"ctest --preset {preset} --parallel 4 -LE performance", server) @@ -971,6 +973,7 @@ def test_server_validation_exercises_release_ndebug(self) -> None: "linux-debug": 4, "linux-sanitizers": 4, "linux-coverage": 4, + "llvm-coverage": 4, "linux-release": 4, }, ) @@ -1015,7 +1018,7 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None client = runner[client_start : runner.index(" client-benchmark)", client_start)] for name, component in (("server", server), ("client", client)): with self.subTest(component=name): - coverage = component.index("cmake --preset linux-coverage") + coverage = component.index('cmake --preset "${coverage_preset}"') release = component.index("cmake --preset linux-release") sanitizers = component.index("cmake --preset linux-sanitizers") self.assertLess(coverage, release) @@ -1027,7 +1030,7 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None ) else: self.assertNotIn("-DENABLE_PRECOMPILED_HEADERS=OFF", component) - self.assertIn("ctest --preset linux-coverage", component) + self.assertIn('ctest --preset "${coverage_preset}"', component) self.assertIn("ctest --preset linux-release", component) self.assertIn("ctest --preset linux-sanitizers", component) @@ -2049,8 +2052,11 @@ def test_linux_checks_pin_image_and_isolate_compiler_caches(self) -> None: self.assertIn('--env ATRINIK_BENCHMARK_REVISION="${expected_revision}"', gpu_coverage) self.assertIn("verify_gpu_coverage_record.py", gpu_coverage) self.assertIn("ATRINIK_GPU_CONFORMANCE_TEST_SUPPRESS_ROOT_GLYPH", gpu_coverage) - self.assertIn("cmake --preset linux-coverage", gpu_coverage) - self.assertIn("ctest --preset linux-coverage", gpu_coverage) + self.assertIn("coverage_preset=linux-coverage", gpu_coverage) + self.assertIn("coverage_preset=llvm-coverage", gpu_coverage) + self.assertIn('--env ATRINIK_COVERAGE_DIRECTORY="${coverage_directory}"', gpu_coverage) + self.assertIn('cmake --preset "${ATRINIK_COVERAGE_PRESET}"', gpu_coverage) + self.assertIn('ctest --preset "${ATRINIK_COVERAGE_PRESET}"', gpu_coverage) self.assertIn("lvp_icd.json", gpu_coverage) self.assertIn("--network none", gpu_coverage) self.assertIn("tools/ci/run_gpu_coverage.sh", client) From 30a242d284ad170a189a06aa0515941f83078168 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:23:27 -0500 Subject: [PATCH 016/148] build(client): migrate Linux package producers to LLVM 23.1.3 --- client/CMakeLists.txt | 6 ++ client/tools/build-appimage.sh | 7 +- cmake/InstallLLVMRuntime.cmake | 27 ++++++ docs/RELEASING.md | 24 ++++- server/Dockerfile | 1 + tools/ci/appimage/Dockerfile | 15 +++- tools/ci/appimage/assemble.py | 9 +- tools/ci/appimage/build-dependencies.sh | 9 ++ tools/ci/appimage/compiler.py | 45 ++++++++++ tools/ci/appimage/packaging.lock.json | 58 +++++++++++- tools/ci/appimage/prepare.py | 14 ++- tools/ci/appimage/test_compiler.py | 89 +++++++++++++++++++ tools/ci/appimage/test_prepare.py | 18 ++++ tools/ci/debian-client/Dockerfile | 5 ++ tools/release/appimage.py | 40 +++++++-- tools/release/finalize_artifacts.py | 12 ++- tools/release/release_artifacts.py | 14 +-- .../fixtures/llvm-readelf-23.1.3/dynamic.txt | 29 ++++++ .../fixtures/llvm-readelf-23.1.3/program.txt | 40 +++++++++ .../llvm-readelf-23.1.3/provenance.json | 35 ++++++++ .../fixtures/llvm-readelf-23.1.3/symbols.txt | 53 +++++++++++ .../fixtures/llvm-readelf-23.1.3/versions.txt | 25 ++++++ tools/tests/test_appimage.py | 53 ++++++++++- tools/tests/test_llvm_runtime_notices.py | 57 ++++++++++++ .../test_release_appimage_integration.py | 11 +++ tools/tests/test_release_artifacts.py | 4 +- 26 files changed, 668 insertions(+), 32 deletions(-) create mode 100644 cmake/InstallLLVMRuntime.cmake create mode 100644 tools/ci/appimage/compiler.py create mode 100644 tools/ci/appimage/test_compiler.py create mode 100644 tools/tests/fixtures/llvm-readelf-23.1.3/dynamic.txt create mode 100644 tools/tests/fixtures/llvm-readelf-23.1.3/program.txt create mode 100644 tools/tests/fixtures/llvm-readelf-23.1.3/provenance.json create mode 100644 tools/tests/fixtures/llvm-readelf-23.1.3/symbols.txt create mode 100644 tools/tests/fixtures/llvm-readelf-23.1.3/versions.txt create mode 100644 tools/tests/test_llvm_runtime_notices.py diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index f90949cd6..f5326c68e 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -1440,6 +1440,12 @@ else () endif () set(INSTALL_SUBDIR_SHARE "share/games/atrinik") set(INSTALL_SUBDIR_DOC "share/doc/atrinik") + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/InstallLLVMRuntime.cmake") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/InstallLLVMRuntime.cmake") + else () + include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/InstallLLVMRuntime.cmake") + endif () + atrinik_install_llvm_runtime_notices("${INSTALL_SUBDIR_DOC}" AtrinikClient) set(ATRINIK_DESKTOP_FILE "${CMAKE_CURRENT_SOURCE_DIR}/atrinik.desktop") if (PACKAGE_TYPE STREQUAL "deb") diff --git a/client/tools/build-appimage.sh b/client/tools/build-appimage.sh index 36e11b9aa..2987f236f 100755 --- a/client/tools/build-appimage.sh +++ b/client/tools/build-appimage.sh @@ -52,6 +52,9 @@ package=${output_directory}/atrinik-classic-client-${version}-linux-x86_64.AppIm [[ ! -e ${package} && ! -L ${package} ]] staging_directory=$(mktemp -d "${output_directory}/.atrinik-appimage.XXXXXX") trap 'rm -rf -- "${staging_directory}"' EXIT +python3 ../tools/ci/appimage/compiler.py "${prefix}/packaging.lock.json" +export PATH="/opt/llvm-23.1.3/bin:${PATH}" +export CC=clang CXX=clang++ AR=llvm-ar RANLIB=llvm-ranlib NM=llvm-nm STRIP=llvm-strip export CMAKE_PREFIX_PATH=${prefix} export PKG_CONFIG_PATH=${prefix}/lib/pkgconfig # Host build tools use the host loader; only dependency discovery/deployment @@ -62,6 +65,7 @@ python3 tools/dependencies.py verify python3 tools/verify_gpu_fixture_provenance.py build_directory=${ATRINIK_APPIMAGE_BUILD_DIRECTORY:-build/appimage-release} cmake -S . -B "${build_directory}" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$(realpath ../cmake/toolchains/llvm.cmake)" \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \ -DBUILD_TESTING=OFF -DPACKAGE_TYPE=appimage \ -DATRINIK_PACKAGE_VERSION="${version}" -DFETCHCONTENT_FULLY_DISCONNECTED=ON \ @@ -92,7 +96,8 @@ env -u VERSION -u SOURCE_DATE_EPOCH "${prefix}/tools/appimagetool" --mksquashfs- --mksquashfs-opt -mkfs-time --mksquashfs-opt "${source_date_epoch}" --runtime-file "${prefix}/tools/runtime" \ "${appdir}" "${staging_directory}/package.AppImage" python3 ../tools/ci/appimage/finalize_runtime.py "${staging_directory}/package.AppImage" -python3 ../tools/release/appimage.py "${staging_directory}/package.AppImage" "${version}" +python3 ../tools/release/appimage.py "${staging_directory}/package.AppImage" "${version}" \ + --compiler-image "$(cat "${prefix}/compiler-image.txt")" chmod 0755 "${staging_directory}/package.AppImage" mv -n -- "${staging_directory}/package.AppImage" "${package}" [[ ! -e ${staging_directory}/package.AppImage ]] diff --git a/cmake/InstallLLVMRuntime.cmake b/cmake/InstallLLVMRuntime.cmake new file mode 100644 index 000000000..9a5e0d22d --- /dev/null +++ b/cmake/InstallLLVMRuntime.cmake @@ -0,0 +1,27 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +include_guard(GLOBAL) + +function(atrinik_install_llvm_runtime_notices destination component) + if (NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + return() + endif () + get_filename_component(compiler "${CMAKE_C_COMPILER}" REALPATH) + get_filename_component(compiler_bin "${compiler}" DIRECTORY) + get_filename_component(compiler_prefix "${compiler_bin}" DIRECTORY) + set(ATRINIK_LLVM_LICENSE_DIRECTORY + "${compiler_prefix}/share/licenses/llvm" CACHE PATH + "Complete notices accompanying the LLVM compiler and static runtimes") + foreach (name IN ITEMS llvm clang clang-tools-extra lld compiler-rt libunwind) + set(notice "${ATRINIK_LLVM_LICENSE_DIRECTORY}/${name}-LICENSE.TXT") + if (NOT EXISTS "${notice}" OR IS_DIRECTORY "${notice}" OR IS_SYMLINK "${notice}") + message(FATAL_ERROR "LLVM runtime installation requires complete notice: ${notice}") + endif () + file(SIZE "${notice}" notice_size) + if (notice_size EQUAL 0) + message(FATAL_ERROR "LLVM runtime installation requires nonempty notice: ${notice}") + endif () + install(FILES "${notice}" DESTINATION "${destination}/licenses/llvm" + COMPONENT "${component}") + endforeach () +endfunction() diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4b2026a31..b92bc8919 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -537,7 +537,14 @@ verified immutable upstream revision and dependency contract. The editor has no Linux binary bundle. The Linux client AppImage producer uses pinned Ubuntu 24.04 x86_64 inputs and -rebuilds the qualified native dependency versions for the glibc 2.39 baseline. +rebuilds the qualified native dependency versions for the glibc 2.39 baseline. The +producer copies `/opt/llvm-23.1.3` from the digest-pinned Classic compiler +image, verifies its locked LLVM source, tool versions and complete notices, +and builds dependencies, probes and the application with Clang 23.1.3, +LLD, compiler-rt and static libunwind. Ubuntu 24.04 development libraries and +the exact APT package inventory remain the target ABI; no LTO or PGO is enabled. +The AppImage and Debian client carry the LLVM and static-runtime notices; +server images preserve the same notices under their application license directory. The packaging tools are linuxdeploy `1-alpha-20251107-1`, appimagetool `1.9.1`, and the static-FUSE type-2 runtime `20251108`. Application dependencies include SDL >= 3.4, image/ttf/mixer, libcurl built with c-ares and OpenSSL, and their @@ -552,11 +559,20 @@ static runtime, including its musl, FUSE and squashfuse components. `ATRINIK_PACKAGE_VERSION=MAJOR.MINOR.PATCH` and emits the exact versioned AppImage. The trusted caller validates it using `python3 tools/release/appimage.py PATH VERSION --revision FULL_SHA ---source-root TRUSTED_CHECKOUT`. Release callers bind both the exact source +--source-root TRUSTED_CHECKOUT --compiler-image IMMUTABLE_COMPILER_IMAGE`. +Release callers bind both the exact source commit and its checkout; standalone inspection may omit these flags and use the -current checkout's trusted inputs. Candidate code cannot replace that caller's +current checkout's trusted inputs. LLVM packages require the approved compiler +image from the trusted caller or an exact image pin in the source lock; +inspection rejects a different well-formed digest. Candidate code cannot replace that caller's validator or redefine the supported host-library boundary. -The trusted caller supplies `tools/ci/appimage/prepare.py`, its Dockerfile +SDK assembly uses the matching `llvm-readelf`. The trusted host inspector +uses distribution `readelf` only for read-only ELF inspection, preserving +historical release recovery on hosts without the SDK; it accepts the verified +LLVM output formats too. Host inspection tools never compile, link or strip +new application or dependency binaries. +The trusted caller supplies `tools/ci/appimage/prepare.py` with required +`--compiler-image ghcr.io/atrinik/classic-build:VERSION@sha256:DIGEST`, its Dockerfile and `smoke.sh`. The exact candidate checkout and staged inputs are read-only container inputs; candidate code runs from a private copy as a non-root user, with networking disabled, dropped capabilities and no-new-privileges. Only the diff --git a/server/Dockerfile b/server/Dockerfile index f84ffc963..87c3a471b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -105,6 +105,7 @@ COPY --from=build /src/server/build/release/atrinik-server-capabilities.json ./s COPY --from=build /src/server/build/release/atrinik-access-status ./server/atrinik-access-status COPY --from=build /src/server/build/release/libplugin_arena.so ./server/libplugin_arena.so COPY --from=build /src/server/build/release/libplugin_python.so ./server/libplugin_python.so +COPY --from=build /opt/llvm-23.1.3/share/licenses/llvm /usr/share/licenses/atrinik-classic/llvm COPY --from=build /src/LICENSE.md /usr/share/licenses/atrinik-classic/LICENSE.md COPY --from=build /src/ATTRIBUTIONS.md /usr/share/doc/atrinik-classic/ATTRIBUTIONS.md COPY --chmod=0755 server/docker/server-entrypoint.sh /usr/local/bin/atrinik-server-entrypoint diff --git a/tools/ci/appimage/Dockerfile b/tools/ci/appimage/Dockerfile index 5cd67991e..fce2ee90e 100644 --- a/tools/ci/appimage/Dockerfile +++ b/tools/ci/appimage/Dockerfile @@ -1,3 +1,6 @@ +ARG ATRINIK_COMPILER_IMAGE +FROM ${ATRINIK_COMPILER_IMAGE} AS compiler + FROM ubuntu:24.04@sha256:f610ab94648195aa356059f5b41d6085c9d4d903c072430cdd1af7bdb646106b AS builder COPY tools/ca-certificates.deb /tmp/ca-certificates.deb RUN echo "641de77d8f142cfd62a1a6f964ba67b20754d3337c480efb529d086075a06c9a /tmp/ca-certificates.deb" | sha256sum -c - && dpkg-deb -x /tmp/ca-certificates.deb /tmp/ca-bootstrap && mkdir -p /etc/ssl/certs && cat /tmp/ca-bootstrap/usr/share/ca-certificates/mozilla/*.crt > /etc/ssl/certs/ca-certificates.crt && rm -rf /tmp/ca-bootstrap /tmp/ca-certificates.deb @@ -14,19 +17,23 @@ RUN rm -f /etc/apt/sources.list.d/ubuntu.sources && printf '%s\n' \ libasound2-dev libpulse-dev libdbus-1-dev libudev-dev libdecor-0-dev libdrm-dev \ libgbm-dev libegl-dev libgl-dev && dpkg-query -W -f='${Package}\t${Version}\t${Architecture}\n' > /build-packages.tsv && rm -rf /var/lib/apt/lists/* RUN sed -i 's/ main universe$/ main/' /etc/apt/sources.list && apt-get -o APT::Update::Error-Mode=any update && apt-get install -y --no-install-recommends libvulkan-dev desktop-file-utils && dpkg-query -W -f='${Package}\t${Version}\t${Architecture}\n' > /build-packages.tsv && rm -rf /var/lib/apt/lists/* +COPY --from=compiler /opt/llvm-23.1.3 /opt/llvm-23.1.3 +ENV PATH="/opt/llvm-23.1.3/bin:${PATH}" CC=clang CXX=clang++ AR=llvm-ar RANLIB=llvm-ranlib NM=llvm-nm STRIP=llvm-strip OBJCOPY=llvm-objcopy OBJDUMP=llvm-objdump READELF=llvm-readelf +COPY compiler.py /inputs/compiler.py +COPY compiler-image.txt /opt/atrinik-appimage/compiler-image.txt COPY sources /inputs/sources COPY tools /inputs/tools COPY runtime-sources /inputs/runtime-sources COPY dependency-inputs.lock.json /inputs/packaging.lock.json COPY build-dependencies.sh /inputs/ -RUN bash /inputs/build-dependencies.sh && rm -rf /inputs/sources /build +RUN python3 /inputs/compiler.py /inputs/packaging.lock.json && bash /inputs/build-dependencies.sh && rm -rf /inputs/sources /build COPY packaging.lock.json /opt/atrinik-appimage/packaging.lock.json RUN chmod -R a+rX /opt/atrinik-appimage && chmod a+r /build-packages.tsv COPY runtime-probe.c /inputs/runtime-probe.c -RUN PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig cc -Wall -Wextra -Werror -O2 /inputs/runtime-probe.c $(PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig pkg-config --cflags --libs sdl3 sdl3-image sdl3-ttf sdl3-mixer libcurl) -o /appimage-runtime-probe +RUN PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig clang -Wall -Wextra -Werror -O2 /inputs/runtime-probe.c $(PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig pkg-config --cflags --libs sdl3 sdl3-image sdl3-ttf sdl3-mixer libcurl) -o /appimage-runtime-probe COPY graphical-probe.c /inputs/graphical-probe.c -RUN PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig cc -Wall -Wextra -Werror -O2 /inputs/graphical-probe.c $(PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig pkg-config --cflags --libs sdl3 sdl3-image sdl3-ttf sdl3-mixer) -o /appimage-graphical-probe -ENV PATH="/opt/atrinik-appimage/bin:/opt/atrinik-appimage/tools:${PATH}" \ +RUN PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig clang -Wall -Wextra -Werror -O2 /inputs/graphical-probe.c $(PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig pkg-config --cflags --libs sdl3 sdl3-image sdl3-ttf sdl3-mixer) -o /appimage-graphical-probe +ENV PATH="/opt/llvm-23.1.3/bin:/opt/atrinik-appimage/bin:/opt/atrinik-appimage/tools:${PATH}" \ CMAKE_PREFIX_PATH=/opt/atrinik-appimage \ PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig \ LD_LIBRARY_PATH=/opt/atrinik-appimage/lib diff --git a/tools/ci/appimage/assemble.py b/tools/ci/appimage/assemble.py index 55d31c458..c7d3b9650 100755 --- a/tools/ci/appimage/assemble.py +++ b/tools/ci/appimage/assemble.py @@ -28,7 +28,7 @@ def run(*args: str) -> str: def dynamic(path: Path) -> tuple[list[str], str | None]: - result = run('readelf', '-dW', str(path)) + result = run('llvm-readelf', '-dW', str(path)) needed = re.findall(r'\(NEEDED\).*?\[(.*?)\]', result) soname = re.findall(r'\(SONAME\).*?\[(.*?)\]', result) return needed, soname[0] if soname else None @@ -43,6 +43,10 @@ def assemble(appdir: Path, prefix: Path, version: str, revision: str) -> None: prepared_lock = prefix / 'packaging.lock.json' if sha256(prepared_lock) != sha256(lock_path): raise ValueError('dependency image packaging lock differs from source lock') + sys.path.insert(0, str(HERE)) + from compiler import image_reference, verify + verify(Path(lock['compiler']['prefix']), lock['compiler']) + compiler_image = image_reference((prefix / 'compiler-image.txt').read_text().strip()) libdir = appdir / 'usr/lib' # linuxdeploy may leave version aliases or intentionally omitted libraries. # Materialize one flat, audited SONAME provider set from the pinned builder. @@ -218,7 +222,8 @@ def copy_library(name: str) -> None: 'bundled_libraries': bundled, 'bundled_library_records': library_records, 'system_license_notices': system_license_notices(appdir, lock, packages), - 'native_inputs': {name: lock[name] for name in ('sources', 'tools', 'runtime')}, + 'native_inputs': {name: lock[name] for name in ('sources', 'tools', 'runtime', 'compiler')}, + 'compiler_image': compiler_image, 'files': {}, } for path in sorted(appdir.rglob('*')): diff --git a/tools/ci/appimage/build-dependencies.sh b/tools/ci/appimage/build-dependencies.sh index 5668d17e9..ba7ff1873 100644 --- a/tools/ci/appimage/build-dependencies.sh +++ b/tools/ci/appimage/build-dependencies.sh @@ -14,10 +14,16 @@ PY for archive in /inputs/sources/*.tar.gz; do tar -xzf "${archive}" -C /build; done export CFLAGS="-ffile-prefix-map=/build=. -fdebug-prefix-map=/build=. -ffile-prefix-map=/opt/atrinik-appimage=/usr" export CXXFLAGS="${CFLAGS}" +export CC=clang CXX=clang++ AR=llvm-ar RANLIB=llvm-ranlib NM=llvm-nm STRIP=llvm-strip +export LDFLAGS="--rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld" export CMAKE_PREFIX_PATH=${prefix} LD_LIBRARY_PATH=${prefix}/lib PKG_CONFIG_PATH=${prefix}/lib/pkgconfig build() { local source=$1; shift cmake -S "/build/${source}" -B "/build/${source}-build" -G Ninja \ + -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_AR=llvm-ar -DCMAKE_RANLIB=llvm-ranlib \ + -DCMAKE_NM=llvm-nm -DCMAKE_STRIP=llvm-strip -DCMAKE_OBJCOPY=llvm-objcopy \ + -DCMAKE_OBJDUMP=llvm-objdump -DCMAKE_READELF=llvm-readelf \ + "-DCMAKE_EXE_LINKER_FLAGS=${LDFLAGS}" "-DCMAKE_SHARED_LINKER_FLAGS=${LDFLAGS}" \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="${prefix}" -DCMAKE_INSTALL_LIBDIR=lib "$@" cmake --build "/build/${source}-build" --parallel 4 cmake --install "/build/${source}-build" @@ -96,3 +102,6 @@ with notice.open('ab') as out: for m in files: out.write(('\n===== '+e['name']+' '+m.name+' =====\n').encode());out.write(source.extractfile(m).read());out.write(b'\n') PYLICENSE + +mkdir -p "${prefix}/share/licenses/llvm" +cp /opt/llvm-23.1.3/share/licenses/llvm/*-LICENSE.TXT "${prefix}/share/licenses/llvm/" diff --git a/tools/ci/appimage/compiler.py b/tools/ci/appimage/compiler.py new file mode 100644 index 000000000..cd1ddd573 --- /dev/null +++ b/tools/ci/appimage/compiler.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Verify the LLVM cohort copied into the distribution packaging producer.""" +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys + + +IMAGE_PATTERN = r'ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}' + + +def image_reference(value): + if not re.fullmatch(IMAGE_PATTERN, value): + raise ValueError('compiler image must be an immutable published classic-build reference') + return value + + +def verify(prefix, compiler): + manifest = json.loads((prefix / 'share/atrinik/llvm-toolchain.json').read_text()) + if any(manifest.get(key) != compiler[key] for key in ('version', 'prefix', 'source', 'defaults', 'tools')): + raise ValueError('compiler producer identity differs from the locked LLVM cohort') + for name in compiler['tools']: + tool = prefix / 'bin' / name + if not tool.is_file(): + raise ValueError(f'compiler producer has a missing LLVM tool: {name}') + output = subprocess.check_output([str(prefix / 'bin' / name), '--version'], text=True, stderr=subprocess.STDOUT, timeout=30) + if not re.search(r'(?:[Vv]ersion|LLD) 23\.1\.3(?:[^0-9.]|$)', output) and not (name == 'llvm-config' and output.strip() == '23.1.3'): + raise ValueError(f'compiler producer has a mismatched LLVM tool: {name}') + for name, expected in compiler['notices'].items(): + notice = prefix / 'share/licenses/llvm' / name + if notice.is_symlink() or not notice.is_file() or hashlib.sha256(notice.read_bytes()).hexdigest() != expected: + raise ValueError(f'compiler producer has a missing or changed notice: {name}') + source = prefix / 'share/atrinik/sources/llvm-project-23.1.3.src.tar.xz' + with source.open('rb') as stream: + if hashlib.file_digest(stream, 'sha256').hexdigest() != compiler['source']['sha256']: + raise ValueError('compiler producer source archive differs from the locked LLVM source') + + +if __name__ == '__main__': + lock = json.loads(Path(sys.argv[1]).read_text()) + verify(Path(lock['compiler']['prefix']), lock['compiler']) diff --git a/tools/ci/appimage/packaging.lock.json b/tools/ci/appimage/packaging.lock.json index e7e434f7f..afc3f827b 100644 --- a/tools/ci/appimage/packaging.lock.json +++ b/tools/ci/appimage/packaging.lock.json @@ -155,7 +155,13 @@ "usr/share/doc/atrinik/licenses/openssl/LICENSE", "usr/share/doc/atrinik/licenses/curl/LICENSE", "usr/share/doc/atrinik/licenses/c-ares/LICENSE", - "usr/share/doc/atrinik/licenses/appimage-runtime/LICENSE" + "usr/share/doc/atrinik/licenses/appimage-runtime/LICENSE", + "usr/share/doc/atrinik/licenses/llvm/llvm-LICENSE.TXT", + "usr/share/doc/atrinik/licenses/llvm/clang-LICENSE.TXT", + "usr/share/doc/atrinik/licenses/llvm/clang-tools-extra-LICENSE.TXT", + "usr/share/doc/atrinik/licenses/llvm/lld-LICENSE.TXT", + "usr/share/doc/atrinik/licenses/llvm/compiler-rt-LICENSE.TXT", + "usr/share/doc/atrinik/licenses/llvm/libunwind-LICENSE.TXT" ], "bootstrap": [ { @@ -2266,5 +2272,55 @@ "soname": "libpulsecommon-16.1.so", "version": "1:16.1+dfsg1-2ubuntu10.1" } + }, + "compiler": { + "family": "Clang", + "version": "23.1.3", + "prefix": "/opt/llvm-23.1.3", + "source": { + "repository": "llvm/llvm-project", + "tag": "llvmorg-23.1.3", + "commit": "0d261d1ca552c95a8f007e061c787ac7132fbcbc", + "url": "https://github.com/llvm/llvm-project/releases/download/llvmorg-23.1.3/llvm-project-23.1.3.src.tar.xz", + "sha256": "c44186a7762ed28954be72e5ff6df9808e0779d4f1bf014ecc4e7e211d31ee34", + "archive_root": "llvm-project-23.1.3.src", + "license": "Apache-2.0 WITH LLVM-exception", + "license_path": "llvm/LICENSE.TXT", + "license_sha256": "8d85c1057d742e597985c7d4e6320b015a9139385cff4cbae06ffc0ebe89afee" + }, + "defaults": { + "runtime": "compiler-rt", + "unwind": "libunwind", + "linker": "lld", + "unwind_linkage": "static" + }, + "tools": [ + "clang", + "clang++", + "clang-format", + "clang-tidy", + "clangd", + "ld.lld", + "llvm-ar", + "llvm-ranlib", + "llvm-nm", + "llvm-strip", + "llvm-objcopy", + "llvm-objdump", + "llvm-readelf", + "llvm-readobj", + "llvm-size", + "llvm-cov", + "llvm-profdata", + "llvm-config" + ], + "notices": { + "llvm-LICENSE.TXT": "8d85c1057d742e597985c7d4e6320b015a9139385cff4cbae06ffc0ebe89afee", + "clang-LICENSE.TXT": "ebcd9bbf783a73d05c53ba4d586b8d5813dcdf3bbec50265860ccc885e606f47", + "clang-tools-extra-LICENSE.TXT": "ebcd9bbf783a73d05c53ba4d586b8d5813dcdf3bbec50265860ccc885e606f47", + "lld-LICENSE.TXT": "f7891568956e34643eb6a0db1462db30820d40d7266e2a78063f2fe233ece5a0", + "compiler-rt-LICENSE.TXT": "1a8f1058753f1ba890de984e48f0242a3a5c29a6a8f2ed9fd813f36985387e8d", + "libunwind-LICENSE.TXT": "b5efebcaca80879234098e52d1725e6d9eb8fb96a19fce625d39184b705f7b6d" + } } } diff --git a/tools/ci/appimage/prepare.py b/tools/ci/appimage/prepare.py index 985439743..ffc2f2dfa 100644 --- a/tools/ci/appimage/prepare.py +++ b/tools/ci/appimage/prepare.py @@ -4,6 +4,7 @@ from concurrent.futures import ThreadPoolExecutor import hashlib import json +import re import os from pathlib import Path import shutil @@ -40,9 +41,16 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--cache', type=Path) + parser.add_argument('--compiler-image', required=True) args = parser.parse_args() root = Path(__file__).resolve().parent lock = json.loads((root / 'packaging.lock.json').read_text()) + if not re.fullmatch(r'ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}', args.compiler_image): + parser.error('--compiler-image must be an immutable published classic-build reference') + if lock['compiler'].get('image') is not None and args.compiler_image != lock['compiler']['image']: + parser.error('--compiler-image differs from the trusted packaging lock') + if args.output.resolve() == root: + parser.error('--output must be a separate generated context directory') args.output.mkdir(parents=True, exist_ok=True) cache = args.cache or args.output / '.cache' cache.mkdir(parents=True, exist_ok=True) @@ -57,14 +65,16 @@ def main(): jobs.append((entry, args.output / 'tools' / entry['name'])) with ThreadPoolExecutor(max_workers=4) as pool: list(pool.map(lambda job: download(job[0], job[1], cache), jobs)) - (args.output / 'dependency-inputs.lock.json').write_text(json.dumps({k: lock[k] for k in ('sources', 'tools', 'runtime', 'license_inputs', 'runtime_sources', 'base_image')}, sort_keys=True) + '\n') + (args.output / 'dependency-inputs.lock.json').write_text(json.dumps({k: lock[k] for k in ('sources', 'tools', 'runtime', 'license_inputs', 'runtime_sources', 'base_image', 'compiler')}, sort_keys=True) + '\n') + (args.output / 'compiler-image.txt').write_text(args.compiler_image + '\n') for name in ('runtime-probe.c', 'graphical-probe.c'): probe = root / name if probe.is_file() and probe.resolve() != (args.output / probe.name).resolve(): shutil.copyfile(probe, args.output / probe.name) - for name in ('Dockerfile', 'build-dependencies.sh', 'packaging.lock.json'): + for name in ('build-dependencies.sh', 'compiler.py', 'packaging.lock.json'): if (root / name).resolve() != (args.output / name).resolve(): shutil.copyfile(root / name, args.output / name) + (args.output / 'Dockerfile').write_text((root / 'Dockerfile').read_text().replace('FROM ${ATRINIK_COMPILER_IMAGE} AS compiler', f'FROM {args.compiler_image} AS compiler')) if __name__ == '__main__': diff --git a/tools/ci/appimage/test_compiler.py b/tools/ci/appimage/test_compiler.py new file mode 100644 index 000000000..061e30cbb --- /dev/null +++ b/tools/ci/appimage/test_compiler.py @@ -0,0 +1,89 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +HERE = Path(__file__).parent +spec = importlib.util.spec_from_file_location('appimage_compiler', HERE / 'compiler.py') +compiler = importlib.util.module_from_spec(spec) +spec.loader.exec_module(compiler) + + +class CompilerTests(unittest.TestCase): + def fixture(self, root): + lock = json.loads((HERE / 'packaging.lock.json').read_text())['compiler'] + lock['notices'] = {'compiler-rt-LICENSE.TXT': hashlib.sha256(b'notice').hexdigest()} + lock['source']['sha256'] = hashlib.sha256(b'source').hexdigest() + manifest = root / 'share/atrinik/llvm-toolchain.json' + manifest.parent.mkdir(parents=True) + manifest.write_text(json.dumps(lock)) + notice = root / 'share/licenses/llvm/compiler-rt-LICENSE.TXT' + notice.parent.mkdir(parents=True) + notice.write_bytes(b'notice') + source = root / 'share/atrinik/sources/llvm-project-23.1.3.src.tar.xz' + source.parent.mkdir() + source.write_bytes(b'source') + (root / 'bin').mkdir() + for name in lock['tools']: + (root / 'bin' / name).touch() + return lock, manifest, notice, source + + def test_image_rejects_mutable_foreign_or_malformed_identity(self): + expected = 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 + self.assertEqual(compiler.image_reference(expected), expected) + for value in (expected.split('@')[0], expected.replace('classic-build', 'other'), + expected[:-1], expected + '\n', 'classic-build:latest'): + with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'immutable'): + compiler.image_reference(value) + + def test_preparation_rejects_missing_or_mutable_image_before_download(self): + with tempfile.TemporaryDirectory() as directory: + for argument in ([], ['--compiler-image', 'classic-build:latest']): + result = subprocess.run([sys.executable, str(HERE / 'prepare.py'), '--output', directory, *argument], capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertEqual(list(Path(directory).iterdir()), []) + + def test_cohort_and_notice_changes_fail_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + lock, manifest, notice, source = self.fixture(root) + def version(args, **kwargs): + return '23.1.3\n' if Path(args[0]).name == 'llvm-config' else 'LLVM version 23.1.3\n' + with patch.object(compiler.subprocess, 'check_output', side_effect=version): + compiler.verify(root, lock) + original = manifest.read_bytes() + changed = json.loads(original) + changed['defaults']['runtime'] = 'libgcc' + manifest.write_text(json.dumps(changed)) + with self.assertRaisesRegex(ValueError, 'identity'): + compiler.verify(root, lock) + manifest.write_bytes(original) + notice.unlink() + with self.assertRaisesRegex(ValueError, 'notice'): + compiler.verify(root, lock) + notice.write_bytes(b'wrong notice') + with self.assertRaisesRegex(ValueError, 'notice'): + compiler.verify(root, lock) + notice.write_bytes(b'notice') + source.write_bytes(b'changed source') + with self.assertRaisesRegex(ValueError, 'source archive'): + compiler.verify(root, lock) + + def test_wrong_tool_version_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + lock, *_ = self.fixture(root) + with patch.object(compiler.subprocess, 'check_output', return_value='LLVM version 23.1.30\n'): + with self.assertRaisesRegex(ValueError, 'mismatched LLVM tool'): + compiler.verify(root, lock) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/ci/appimage/test_prepare.py b/tools/ci/appimage/test_prepare.py index 84f19a30e..36ba422b0 100644 --- a/tools/ci/appimage/test_prepare.py +++ b/tools/ci/appimage/test_prepare.py @@ -2,6 +2,8 @@ from pathlib import Path import tempfile import unittest +import json +import sys from unittest.mock import patch spec = importlib.util.spec_from_file_location('prepare', Path(__file__).with_name('prepare.py')) @@ -10,6 +12,22 @@ class IntegrityTests(unittest.TestCase): + def test_context_embeds_the_exact_immutable_compiler_cohort(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source_lock = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) + image = source_lock['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 + def materialize(entry, destination, cache): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b'fixture') + with patch.object(sys, 'argv', ['prepare.py', '--output', directory, '--compiler-image', image]), patch.object(prepare, 'download', side_effect=materialize): + prepare.main() + staged_lock = json.loads((root / 'dependency-inputs.lock.json').read_text()) + self.assertEqual(staged_lock['compiler'], source_lock['compiler']) + self.assertIn(f'FROM {image} AS compiler', (root / 'Dockerfile').read_text()) + self.assertEqual((root / 'compiler-image.txt').read_text(), image + '\n') + self.assertEqual((root / 'compiler.py').read_bytes(), (Path(__file__).with_name('compiler.py')).read_bytes()) + def test_corrupt_cache_fails_without_network_or_output(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tools/ci/debian-client/Dockerfile b/tools/ci/debian-client/Dockerfile index 5e96dda41..68ba495ce 100644 --- a/tools/ci/debian-client/Dockerfile +++ b/tools/ci/debian-client/Dockerfile @@ -1,3 +1,6 @@ +ARG ATRINIK_COMPILER_IMAGE +FROM ${ATRINIK_COMPILER_IMAGE} AS compiler + # Debian testing supplies the SDL 3.4 and SDL3_mixer APIs used by Classic. # Freeze build and install inputs together; runtime dependencies remain ranges. FROM debian:forky-slim@sha256:3c80e257eff6274d593eb33a94bda5174ccd619836e6a728a73e11c97f2213e3 AS runtime @@ -17,6 +20,8 @@ RUN printf '%s\n' 'path-include=/usr/share/doc/atrinik' \ 'path-include=/usr/share/doc/atrinik/*' > /etc/dpkg/dpkg.cfg.d/zz-atrinik-notices FROM runtime AS build +COPY --from=compiler /opt/llvm-23.1.3 /opt/llvm-23.1.3 +ENV PATH="/opt/llvm-23.1.3/bin:${PATH}" CC=clang CXX=clang++ AR=llvm-ar RANLIB=llvm-ranlib NM=llvm-nm STRIP=llvm-strip RUN apt-get -o Acquire::Retries=3 -o APT::Update::Error-Mode=any update \ && apt-get install -y --no-install-recommends \ binutils build-essential cmake dpkg-dev file libcurl4-openssl-dev \ diff --git a/tools/release/appimage.py b/tools/release/appimage.py index 2dd6aa6b6..118f0f021 100644 --- a/tools/release/appimage.py +++ b/tools/release/appimage.py @@ -285,7 +285,7 @@ def _elf(path: Path, *, executable: bool = False, lock: dict | None = None) -> t symbols = _run("readelf", "--wide", "--symbols", str(path)) if accepted is not None: for symbol in accepted.get("weak_undefined_symbols", []): - row = r"^[ \t]*[0-9]+:[ \t]+0+[ \t]+0[ \t]+NOTYPE[ \t]+WEAK[ \t]+DEFAULT[ \t]+UND[ \t]+" + re.escape(symbol) + r"[ \t]*$" + row = r"^[ \t]*[0-9]+:[ \t]+0+[ \t]+(?:0|0x0)[ \t]+NOTYPE[ \t]+WEAK[ \t]+DEFAULT[ \t]+UND[ \t]+" + re.escape(symbol) + r"[ \t]*$" symbols, count = re.subn(row, "", symbols, flags=re.MULTILINE) if count != 1: raise ValueError(f"AppImage ELF inspection exception has unexpected symbols: {path.name}") @@ -418,7 +418,7 @@ def system_license_notices(root: Path | str, lock: dict, packages: Iterable[str] def _payload(root: Path, entries: dict[str, Entry], version: str, lock: dict, lock_digest: str, source_root: Path, revision: str | None, - source_date_epoch: int | None, squashfs_epoch: int) -> dict: + source_date_epoch: int | None, squashfs_epoch: int, compiler_image: str | None = None) -> dict: allowed = ("usr/bin", "usr/lib", "usr/share/games/atrinik", "usr/share/doc/atrinik", "usr/share/atrinik", "usr/share/alsa", "usr/share/applications", "usr/share/pixmaps") top_files = {"AppRun", "atrinik.desktop", "atrinik.png", ".DirIcon"} @@ -465,6 +465,15 @@ def required(name: str) -> Path: if not isinstance(notice, str) or not _safe_name(notice) or not notice.startswith("usr/share/doc/atrinik/"): raise ValueError("invalid notice in trusted AppImage lock") required(notice) + compiler = lock.get("compiler") + if compiler is not None: + if compiler.get("family") != "Clang" or compiler.get("version") != "23.1.3" or compiler.get("defaults") != {"runtime": "compiler-rt", "unwind": "libunwind", "linker": "lld", "unwind_linkage": "static"}: + raise ValueError("invalid LLVM cohort in trusted AppImage lock") + for name, expected in compiler["notices"].items(): + if not _safe_name(name) or "/" in name or not re.fullmatch(r"[0-9a-f]{64}", expected): + raise ValueError("invalid LLVM notice in trusted AppImage lock") + if _sha256(required("usr/share/doc/atrinik/licenses/llvm/" + name)) != expected: + raise ValueError("AppImage LLVM runtime notice differs from trusted source") required("usr/share/doc/atrinik/LICENSE.md") config_path = required("usr/share/atrinik/openssl.cnf") config = config_path.read_text().splitlines() @@ -497,6 +506,18 @@ def required(name: str) -> Path: or epoch != squashfs_epoch): raise ValueError("AppImage source epoch differs from trusted source or SquashFS") native_inputs = {key: lock.get(key, {}) for key in ("sources", "tools", "runtime")} + if compiler is not None: + native_inputs["compiler"] = compiler + locked_compiler_image = compiler.get("image") + if locked_compiler_image is not None and compiler_image is not None and locked_compiler_image != compiler_image: + raise ValueError("expected compiler image differs from trusted source lock") + expected_compiler_image = locked_compiler_image or compiler_image + if expected_compiler_image is None: + raise ValueError("AppImage inspection requires a trusted expected compiler image") + if metadata.get("compiler_image") != expected_compiler_image: + raise ValueError("AppImage compiler producer identity differs from trusted reference") + if not re.fullmatch(r"ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}", str(metadata.get("compiler_image", ""))): + raise ValueError("AppImage compiler producer identity must be immutable") if metadata.get("native_inputs") != native_inputs: raise ValueError("AppImage native inputs differ from trusted lock") file_inventory = {name: _sha256(root / name) @@ -604,7 +625,7 @@ def required(name: str) -> Path: def read_appimage_inventory(path: Path | str, version: str, *, revision: str | None = None, source_root: Path | str | None = None, - source_date_epoch: int | None = None) -> dict: + source_date_epoch: int | None = None, compiler_image: str | None = None) -> dict: """Validate once and return metadata bound to the trusted source checkout.""" if VERSION.fullmatch(version) is None: raise ValueError("AppImage version must be MAJOR.MINOR.PATCH") @@ -612,6 +633,8 @@ def read_appimage_inventory(path: Path | str, version: str, *, revision: str | N raise ValueError("AppImage revision must be a full lowercase commit ID") if source_date_epoch is not None and (type(source_date_epoch) is not int or not 0 <= source_date_epoch <= 0xffffffff): raise ValueError("AppImage expected source epoch must be a uint32 integer") + if compiler_image is not None and not re.fullmatch(r"ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}", compiler_image): + raise ValueError("expected compiler image must be an immutable published reference") source = Path(source_root) if source_root is not None else ROOT lock_path = source / "tools/ci/appimage/packaging.lock.json" lock = _json(lock_path) @@ -643,16 +666,16 @@ def read_appimage_inventory(path: Path | str, version: str, *, revision: str | N _run("unsquashfs", "-no-xattrs", "-processors", "1", "-o", offset, "-d", str(destination), str(snapshot)) return _payload(destination, entries, version, lock, lock_digest, source, revision, - source_date_epoch, squashfs_epoch) + source_date_epoch, squashfs_epoch, compiler_image) def validate_appimage(path: Path | str, version: str, *, revision: str | None = None, source_root: Path | str | None = None, - source_date_epoch: int | None = None) -> None: + source_date_epoch: int | None = None, compiler_image: str | None = None) -> None: """Raise ValueError unless PATH satisfies the trusted source build contract.""" try: read_appimage_inventory(path, version, revision=revision, source_root=source_root, - source_date_epoch=source_date_epoch) + source_date_epoch=source_date_epoch, compiler_image=compiler_image) except (OSError, UnicodeError) as exc: raise ValueError(f"cannot inspect AppImage: {exc}") from exc @@ -663,6 +686,7 @@ def main() -> int: parser.add_argument("version") parser.add_argument("--revision", help="full expected source commit") parser.add_argument("--source-root", type=Path, help="trusted source checkout at the expected commit") + parser.add_argument("--compiler-image", help="trusted expected immutable compiler image") parser.add_argument("--source-date-epoch", type=int, help="expected source commit timestamp") args = parser.parse_args() try: @@ -684,9 +708,9 @@ def main() -> int: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(_run_bytes("git", "-C", str(source), "show", f"{args.revision}:{name}")) validate_appimage(args.path, args.version, revision=args.revision, source_root=immutable, - source_date_epoch=epoch) + source_date_epoch=epoch, compiler_image=args.compiler_image) else: - validate_appimage(args.path, args.version, source_date_epoch=args.source_date_epoch) + validate_appimage(args.path, args.version, source_date_epoch=args.source_date_epoch, compiler_image=args.compiler_image) except (ValueError, OSError, UnicodeError) as exc: parser.exit(1, f"invalid AppImage: {exc}\n") print(f"validated AppImage: {args.path.name}") diff --git a/tools/release/finalize_artifacts.py b/tools/release/finalize_artifacts.py index 19dac68fe..d3b773999 100755 --- a/tools/release/finalize_artifacts.py +++ b/tools/release/finalize_artifacts.py @@ -549,8 +549,13 @@ def build_spdx( + "; ".join(f"{file['path']} sha256:{file['sha256']}" for file in notice["files"]), }) native_inputs = appimage_inventory["native_inputs"] - for record in [*native_inputs["sources"], *native_inputs["tools"], - {"name": "appimage-runtime", **native_inputs["runtime"]}]: + records = [*native_inputs["sources"], *native_inputs["tools"], + {"name": "appimage-runtime", **native_inputs["runtime"]}] + compiler = native_inputs.get("compiler") + if compiler is not None: + records.append({"name": "llvm-static-runtimes", "version": compiler["version"], + **compiler["source"]}) + for record in records: name = str(record["name"]) identifier = f"SPDXRef-AppImageInput-{re.sub(r'[^A-Za-z0-9.-]', '-', name)}" packages.append({ @@ -653,6 +658,7 @@ def main() -> int: parser.add_argument("--revision", required=True) parser.add_argument("--source-epoch", type=int, required=True) parser.add_argument("--directory", type=Path, required=True) + parser.add_argument("--compiler-image", help="trusted expected immutable compiler image") arguments = parser.parse_args() if not VERSION_RE.fullmatch(arguments.version): parser.error("--version must be MAJOR.MINOR.PATCH") @@ -749,7 +755,7 @@ def main() -> int: inventory = read_appimage_inventory( directory / f"atrinik-classic-client-{arguments.version}-linux-x86_64.AppImage", arguments.version, revision=arguments.revision, source_root=Path.cwd(), - source_date_epoch=arguments.source_epoch, + source_date_epoch=arguments.source_epoch, compiler_image=arguments.compiler_image, ) packaging_lock = json.loads(git_value(root, "show", f"{arguments.revision}:tools/ci/appimage/packaging.lock.json")) diff --git a/tools/release/release_artifacts.py b/tools/release/release_artifacts.py index d5b4e265b..4c3df0960 100644 --- a/tools/release/release_artifacts.py +++ b/tools/release/release_artifacts.py @@ -185,7 +185,7 @@ def validate_source_metadata(manifest: dict, source_root: Path, revision: str, v def validate_appimage_metadata(directory: Path, manifest: dict, source_root: Path, - revision: str, version: str) -> None: + revision: str, version: str, compiler_image: str | None = None) -> None: # The helper's current checkout cannot stand in for a historical source. # Give the package validator only the trusted files from this exact commit. from appimage import TRUSTED_SOURCE_FILES, read_appimage_inventory @@ -197,7 +197,7 @@ def validate_appimage_metadata(directory: Path, manifest: dict, source_root: Pat path.write_text(git_value(source_root, "show", f"{revision}:{relative}", preserve_whitespace=True)) inventory = read_appimage_inventory( directory / f"atrinik-classic-client-{version}-linux-x86_64.AppImage", - version, revision=revision, source_root=root, source_date_epoch=manifest["source_epoch"], + version, revision=revision, source_root=root, source_date_epoch=manifest["source_epoch"], compiler_image=compiler_image, ) if manifest.get("appimage_inventory") != inventory: raise RuntimeError("candidate AppImage inventory differs from validated package") @@ -210,7 +210,8 @@ def validate_appimage_metadata(directory: Path, manifest: dict, source_root: Pat raise RuntimeError("candidate SPDX differs from AppImage and locked inputs") -def validate_candidate(directory: Path, tag: str, revision: str, source_root: Path = ROOT) -> dict[str, tuple[int, str]]: +def validate_candidate(directory: Path, tag: str, revision: str, source_root: Path = ROOT, + compiler_image: str | None = None) -> dict[str, tuple[int, str]]: if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag) is None: raise RuntimeError("tag must be vMAJOR.MINOR.PATCH") version = tag[1:] @@ -252,7 +253,8 @@ def validate_candidate(directory: Path, tag: str, revision: str, source_root: Pa if schema == 2: validate_deb(directory / f"atrinik-classic-client-{version}-linux-amd64.deb", version) if schema == 3: - validate_appimage_metadata(directory, manifest, source_root, revision, version) + validate_appimage_metadata(directory, manifest, source_root, revision, version, + compiler_image=compiler_image) return actual @@ -262,6 +264,7 @@ def main() -> int: parser.add_argument("--tag") parser.add_argument("--revision", required=True) parser.add_argument("--source-root", type=Path, default=Path.cwd()) + parser.add_argument("--compiler-image", help="trusted expected immutable compiler image") parser.add_argument("--print-source-schema", action="store_true") args = parser.parse_args() if args.print_source_schema: @@ -275,7 +278,8 @@ def main() -> int: if args.directory is None or args.tag is None: parser.error("candidate validation requires --directory and --tag") try: - assets = validate_candidate(args.directory, args.tag, args.revision, args.source_root) + assets = validate_candidate(args.directory, args.tag, args.revision, args.source_root, + compiler_image=args.compiler_image) except (OSError, ValueError, RuntimeError, tarfile.TarError) as error: parser.exit(1, f"release candidate validation failed: {error}\n") print(f"verified {len(assets)} release assets") diff --git a/tools/tests/fixtures/llvm-readelf-23.1.3/dynamic.txt b/tools/tests/fixtures/llvm-readelf-23.1.3/dynamic.txt new file mode 100644 index 000000000..66a81c5fe --- /dev/null +++ b/tools/tests/fixtures/llvm-readelf-23.1.3/dynamic.txt @@ -0,0 +1,29 @@ +Dynamic section at offset 0x7cc8 contains 27 entries: + Tag Type Name/Value + 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] + 0x000000000000000c (INIT) 0x2000 + 0x000000000000000d (FINI) 0x53b4 + 0x0000000000000019 (INIT_ARRAY) 0x7c88 + 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes) + 0x000000000000001a (FINI_ARRAY) 0x7c90 + 0x000000000000001c (FINI_ARRAYSZ) 8 (bytes) + 0x000000006ffffef5 (GNU_HASH) 0x390 + 0x0000000000000005 (STRTAB) 0x8a0 + 0x0000000000000006 (SYMTAB) 0x3f0 + 0x000000000000000a (STRSZ) 701 (bytes) + 0x000000000000000b (SYMENT) 24 (bytes) + 0x0000000000000015 (DEBUG) 0x0 + 0x0000000000000003 (PLTGOT) 0x7eb8 + 0x0000000000000002 (PLTRELSZ) 792 (bytes) + 0x0000000000000014 (PLTREL) RELA + 0x0000000000000017 (JMPREL) 0xde8 + 0x0000000000000007 (RELA) 0xc38 + 0x0000000000000008 (RELASZ) 432 (bytes) + 0x0000000000000009 (RELAENT) 24 (bytes) + 0x000000000000001e (FLAGS) BIND_NOW + 0x000000006ffffffb (FLAGS_1) NOW PIE + 0x000000006ffffffe (VERNEED) 0xbc8 + 0x000000006fffffff (VERNEEDNUM) 1 + 0x000000006ffffff0 (VERSYM) 0xb5e + 0x000000006ffffff9 (RELACOUNT) 9 + 0x0000000000000000 (NULL) 0x0 diff --git a/tools/tests/fixtures/llvm-readelf-23.1.3/program.txt b/tools/tests/fixtures/llvm-readelf-23.1.3/program.txt new file mode 100644 index 000000000..73f02fb78 --- /dev/null +++ b/tools/tests/fixtures/llvm-readelf-23.1.3/program.txt @@ -0,0 +1,40 @@ + +Elf file type is DYN (Shared object file) +Entry point 0x2db0 +There are 14 program headers, starting at offset 64 + +Program Headers: + Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align + PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x000310 0x000310 R 0x8 + INTERP 0x000374 0x0000000000000374 0x0000000000000374 0x00001c 0x00001c R 0x1 + [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] + LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x001100 0x001100 R 0x1000 + LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x0033c1 0x0033c1 R E 0x1000 + LOAD 0x006000 0x0000000000006000 0x0000000000006000 0x000e64 0x000e64 R 0x1000 + LOAD 0x007c88 0x0000000000007c88 0x0000000000007c88 0x00038c 0x0003f0 RW 0x1000 + DYNAMIC 0x007cc8 0x0000000000007cc8 0x0000000000007cc8 0x0001f0 0x0001f0 RW 0x8 + NOTE 0x000350 0x0000000000000350 0x0000000000000350 0x000024 0x000024 R 0x4 + NOTE 0x006da0 0x0000000000006da0 0x0000000000006da0 0x000030 0x000030 R 0x8 + NOTE 0x006dd0 0x0000000000006dd0 0x0000000000006dd0 0x000094 0x000094 R 0x4 + GNU_PROPERTY 0x006da0 0x0000000000006da0 0x0000000000006da0 0x000030 0x000030 R 0x8 + GNU_EH_FRAME 0x006a10 0x0000000000006a10 0x0000000000006a10 0x0000ac 0x0000ac R 0x4 + GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10 + GNU_RELRO 0x007c88 0x0000000000007c88 0x0000000000007c88 0x000378 0x000378 R 0x1 + + Section to Segment mapping: + Segment Sections... + 00 + 01 .interp + 02 .note.gnu.build-id .interp .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt + 03 .init .plt .plt.got .plt.sec .text .fini + 04 .rodata .eh_frame_hdr .eh_frame .note.gnu.property .note.ABI-tag .note.package + 05 .init_array .fini_array .data.rel.ro .dynamic .got .data .bss + 06 .dynamic + 07 .note.gnu.build-id + 08 .note.gnu.property + 09 .note.ABI-tag .note.package + 10 .note.gnu.property + 11 .eh_frame_hdr + 12 + 13 .init_array .fini_array .data.rel.ro .dynamic .got + None .gnu_debugaltlink .gnu_debuglink .shstrtab diff --git a/tools/tests/fixtures/llvm-readelf-23.1.3/provenance.json b/tools/tests/fixtures/llvm-readelf-23.1.3/provenance.json new file mode 100644 index 000000000..f3f105e44 --- /dev/null +++ b/tools/tests/fixtures/llvm-readelf-23.1.3/provenance.json @@ -0,0 +1,35 @@ +{ + "llvm_version": "23.1.3", + "tool_sha256": "8ce8e7b129d9fa8b0c6ec6b1931f6bf85886e305aae2c4639e17bd1541db9842", + "input": "/usr/bin/true", + "input_sha256": "913a39cd38f353497086bcf317b12f91f93c23b51869cea763b8340b4f84cfd3", + "tool_archive_sha256": "5f9c6ed95b2d4bdb2869a488c5fd5857fbdabcf288a0aa3eb1da43f6a08d8ab4", + "commands": { + "dynamic": [ + "llvm-readelf", + "--wide", + "--dynamic", + "/usr/bin/true" + ], + "versions": [ + "llvm-readelf", + "--wide", + "--version-info", + "/usr/bin/true" + ], + "symbols": [ + "llvm-readelf", + "--wide", + "--symbols", + "/usr/bin/true" + ], + "program": [ + "llvm-readelf", + "--wide", + "--program-headers", + "/usr/bin/true" + ] + }, + "tool_archive_url": "https://github.com/mstorsjo/llvm-mingw/releases/download/20261006/llvm-mingw-20261006-ucrt-ubuntu-22.04-x86_64.tar.xz", + "normalization": "Trailing horizontal whitespace and redundant terminal blank lines removed; fields and internal spacing retained." +} diff --git a/tools/tests/fixtures/llvm-readelf-23.1.3/symbols.txt b/tools/tests/fixtures/llvm-readelf-23.1.3/symbols.txt new file mode 100644 index 000000000..4011aa4b4 --- /dev/null +++ b/tools/tests/fixtures/llvm-readelf-23.1.3/symbols.txt @@ -0,0 +1,53 @@ + +Symbol table '.dynsym' contains 50 entries: + Num: Value Size Type Bind Vis Ndx Name + 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND + 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __vfprintf_chk@GLIBC_2.3.4 + 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.34 + 3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND abort@GLIBC_2.2.5 + 4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __errno_location@GLIBC_2.2.5 + 5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strncmp@GLIBC_2.2.5 + 6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTable + 7: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _exit@GLIBC_2.2.5 + 8: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __fpending@GLIBC_2.2.5 + 9: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fcntl@GLIBC_2.2.5 + 10: 0000000000000000 0 FUNC GLOBAL DEFAULT UND textdomain@GLIBC_2.2.5 + 11: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fclose@GLIBC_2.2.5 + 12: 0000000000000000 0 FUNC GLOBAL DEFAULT UND bindtextdomain@GLIBC_2.2.5 + 13: 0000000000000000 0 FUNC GLOBAL DEFAULT UND dcgettext@GLIBC_2.2.5 + 14: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __ctype_get_mb_cur_max@GLIBC_2.2.5 + 15: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strlen@GLIBC_2.2.5 + 16: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __stack_chk_fail@GLIBC_2.4 + 17: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __overflow@GLIBC_2.2.5 + 18: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strrchr@GLIBC_2.2.5 + 19: 0000000000000000 0 FUNC GLOBAL DEFAULT UND mbrtoc32@GLIBC_2.16 + 20: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memcmp@GLIBC_2.2.5 + 21: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fputs_unlocked@GLIBC_2.2.5 + 22: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strcmp@GLIBC_2.2.5 + 23: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fputc_unlocked@GLIBC_2.2.5 + 24: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ + 25: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strerror_r@GLIBC_2.2.5 + 26: 0000000000000000 0 FUNC GLOBAL DEFAULT UND nl_langinfo@GLIBC_2.2.5 + 27: 0000000000000000 0 FUNC GLOBAL DEFAULT UND setlocale@GLIBC_2.2.5 + 28: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __printf_chk@GLIBC_2.3.4 + 29: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __cxa_atexit@GLIBC_2.2.5 + 30: 0000000000000000 0 FUNC GLOBAL DEFAULT UND exit@GLIBC_2.2.5 + 31: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fwrite@GLIBC_2.2.5 + 32: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __fprintf_chk@GLIBC_2.3.4 + 33: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable + 34: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fflush_unlocked@GLIBC_2.2.5 + 35: 0000000000000000 0 FUNC GLOBAL DEFAULT UND mbsinit@GLIBC_2.2.5 + 36: 0000000000000000 0 FUNC GLOBAL DEFAULT UND iswprint@GLIBC_2.2.5 + 37: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __ctype_b_loc@GLIBC_2.3 + 38: 0000000000008028 8 OBJECT GLOBAL DEFAULT 28 stdout@GLIBC_2.2.5 + 39: 0000000000008050 4 OBJECT GLOBAL DEFAULT 28 error_message_count + 40: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5 + 41: 0000000000008020 8 OBJECT GLOBAL DEFAULT 28 __progname@GLIBC_2.2.5 + 42: 00000000000026c8 388 FUNC GLOBAL DEFAULT 14 error_at_line + 43: 0000000000008030 8 OBJECT WEAK DEFAULT 28 program_invocation_name@GLIBC_2.2.5 + 44: 0000000000008030 8 OBJECT GLOBAL DEFAULT 28 __progname_full@GLIBC_2.2.5 + 45: 0000000000008020 8 OBJECT WEAK DEFAULT 28 program_invocation_short_name@GLIBC_2.2.5 + 46: 0000000000008040 8 OBJECT GLOBAL DEFAULT 28 stderr@GLIBC_2.2.5 + 47: 000000000000257f 265 FUNC GLOBAL DEFAULT 14 error + 48: 0000000000008058 8 OBJECT GLOBAL DEFAULT 28 error_print_progname + 49: 0000000000008054 4 OBJECT GLOBAL DEFAULT 28 error_one_per_line diff --git a/tools/tests/fixtures/llvm-readelf-23.1.3/versions.txt b/tools/tests/fixtures/llvm-readelf-23.1.3/versions.txt new file mode 100644 index 000000000..be2047579 --- /dev/null +++ b/tools/tests/fixtures/llvm-readelf-23.1.3/versions.txt @@ -0,0 +1,25 @@ +Version symbols section '.gnu.version' contains 50 entries: + Addr: 0000000000000b5e Offset: 0x000b5e Link: 4 (.dynsym) + 000: 0 (*local*) 3 (GLIBC_2.3.4) 4 (GLIBC_2.34) 2 (GLIBC_2.2.5) + 004: 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 0 (*local*) 2 (GLIBC_2.2.5) + 008: 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) + 00c: 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) + 010: 5 (GLIBC_2.4) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 6 (GLIBC_2.16) + 014: 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) + 018: 0 (*local*) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) + 01c: 3 (GLIBC_2.3.4) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) + 020: 3 (GLIBC_2.3.4) 0 (*local*) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) + 024: 2 (GLIBC_2.2.5) 7 (GLIBC_2.3) 2 (GLIBC_2.2.5) 1 (*global*) + 028: 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 1 (*global*) 2 (GLIBC_2.2.5) + 02c: 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 1 (*global*) + 030: 1 (*global*) 1 (*global*) + +Version needs section '.gnu.version_r' contains 1 entries: + Addr: 0000000000000bc8 Offset: 0x000bc8 Link: 5 (.dynstr) + 0x0000: Version: 1 File: libc.so.6 Cnt: 6 + 0x0010: Name: GLIBC_2.3 Flags: none Version: 7 + 0x0020: Name: GLIBC_2.16 Flags: none Version: 6 + 0x0030: Name: GLIBC_2.4 Flags: none Version: 5 + 0x0040: Name: GLIBC_2.34 Flags: none Version: 4 + 0x0050: Name: GLIBC_2.3.4 Flags: none Version: 3 + 0x0060: Name: GLIBC_2.2.5 Flags: none Version: 2 diff --git a/tools/tests/test_appimage.py b/tools/tests/test_appimage.py index a4e39d636..6b900e478 100644 --- a/tools/tests/test_appimage.py +++ b/tools/tests/test_appimage.py @@ -567,6 +567,56 @@ def test_glib_exception_requires_exact_weak_undefined_symbol_rows(self): with self.assertRaisesRegex(ValueError, "symbols|instrumentation"): appimage._elf(elf, lock=lock) + def test_llvm_runtime_notices_and_producer_are_bound_to_lock(self): + compiler = {"family": "Clang", "version": "23.1.3", + "image": "ghcr.io/atrinik/classic-build:1.2.3@sha256:" + "a" * 64, + "defaults": {"runtime": "compiler-rt", "unwind": "libunwind", "linker": "lld", "unwind_linkage": "static"}, + "notices": {"compiler-rt-LICENSE.TXT": hashlib.sha256(b"notice").hexdigest()}} + self.lock["compiler"] = compiler + self.lock_path.write_text(json.dumps(self.lock, sort_keys=True) + "\n") + self.metadata["lock_sha256"] = hashlib.sha256(self.lock_path.read_bytes()).hexdigest() + self.metadata["native_inputs"]["compiler"] = compiler + self.metadata["compiler_image"] = "ghcr.io/atrinik/classic-build:1.2.3@sha256:" + "a" * 64 + notice = self.write("usr/share/doc/atrinik/licenses/llvm/compiler-rt-LICENSE.TXT", b"notice") + self.validate() + with self.assertRaisesRegex(ValueError, "differs from trusted source lock"): + self.validate(compiler_image="ghcr.io/atrinik/classic-build:1.2.3@sha256:" + "b" * 64) + expected_image = compiler.pop("image") + self.lock_path.write_text(json.dumps(self.lock, sort_keys=True) + "\n") + self.metadata["lock_sha256"] = hashlib.sha256(self.lock_path.read_bytes()).hexdigest() + with self.assertRaisesRegex(ValueError, "requires a trusted expected compiler image"): + self.validate() + self.validate(compiler_image=expected_image) + compiler["image"] = expected_image + self.lock_path.write_text(json.dumps(self.lock, sort_keys=True) + "\n") + self.metadata["lock_sha256"] = hashlib.sha256(self.lock_path.read_bytes()).hexdigest() + notice.write_bytes(b"tampered") + with self.assertRaisesRegex(ValueError, "LLVM runtime notice"): + self.validate() + notice.unlink() + with self.assertRaisesRegex(ValueError, "required file"): + self.validate() + self.write("usr/share/doc/atrinik/licenses/llvm/compiler-rt-LICENSE.TXT", b"notice") + self.metadata["compiler_image"] = "classic-build:latest" + with self.assertRaisesRegex(ValueError, "producer identity"): + self.validate() + + def test_real_llvm_readelf_output_formats(self): + fixtures = Path(__file__).parent / "fixtures/llvm-readelf-23.1.3" + outputs = [(fixtures / (name + ".txt")).read_text() + for name in ("dynamic", "versions", "symbols", "program")] + with mock.patch.object(appimage, "_run", side_effect=outputs): + needed, _ = appimage._elf(Path("/usr/bin/true"), executable=True) + self.assertIn("libc.so.6", needed) + # Real LLVM SONAME/dynamic output drives the SDK closure parser too. + import importlib.util + spec = importlib.util.spec_from_file_location("appimage_assemble", Path(__file__).parents[1] / "ci/appimage/assemble.py") + assemble = importlib.util.module_from_spec(spec) + spec.loader.exec_module(assemble) + with mock.patch.object(assemble, "run", return_value=outputs[0]) as run: + self.assertEqual(assemble.dynamic(Path("fixture")), (["libc.so.6"], None)) + self.assertEqual(run.call_args.args[0], "llvm-readelf") + def test_extended_attributes_and_trailing_payload_rejected(self): for mutation in ("xattrs", "trailing"): image = self.image() @@ -691,7 +741,8 @@ def git(*args): args = ["appimage.py", str(candidate), self.version, "--revision", self.revision, "--source-root", str(self.source)] validate = appimage.validate_appimage - def verify_shared_resources(path, version, *, revision, source_root, source_date_epoch): + def verify_shared_resources(path, version, *, revision, source_root, source_date_epoch, compiler_image): + self.assertIsNone(compiler_image) self.assertEqual((source_root / "fixture-extra.txt").read_text(), "shared resource contract\n") self.assertEqual(source_date_epoch, self.metadata["source_date_epoch"]) validate(path, version, revision=revision, source_root=source_root, source_date_epoch=source_date_epoch) diff --git a/tools/tests/test_llvm_runtime_notices.py b/tools/tests/test_llvm_runtime_notices.py new file mode 100644 index 000000000..529a5ebd9 --- /dev/null +++ b/tools/tests/test_llvm_runtime_notices.py @@ -0,0 +1,57 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +NAMES = ('llvm', 'clang', 'clang-tools-extra', 'lld', 'compiler-rt', 'libunwind') + + +@unittest.skipUnless(shutil.which('cmake'), 'CMake unavailable') +class LinuxRuntimeNotices(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.notices = self.root / 'llvm/share/licenses/llvm' + self.notices.mkdir(parents=True) + self.compiler = self.root / 'llvm/bin/clang' + self.compiler.parent.mkdir() + self.compiler.touch() + for name in NAMES: + (self.notices / (name + '-LICENSE.TXT')).write_text(name + ' complete notice\n') + (self.root / 'CMakeLists.txt').write_text(f'''cmake_minimum_required(VERSION 3.25) +project(NoticeFixture NONE) +set(CMAKE_SYSTEM_NAME Linux) +set(CMAKE_C_COMPILER "{self.compiler}") +include("{ROOT / 'cmake/InstallLLVMRuntime.cmake'}") +atrinik_install_llvm_runtime_notices(share/doc/atrinik AtrinikClient) +''') + + def configure(self): + return subprocess.run(['cmake', '-S', str(self.root), '-B', str(self.root / 'build')], text=True, capture_output=True) + + def test_client_component_installs_all_complete_runtime_notices(self): + result = self.configure() + self.assertEqual(result.returncode, 0, result.stderr) + prefix = self.root / 'install' + subprocess.run(['cmake', '--install', str(self.root / 'build'), '--prefix', str(prefix), '--component', 'AtrinikClient'], check=True, capture_output=True) + for name in NAMES: + expected = self.notices / (name + '-LICENSE.TXT') + self.assertEqual((prefix / 'share/doc/atrinik/licenses/llvm' / expected.name).read_bytes(), expected.read_bytes()) + + def test_missing_symlinked_or_empty_static_runtime_notice_is_rejected(self): + notice = self.notices / 'compiler-rt-LICENSE.TXT' + for kind in ('missing', 'symlink', 'empty'): + notice.unlink(missing_ok=True) + if kind == 'symlink': + notice.symlink_to(self.notices / 'llvm-LICENSE.TXT') + elif kind == 'empty': + notice.touch() + result = self.configure() + with self.subTest(kind=kind): + self.assertNotEqual(result.returncode, 0) + self.assertIn('runtime installation requires', result.stderr) diff --git a/tools/tests/test_release_appimage_integration.py b/tools/tests/test_release_appimage_integration.py index feeba74d2..aa7a9c975 100644 --- a/tools/tests/test_release_appimage_integration.py +++ b/tools/tests/test_release_appimage_integration.py @@ -108,6 +108,17 @@ def test_resealed_certificate_tampering_is_rejected_before_spdx(self): with self.assertRaisesRegex(ValueError, "certificate trust differs from trusted source"): self.validate() + def test_llvm_static_runtime_source_is_preserved_in_spdx(self): + source = {"url": "https://example.invalid/llvm-project-23.1.3.src.tar.xz", + "sha256": "a" * 64, "license": "Apache-2.0 WITH LLVM-exception"} + self.package.metadata["native_inputs"]["compiler"] = {"version": "23.1.3", "source": source} + self.candidate() + sbom = json.loads((self.assets / f"atrinik-classic-{self.version}.spdx.json").read_text()) + runtime = next(package for package in sbom["packages"] if package["name"] == "llvm-static-runtimes") + self.assertEqual(runtime["versionInfo"], "23.1.3") + self.assertEqual(runtime["licenseDeclared"], source["license"]) + self.assertEqual(runtime["checksums"], [{"algorithm": "SHA256", "checksumValue": source["sha256"]}]) + def test_resealed_source_epoch_tampering_is_rejected_before_spdx(self): self.package.metadata["source_date_epoch"] = self.epoch + 1 self.candidate(invalid_spdx=True) diff --git a/tools/tests/test_release_artifacts.py b/tools/tests/test_release_artifacts.py index 184e6a231..659ceb3aa 100644 --- a/tools/tests/test_release_artifacts.py +++ b/tools/tests/test_release_artifacts.py @@ -109,6 +109,7 @@ def test_schema_three_invokes_package_metadata_validator_and_propagates_failure( artifacts.validate_candidate(self.directory, "v" + self.version, self.revision) self.assertEqual(validator.call_args.args[0], self.directory) self.assertEqual(validator.call_args.args[3:], (self.revision, self.version)) + self.assertEqual(validator.call_args.kwargs, {"compiler_image": None}) def test_manifest_cannot_select_different_source_contract(self): self.candidate(1) @@ -219,7 +220,8 @@ def test_appimage_inventory_and_spdx_are_reverified_against_exact_source(self): sbom = artifacts.build_spdx([image], self.version, self.revision, 123, [], inventory) sbom_path = self.directory / f"atrinik-classic-{self.version}.spdx.json" sbom_path.write_text(json.dumps(sbom)) - def reader(path, version, *, revision, source_root, source_date_epoch): + def reader(path, version, *, revision, source_root, source_date_epoch, compiler_image): + self.assertIsNone(compiler_image) self.assertEqual(source_date_epoch, manifest["source_epoch"]) self.assertEqual(path, image) self.assertEqual((version, revision), (self.version, self.revision)) From 312b96d526cb813491b235d947566fb53c193723 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:25:18 -0500 Subject: [PATCH 017/148] build(classic): bind and validate remaining LLVM binutils --- cmake/AtrinikLLVM.cmake | 3 ++- cmake/toolchains/llvm.cmake | 8 ++++++- tools/tests/test_llvm_toolchain.py | 38 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/cmake/AtrinikLLVM.cmake b/cmake/AtrinikLLVM.cmake index 0e6249638..cf91c2991 100644 --- a/cmake/AtrinikLLVM.cmake +++ b/cmake/AtrinikLLVM.cmake @@ -27,7 +27,8 @@ function(atrinik_require_llvm) NOT CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL "23.1.3")) message(FATAL_ERROR "Classic C++ build dependencies require Clang 23.1.3") endif () - foreach (tool CMAKE_AR CMAKE_RANLIB CMAKE_LINKER) + foreach (tool CMAKE_AR CMAKE_RANLIB CMAKE_LINKER CMAKE_NM CMAKE_STRIP + CMAKE_OBJCOPY CMAKE_OBJDUMP CMAKE_READELF) execute_process(COMMAND "${${tool}}" --version OUTPUT_VARIABLE tool_version ERROR_VARIABLE tool_error RESULT_VARIABLE tool_result) diff --git a/cmake/toolchains/llvm.cmake b/cmake/toolchains/llvm.cmake index f99eef298..d515d15ee 100644 --- a/cmake/toolchains/llvm.cmake +++ b/cmake/toolchains/llvm.cmake @@ -12,8 +12,14 @@ find_program(CMAKE_CXX_COMPILER NAMES clang++ REQUIRED) find_program(CMAKE_AR NAMES llvm-ar REQUIRED) find_program(CMAKE_RANLIB NAMES llvm-ranlib REQUIRED) find_program(CMAKE_LINKER NAMES ld.lld REQUIRED) +find_program(CMAKE_NM NAMES llvm-nm REQUIRED) +find_program(CMAKE_STRIP NAMES llvm-strip REQUIRED) +find_program(CMAKE_OBJCOPY NAMES llvm-objcopy REQUIRED) +find_program(CMAKE_OBJDUMP NAMES llvm-objdump REQUIRED) +find_program(CMAKE_READELF NAMES llvm-readelf REQUIRED) -foreach (tool CMAKE_C_COMPILER CMAKE_CXX_COMPILER CMAKE_AR CMAKE_RANLIB CMAKE_LINKER) +foreach (tool CMAKE_C_COMPILER CMAKE_CXX_COMPILER CMAKE_AR CMAKE_RANLIB CMAKE_LINKER + CMAKE_NM CMAKE_STRIP CMAKE_OBJCOPY CMAKE_OBJDUMP CMAKE_READELF) execute_process(COMMAND "${${tool}}" --version OUTPUT_VARIABLE tool_version ERROR_VARIABLE tool_error RESULT_VARIABLE tool_result) diff --git a/tools/tests/test_llvm_toolchain.py b/tools/tests/test_llvm_toolchain.py index aef5b9af3..997286a88 100644 --- a/tools/tests/test_llvm_toolchain.py +++ b/tools/tests/test_llvm_toolchain.py @@ -39,6 +39,11 @@ def settings(self, platform="Linux", compiler="Clang", version="23.1.3"): set(CMAKE_AR "{self.tool}") set(CMAKE_RANLIB "{self.tool}") set(CMAKE_LINKER "{self.tool}") +set(CMAKE_NM "{self.tool}") +set(CMAKE_STRIP "{self.tool}") +set(CMAKE_OBJCOPY "{self.tool}") +set(CMAKE_OBJDUMP "{self.tool}") +set(CMAKE_READELF "{self.tool}") function(add_link_options) file(WRITE "{self.capture}" "${{ARGV}}") endfunction() @@ -92,6 +97,39 @@ def test_mismatched_archive_tools_are_rejected(self): self.assertNotEqual(result.returncode, 0) self.assertIn("CMAKE_AR must be LLVM 23.1.3", result.stderr) + def test_native_toolchain_binds_all_llvm_inspection_tools(self): + names = { + "CMAKE_C_COMPILER": "clang", "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_AR": "llvm-ar", "CMAKE_RANLIB": "llvm-ranlib", "CMAKE_LINKER": "ld.lld", + "CMAKE_NM": "llvm-nm", "CMAKE_STRIP": "llvm-strip", "CMAKE_OBJCOPY": "llvm-objcopy", + "CMAKE_OBJDUMP": "llvm-objdump", "CMAKE_READELF": "llvm-readelf", + } + for name in names.values(): + (self.root / name).symlink_to(self.tool) + body = f''' +set(CMAKE_HOST_SYSTEM_NAME Linux) +set(CMAKE_PROGRAM_PATH "{self.root}") +include("{TOOLCHAIN}") +''' + for variable, name in names.items(): + body += f''' +if (NOT {variable} STREQUAL "{self.root / name}") + message(FATAL_ERROR "Unexpected {variable}: ${{{variable}}}") +endif () +''' + result = self.script(body) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_wrong_inspection_utility_version_is_rejected(self): + wrong = self.root / "old-llvm-tool" + wrong.write_text("#!/bin/sh\nprintf 'LLVM version 23.1.2\\n'\n") + wrong.chmod(0o755) + for variable in ("CMAKE_NM", "CMAKE_STRIP", "CMAKE_OBJCOPY", "CMAKE_OBJDUMP", "CMAKE_READELF"): + with self.subTest(variable=variable): + result = self.script(self.settings() + f'set({variable} "{wrong}")\natrinik_require_llvm()\n') + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"{variable} must be LLVM 23.1.3", result.stderr) + def test_missing_llvm_fails_toolchain_selection(self): empty = self.root / "empty" empty.mkdir() From 46921152874248f6dd70f20f060c1c6970b7a2e4 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:27:46 -0500 Subject: [PATCH 018/148] test(ci): assert final LLVM coverage preset contracts --- tools/tests/test_workflow_contracts.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index e03ea916b..3846c054c 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -950,16 +950,19 @@ def test_server_validation_exercises_release_ndebug(self) -> None: runner = (ROOT / "tools" / "ci" / "run_linux_check.sh").read_text( encoding="utf-8" ) - self.assertIn("coverage_preset=linux-coverage", runner) - self.assertIn("coverage_preset=llvm-coverage", runner) + self.assertIn(".tools.clang", runner) + self.assertIn("= 23.1.3", runner) + self.assertIn("--build-dir build/llvm-coverage", runner) + self.assertNotIn("coverage_preset=linux-coverage", runner) + self.assertNotIn("ATRINIK_LLVM_TOOLCHAIN", runner) server = runner[runner.index(" server)") : runner.index(" client)")] - coverage = server.index('cmake --preset "${coverage_preset}"') + coverage = server.index("cmake --preset llvm-coverage") release = server.index("cmake --preset linux-release") sanitizers = server.index("cmake --preset linux-sanitizers") self.assertLess(coverage, release) self.assertLess(release, sanitizers) self.assertIn("cmake --build --preset linux-release --parallel", server) - for preset in ('"${coverage_preset}"', "linux-release", "linux-sanitizers"): + for preset in ("llvm-coverage", "linux-release", "linux-sanitizers"): self.assertIn(f"ctest --preset {preset} --parallel 4", server) self.assertIn(f"ctest --preset {preset} --parallel 4 -LE performance", server) @@ -1018,7 +1021,7 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None client = runner[client_start : runner.index(" client-benchmark)", client_start)] for name, component in (("server", server), ("client", client)): with self.subTest(component=name): - coverage = component.index('cmake --preset "${coverage_preset}"') + coverage = component.index("cmake --preset llvm-coverage") release = component.index("cmake --preset linux-release") sanitizers = component.index("cmake --preset linux-sanitizers") self.assertLess(coverage, release) @@ -1030,7 +1033,7 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None ) else: self.assertNotIn("-DENABLE_PRECOMPILED_HEADERS=OFF", component) - self.assertIn('ctest --preset "${coverage_preset}"', component) + self.assertIn("ctest --preset llvm-coverage", component) self.assertIn("ctest --preset linux-release", component) self.assertIn("ctest --preset linux-sanitizers", component) @@ -2052,11 +2055,11 @@ def test_linux_checks_pin_image_and_isolate_compiler_caches(self) -> None: self.assertIn('--env ATRINIK_BENCHMARK_REVISION="${expected_revision}"', gpu_coverage) self.assertIn("verify_gpu_coverage_record.py", gpu_coverage) self.assertIn("ATRINIK_GPU_CONFORMANCE_TEST_SUPPRESS_ROOT_GLYPH", gpu_coverage) - self.assertIn("coverage_preset=linux-coverage", gpu_coverage) - self.assertIn("coverage_preset=llvm-coverage", gpu_coverage) - self.assertIn('--env ATRINIK_COVERAGE_DIRECTORY="${coverage_directory}"', gpu_coverage) - self.assertIn('cmake --preset "${ATRINIK_COVERAGE_PRESET}"', gpu_coverage) - self.assertIn('ctest --preset "${ATRINIK_COVERAGE_PRESET}"', gpu_coverage) + self.assertIn("coverage_build=${client_root}/build/llvm-coverage", gpu_coverage) + self.assertIn('--env LLVM_PROFILE_FILE="${compiled_home}/build/llvm-coverage/llvm-profiles/%m-%p.profraw"', gpu_coverage) + self.assertIn("cmake --preset llvm-coverage", gpu_coverage) + self.assertIn("ctest --preset llvm-coverage", gpu_coverage) + self.assertNotIn("ATRINIK_LLVM_TOOLCHAIN", gpu_coverage) self.assertIn("lvp_icd.json", gpu_coverage) self.assertIn("--network none", gpu_coverage) self.assertIn("tools/ci/run_gpu_coverage.sh", client) From 83dd8adb62d8ddd93061edf8d0c526613706b46f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:21:04 -0500 Subject: [PATCH 019/148] fix(windows): resolve sealed Python providers against reachable target imports --- docs/WINDOWS_TOOLCHAIN.md | 6 + tools/ci/stage_windows_runtime.py | 144 ++++++++++++++++------ tools/release/package_sources.py | 1 + tools/tests/test_package_sources.py | 5 +- tools/tests/test_stage_windows_runtime.py | 12 +- tools/tests/test_windows_trust_notices.py | 64 ++++++++++ 6 files changed, 195 insertions(+), 37 deletions(-) diff --git a/docs/WINDOWS_TOOLCHAIN.md b/docs/WINDOWS_TOOLCHAIN.md index 2adec2ac5..be2d0d386 100644 --- a/docs/WINDOWS_TOOLCHAIN.md +++ b/docs/WINDOWS_TOOLCHAIN.md @@ -48,6 +48,12 @@ schema-1 `python-runtime.json` receipt of every relative file and its SHA-256. At install time it snapshots the entire recursive Python tree, rejects file additions, removals, changed bytes and symlinks, and inspects every copied DLL, extension and executable. The installer ships this validated snapshot. +The target dependency graph is resolved first. An unused SDK DLL with the same +name as a sealed Python DLL stays out of the package; Python retains its own +receipt-checked bytes. A reachable target import needing that name fails with +a provider conflict. Nested producer modules keep their prefix-relative paths +in the optional `modules` inventory; their bytes, imports and notices are +validated without flattening them into the application DLL directory. Both packages retain the producer's `windows-dependencies.json` and `windows-pe-closure.json` below `licenses/`, every dependency notice listed by diff --git a/tools/ci/stage_windows_runtime.py b/tools/ci/stage_windows_runtime.py index 91ba44394..1e83f093a 100644 --- a/tools/ci/stage_windows_runtime.py +++ b/tools/ci/stage_windows_runtime.py @@ -16,51 +16,80 @@ DLL_NAME = re.compile(r"[A-Za-z0-9_+.-]+\.dll", re.IGNORECASE) +IMPORT_NAME = re.compile(r"[A-Za-z0-9_+.-]+\.(?:dll|drv)", re.IGNORECASE) GCC_RUNTIME = re.compile( r"(?:libgcc_s|libstdc\+\+|libssp|libgomp|libatomic|libquadmath)[^/]*\.dll", re.IGNORECASE, ) SYSTEM_DLLS = { "advapi32.dll", + "avrt.dll", "bcrypt.dll", + "cabinet.dll", "cfgmgr32.dll", "comctl32.dll", "comdlg32.dll", "crypt32.dll", + "d2d1.dll", + "d3d11.dll", "d3d12.dll", + "d3d9.dll", "d3dcompiler_47.dll", + "dbghelp.dll", + "dinput8.dll", "dnsapi.dll", "dwmapi.dll", + "dwrite.dll", "dxgi.dll", "gdi32.dll", + "gdiplus.dll", "hid.dll", "imagehlp.dll", "imm32.dll", "iphlpapi.dll", "kernel32.dll", - "msvcrt.dll", + "ksuser.dll", + "mf.dll", + "mfplat.dll", + "mfreadwrite.dll", + "mfuuid.dll", + "msimg32.dll", "mswsock.dll", "ncrypt.dll", + "netapi32.dll", + "netio.dll", "normaliz.dll", + "ntdll.dll", "ole32.dll", "oleaut32.dll", + "opengl32.dll", "powrprof.dll", + "propsys.dll", "psapi.dll", "rpcrt4.dll", + "runtimeobject.dll", "secur32.dll", "setupapi.dll", "shell32.dll", "shlwapi.dll", - "user32.dll", "ucrtbase.dll", + "uiautomationcore.dll", + "user32.dll", + "userenv.dll", + "usp10.dll", "uxtheme.dll", "version.dll", + "winhttp.dll", + "wininet.dll", "winmm.dll", + "winspool.drv", "wintrust.dll", "wldap32.dll", "ws2_32.dll", "wsock32.dll", + "wtsapi32.dll", } + MAX_RUNTIME_DLLS = 256 @@ -82,7 +111,7 @@ def parse_imports(output: str) -> set[str]: block = [] elif block is not None: if stripped == "}": - if len(block) != 1 or DLL_NAME.fullmatch(block[0]) is None: + if len(block) != 1 or IMPORT_NAME.fullmatch(block[0]) is None: raise StageError("LLVM COFF import block has an invalid DLL name") imports.add(block[0]) block = None @@ -109,10 +138,11 @@ def imported_dlls(readobj: str, binary: Path) -> set[str]: return parse_imports(result.stdout) -def is_system_dll(name: str) -> bool: +def is_system_dll(name: str, *, external_python: bool = False) -> bool: normalized = name.lower() return ( normalized in SYSTEM_DLLS + or external_python and normalized == "msvcrt.dll" or normalized.startswith("api-ms-win-") or normalized.startswith("ext-ms-win-") ) @@ -196,7 +226,9 @@ def hash_record(value, name: str) -> str: return value -def producer_inventory(path: Path, runtime_dir: Path) -> tuple[dict[str, dict], dict[str, tuple[Path, str]]]: +def producer_inventory(path: Path, runtime_dir: Path, + inspector: Callable[[Path], set[str]] | None = None, + ) -> tuple[dict[str, dict], dict[str, tuple[Path, str]]]: manifest = read_inventory(path) if manifest.get("schema_version") != 2 or not isinstance(manifest.get("dlls"), dict): raise StageError("unsupported producer PE inventory schema") @@ -248,9 +280,29 @@ def producer_inventory(path: Path, runtime_dir: Path) -> tuple[dict[str, dict], hash_record(record.get("sha256"), name) hash_record(record.get("source_sha256"), f"{name} source") imports = record.get("imports") - if not isinstance(imports, list) or any(not isinstance(item, str) or DLL_NAME.fullmatch(item) is None for item in imports): + if not isinstance(imports, list) or any(not isinstance(item, str) or IMPORT_NAME.fullmatch(item) is None for item in imports): raise StageError(f"invalid producer imports: {name}") records[name.lower()] = {**record, "imports": [item.lower() for item in imports]} + modules = manifest.get("modules", {}) + if not isinstance(modules, dict) or len(modules) > MAX_RUNTIME_DLLS: + raise StageError("invalid producer module inventory") + module_names = set() + with tempfile.TemporaryDirectory(prefix="atrinik-module-inspection-") as temporary: + for name, record in modules.items(): + relative = safe_relative_path(name) + if relative.suffix.lower() != ".dll" or name.lower() in module_names or not isinstance(record, dict): + raise StageError(f"invalid producer module record: {name}") + module_names.add(name.lower()) + if record.get("owner") not in owners: + raise StageError(f"producer module has no notice owner: {name}") + hash_record(record.get("source_sha256"), f"{name} source") + imports = record.get("imports") + if not isinstance(imports, list) or any(not isinstance(item, str) or IMPORT_NAME.fullmatch(item) is None for item in imports): + raise StageError(f"invalid producer module imports: {name}") + snapshot = Path(temporary) / relative + copy_snapshot(runtime_dir.parent / relative, snapshot, hash_record(record.get("sha256"), name)) + if inspector is not None and {item.lower() for item in inspector(snapshot)} != {item.lower() for item in imports}: + raise StageError(f"module imports differ from producer inventory: {name}") return records, notice_files @@ -323,6 +375,7 @@ def stage_runtime( for name, (source, digest) in producer_notices.items(): copy_snapshot(source, output_dir / "licenses" / safe_relative_path(name), digest) python_binaries = [] + python_sources: dict[str, Path] = {} if python_runtime is not None: if python_inventory_path is None: raise StageError("Python runtime requires a producer file inventory") @@ -330,10 +383,8 @@ def stage_runtime( for binary in python_binaries: if binary.suffix.lower() == ".dll": name = binary.name.lower() - if name in sources: - raise StageError(f"duplicate case-insensitive runtime DLL: {binary.name}") - sources[name] = binary - queue: list[tuple[Path, Path, dict | None]] = [] + python_sources[name] = binary + queue: list[tuple[Path, Path, dict | None, bool]] = [] staged: dict[str, Path] = {} with tempfile.TemporaryDirectory(prefix="atrinik-pe-inspection-") as temporary: @@ -361,36 +412,58 @@ def stage(source: Path, copy: bool = True, external: bool = False) -> None: inspection = output_dir / source.name if copy else inspection_dir / source.name copy_snapshot(source, inspection, record["sha256"] if record else None) staged[normalized] = source - queue.append((source, inspection, record)) + queue.append((source, inspection, record, external)) for binary in binaries: stage(binary, copy=not dlls_only) - for binary in python_binaries: - stage(binary, copy=False, external=True) position = 0 runtime_count = 0 - while position < len(queue): - original, binary, record = queue[position] - position += 1 - imports = inspector(binary) - if record is not None and {name.lower() for name in imports} != set(record["imports"]): - raise StageError(f"runtime imports differ from producer inventory: {original.name}") - for dependency in sorted(imports, key=str.lower): - if DLL_NAME.fullmatch(dependency) is None: - raise StageError(f"invalid imported DLL name for {binary.name}: {dependency}") - normalized = dependency.lower() - if GCC_RUNTIME.fullmatch(normalized): - raise StageError(f"GCC runtime DLL in LLVM closure: {dependency}") - if is_system_dll(dependency) or normalized in staged: - continue - source = sources.get(normalized) - if source is None: - raise StageError(f"unresolved runtime DLL for {binary.name}: {dependency}") - runtime_count += 1 - if runtime_count > MAX_RUNTIME_DLLS: - raise StageError("Windows runtime dependency closure is unexpectedly large") - stage(source) + + def inspect_queue() -> None: + nonlocal position, runtime_count + while position < len(queue): + original, binary, record, external = queue[position] + position += 1 + imports = inspector(binary) + if record is not None and {name.lower() for name in imports} != set(record["imports"]): + raise StageError(f"runtime imports differ from producer inventory: {original.name}") + for dependency in sorted(imports, key=str.lower): + if IMPORT_NAME.fullmatch(dependency) is None: + raise StageError(f"invalid imported DLL name for {binary.name}: {dependency}") + normalized = dependency.lower() + if GCC_RUNTIME.fullmatch(normalized): + raise StageError(f"GCC runtime DLL in LLVM closure: {dependency}") + if is_system_dll(dependency, external_python=external): + continue + # Resolve the actual target graph before admitting all of + # Python's sealed files. A full SDK's unused duplicate is + # harmless; a target import needing it cannot share the + # package filename with Python's independently built DLL. + if not external and normalized in sources and normalized in python_sources: + raise StageError(f"reachable DLL provider conflict: {dependency}") + python_source = python_sources.get(normalized) + source = python_source if external and python_source is not None else sources.get(normalized) + selected_external = source is not None and source == python_source + if source is None: + source = python_source + selected_external = source is not None + previous = staged.get(normalized) + if previous is not None: + if source is not None and previous != source: + raise StageError(f"reachable DLL provider conflict: {dependency}") + continue + if source is None: + raise StageError(f"unresolved runtime DLL for {binary.name}: {dependency}") + runtime_count += 1 + if runtime_count > MAX_RUNTIME_DLLS: + raise StageError("Windows runtime dependency closure is unexpectedly large") + stage(source, copy=not selected_external, external=selected_external) + + inspect_queue() + for binary in python_binaries: + stage(binary, copy=False, external=True) + inspect_queue() return sorted((path.relative_to(output_dir).as_posix() for path in output_dir.rglob("*") if path.is_file()), key=str.lower) @@ -433,7 +506,8 @@ def main() -> int: frozen_python = snapshot_dir / "python-runtime.json" copy_snapshot(arguments.python_inventory or inventory_path.parent / "python-runtime.json", frozen_python, max_bytes=4 * 1024 * 1024) - records, notices = producer_inventory(frozen_inventory, arguments.runtime_dir[0]) + records, notices = producer_inventory(frozen_inventory, arguments.runtime_dir[0], + lambda binary: imported_dlls(arguments.readobj, binary)) staged = stage_runtime( arguments.binaries, arguments.runtime_dir, diff --git a/tools/release/package_sources.py b/tools/release/package_sources.py index 8d56dc0f2..62602f55d 100755 --- a/tools/release/package_sources.py +++ b/tools/release/package_sources.py @@ -29,6 +29,7 @@ "cmake/AtrinikVersion.cmake", "cmake/AtrinikLLVM.cmake", "cmake/InstallWindowsRuntime.cmake", + "cmake/InstallLLVMRuntime.cmake", "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", "tools/ci/stage_windows_runtime.py", diff --git a/tools/tests/test_package_sources.py b/tools/tests/test_package_sources.py index 0f42710bb..681345b28 100644 --- a/tools/tests/test_package_sources.py +++ b/tools/tests/test_package_sources.py @@ -34,6 +34,7 @@ def setUp(self) -> None: "cmake/AtrinikVersion.cmake": "# version contract\n", "cmake/AtrinikLLVM.cmake": "# LLVM initialization contract\n", "cmake/InstallWindowsRuntime.cmake": "# Windows runtime closure\n", + "cmake/InstallLLVMRuntime.cmake": "# Native LLVM runtime notices\n", "cmake/toolchains/llvm.cmake": "# LLVM toolchain\n", "tools/ci/llvm_coverage.py": "# LLVM coverage conversion\n", "tools/ci/stage_windows_runtime.py": "# LLVM inspector\n", @@ -104,6 +105,7 @@ def test_scoped_archive_contains_shared_evidence_but_not_other_modules(self) -> "atrinik-classic-client-5.6.0/cmake/AtrinikVersion.cmake", names ) self.assertIn("atrinik-classic-client-5.6.0/cmake/InstallWindowsRuntime.cmake", names) + self.assertIn("atrinik-classic-client-5.6.0/cmake/InstallLLVMRuntime.cmake", names) self.assertIn("atrinik-classic-client-5.6.0/tools/ci/stage_windows_runtime.py", names) self.assertIn("atrinik-classic-client-5.6.0/cmake/toolchains/llvm.cmake", names) self.assertIn("atrinik-classic-client-5.6.0/cmake/AtrinikLLVM.cmake", names) @@ -138,7 +140,8 @@ def test_unsafe_source_path_is_rejected(self) -> None: def test_standalone_native_packages_retain_windows_closure_tools(self) -> None: for scope in ("client", "server"): for path in ("cmake/InstallWindowsRuntime.cmake", "tools/ci/stage_windows_runtime.py", - "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", "cmake/AtrinikLLVM.cmake"): + "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", "cmake/AtrinikLLVM.cmake", + "cmake/InstallLLVMRuntime.cmake"): with self.subTest(scope=scope, path=path): self.assertEqual(package_sources.selected_name(scope, path, "package"), f"package/{path}") diff --git a/tools/tests/test_stage_windows_runtime.py b/tools/tests/test_stage_windows_runtime.py index f7c084cb4..6330c0cdf 100644 --- a/tools/tests/test_stage_windows_runtime.py +++ b/tools/tests/test_stage_windows_runtime.py @@ -120,7 +120,17 @@ def test_inspector_uses_llvm_coff_import_mode_and_propagates_failure(self) -> No stage_windows_runtime.imported_dlls("llvm-readobj", Path("fixture.exe")) def test_system_ucrt_and_external_python_crt_are_not_staged(self) -> None: - for name in ("ucrtbase.dll", "api-ms-win-crt-runtime-l1-1-0.dll", "msvcrt.dll"): + for name in ("ucrtbase.dll", "api-ms-win-crt-runtime-l1-1-0.dll"): + self.assertTrue(stage_windows_runtime.is_system_dll(name)) + self.assertFalse(stage_windows_runtime.is_system_dll("msvcrt.dll")) + self.assertTrue(stage_windows_runtime.is_system_dll("msvcrt.dll", external_python=True)) + + def test_inbox_driver_import_is_parsed_and_only_explicit_system_driver_is_accepted(self) -> None: + imports = stage_windows_runtime.parse_imports("Format: COFF-x86-64\nImport {\nName: WINSPOOL.DRV\n}\n") + self.assertEqual(imports, {"WINSPOOL.DRV"}) + self.assertTrue(stage_windows_runtime.is_system_dll("WINSPOOL.DRV")) + self.assertFalse(stage_windows_runtime.is_system_dll("unknown.drv")) + for name in ("dwrite.dll", "d3d11.dll", "netapi32.dll", "userenv.dll", "usp10.dll", "avrt.dll"): self.assertTrue(stage_windows_runtime.is_system_dll(name)) def test_gcc_runtime_in_imports_or_unused_prefix_fails(self) -> None: diff --git a/tools/tests/test_windows_trust_notices.py b/tools/tests/test_windows_trust_notices.py index 6a2693dfc..fdc329126 100644 --- a/tools/tests/test_windows_trust_notices.py +++ b/tools/tests/test_windows_trust_notices.py @@ -343,6 +343,70 @@ def test_python_late_additions_nested_gcc_and_changed_bytes_cannot_ship(self): self.assertFalse((root / "installed/server/late.pyd").exists()) self.assertFalse((root / "installed/server/nested/libgcc_s_seh-1.dll").exists()) + def test_python_sealed_libffi_ignores_only_unreachable_sdk_duplicate(self): + for reachable in (False, True): + with self.subTest(reachable=reachable): + root, result, _, _ = self.fixture("server", cares=False, python=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + inventories = root / "toolchain/share/atrinik/windows-dependencies" + target_bytes, python_bytes = b"LLVM SDK ffi", b"sealed CPython ffi" + (root / "toolchain/bin/libffi-8.dll").write_bytes(target_bytes) + (root / "python/libffi-8.dll").write_bytes(python_bytes) + python_receipt = json.loads((inventories / "python-runtime.json").read_text()) + python_receipt["files"]["libffi-8.dll"] = {"sha256": hashlib.sha256(python_bytes).hexdigest()} + (inventories / "python-runtime.json").write_text(json.dumps(python_receipt)) + producer_receipt = json.loads((inventories / "pe-closure.json").read_text()) + producer_receipt["dlls"]["libffi-8.dll"] = { + "owner": "fixture-curl", "source_sha256": "1" * 64, + "sha256": hashlib.sha256(target_bytes).hexdigest(), "imports": []} + producer_receipt["dlls"]["libcurl-4.dll"]["imports"] = ["libffi-8.dll"] if reachable else [] + (inventories / "pe-closure.json").write_text(json.dumps(producer_receipt)) + inspector = root / "llvm-readobj" + script = inspector.read_text() + script += ('\nif binary.name == "_ssl.pyd":\n' + ' print("Import {\\nName: libffi-8.dll\\n}")\n') + if reachable: + script += ('\nif binary.name == "libcurl-4.dll":\n' + ' print("Import {\\nName: libffi-8.dll\\n}")\n') + inspector.write_text(script) + if reachable: + result = self.install_result(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("reachable DLL provider conflict", " ".join((result.stdout + result.stderr).split())) + else: + with self.package(root, "server") as archive: + filename = next(name for name in archive.namelist() if name.endswith("/server/libffi-8.dll")) + self.assertEqual(archive.read(filename), python_bytes) + + def test_producer_nested_modules_are_checked_without_flattening(self): + for damage in (None, "bytes", "imports", "owner"): + with self.subTest(damage=damage): + root, result, _, _ = self.fixture("client", cares=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + module = root / "toolchain/lib/ossl-modules/legacy.dll" + module.parent.mkdir(parents=True) + module.write_bytes(b"verified legacy module") + inventory = root / "toolchain/share/atrinik/windows-dependencies/pe-closure.json" + manifest = json.loads(inventory.read_text()) + record = {"sha256": hashlib.sha256(module.read_bytes()).hexdigest(), + "owner": "fixture-curl", "source_sha256": "1" * 64, "imports": []} + if damage == "owner": + record["owner"] = "unowned" + elif damage == "imports": + record["imports"] = ["kernel32.dll"] + elif damage == "bytes": + module.write_bytes(b"changed module") + manifest["modules"] = {"lib/ossl-modules/legacy.dll": record} + inventory.write_text(json.dumps(manifest)) + if damage is None: + with self.package(root, "client") as archive: + self.assertFalse(any(name.endswith("legacy.dll") for name in archive.namelist())) + metadata = next(name for name in archive.namelist() if name.endswith("windows-pe-closure.json")) + self.assertIn("modules", json.loads(archive.read(metadata))) + else: + result = self.install_result(root) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + if __name__ == "__main__": unittest.main() From fd9b4fddbf2885752eac1cf000111f51352f2b2e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:28:58 -0500 Subject: [PATCH 020/148] fix(windows): bind missing providers and parse LLVM delayed imports --- tools/ci/stage_windows_runtime.py | 35 +++++++++++++++++------ tools/tests/test_stage_windows_runtime.py | 17 +++++++++-- tools/tests/test_windows_trust_notices.py | 6 ++-- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/tools/ci/stage_windows_runtime.py b/tools/ci/stage_windows_runtime.py index 1e83f093a..7a7da5674 100644 --- a/tools/ci/stage_windows_runtime.py +++ b/tools/ci/stage_windows_runtime.py @@ -103,20 +103,35 @@ def parse_imports(output: str) -> set[str]: raise StageError("LLVM inspector did not identify an x86-64 COFF image") imports: set[str] = set() block = None + outer_kind = None + depth = 0 for line in output.splitlines(): stripped = line.strip() if stripped in {"Import {", "DelayImport {"}: - if block is not None: + if block is None: + block = [] + outer_kind = stripped + depth = 1 + elif outer_kind == "DelayImport {" and stripped == "Import {" and depth == 1: + # LLVM nests delayed symbols inside their DLL's DelayImport. + depth = 2 + else: raise StageError("nested LLVM COFF import block") - block = [] elif block is not None: if stripped == "}": - if len(block) != 1 or IMPORT_NAME.fullmatch(block[0]) is None: - raise StageError("LLVM COFF import block has an invalid DLL name") - imports.add(block[0]) - block = None + depth -= 1 + if depth == 0: + if len(block) != 1 or IMPORT_NAME.fullmatch(block[0]) is None: + raise StageError("LLVM COFF import block has an invalid DLL name") + imports.add(block[0]) + block = None + outer_kind = None elif stripped.startswith("Name:"): + if depth != 1: + raise StageError("LLVM delayed symbol has an unexpected DLL name") block.append(stripped.removeprefix("Name:").strip()) + elif stripped.endswith("{"): + raise StageError("unknown nested LLVM COFF import block") if block is not None: raise StageError("unterminated LLVM COFF import block") return imports @@ -143,8 +158,7 @@ def is_system_dll(name: str, *, external_python: bool = False) -> bool: return ( normalized in SYSTEM_DLLS or external_python and normalized == "msvcrt.dll" - or normalized.startswith("api-ms-win-") - or normalized.startswith("ext-ms-win-") + or normalized.endswith(".dll") and normalized.startswith(("api-ms-win-", "ext-ms-win-")) ) @@ -440,7 +454,10 @@ def inspect_queue() -> None: # Python's sealed files. A full SDK's unused duplicate is # harmless; a target import needing it cannot share the # package filename with Python's independently built DLL. - if not external and normalized in sources and normalized in python_sources: + expected_target = normalized in sources or ( + producer_records is not None and normalized in producer_records + ) + if not external and expected_target and normalized in python_sources: raise StageError(f"reachable DLL provider conflict: {dependency}") python_source = python_sources.get(normalized) source = python_source if external and python_source is not None else sources.get(normalized) diff --git a/tools/tests/test_stage_windows_runtime.py b/tools/tests/test_stage_windows_runtime.py index 6330c0cdf..7cbbdd8ae 100644 --- a/tools/tests/test_stage_windows_runtime.py +++ b/tools/tests/test_stage_windows_runtime.py @@ -89,7 +89,14 @@ def test_llvm_parser_accepts_normal_and_delayed_imports(self) -> None: DelayImport { Name: libcrypto-4-x64.dll Attributes: 0x1 - Symbol: SSL_connect (0) + Import { + Symbol: SSL_connect (0) + Address: 0x180001AF7 + } + Import { + Symbol: SSL_read (1) + Address: 0x180001AD3 + } } """ self.assertEqual( @@ -102,7 +109,11 @@ def test_llvm_parser_rejects_unrecognized_and_malformed_output(self) -> None: "Format: COFF-i386\n", "Format: COFF-x86-64\nImport {\n", "Format: COFF-x86-64\nImport {\nName: ../bad.dll\n}\n", "Format: COFF-x86-64\nImport {\n}\n", - "Format: COFF-x86-64\nImport {\nName: a.dll\nName: b.dll\n}\n"): + "Format: COFF-x86-64\nImport {\nName: a.dll\nName: b.dll\n}\n", + "Format: COFF-x86-64\nImport {\nName: a.dll\nImport {\n}\n}\n", + "Format: COFF-x86-64\nDelayImport {\nName: a.dll\nImport {\nName: b.dll\n}\n}\n", + "Format: COFF-x86-64\nDelayImport {\nName: a.dll\nImport {\nSymbol: foo\n}\n", + "Format: COFF-x86-64\nDelayImport {\nName: a.dll\nUnknown {\n}\n}\n"): with self.subTest(output=output), self.assertRaises(stage_windows_runtime.StageError): stage_windows_runtime.parse_imports(output) @@ -130,6 +141,8 @@ def test_inbox_driver_import_is_parsed_and_only_explicit_system_driver_is_accept self.assertEqual(imports, {"WINSPOOL.DRV"}) self.assertTrue(stage_windows_runtime.is_system_dll("WINSPOOL.DRV")) self.assertFalse(stage_windows_runtime.is_system_dll("unknown.drv")) + self.assertFalse(stage_windows_runtime.is_system_dll("api-ms-win-private.drv")) + self.assertFalse(stage_windows_runtime.is_system_dll("ext-ms-win-private.pyd")) for name in ("dwrite.dll", "d3d11.dll", "netapi32.dll", "userenv.dll", "usp10.dll", "avrt.dll"): self.assertTrue(stage_windows_runtime.is_system_dll(name)) diff --git a/tools/tests/test_windows_trust_notices.py b/tools/tests/test_windows_trust_notices.py index fdc329126..f651522d5 100644 --- a/tools/tests/test_windows_trust_notices.py +++ b/tools/tests/test_windows_trust_notices.py @@ -344,8 +344,8 @@ def test_python_late_additions_nested_gcc_and_changed_bytes_cannot_ship(self): self.assertFalse((root / "installed/server/nested/libgcc_s_seh-1.dll").exists()) def test_python_sealed_libffi_ignores_only_unreachable_sdk_duplicate(self): - for reachable in (False, True): - with self.subTest(reachable=reachable): + for reachable, missing_sdk in ((False, False), (True, False), (True, True)): + with self.subTest(reachable=reachable, missing_sdk=missing_sdk): root, result, _, _ = self.fixture("server", cares=False, python=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) inventories = root / "toolchain/share/atrinik/windows-dependencies" @@ -369,6 +369,8 @@ def test_python_sealed_libffi_ignores_only_unreachable_sdk_duplicate(self): script += ('\nif binary.name == "libcurl-4.dll":\n' ' print("Import {\\nName: libffi-8.dll\\n}")\n') inspector.write_text(script) + if missing_sdk: + (root / "toolchain/bin/libffi-8.dll").unlink() if reachable: result = self.install_result(root) self.assertNotEqual(result.returncode, 0) From f1d38b8d96512c6e1deeae1a10a10d373192bafc Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:33:37 -0500 Subject: [PATCH 021/148] fix(release): reject LLVM coverage in Linux AppImages --- client/tools/build-appimage.sh | 2 +- tools/release/appimage.py | 2 +- .../fixtures/llvm-coverage-23.1.3/fixture.c | 7 ++++ .../llvm-coverage-23.1.3/provenance.json | 24 +++++++++++ .../llvm-coverage-23.1.3/readelf-stripped.txt | 27 +++++++++++++ .../fixtures/llvm-coverage-23.1.3/readelf.txt | 40 +++++++++++++++++++ tools/tests/test_appimage.py | 18 +++++++++ 7 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 tools/tests/fixtures/llvm-coverage-23.1.3/fixture.c create mode 100644 tools/tests/fixtures/llvm-coverage-23.1.3/provenance.json create mode 100644 tools/tests/fixtures/llvm-coverage-23.1.3/readelf-stripped.txt create mode 100644 tools/tests/fixtures/llvm-coverage-23.1.3/readelf.txt diff --git a/client/tools/build-appimage.sh b/client/tools/build-appimage.sh index 2987f236f..2fefec475 100755 --- a/client/tools/build-appimage.sh +++ b/client/tools/build-appimage.sh @@ -67,7 +67,7 @@ build_directory=${ATRINIK_APPIMAGE_BUILD_DIRECTORY:-build/appimage-release} cmake -S . -B "${build_directory}" -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="$(realpath ../cmake/toolchains/llvm.cmake)" \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \ - -DBUILD_TESTING=OFF -DPACKAGE_TYPE=appimage \ + -DBUILD_TESTING=OFF -DENABLE_COVERAGE=OFF -DPACKAGE_TYPE=appimage \ -DATRINIK_PACKAGE_VERSION="${version}" -DFETCHCONTENT_FULLY_DISCONNECTED=ON \ -DATRINIK_APPIMAGE_SOURCE_REVISION="${revision}" \ "-DCMAKE_C_FLAGS=-ffile-prefix-map=$(realpath ..)=. -fdebug-prefix-map=$(realpath ..)=." \ diff --git a/tools/release/appimage.py b/tools/release/appimage.py index 118f0f021..611b9727c 100644 --- a/tools/release/appimage.py +++ b/tools/release/appimage.py @@ -25,7 +25,7 @@ MAX_IMAGE = 2 * 1024**3 MAX_PAYLOAD = 4 * 1024**3 BUILD_PATHS = re.compile(rb"/(?:home|root|workspace|build|__w|work)/|/tmp/(?:source/|build[-/]|atrinik-appimage[^/]*?/)|/opt/atrinik-appimage/") -TEST_FEATURES = re.compile(rb"(?:__gcov|__asan|__ubsan|__tsan|__lsan|ATRINIK_[A-Z_]*TESTING|atrinik_live_movement_test|movement_fault_injection|--gpu-player-view|injected GPU conformance fault|--help-parser-test|--widget-priority-test|--sound-test)") +TEST_FEATURES = re.compile(rb"(?:__gcov|__llvm_prf|__llvm_cov|__llvm_profile|__asan|__ubsan|__tsan|__lsan|ATRINIK_[A-Z_]*TESTING|atrinik_live_movement_test|movement_fault_injection|--gpu-player-view|injected GPU conformance fault|--help-parser-test|--widget-priority-test|--sound-test)") HOST_ONLY = re.compile(r"(?:ld-linux.*|lib(?:c|m|pthread|dl|rt|resolv|util|anl)\.so(?:\..*)?|lib(?:vulkan|GL|EGL|GLX|OpenGL|GLES).*|.*_dri\.so)") VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") SOURCE_LIBRARIES = { diff --git a/tools/tests/fixtures/llvm-coverage-23.1.3/fixture.c b/tools/tests/fixtures/llvm-coverage-23.1.3/fixture.c new file mode 100644 index 000000000..35d1941f1 --- /dev/null +++ b/tools/tests/fixtures/llvm-coverage-23.1.3/fixture.c @@ -0,0 +1,7 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + */ +int fixture_count(int value) +{ + return value > 0 ? value + 1 : value - 1; +} diff --git a/tools/tests/fixtures/llvm-coverage-23.1.3/provenance.json b/tools/tests/fixtures/llvm-coverage-23.1.3/provenance.json new file mode 100644 index 000000000..65d939b54 --- /dev/null +++ b/tools/tests/fixtures/llvm-coverage-23.1.3/provenance.json @@ -0,0 +1,24 @@ +{ + "llvm_version": "23.1.3", + "tool_archive_url": "https://github.com/mstorsjo/llvm-mingw/releases/download/20261006/llvm-mingw-20261006-ucrt-ubuntu-22.04-x86_64.tar.xz", + "tool_archive_sha256": "5f9c6ed95b2d4bdb2869a488c5fd5857fbdabcf288a0aa3eb1da43f6a08d8ab4", + "tools": { + "clang": "5996bd5db24162e3767bdf7c75f7dcc0bf2bc3d1957433aecf176e62e153b40a", + "ld.lld": "cbae3badcf29268220242e8875cb6c288336ab593238099746f0071c15bbf581", + "llvm-strip": "1a1580a3a0b08d0889a75cb7d74e634fb776520227be3f2d8f997747aa85800f", + "llvm-readelf": "8ce8e7b129d9fa8b0c6ec6b1931f6bf85886e305aae2c4639e17bd1541db9842" + }, + "source_sha256": "3d937dfb064e53c220a52ed499f5af5cb2564ed9a966811180b9caac18a07a1f", + "elf_sha256": { + "libcoverage-fixture.so": "a276160cf76ae2cb78cb2cca27d53ef189bb7a35cc1d26facc821c773a67f35b", + "libcoverage-fixture-stripped.so": "8741067d90142f7d30e05e8ea00dac8b06e01629d51edc9edcdf024819ee87a8" + }, + "commands": [ + "clang -c tools/tests/fixtures/llvm-coverage-23.1.3/fixture.c -o build/llvm-coverage-package-fixture/fixture.o -O2 -fPIC -fprofile-instr-generate -fcoverage-mapping -ffile-prefix-map=SOURCE_ROOT=. -fcoverage-prefix-map=SOURCE_ROOT=.", + "ld.lld -shared -soname libcoverage-fixture.so build/llvm-coverage-package-fixture/fixture.o -o build/llvm-coverage-package-fixture/libcoverage-fixture.so", + "llvm-readelf --wide --sections --symbols build/llvm-coverage-package-fixture/libcoverage-fixture.so", + "llvm-strip --strip-all build/llvm-coverage-package-fixture/libcoverage-fixture.so -o build/llvm-coverage-package-fixture/libcoverage-fixture-stripped.so", + "llvm-readelf --wide --sections --symbols build/llvm-coverage-package-fixture/libcoverage-fixture-stripped.so" + ], + "normalization": "Trailing horizontal whitespace and redundant terminal blank lines removed; fields and internal spacing retained." +} diff --git a/tools/tests/fixtures/llvm-coverage-23.1.3/readelf-stripped.txt b/tools/tests/fixtures/llvm-coverage-23.1.3/readelf-stripped.txt new file mode 100644 index 000000000..e82383d75 --- /dev/null +++ b/tools/tests/fixtures/llvm-coverage-23.1.3/readelf-stripped.txt @@ -0,0 +1,27 @@ +There are 13 section headers, starting at offset 0x460: + +Section Headers: + [Nr] Name Type Address Off Size ES Flg Lk Inf Al + [ 0] NULL 0000000000000000 000000 000000 00 0 0 0 + [ 1] .dynsym DYNSYM 0000000000000200 000200 000030 18 A 4 1 8 + [ 2] .gnu.hash GNU_HASH 0000000000000230 000230 000020 00 A 1 0 8 + [ 3] .hash HASH 0000000000000250 000250 000018 04 A 1 0 4 + [ 4] .dynstr STRTAB 0000000000000268 000268 000026 00 A 0 0 1 + [ 5] __llvm_prf_names PROGBITS 000000000000028e 00028e 000017 00 AR 0 0 1 + [ 6] .eh_frame PROGBITS 00000000000002a8 0002a8 000034 00 A 0 0 8 + [ 7] .text PROGBITS 00000000000012e0 0002e0 000024 00 AX 0 0 16 + [ 8] .dynamic DYNAMIC 0000000000002308 000308 000080 10 WA 4 0 8 + [ 9] .relro_padding NOBITS 0000000000002388 000388 000c78 00 WA 0 0 1 + [10] __llvm_prf_cnts PROGBITS 0000000000003388 000388 000010 00 WA 0 0 8 + [11] __llvm_prf_data PROGBITS 0000000000003398 000398 000048 00 WA 0 0 8 + [12] .shstrtab STRTAB 0000000000000000 0003e0 00007e 00 0 0 1 +Key to Flags: + W (write), A (alloc), X (execute), M (merge), S (strings), I (info), + L (link order), O (extra OS processing required), G (group), T (TLS), + C (compressed), x (unknown), o (OS specific), E (exclude), + R (retain), l (large), p (processor specific) + +Symbol table '.dynsym' contains 2 entries: + Num: Value Size Type Bind Vis Ndx Name + 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND + 1: 00000000000012e0 36 FUNC GLOBAL DEFAULT 7 fixture_count diff --git a/tools/tests/fixtures/llvm-coverage-23.1.3/readelf.txt b/tools/tests/fixtures/llvm-coverage-23.1.3/readelf.txt new file mode 100644 index 000000000..11f7190af --- /dev/null +++ b/tools/tests/fixtures/llvm-coverage-23.1.3/readelf.txt @@ -0,0 +1,40 @@ +There are 18 section headers, starting at offset 0x6b8: + +Section Headers: + [Nr] Name Type Address Off Size ES Flg Lk Inf Al + [ 0] NULL 0000000000000000 000000 000000 00 0 0 0 + [ 1] .dynsym DYNSYM 0000000000000200 000200 000030 18 A 4 1 8 + [ 2] .gnu.hash GNU_HASH 0000000000000230 000230 000020 00 A 1 0 8 + [ 3] .hash HASH 0000000000000250 000250 000018 04 A 1 0 4 + [ 4] .dynstr STRTAB 0000000000000268 000268 000026 00 A 0 0 1 + [ 5] __llvm_prf_names PROGBITS 000000000000028e 00028e 000017 00 AR 0 0 1 + [ 6] .eh_frame PROGBITS 00000000000002a8 0002a8 000034 00 A 0 0 8 + [ 7] .text PROGBITS 00000000000012e0 0002e0 000024 00 AX 0 0 16 + [ 8] .dynamic DYNAMIC 0000000000002308 000308 000080 10 WA 4 0 8 + [ 9] .relro_padding NOBITS 0000000000002388 000388 000c78 00 WA 0 0 1 + [10] __llvm_prf_cnts PROGBITS 0000000000003388 000388 000010 00 WA 0 0 8 + [11] __llvm_prf_data PROGBITS 0000000000003398 000398 000048 00 WA 0 0 8 + [12] __llvm_covfun PROGBITS 0000000000000000 0003e0 000046 00 R 0 0 8 + [13] __llvm_covmap PROGBITS 0000000000000000 000428 00004c 00 R 0 0 8 + [14] .comment PROGBITS 0000000000000000 000474 0000d1 01 MS 0 0 1 + [15] .symtab SYMTAB 0000000000000000 000548 000078 18 17 4 8 + [16] .shstrtab STRTAB 0000000000000000 0005c0 0000b9 00 0 0 1 + [17] .strtab STRTAB 0000000000000000 000679 00003d 00 0 0 1 +Key to Flags: + W (write), A (alloc), X (execute), M (merge), S (strings), I (info), + L (link order), O (extra OS processing required), G (group), T (TLS), + C (compressed), x (unknown), o (OS specific), E (exclude), + R (retain), l (large), p (processor specific) + +Symbol table '.dynsym' contains 2 entries: + Num: Value Size Type Bind Vis Ndx Name + 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND + 1: 00000000000012e0 36 FUNC GLOBAL DEFAULT 7 fixture_count + +Symbol table '.symtab' contains 5 entries: + Num: Value Size Type Bind Vis Ndx Name + 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND + 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS fixture.c + 2: 0000000000000000 70 OBJECT LOCAL HIDDEN 12 __covrec_6F566FFA7D830C7Cu + 3: 0000000000002308 0 NOTYPE LOCAL HIDDEN 8 _DYNAMIC + 4: 00000000000012e0 36 FUNC GLOBAL DEFAULT 7 fixture_count diff --git a/tools/tests/test_appimage.py b/tools/tests/test_appimage.py index 6b900e478..968a6acf6 100644 --- a/tools/tests/test_appimage.py +++ b/tools/tests/test_appimage.py @@ -465,11 +465,29 @@ def test_glibc_floor_rpath_and_instrumentation_inspectors(self): ("(RUNPATH) Library runpath: [/tmp/build/lib]", "", "", "search path"), ("(NEEDED) Shared library: [../liboutside.so]", "", "", "library identity"), ("", "", "__asan_init", "test instrumentation"), + ("", "", "__llvm_profile_runtime", "test instrumentation"), ): with self.subTest(expected=expected), mock.patch.object(appimage, "_run", side_effect=[dynamic, versions, symbols]): with self.assertRaisesRegex(ValueError, expected): appimage._elf(elf, executable=True) + def test_real_llvm_coverage_sections_rejected_before_and_after_stripping(self): + fixtures = Path(__file__).parent / "fixtures/llvm-coverage-23.1.3" + for name in ("readelf.txt", "readelf-stripped.txt"): + output = (fixtures / name).read_text() + # These section names come from a Clang 23.1.3 coverage build; + # llvm-strip removes symbols/mapping but retains profile counters. + sections = [line.split('] ', 1)[1].split()[0] for line in output.splitlines() + if " __llvm_" in line and "PROGBITS" in line] + self.assertTrue(any(section.startswith("__llvm_prf") for section in sections)) + if name == "readelf.txt": + self.assertTrue(any(section.startswith("__llvm_cov") for section in sections)) + for section in sections: + elf = self.write("usr/lib/coverage-fixture.so", Path("/usr/bin/true").read_bytes() + section.encode() + b"\0") + with self.subTest(output=name, section=section), mock.patch.object(appimage, "_run", return_value=""): + with self.assertRaisesRegex(ValueError, "test instrumentation"): + appimage._elf(elf) + def test_libcap_interpreter_exception_requires_exact_locked_identity(self): cap = self.write("usr/lib/libcap.so.2", Path("/usr/bin/true").read_bytes()) psx = self.write("usr/lib/libpsx.so.2", cap.read_bytes()) From 9edbc496fe62823bce013035892758cf333aa2ca Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:34:17 -0500 Subject: [PATCH 022/148] fix(build): select LLVM coverage toolchains from archive layouts --- CMakePresets.json | 5 +- client/CMakePresets.json | 5 +- libatrinik/CMakePresets.json | 5 +- libatrinik/pathfinding/CMakePresets.json | 3 +- server/CMakePresets.json | 5 +- tools/tests/test_coverage_presets.py | 80 +++++++++++++++++++++++- 6 files changed, 83 insertions(+), 20 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index 70dfa430b..c3c215e45 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -32,10 +32,7 @@ { "name": "llvm-coverage", "inherits": "linux-coverage", - "binaryDir": "${sourceDir}/build/integrated-llvm-coverage", - "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/cmake/toolchains/llvm.cmake" - } + "binaryDir": "${sourceDir}/build/integrated-llvm-coverage" }, { "name": "linux-sanitizers", diff --git a/client/CMakePresets.json b/client/CMakePresets.json index f8985d872..65135c7c5 100644 --- a/client/CMakePresets.json +++ b/client/CMakePresets.json @@ -34,10 +34,7 @@ { "name": "llvm-coverage", "inherits": "linux-coverage", - "binaryDir": "${sourceDir}/build/llvm-coverage", - "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" - } + "binaryDir": "${sourceDir}/build/llvm-coverage" }, { "name": "linux-sanitizers", diff --git a/libatrinik/CMakePresets.json b/libatrinik/CMakePresets.json index 73ef2a90b..e958b970d 100644 --- a/libatrinik/CMakePresets.json +++ b/libatrinik/CMakePresets.json @@ -15,10 +15,7 @@ { "name": "llvm-coverage", "inherits": "linux-coverage", - "binaryDir": "${sourceDir}/build/llvm-coverage", - "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" - } + "binaryDir": "${sourceDir}/build/llvm-coverage" }, { "name": "linux-sanitizers", diff --git a/libatrinik/pathfinding/CMakePresets.json b/libatrinik/pathfinding/CMakePresets.json index 64bbe21f6..e0bcb3d58 100644 --- a/libatrinik/pathfinding/CMakePresets.json +++ b/libatrinik/pathfinding/CMakePresets.json @@ -8,8 +8,7 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "BUILD_TESTING": true, - "ENABLE_COVERAGE": true, - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../../cmake/toolchains/llvm.cmake" + "ENABLE_COVERAGE": true } } ], diff --git a/server/CMakePresets.json b/server/CMakePresets.json index 8560bf2f2..067cebad3 100644 --- a/server/CMakePresets.json +++ b/server/CMakePresets.json @@ -36,10 +36,7 @@ { "name": "llvm-coverage", "inherits": "linux-coverage", - "binaryDir": "${sourceDir}/build/llvm-coverage", - "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" - } + "binaryDir": "${sourceDir}/build/llvm-coverage" }, { "name": "linux-sanitizers", diff --git a/tools/tests/test_coverage_presets.py b/tools/tests/test_coverage_presets.py index d027ac3b8..56c2eb70b 100644 --- a/tools/tests/test_coverage_presets.py +++ b/tools/tests/test_coverage_presets.py @@ -3,12 +3,16 @@ import importlib.util import json +import platform from pathlib import Path import shutil import subprocess import tempfile +import tarfile import unittest +from tools.release import package_sources + ROOT = Path(__file__).resolve().parents[2] spec = importlib.util.spec_from_file_location("llvm_coverage", ROOT / "tools/ci/llvm_coverage.py") coverage = importlib.util.module_from_spec(spec) @@ -42,13 +46,85 @@ def test_opt_in_has_matching_configure_build_and_test_presets(self): tested = preset(data["testPresets"], "llvm-coverage") self.assertTrue(configured["cacheVariables"]["ENABLE_COVERAGE"]) self.assertEqual(configured["cacheVariables"]["CMAKE_BUILD_TYPE"], "Debug") - toolchain = configured["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"].replace("${sourceDir}", str(source)) - self.assertEqual(Path(toolchain).resolve(), ROOT / "cmake/toolchains/llvm.cmake") + # The entry point selects the shared toolchain by physical module + # location; a fixed preset path breaks scoped source archives. + self.assertNotIn("CMAKE_TOOLCHAIN_FILE", configured["cacheVariables"]) + self.assertNotIn("toolchainFile", configured) self.assertEqual(built["configurePreset"], "llvm-coverage") self.assertEqual(tested["configurePreset"], "llvm-coverage") self.assertEqual(tested["environment"]["LLVM_PROFILE_FILE"], configured["binaryDir"] + "/llvm-profiles/%m-%p.profraw") + + def assert_configure_selects_toolchain(self, source, build, toolchain): + # Run actual preset configuration and the actual authored CMake prefix. + # Abort at project() before language detection: this verifies selection + # without inventing compiler identity or permitting a product policy bypass. + build.mkdir(parents=True) + probe = build / "toolchain-selection-probe.cmake" + probe.write_text(f''' +file(REAL_PATH "${{CMAKE_TOOLCHAIN_FILE}}" selected_toolchain) +if (NOT selected_toolchain STREQUAL "{toolchain.as_posix()}" OR + NOT EXISTS "${{selected_toolchain}}") + message(FATAL_ERROR "Unexpected selected toolchain: ${{CMAKE_TOOLCHAIN_FILE}}") +endif () +if (NOT ENABLE_COVERAGE OR NOT BUILD_TESTING OR NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + message(FATAL_ERROR "Coverage preset options were not applied") +endif () +message(FATAL_ERROR "ATRINIK_TOOLCHAIN_SELECTION_PROBE_COMPLETED") +''', encoding="utf-8") + result = subprocess.run( + ["cmake", "--preset", "llvm-coverage", "-B", str(build), + "-DATRINIK_PACKAGE_VERSION=9.8.7", + "-DCMAKE_PROJECT_INCLUDE_BEFORE=" + str(probe)], + cwd=source, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("ATRINIK_TOOLCHAIN_SELECTION_PROBE_COMPLETED", + result.stdout + result.stderr) + + @unittest.skipUnless(shutil.which("cmake") and platform.system() == "Linux", + "Native Linux CMake selection probe unavailable") + def test_actual_checkout_presets_select_shared_native_toolchain(self): + with tempfile.TemporaryDirectory() as temporary: + for index, module in enumerate(MODULES): + with self.subTest(module=module): + self.assert_configure_selects_toolchain( + ROOT / module, Path(temporary) / str(index), + ROOT / "cmake/toolchains/llvm.cmake") + + @unittest.skipUnless(shutil.which("cmake") and platform.system() == "Linux", + "Native Linux CMake selection probe unavailable") + def test_actual_scoped_archive_presets_select_packaged_native_toolchain(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_tar = root / "source.tar" + # Package the actual authored entry points/presets and shared modules + # through the release archiver. Dependencies beyond project() are not + # needed for this deliberately bounded selection probe. + with tarfile.open(source_tar, "w") as archive: + for module in MODULES: + for name in ("CMakeLists.txt", "CMakePresets.json"): + relative = Path(module) / name + archive.add(ROOT / relative, arcname=relative.as_posix()) + archive.add(ROOT / "cmake", arcname="cmake") + for scope in ("root", "client", "server", "libatrinik"): + with self.subTest(scope=scope): + package = root / (scope + ".tar.gz") + package_sources.build_archive(source_tar, package, scope, + "9.8.7", 42, "f" * 40) + destination = root / scope + with tarfile.open(package) as archive: + archive.extractall(destination, filter="data") + source = next(destination.iterdir()) + toolchain = source / "cmake/toolchains/llvm.cmake" + self.assert_configure_selects_toolchain( + source, root / (scope + "-build"), toolchain) + pathfinding = source / ("libatrinik/pathfinding" if scope == "root" else + "pathfinding" if scope == "libatrinik" else + "dependencies/libatrinik/pathfinding") + self.assert_configure_selects_toolchain( + pathfinding, root / (scope + "-pathfinding-build"), toolchain) + def test_native_profile_preparation_leaves_existing_gcc_cache_untouched(self): for module in MODULES[:-1]: with self.subTest(module=module), tempfile.TemporaryDirectory() as temporary: From c3522fcaa2e9c2c16b8ac097260a5cc49b0d5a4d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:38:55 -0500 Subject: [PATCH 023/148] fix(build): use pinned native LLVM for macOS Classic builds --- .github/workflows/gpu-qualification.yml | 9 +- cmake/AtrinikLLVM.cmake | 7 + cmake/InstallLLVMRuntime.cmake | 10 +- cmake/toolchains/llvm-macos.cmake | 66 ++++++ docs/MACOS_TOOLCHAIN.md | 52 +++++ tools/ci/macos_llvm_probe/CMakeLists.txt | 8 + tools/ci/macos_llvm_probe/main.c | 6 + tools/ci/macos_llvm_probe/main.cpp | 7 + tools/ci/prepare_macos_llvm.sh | 80 ++++++++ tools/release/package_sources.py | 1 + tools/tests/test_macos_llvm_toolchain.py | 246 +++++++++++++++++++++++ tools/tests/test_package_sources.py | 5 +- 12 files changed, 493 insertions(+), 4 deletions(-) create mode 100644 cmake/toolchains/llvm-macos.cmake create mode 100644 docs/MACOS_TOOLCHAIN.md create mode 100644 tools/ci/macos_llvm_probe/CMakeLists.txt create mode 100644 tools/ci/macos_llvm_probe/main.c create mode 100644 tools/ci/macos_llvm_probe/main.cpp create mode 100755 tools/ci/prepare_macos_llvm.sh create mode 100644 tools/tests/test_macos_llvm_toolchain.py diff --git a/.github/workflows/gpu-qualification.yml b/.github/workflows/gpu-qualification.yml index bbc1757fd..fbca88a22 100644 --- a/.github/workflows/gpu-qualification.yml +++ b/.github/workflows/gpu-qualification.yml @@ -161,6 +161,13 @@ jobs: grep -F 'clang version 23.1.3' client/build/gpu-qualification/compiler.txt sha256sum --check client/build/gpu-qualification/shader-manifest.sha256 cmake -E copy_directory client/build/gpu-qualification/sound client/sound + - name: Prepare and verify pinned native macOS LLVM + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + tools/ci/prepare_macos_llvm.sh "${RUNNER_TEMP}/classic-llvm-macos" + echo "${RUNNER_TEMP}/classic-llvm-macos/llvm/bin" >>"${GITHUB_PATH}" - name: Configure clean Release qualification build if: runner.os != 'Windows' shell: bash @@ -190,7 +197,7 @@ jobs: ' else cmake -S client -B client/build/gpu-qualification -G Ninja \ - -DCMAKE_TOOLCHAIN_FILE="${GITHUB_WORKSPACE}/cmake/toolchains/llvm.cmake" \ + -DCMAKE_TOOLCHAIN_FILE="${GITHUB_WORKSPACE}/cmake/toolchains/llvm-macos.cmake" \ -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON \ -DATRINIK_GPU_CONFORMANCE_REQUIRED=ON -DPACKAGE_TYPE=none \ "-DATRINIK_GPU_SHADER_DIRECTORY=${ATRINIK_QUALIFICATION_SHADER_DIRECTORY}" diff --git a/cmake/AtrinikLLVM.cmake b/cmake/AtrinikLLVM.cmake index cf91c2991..f6f9f8483 100644 --- a/cmake/AtrinikLLVM.cmake +++ b/cmake/AtrinikLLVM.cmake @@ -12,6 +12,11 @@ function(atrinik_initialize_llvm_toolchain) set(CMAKE_TOOLCHAIN_FILE "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/toolchains/llvm.cmake" CACHE FILEPATH "Pinned native LLVM toolchain") + elseif (NOT CMAKE_TOOLCHAIN_FILE AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin" AND + NOT CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(CMAKE_TOOLCHAIN_FILE + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/toolchains/llvm-macos.cmake" CACHE FILEPATH + "Pinned native macOS LLVM toolchain") endif () endfunction() @@ -40,5 +45,7 @@ function(atrinik_require_llvm) endforeach () if (CMAKE_SYSTEM_NAME STREQUAL "Linux") add_link_options(--rtlib=compiler-rt --unwindlib=libunwind -fuse-ld=lld) + elseif (CMAKE_SYSTEM_NAME STREQUAL "Darwin") + add_link_options(--rtlib=compiler-rt "-fuse-ld=${CMAKE_LINKER}") endif () endfunction() diff --git a/cmake/InstallLLVMRuntime.cmake b/cmake/InstallLLVMRuntime.cmake index 9a5e0d22d..c9b6abe75 100644 --- a/cmake/InstallLLVMRuntime.cmake +++ b/cmake/InstallLLVMRuntime.cmake @@ -3,7 +3,8 @@ include_guard(GLOBAL) function(atrinik_install_llvm_runtime_notices destination component) - if (NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + if (NOT CMAKE_SYSTEM_NAME STREQUAL "Linux" AND + NOT CMAKE_SYSTEM_NAME STREQUAL "Darwin") return() endif () get_filename_component(compiler "${CMAKE_C_COMPILER}" REALPATH) @@ -12,7 +13,12 @@ function(atrinik_install_llvm_runtime_notices destination component) set(ATRINIK_LLVM_LICENSE_DIRECTORY "${compiler_prefix}/share/licenses/llvm" CACHE PATH "Complete notices accompanying the LLVM compiler and static runtimes") - foreach (name IN ITEMS llvm clang clang-tools-extra lld compiler-rt libunwind) + if (CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(runtime_notices llvm clang lld compiler-rt) + else () + set(runtime_notices llvm clang clang-tools-extra lld compiler-rt libunwind) + endif () + foreach (name IN LISTS runtime_notices) set(notice "${ATRINIK_LLVM_LICENSE_DIRECTORY}/${name}-LICENSE.TXT") if (NOT EXISTS "${notice}" OR IS_DIRECTORY "${notice}" OR IS_SYMLINK "${notice}") message(FATAL_ERROR "LLVM runtime installation requires complete notice: ${notice}") diff --git a/cmake/toolchains/llvm-macos.cmake b/cmake/toolchains/llvm-macos.cmake new file mode 100644 index 000000000..680c15f42 --- /dev/null +++ b/cmake/toolchains/llvm-macos.cmake @@ -0,0 +1,66 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +# Native macOS LLVM cohort; Xcode supplies the platform SDK and libc++. +if (NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + message(FATAL_ERROR "This LLVM toolchain requires a native macOS host") +endif () + +find_program(CMAKE_C_COMPILER NAMES clang REQUIRED) +find_program(CMAKE_CXX_COMPILER NAMES clang++ REQUIRED) +find_program(CMAKE_AR NAMES llvm-ar REQUIRED) +find_program(CMAKE_RANLIB NAMES llvm-ranlib REQUIRED) +find_program(CMAKE_LINKER NAMES ld64.lld REQUIRED) +find_program(CMAKE_NM NAMES llvm-nm REQUIRED) +find_program(CMAKE_STRIP NAMES llvm-strip REQUIRED) +find_program(CMAKE_OBJCOPY NAMES llvm-objcopy REQUIRED) +find_program(CMAKE_OBJDUMP NAMES llvm-objdump REQUIRED) +find_program(CMAKE_READELF NAMES llvm-readelf REQUIRED) + +foreach (tool CMAKE_C_COMPILER CMAKE_CXX_COMPILER CMAKE_AR CMAKE_RANLIB CMAKE_LINKER + CMAKE_NM CMAKE_STRIP CMAKE_OBJCOPY CMAKE_OBJDUMP CMAKE_READELF) + execute_process(COMMAND "${${tool}}" --version + OUTPUT_VARIABLE tool_version ERROR_VARIABLE tool_error + RESULT_VARIABLE tool_result) + if (NOT tool_result EQUAL 0 OR tool_version MATCHES "Apple clang" OR + NOT tool_version MATCHES "[Vv]ersion 23\\.1\\.3([^0-9.]|$)" AND + NOT tool_version MATCHES "LLD 23\\.1\\.3([^0-9.]|$)") + message(FATAL_ERROR "${tool} must be upstream LLVM 23.1.3: ${tool_version}${tool_error}") + endif () +endforeach () + +# An explicit SDK, deployment target and architectures remain caller-owned. +find_program(ATRINIK_XCRUN NAMES xcrun REQUIRED) +if (NOT CMAKE_OSX_SYSROOT) + execute_process(COMMAND "${ATRINIK_XCRUN}" --sdk macosx --show-sdk-path + OUTPUT_VARIABLE sdk OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE sdk_result) + if (NOT sdk_result EQUAL 0 OR NOT IS_DIRECTORY "${sdk}") + message(FATAL_ERROR "A selected Xcode macOS SDK is required") + endif () + set(CMAKE_OSX_SYSROOT "${sdk}" CACHE PATH "Selected Xcode macOS SDK") +endif () +execute_process(COMMAND "${ATRINIK_XCRUN}" --sdk macosx --find clang++ + OUTPUT_VARIABLE apple_cxx OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE apple_result) +get_filename_component(apple_bin "${apple_cxx}" DIRECTORY) +get_filename_component(apple_usr "${apple_bin}" DIRECTORY) +set(apple_cxx_headers "${apple_usr}/include/c++/v1") +if (NOT apple_result EQUAL 0 OR NOT IS_DIRECTORY "${apple_cxx_headers}") + message(FATAL_ERROR "The selected Xcode platform libc++ headers are required") +endif () +# Avoid the downloaded cohort's newer libc++ headers with the system libc++ ABI. +set(CMAKE_CXX_FLAGS_INIT "-nostdinc++ -isystem \"${apple_cxx_headers}\"") + +execute_process(COMMAND "${CMAKE_C_COMPILER}" --rtlib=compiler-rt -print-libgcc-file-name + OUTPUT_VARIABLE compiler_rt OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE runtime_result) +if (NOT runtime_result EQUAL 0 OR NOT EXISTS "${compiler_rt}" OR + NOT compiler_rt MATCHES "/libclang_rt\\.osx\\.a$") + message(FATAL_ERROR "LLVM 23.1.3 macOS compiler-rt builtins are required: ${compiler_rt}") +endif () +set(CMAKE_C_COMPILER_AR "${CMAKE_AR}") +set(CMAKE_CXX_COMPILER_AR "${CMAKE_AR}") +set(CMAKE_C_COMPILER_RANLIB "${CMAKE_RANLIB}") +set(CMAKE_CXX_COMPILER_RANLIB "${CMAKE_RANLIB}") +# Mach-O LLD, LLVM builtins and platform unwind; preserve build-type optimization. +set(CMAKE_EXE_LINKER_FLAGS_INIT "--rtlib=compiler-rt -fuse-ld=\"${CMAKE_LINKER}\"") +set(CMAKE_SHARED_LINKER_FLAGS_INIT "--rtlib=compiler-rt -fuse-ld=\"${CMAKE_LINKER}\"") +set(CMAKE_MODULE_LINKER_FLAGS_INIT "--rtlib=compiler-rt -fuse-ld=\"${CMAKE_LINKER}\"") diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md new file mode 100644 index 000000000..133303f5c --- /dev/null +++ b/docs/MACOS_TOOLCHAIN.md @@ -0,0 +1,52 @@ +# Native macOS LLVM toolchain + +Classic uses upstream LLVM 23.1.3 on macOS: Clang, Mach-O LLD (`ld64.lld`), +LLVM archive/inspection tools, and compiler-rt builtins. Xcode supplies the +selected macOS SDK, system libc++ headers/runtime, frameworks and unwind runtime. +Apple Clang is only used to bootstrap the LLVM source installation on Intel; +it is rejected as a Classic compiler. Build-type optimization, application +dependencies, LTO and PGO remain unchanged. + +Install CMake 3.21 or newer, Ninja, Python and the normal client dependencies, +and select the intended Xcode developer directory before preparing LLVM. The +self-hosted Metal runners need the same prerequisites. From the Classic root: + +```sh +tools/ci/prepare_macos_llvm.sh /absolute/absent/task-llvm-directory +export PATH=/absolute/absent/task-llvm-directory/llvm/bin:$PATH +cmake -S client -B client/build/macos -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$PWD/cmake/toolchains/llvm-macos.cmake" \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DPACKAGE_TYPE=none +cmake --build client/build/macos +ctest --test-dir client/build/macos --output-on-failure +``` + +The install directory must be absent; preparation preserves failed stages for +inspection rather than replacing existing installations. The helper verifies +pinned SHA-256 values from the official +[LLVM 23.1.3 release](https://github.com/llvm/llvm-project/releases/tag/llvmorg-23.1.3) +metadata before extraction. Apple Silicon uses its official binary archive; +Intel builds Clang, LLD and compiler-rt from the pinned release source archive +because the release provides no Intel macOS binary. Intel bootstrap defaults to +two compile jobs and one link job; `ATRINIK_MACOS_LLVM_BUILD_JOBS` selects a +positive compile-job limit appropriate to the runner's available CPU/memory. +Preparation configures, +links and runs C and C++ probes through the same native toolchain. Every +selected compiler and utility must report exactly 23.1.3, and the compiler-rt +builtins archive must exist. Complete LLVM/runtime notices accompany the install. + +Darwin builds automatically select this toolchain before `project()` when no +explicit toolchain was supplied. An explicit `CMAKE_OSX_SYSROOT`, deployment +target and architecture list are preserved. Without an SDK override, `xcrun` +selects the macOS SDK. The platform libc++ headers come from the selected Xcode +toolchain rather than the downloaded LLVM cohort. Mach-O LLD is selected by its +absolute path, following [LLD's native macOS interface](https://lld.llvm.org/MachO/index.html). + +Linux CMake-script fixtures verify selection, version rejection, platform-header +selection and missing-runtime failures. They do not prove that a release archive +runs on macOS, that SDK probes compile, or that Metal works. Native acceptance +requires successful preparation/probes and the production client/GPU harness +build on each supported Mac architecture, followed by the unchanged Metal +reference and minimum hardware qualification rows in +`.github/workflows/gpu-qualification.yml`. These rows retain their GPU checks, +stress runs, lifecycle recovery, production fixtures and human-approved goldens. diff --git a/tools/ci/macos_llvm_probe/CMakeLists.txt b/tools/ci/macos_llvm_probe/CMakeLists.txt new file mode 100644 index 000000000..46efdaf76 --- /dev/null +++ b/tools/ci/macos_llvm_probe/CMakeLists.txt @@ -0,0 +1,8 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +cmake_minimum_required(VERSION 3.21) +include(../../../cmake/AtrinikLLVM.cmake) +project(AtrinikMacOSLLVMProbe LANGUAGES C CXX) +atrinik_require_llvm() +add_executable(llvm-c-probe main.c) +add_executable(llvm-cxx-probe main.cpp) diff --git a/tools/ci/macos_llvm_probe/main.c b/tools/ci/macos_llvm_probe/main.c new file mode 100644 index 000000000..38a7a0456 --- /dev/null +++ b/tools/ci/macos_llvm_probe/main.c @@ -0,0 +1,6 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ +int main(void) +{ + return 0; +} diff --git a/tools/ci/macos_llvm_probe/main.cpp b/tools/ci/macos_llvm_probe/main.cpp new file mode 100644 index 000000000..acbe4ed0b --- /dev/null +++ b/tools/ci/macos_llvm_probe/main.cpp @@ -0,0 +1,7 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ +#include +int main() +{ + return std::string("LLVM").size() == 4 ? 0 : 1; +} diff --git a/tools/ci/prepare_macos_llvm.sh b/tools/ci/prepare_macos_llvm.sh new file mode 100755 index 000000000..be6451e2f --- /dev/null +++ b/tools/ci/prepare_macos_llvm.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +set -euo pipefail + +# Official release asset SHA-256 values retrieved from llvm/llvm-project's +# llvmorg-23.1.3 release metadata. All writes stay in a new caller-owned directory. +if test "$(uname -s)" != Darwin; then + echo 'LLVM macOS preparation requires a native macOS host' >&2 + exit 1 +fi +if test "$#" -ne 1; then + echo 'usage: prepare_macos_llvm.sh ABSENT_INSTALL_DIRECTORY' >&2 + exit 1 +fi +root=$1 +case "${root}" in /*) ;; *) echo 'install directory must be absolute' >&2; exit 1 ;; esac +mkdir "${root}" +stage="${root}/stage" +mkdir "${stage}" +base=https://github.com/llvm/llvm-project/releases/download/llvmorg-23.1.3 +source_archive=llvm-project-23.1.3.src.tar.xz +source_digest=c44186a7762ed28954be72e5ff6df9808e0779d4f1bf014ecc4e7e211d31ee34 +# The full compiler-rt license includes runtime exceptions and third-party terms. +# Extract notices from verified upstream source on both native architectures. +curl --fail --location --retry 3 "${base}/${source_archive}" --output "${stage}/${source_archive}" +printf '%s %s\n' "${source_digest}" "${stage}/${source_archive}" | shasum -a 256 --check +case "$(uname -m)" in + arm64) + archive=LLVM-23.1.3-macOS-ARM64.tar.xz + digest=3dcfd88aec10be204a24037bcb0dc0ad777882e9dbe8ac60e2639060d8a794b1 + ;; + x86_64) + archive=${source_archive} + digest=${source_digest} + ;; + *) echo 'unsupported native macOS architecture' >&2; exit 1 ;; +esac +if test "${archive}" != "${source_archive}"; then + curl --fail --location --retry 3 "${base}/${archive}" --output "${stage}/${archive}" + printf '%s %s\n' "${digest}" "${stage}/${archive}" | shasum -a 256 --check +fi +if test "$(uname -m)" = arm64; then + mkdir "${root}/llvm" + tar -xf "${stage}/${archive}" -C "${root}/llvm" --strip-components=1 +else + # Upstream publishes no Intel macOS binary for this release. Apple Clang is + # only the bootstrap compiler; Classic uses the resulting LLVM 23.1.3 cohort. + tar -xf "${stage}/${archive}" -C "${stage}" + sdk=$(xcrun --sdk macosx --show-sdk-path) + bootstrap_c=$(xcrun --sdk macosx --find clang) + bootstrap_cxx=$(xcrun --sdk macosx --find clang++) + # Keep compile concurrency separate from memory-heavy LLVM link jobs. + jobs=${ATRINIK_MACOS_LLVM_BUILD_JOBS:-2} + case "${jobs}" in ''|*[!0-9]*|0) echo 'LLVM build jobs must be a positive integer' >&2; exit 1 ;; esac + cmake -S "${stage}/llvm-project-23.1.3.src/llvm" -B "${stage}/build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="${root}/llvm" \ + -DCMAKE_C_COMPILER="${bootstrap_c}" -DCMAKE_CXX_COMPILER="${bootstrap_cxx}" \ + -DCMAKE_OSX_SYSROOT="${sdk}" \ + '-DLLVM_ENABLE_PROJECTS=clang;lld' -DLLVM_ENABLE_RUNTIMES=compiler-rt \ + -DLLVM_TARGETS_TO_BUILD=Native -DLLVM_PARALLEL_COMPILE_JOBS="${jobs}" \ + -DLLVM_PARALLEL_LINK_JOBS=1 + cmake --build "${stage}/build" --parallel "${jobs}" + cmake --install "${stage}/build" +fi +mkdir -p "${root}/llvm/share/licenses/llvm" +for name in llvm clang lld compiler-rt; do + tar -xOf "${stage}/${source_archive}" "llvm-project-23.1.3.src/${name}/LICENSE.TXT" \ + > "${root}/llvm/share/licenses/llvm/${name}-LICENSE.TXT" + test -s "${root}/llvm/share/licenses/llvm/${name}-LICENSE.TXT" +done + +# Validate every selected tool and compiler-rt, and compile/link/run native +# C/C++ probes through the same toolchain and platform SDK as Classic. +cmake -S "$(dirname "$0")/macos_llvm_probe" -B "${stage}/probe" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$(cd "$(dirname "$0")/../.." && pwd)/cmake/toolchains/llvm-macos.cmake" \ + -DCMAKE_PROGRAM_PATH="${root}/llvm/bin" +cmake --build "${stage}/probe" +"${stage}/probe/llvm-c-probe" +"${stage}/probe/llvm-cxx-probe" diff --git a/tools/release/package_sources.py b/tools/release/package_sources.py index 62602f55d..df197a87a 100755 --- a/tools/release/package_sources.py +++ b/tools/release/package_sources.py @@ -31,6 +31,7 @@ "cmake/InstallWindowsRuntime.cmake", "cmake/InstallLLVMRuntime.cmake", "cmake/toolchains/llvm.cmake", + "cmake/toolchains/llvm-macos.cmake", "tools/ci/llvm_coverage.py", "tools/ci/stage_windows_runtime.py", ) diff --git a/tools/tests/test_macos_llvm_toolchain.py b/tools/tests/test_macos_llvm_toolchain.py new file mode 100644 index 000000000..e11b51b4f --- /dev/null +++ b/tools/tests/test_macos_llvm_toolchain.py @@ -0,0 +1,246 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +MODULE = ROOT / "cmake" / "AtrinikLLVM.cmake" +TOOLCHAIN = ROOT / "cmake" / "toolchains" / "llvm-macos.cmake" +TOOLS = { + "CMAKE_C_COMPILER": "clang", "CMAKE_CXX_COMPILER": "clang++", + "CMAKE_AR": "llvm-ar", "CMAKE_RANLIB": "llvm-ranlib", + "CMAKE_LINKER": "ld64.lld", "CMAKE_NM": "llvm-nm", + "CMAKE_STRIP": "llvm-strip", "CMAKE_OBJCOPY": "llvm-objcopy", + "CMAKE_OBJDUMP": "llvm-objdump", "CMAKE_READELF": "llvm-readelf", +} + + +@unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") +class MacOSLLVMToolchainTests(unittest.TestCase): + """Darwin policy fixtures; no claim of compiling against an actual SDK.""" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="macos llvm ") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.sdk = self.root / "MacOSX.sdk" + self.sdk.mkdir() + self.apple_usr = self.root / "Xcode/usr" + (self.apple_usr / "include/c++/v1").mkdir(parents=True) + self.runtime = self.root / "libclang_rt.osx.a" + self.runtime.write_bytes(b"archive fixture") + self.bin = self.root / "bin" + self.bin.mkdir() + self.tool = self.bin / "tool" + self.tool.write_text( + '#!/bin/sh\nif test "$1" = --rtlib=compiler-rt; then\n' + f" printf '%s\\n' '{self.runtime}'\n" + "else\n printf 'LLVM version 23.1.3\\n'\nfi\n" + ) + self.tool.chmod(0o755) + for name in TOOLS.values(): + (self.bin / name).symlink_to(self.tool) + self.xcrun = self.bin / "xcrun" + self.xcrun.write_text( + '#!/bin/sh\nif test "$3" = --show-sdk-path; then\n' + f" printf '%s\\n' '{self.sdk}'\n" + f"else\n printf '%s\\n' '{self.apple_usr}/bin/clang++'\nfi\n" + ) + self.xcrun.chmod(0o755) + + def script(self, body): + path = self.root / "test.cmake" + path.write_text(f'include("{MODULE}")\n' + body) + return subprocess.run([shutil.which("cmake"), "-P", str(path)], capture_output=True, text=True) + + def settings(self): + return f''' +set(CMAKE_HOST_SYSTEM_NAME Darwin) +set(CMAKE_PROGRAM_PATH "{self.bin}") +include("{TOOLCHAIN}") +''' + + def test_darwin_default_uses_native_macos_toolchain(self): + result = self.script(f''' +set(CMAKE_HOST_SYSTEM_NAME Darwin) +atrinik_initialize_llvm_toolchain() +if (NOT CMAKE_TOOLCHAIN_FILE STREQUAL "{TOOLCHAIN}") + message(FATAL_ERROR "wrong Darwin selection") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + + def test_explicit_darwin_cross_toolchain_is_preserved(self): + result = self.script(''' +set(CMAKE_HOST_SYSTEM_NAME Darwin) +set(CMAKE_TOOLCHAIN_FILE custom.cmake) +atrinik_initialize_llvm_toolchain() +if (NOT CMAKE_TOOLCHAIN_FILE STREQUAL "custom.cmake") + message(FATAL_ERROR "explicit toolchain overwritten") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + + def test_darwin_host_windows_target_avoids_macos_selection(self): + result = self.script(''' +set(CMAKE_HOST_SYSTEM_NAME Darwin) +set(CMAKE_SYSTEM_NAME Windows) +atrinik_initialize_llvm_toolchain() +if (CMAKE_TOOLCHAIN_FILE) + message(FATAL_ERROR "cross target selected native toolchain") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + + def test_macos_binds_macho_lld_sdk_and_system_cxx_headers(self): + body = self.settings() + for variable, name in TOOLS.items(): + body += f''' +if (NOT {variable} STREQUAL "{self.bin / name}") + message(FATAL_ERROR "wrong {variable}") +endif () +''' + body += f''' +if (NOT CMAKE_OSX_SYSROOT STREQUAL "{self.sdk}" OR + NOT CMAKE_CXX_FLAGS_INIT MATCHES "-nostdinc[+]?[+]" OR + NOT CMAKE_CXX_FLAGS_INIT MATCHES "Xcode/usr/include/c[+][+]/v1") + message(FATAL_ERROR "platform SDK/libc++ selection changed") +endif () +foreach (flags CMAKE_EXE_LINKER_FLAGS_INIT CMAKE_SHARED_LINKER_FLAGS_INIT CMAKE_MODULE_LINKER_FLAGS_INIT) + if (NOT "${{${{flags}}}}" MATCHES "--rtlib=compiler-rt" OR + NOT "${{${{flags}}}}" MATCHES "ld64.lld" OR + "${{${{flags}}}}" MATCHES "--unwindlib=|flto|fprofile|-O[0-9]") + message(FATAL_ERROR "incorrect Darwin linker flags") + endif () +endforeach () +''' + result = self.script(body) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_explicit_sdk_deployment_and_architectures_are_preserved(self): + result = self.script(''' +set(CMAKE_OSX_SYSROOT explicit-sdk) +set(CMAKE_OSX_DEPLOYMENT_TARGET 13.0) +set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64") +''' + self.settings() + ''' +if (NOT CMAKE_OSX_SYSROOT STREQUAL "explicit-sdk" OR + NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL "13.0" OR + NOT CMAKE_OSX_ARCHITECTURES STREQUAL "arm64;x86_64") + message(FATAL_ERROR "caller platform selection overwritten") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + + def test_linux_host_cannot_select_macos_toolchain(self): + result = self.script(f'set(CMAKE_HOST_SYSTEM_NAME Linux)\ninclude("{TOOLCHAIN}")\n') + self.assertNotEqual(result.returncode, 0) + self.assertIn("native macOS host", result.stderr) + + def test_missing_compiler_rt_and_platform_headers_fail(self): + self.runtime.unlink() + result = self.script(self.settings()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("compiler-rt builtins", result.stderr) + self.runtime.write_bytes(b"archive fixture") + (self.apple_usr / "include/c++/v1").rmdir() + result = self.script(self.settings()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("platform libc++ headers", result.stderr) + + def test_apple_clang_and_other_versions_fail(self): + for version in ("Apple clang version 23.1.3", "clang version 23.1.2", "clang version 23.1.30"): + with self.subTest(version=version): + self.tool.write_text(f"#!/bin/sh\nprintf '%s\\n' '{version}'\n") + result = self.script(self.settings()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("upstream LLVM 23.1.3", result.stderr) + + @unittest.skipUnless(shutil.which("shasum"), "SHA-256 utility unavailable") + def test_preparation_rejects_tampered_archive_before_extraction(self): + fake_bin = self.root / "download-bin" + fake_bin.mkdir() + commands = { + "uname": "#!/bin/sh\ncase $1 in -s) echo Darwin ;; -m) echo arm64 ;; esac\n", + "curl": "#!/bin/sh\nfor arg do output=$arg; done\nprintf tampered > \"$output\"\n", + "tar": f"#!/bin/sh\ntouch '{self.root / 'extracted'}'\n", + } + for name, content in commands.items(): + path = fake_bin / name + path.write_text(content) + path.chmod(0o755) + environment = dict(os.environ, PATH=str(fake_bin) + os.pathsep + os.environ["PATH"]) + result = subprocess.run( + ["bash", str(ROOT / "tools/ci/prepare_macos_llvm.sh"), str(self.root / "candidate")], + env=environment, capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("FAILED", result.stdout) + self.assertFalse((self.root / "extracted").exists()) + + def test_preparation_rejects_linux_without_writing(self): + if shutil.which("uname") and subprocess.check_output(["uname", "-s"], text=True).strip() != "Darwin": + destination = self.root / "uncreated" + result = subprocess.run( + ["bash", str(ROOT / "tools/ci/prepare_macos_llvm.sh"), str(destination)], + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(destination.exists()) + + +@unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") +class MacOSRuntimeNotices(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.notices = self.root / "llvm/share/licenses/llvm" + self.notices.mkdir(parents=True) + compiler = self.root / "llvm/bin/clang" + compiler.parent.mkdir() + compiler.touch() + self.names = ("llvm", "clang", "lld", "compiler-rt") + for name in self.names: + (self.notices / (name + "-LICENSE.TXT")).write_text(name + " complete notice\n") + (self.root / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.21) +project(MacOSNoticeFixture NONE) +set(CMAKE_SYSTEM_NAME Darwin) +set(CMAKE_C_COMPILER "{compiler}") +include("{ROOT / 'cmake/InstallLLVMRuntime.cmake'}") +atrinik_install_llvm_runtime_notices(share/doc/atrinik AtrinikClient) +''') + + def configure(self): + return subprocess.run( + ["cmake", "-S", str(self.root), "-B", str(self.root / "build")], + capture_output=True, text=True, + ) + + def test_complete_darwin_runtime_notices_are_installed(self): + result = self.configure() + self.assertEqual(result.returncode, 0, result.stderr) + prefix = self.root / "install" + subprocess.run( + ["cmake", "--install", str(self.root / "build"), "--prefix", str(prefix), + "--component", "AtrinikClient"], check=True, capture_output=True, + ) + installed = prefix / "share/doc/atrinik/licenses/llvm" + self.assertEqual(sorted(p.name for p in installed.iterdir()), + sorted(name + "-LICENSE.TXT" for name in self.names)) + for path in installed.iterdir(): + self.assertEqual(path.read_bytes(), (self.notices / path.name).read_bytes()) + + def test_missing_darwin_compiler_rt_notice_fails(self): + (self.notices / "compiler-rt-LICENSE.TXT").unlink() + result = self.configure() + self.assertNotEqual(result.returncode, 0) + self.assertIn("runtime installation requires complete notice", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_package_sources.py b/tools/tests/test_package_sources.py index 681345b28..c687a98e8 100644 --- a/tools/tests/test_package_sources.py +++ b/tools/tests/test_package_sources.py @@ -36,6 +36,7 @@ def setUp(self) -> None: "cmake/InstallWindowsRuntime.cmake": "# Windows runtime closure\n", "cmake/InstallLLVMRuntime.cmake": "# Native LLVM runtime notices\n", "cmake/toolchains/llvm.cmake": "# LLVM toolchain\n", + "cmake/toolchains/llvm-macos.cmake": "# macOS LLVM toolchain\n", "tools/ci/llvm_coverage.py": "# LLVM coverage conversion\n", "tools/ci/stage_windows_runtime.py": "# LLVM inspector\n", "docs/history/imports.json": "{}\n", @@ -108,6 +109,7 @@ def test_scoped_archive_contains_shared_evidence_but_not_other_modules(self) -> self.assertIn("atrinik-classic-client-5.6.0/cmake/InstallLLVMRuntime.cmake", names) self.assertIn("atrinik-classic-client-5.6.0/tools/ci/stage_windows_runtime.py", names) self.assertIn("atrinik-classic-client-5.6.0/cmake/toolchains/llvm.cmake", names) + self.assertIn("atrinik-classic-client-5.6.0/cmake/toolchains/llvm-macos.cmake", names) self.assertIn("atrinik-classic-client-5.6.0/cmake/AtrinikLLVM.cmake", names) self.assertIn("atrinik-classic-client-5.6.0/tools/ci/llvm_coverage.py", names) self.assertIn( @@ -140,7 +142,8 @@ def test_unsafe_source_path_is_rejected(self) -> None: def test_standalone_native_packages_retain_windows_closure_tools(self) -> None: for scope in ("client", "server"): for path in ("cmake/InstallWindowsRuntime.cmake", "tools/ci/stage_windows_runtime.py", - "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", "cmake/AtrinikLLVM.cmake", + "cmake/toolchains/llvm.cmake", "cmake/toolchains/llvm-macos.cmake", + "tools/ci/llvm_coverage.py", "cmake/AtrinikLLVM.cmake", "cmake/InstallLLVMRuntime.cmake"): with self.subTest(scope=scope, path=path): self.assertEqual(package_sources.selected_name(scope, path, "package"), From 3e34e799cce030c30ae75eb53ca1a5e96b50caa5 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:38:05 -0500 Subject: [PATCH 024/148] build(ci): preserve release compiler identity in packaging recovery --- .github/workflows/build-release-candidate.yml | 59 +++++++- .github/workflows/check.yml | 12 +- .github/workflows/package-release.yml | 15 +- docs/CI-LINUX-IMAGE.md | 17 ++- tools/ci/classify_changes.py | 7 +- tools/release/resolve_compiler_image.py | 95 ++++++++++++ tools/tests/test_appimage_workflow.py | 48 +++++- tools/tests/test_classify_changes.py | 7 + tools/tests/test_compiler_image.py | 142 ++++++++++++++++++ tools/tests/test_linux_deb_workflow.py | 1 + tools/tests/test_workflow_contracts.py | 10 ++ 11 files changed, 394 insertions(+), 19 deletions(-) create mode 100644 tools/release/resolve_compiler_image.py create mode 100644 tools/tests/test_compiler_image.py diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index d666f3065..77fbaef09 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -71,6 +71,7 @@ jobs: tag: ${{ steps.metadata.outputs.tag }} version: ${{ steps.metadata.outputs.version }} artifact_schema: ${{ steps.metadata.outputs.artifact_schema }} + compiler_image: ${{ steps.metadata.outputs.compiler_image }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -108,8 +109,12 @@ jobs: artifact_schema=$(python3 build/release-automation/tools/release/release_artifacts.py \ --print-source-schema --revision "${commit}" --source-root "${GITHUB_WORKSPACE}") [[ ${artifact_schema} == 1 || ${artifact_schema} == 2 || ${artifact_schema} == 3 ]] + compiler_image=$(python3 build/release-automation/tools/release/resolve_compiler_image.py \ + --source-root "${GITHUB_WORKSPACE}" --revision "${commit}" \ + --current-image "${CLASSIC_LINUX_IMAGE}") { echo "artifact_schema=${artifact_schema}" + echo "compiler_image=${compiler_image}" echo "tag=${RELEASE_TAG}" echo "version=${RELEASE_VERSION}" echo "commit=${commit}" @@ -176,6 +181,8 @@ jobs: sources: name: Build source packages and protocol wheel + env: + CLASSIC_LINUX_IMAGE: ${{ needs.metadata.outputs.compiler_image }} permissions: contents: read needs: metadata @@ -232,6 +239,8 @@ jobs: client-linux: name: Build and install Debian client package + env: + CLASSIC_LINUX_IMAGE: ${{ needs.metadata.outputs.compiler_image }} needs: [metadata, dependencies, gpu-shaders] if: needs.metadata.outputs.artifact_schema == '2' permissions: @@ -268,8 +277,10 @@ jobs: - name: Prepare pinned Debian testing build and clean runtime run: | docker build --platform linux/amd64 --target build --tag classic-deb-build \ + --build-arg "ATRINIK_COMPILER_IMAGE=${CLASSIC_LINUX_IMAGE}" \ --file tools/ci/debian-client/Dockerfile tools/ci/debian-client docker build --platform linux/amd64 --target runtime --tag classic-deb-runtime \ + --build-arg "ATRINIK_COMPILER_IMAGE=${CLASSIC_LINUX_IMAGE}" \ --file tools/ci/debian-client/Dockerfile tools/ci/debian-client mkdir -p build/linux-package-evidence build/linux-packages build/discord-config docker image inspect classic-deb-build classic-deb-runtime \ @@ -339,6 +350,8 @@ jobs: client-appimage: name: Build and smoke Ubuntu-baseline AppImage + env: + CLASSIC_LINUX_IMAGE: ${{ needs.metadata.outputs.compiler_image }} # The runner retains Actions service/cache authority; candidate code must # never inherit it. Trusted caller scripts build/inspect on the host, while # candidate scripts and payloads execute only in offline, unprivileged @@ -380,12 +393,26 @@ jobs: name: discord-application-id-${{ inputs.tag }} path: build/discord-config - name: Prepare pinned Ubuntu builder and clean runtime images + env: + RELEASE_REVISION: ${{ needs.metadata.outputs.commit }} run: | mkdir -p build/appimage-evidence build/appimage-packages build/discord-config + descriptor_path=$(mktemp "${RUNNER_TEMP}/classic-compiler-descriptor.XXXXXX") + trap 'rm -f "${descriptor_path}"' EXIT + python3 tools/release/resolve_compiler_image.py \ + --source-root "${GITHUB_WORKSPACE}/candidate-source" --revision "${RELEASE_REVISION}" \ + --current-image "${CLASSIC_LINUX_IMAGE}" --source-packaging-lock-output "${descriptor_path}" \ + > build/appimage-evidence/compiler-image.txt + compiler_arguments=() + if test -s "${descriptor_path}"; then + compiler_arguments=(--source-packaging-lock "${descriptor_path}") + fi python3 tools/ci/appimage/prepare.py --output build/appimage-context \ + --compiler-image "${CLASSIC_LINUX_IMAGE}" "${compiler_arguments[@]}" \ > build/appimage-evidence/prepare.log 2>&1 cp build/appimage-context/packaging.lock.json build/appimage-evidence/ docker build --platform linux/amd64 --target build --tag classic-appimage-build \ + --build-arg "ATRINIK_COMPILER_IMAGE=${CLASSIC_LINUX_IMAGE}" \ --file build/appimage-context/Dockerfile build/appimage-context \ > build/appimage-evidence/image-build.log 2>&1 for runtime in ubuntu24 ubuntu26 debian13; do @@ -462,6 +489,7 @@ jobs: "${RELEASE_VERSION}" \ --revision "${RELEASE_REVISION}" \ --source-root "${GITHUB_WORKSPACE}/candidate-source" \ + --compiler-image "${CLASSIC_LINUX_IMAGE}" \ > build/appimage-evidence/payload-validation.log 2>&1 - name: Smoke in clean Ubuntu 24, Ubuntu 26, and Debian 13 runtimes env: @@ -733,6 +761,8 @@ jobs: candidate: name: Validate complete release candidate + env: + CLASSIC_LINUX_IMAGE: ${{ needs.metadata.outputs.compiler_image }} if: >- always() && needs.metadata.result == 'success' && @@ -769,13 +799,30 @@ jobs: run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends squashfs-tools binutils + - name: Check out the trusted compiler-image resolver + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: build/ci-automation + persist-credentials: false + sparse-checkout: tools/release/resolve_compiler_image.py - name: Validate and index the complete candidate - run: >- - python3 tools/release/finalize_artifacts.py - --version '${{ needs.metadata.outputs.version }}' - --revision '${{ needs.metadata.outputs.commit }}' - --source-epoch '${{ needs.metadata.outputs.source_epoch }}' - --directory build/release + env: + RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_REVISION: ${{ needs.metadata.outputs.commit }} + RELEASE_SOURCE_EPOCH: ${{ needs.metadata.outputs.source_epoch }} + run: | + arguments_path=$(mktemp "${RUNNER_TEMP}/classic-compiler-arguments.XXXXXX") + trap 'rm -f "${arguments_path}"' EXIT + python3 build/ci-automation/tools/release/resolve_compiler_image.py \ + --source-root "${GITHUB_WORKSPACE}" --revision "${RELEASE_REVISION}" \ + --current-image "${CLASSIC_LINUX_IMAGE}" --source-finalizer-arguments \ + > "${arguments_path}" + mapfile -t compiler_arguments < "${arguments_path}" + python3 tools/release/finalize_artifacts.py \ + --version "${RELEASE_VERSION}" --revision "${RELEASE_REVISION}" \ + --source-epoch "${RELEASE_SOURCE_EPOCH}" --directory build/release \ + "${compiler_arguments[@]}" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: complete-release-candidate-${{ needs.metadata.outputs.tag }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2a55c08ad..29fc43a26 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -213,8 +213,11 @@ jobs: python3 -m coverage run --branch --source=tools \ tools/verify_import_history.py \ --release-history-ref "${release_history_ref}" - python3 -m coverage run --append --branch --source=tools \ + # Real native package acceptance runs separately in the pinned image. + CI= python3 -m coverage run --append --branch --source=tools \ -m unittest discover -s tools/tests -p 'test_*.py' + python3 -m coverage run --append --branch --source=tools \ + -m unittest discover -s tools/ci/appimage -p 'test_*.py' python3 -m coverage xml --omit='tools/tests/*' \ -o tools/coverage.xml - name: Validate first-parent release analysis @@ -293,7 +296,12 @@ jobs: --volume "${RUNNER_TEMP}/classic-ccache/core:/cache/ccache" \ --workdir /workspace \ "${CLASSIC_LINUX_IMAGE}" \ - tools/ci/run_linux_check.sh core /workspace + bash -c ' + set -euo pipefail + CI=1 python3 -m unittest -v \ + tools.tests.test_version_metadata.VersionMetadataTests.test_pathfinding_physical_tag_untagged_and_installed_package + tools/ci/run_linux_check.sh core /workspace + ' - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 if: always() with: diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 19aacecfc..4981340ba 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -24,6 +24,9 @@ permissions: contents: read packages: read +env: + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + concurrency: group: classic-release-publication queue: max @@ -263,10 +266,14 @@ jobs: --tag "${RELEASE_VERSION}" \ --github-output "${GITHUB_OUTPUT}" - name: Recheck the revision-specific candidate before publication - run: >- - python3 build/release-automation/tools/release/release_artifacts.py - --directory build/release --tag "${RELEASE_TAG}" - --revision "${RELEASE_COMMIT}" --source-root "${GITHUB_WORKSPACE}" + run: | + expected_compiler_image=$(python3 build/release-automation/tools/release/resolve_compiler_image.py \ + --source-root "${GITHUB_WORKSPACE}" --revision "${RELEASE_COMMIT}" \ + --current-image "${CLASSIC_LINUX_IMAGE}") + python3 build/release-automation/tools/release/release_artifacts.py \ + --directory build/release --tag "${RELEASE_TAG}" \ + --revision "${RELEASE_COMMIT}" --source-root "${GITHUB_WORKSPACE}" \ + --compiler-image "${expected_compiler_image}" - name: Attest downloadable artifact provenance uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 with: diff --git a/docs/CI-LINUX-IMAGE.md b/docs/CI-LINUX-IMAGE.md index 80abf9ed6..83d146c6f 100644 --- a/docs/CI-LINUX-IMAGE.md +++ b/docs/CI-LINUX-IMAGE.md @@ -208,7 +208,7 @@ artifacts. Preserve their complete TSV files when recalculating this ledger. ## LLVM 23.1.3 consumer migration Current native Check and benchmark consumers select Clang 23.1.3, LLD and -compiler-rt explicitly through `ATRINIK_LLVM_TOOLCHAIN=1`. The compiler cache +compiler-rt explicitly through the shared CMake toolchain contract. The compiler cache identity is `clang-23.1.3-lld-compiler-rt`; epoch 2 separates this contract from the historical GCC caches. Release optimization and sanitizer/test selection remain unchanged. Native coverage is generated by `llvm-profdata` and @@ -219,6 +219,21 @@ Python tool coverage remains `tools/coverage.xml`. Coverage generation fails if the exact LLVM tools, instrumented objects or profile data are absent; optional Codecov reporting does not weaken those native checks. +The host Python tooling suite retains its SquashFS fixtures and coverage. Its +real native installed-package test runs separately with `CI=1` inside the +pinned Classic Linux producer, where missing LLVM tools fail the job. The host +suite permits the explicit LLVM-unavailable skip only for that native test. + +Release recovery reads the compiler image from the exact tagged Git blob of +`tools/ci/appimage/packaging.lock.json`. An existing LLVM descriptor requires +its immutable image; a newer approved image never replaces that historical +identity. Sources with no compiler descriptor use the current approved image, +and their historical finalizer receives only arguments that it supports. +The trusted AppImage preparation script accepts the extracted source lock +only when every field except the immutable compiler image matches its +approved lock. It preserves the validated original lock bytes, including +their source hash, and retains approved SDK recipes and dependency URLs. + The existing immutable 1.16.0 provider image pins are retained while the new LLVM producer is qualified and published. Those historical images do not satisfy the LLVM consumer contract. Before readiness, replace every Linux, diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index b7141e6bb..5ca8abbea 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -25,6 +25,7 @@ "codecov.yml", "tools/tests/test_llvm_coverage.py", "tools/tests/test_llvm_toolchain.py", + "tools/tests/test_llvm_runtime_notices.py", "tools/tests/test_gpu_qualification_runtime.py", "LICENSE.md", "docs/history/component-release-map.json", @@ -44,7 +45,11 @@ "server/", "tools/ci/", ) -WINDOWS_TEST_PATHS = {"CMakeLists.txt", "cmake/InstallWindowsRuntime.cmake"} +WINDOWS_TEST_PATHS = { + "CMakeLists.txt", "cmake/AtrinikLLVM.cmake", "cmake/InstallLLVMRuntime.cmake", + "cmake/InstallWindowsRuntime.cmake", "cmake/toolchains/llvm.cmake", + "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", +} BENCHMARK_CONTRACT_PATHS = { ".github/workflows/pr-benchmarks.yml", "tools/ci/classify_changes.py", diff --git a/tools/release/resolve_compiler_image.py b/tools/release/resolve_compiler_image.py new file mode 100644 index 000000000..9a758fcc8 --- /dev/null +++ b/tools/release/resolve_compiler_image.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Resolve the trusted compiler image from an exact release's source lock.""" +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import subprocess + + +IMAGE = re.compile(r"ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}") +LOCK = "tools/ci/appimage/packaging.lock.json" + + +def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate source lock key: {key}") + result[key] = value + return result + + +def resolve_descriptor(root: Path, revision: str, current_image: str) -> dict[str, object] | None: + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("release revision must be an exact lowercase commit SHA") + if IMAGE.fullmatch(current_image) is None: + raise ValueError("current compiler image must be an immutable published reference") + def git(*arguments: str) -> bytes: + return subprocess.run(["git", "-C", str(root), *arguments], check=True, + capture_output=True, timeout=30).stdout + if git("rev-parse", "--verify", revision + "^{commit}").decode().strip() != revision: + raise ValueError("release revision is not the selected commit") + entry = git("ls-tree", revision, "--", LOCK) + if not entry: + # Pre-AppImage source has no compiler descriptor to constrain. + return None + fields = entry.decode().split() + if fields[:2] != ["100644", "blob"]: + raise ValueError("release packaging lock must be a regular Git blob") + locked = json.loads(git("show", revision + ":" + LOCK), object_pairs_hook=unique_object) + if not isinstance(locked, dict): + raise ValueError("release packaging lock must be an object") + if "compiler" not in locked: + return None + compiler = locked["compiler"] + if not isinstance(compiler, dict): + raise ValueError("release compiler descriptor must be an object") + image = compiler.get("image") + if not isinstance(image, str) or IMAGE.fullmatch(image) is None: + raise ValueError("LLVM release lock requires its immutable compiler image") + return compiler + + +def resolve_image(root: Path, revision: str, current_image: str, *, source_finalizer: bool = False) -> str | None: + compiler = resolve_descriptor(root, revision, current_image) + if compiler is None: + return None if source_finalizer else current_image + return str(compiler["image"]) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--revision", required=True) + parser.add_argument("--current-image", required=True) + parser.add_argument("--source-finalizer-arguments", action="store_true", + help="emit only arguments supported by the selected source finalizer") + parser.add_argument("--source-packaging-lock-output", type=Path, + help="write exact tagged LLVM source lock bytes for trusted SDK validation") + options = parser.parse_args() + try: + image = resolve_image(options.source_root, options.revision, options.current_image, + source_finalizer=options.source_finalizer_arguments) + if options.source_packaging_lock_output is not None: + descriptor = resolve_descriptor(options.source_root, options.revision, options.current_image) + lock_bytes = b"" if descriptor is None else subprocess.run( + ["git", "-C", str(options.source_root), "show", options.revision + ":" + LOCK], + check=True, capture_output=True, timeout=30).stdout + options.source_packaging_lock_output.write_bytes(lock_bytes) + if image is not None: + if options.source_finalizer_arguments: + print("--compiler-image") + print(image) + except (ValueError, OSError, subprocess.SubprocessError) as error: + parser.exit(1, f"cannot resolve trusted compiler image: {error}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_appimage_workflow.py b/tools/tests/test_appimage_workflow.py index 562059872..107a43e23 100644 --- a/tools/tests/test_appimage_workflow.py +++ b/tools/tests/test_appimage_workflow.py @@ -2,6 +2,7 @@ import json import os import re +import shutil import subprocess import sys import tempfile @@ -54,6 +55,25 @@ def workflow(self) -> str: def job(self) -> str: return self.workflow().split(' client-appimage:\n', 1)[1].split(' client-windows:\n', 1)[0] + def test_compiler_identity_is_bound_through_build_and_validation(self) -> None: + workflow = self.workflow() + job = self.job() + self.assertIn('--compiler-image "${CLASSIC_LINUX_IMAGE}"', job) + self.assertIn("CLASSIC_LINUX_IMAGE: ${{ needs.metadata.outputs.compiler_image }}", job) + self.assertIn('--build-arg "ATRINIK_COMPILER_IMAGE=${CLASSIC_LINUX_IMAGE}"', job) + payload = job.split(' - name: Validate AppImage payload with trusted host tools', 1)[1].split(' - name:', 1)[0] + self.assertIn('--compiler-image "${CLASSIC_LINUX_IMAGE}"', payload) + candidate = workflow.split(' candidate:\n', 1)[1] + self.assertIn("--source-finalizer-arguments", candidate) + self.assertIn('"${compiler_arguments[@]}"', candidate) + publish = (ROOT / '.github/workflows/package-release.yml').read_text() + verifier = publish.split(' - name: Recheck the revision-specific candidate before publication', 1)[1].split(' - name:', 1)[0] + self.assertIn('--compiler-image "${expected_compiler_image}"', verifier) + self.assertIn("resolve_compiler_image.py", verifier) + self.assertIn('--revision "${RELEASE_COMMIT}"', verifier) + core = (ROOT / '.github/workflows/check.yml').read_text().split(' core:\n', 1)[1].split(' windows-test-build:\n', 1)[0] + self.assertIn("-m unittest discover -s tools/ci/appimage -p 'test_*.py'", core) + def test_schema_specific_jobs_fail_closed(self) -> None: workflow = self.workflow() job = self.job() @@ -85,7 +105,9 @@ def test_trusted_preparation_and_smoke_do_not_mount_candidate_or_secrets(self) - '--env-file', 'secrets.'): self.assertNotIn(forbidden, job) preparation = job.split(' - name: Prepare pinned Ubuntu', 1)[1].split(' - name:', 1)[0] - self.assertNotIn('candidate-source', preparation) + self.assertIn('tools/release/resolve_compiler_image.py', preparation) + self.assertIn('--source-root "${GITHUB_WORKSPACE}/candidate-source"', preparation) + self.assertIn('compiler_arguments=(--source-packaging-lock "${descriptor_path}")', preparation) self.assertIn('prepare.py --output build/appimage-context', preparation) self.assertIn('--file build/appimage-context/Dockerfile build/appimage-context', preparation) self.assertIn('cp build/appimage-context/packaging.lock.json build/appimage-evidence/', preparation) @@ -152,6 +174,7 @@ def test_host_payload_validation_binds_candidate_git_coordinates_before_smoke(se cwd=workspace, capture_output=True, text=True, env=dict(os.environ, GITHUB_WORKSPACE=str(workspace), RELEASE_VERSION='5.17.0', RELEASE_REVISION='a' * 40, + CLASSIC_LINUX_IMAGE="ghcr.io/atrinik/classic-build:1.16.0@sha256:" + "a" * 64, FAIL_VALIDATION=fail_validation, PATH=f"{workspace / 'bin'}:{os.environ['PATH']}")) self.assertEqual(result.returncode, 7 if fail_validation == '1' else 0, result.stderr) @@ -159,7 +182,8 @@ def test_host_payload_validation_binds_candidate_git_coordinates_before_smoke(se self.assertEqual(args, ['tools/release/appimage.py', 'build/appimage-packages/atrinik-classic-client-5.17.0-linux-x86_64.AppImage', '5.17.0', '--revision', 'a' * 40, - '--source-root', str(workspace / 'candidate-source')]) + '--source-root', str(workspace / 'candidate-source'), + '--compiler-image', 'ghcr.io/atrinik/classic-build:1.16.0@sha256:' + 'a' * 64]) self.assertNotIn('candidate-source/tools', ' '.join(args)) self.assertLess(job.index('--source-root "${GITHUB_WORKSPACE}/candidate-source"'), job.index('"classic-appimage-${runtime}" bash /smoke.sh')) @@ -172,9 +196,14 @@ def test_preparation_captures_actual_builder_inventory_and_lock(self) -> None: (workspace / 'bin').mkdir() trusted = workspace / 'tools/ci/appimage' trusted.mkdir(parents=True) + resolver = workspace / 'tools/release' + resolver.mkdir(parents=True) + shutil.copy2(ROOT / 'tools/release/resolve_compiler_image.py', resolver) (trusted / 'prepare.py').write_text("""import argparse, pathlib parser = argparse.ArgumentParser() parser.add_argument('--output', type=pathlib.Path, required=True) +parser.add_argument('--compiler-image', required=True) +parser.add_argument('--source-packaging-lock') out = parser.parse_args().output out.mkdir(parents=True) (out / 'packaging.lock.json').write_text('{"fixture": "locked inputs"}\\n') @@ -191,14 +220,23 @@ def test_preparation_captures_actual_builder_inventory_and_lock(self) -> None: print('sha256:' + 'a' * 64) """) docker.chmod(0o755) - # Preparation must not touch candidate input, even if it contains - # names that resemble the trusted preparation assets. + # Only the trusted resolver reads committed candidate lock data; + # candidate preparation code never executes on the host. candidate = workspace / 'candidate-source/tools/ci/appimage' candidate.mkdir(parents=True) (candidate / 'prepare.py').write_text('raise RuntimeError("candidate executed")') + (candidate / 'packaging.lock.json').write_text('{}') + source = workspace / 'candidate-source' + for arguments in (['init', '--quiet'], ['add', '.'], + ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', + '-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'fixture']): + subprocess.run(['git', '-C', str(source), *arguments], check=True) + revision = subprocess.check_output(['git', '-C', str(source), 'rev-parse', 'HEAD'], text=True).strip() result = subprocess.run(['bash', '-euo', 'pipefail', '-c', script], cwd=workspace, capture_output=True, text=True, - env=dict(os.environ, PATH=f"{workspace / 'bin'}:{os.environ['PATH']}")) + env=dict(os.environ, CLASSIC_LINUX_IMAGE="ghcr.io/atrinik/classic-build:1.16.0@sha256:" + "a" * 64, + GITHUB_WORKSPACE=str(workspace), RUNNER_TEMP=str(workspace), RELEASE_REVISION=revision, + PATH=f"{workspace / 'bin'}:{os.environ['PATH']}")) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, '') self.assertEqual((workspace / 'build/appimage-evidence/packaging.lock.json').read_text(), diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index ee8e2a741..87a4f2e7b 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -103,6 +103,7 @@ def test_llvm_contract_changes_select_both_native_consumers(self) -> None: for path in ("cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", "tools/tests/test_llvm_coverage.py", "tools/tests/test_llvm_toolchain.py", + "tools/tests/test_llvm_runtime_notices.py", "tools/tests/test_gpu_qualification_runtime.py"): with self.subTest(path=path): result = classify_changes.classify([path]) @@ -127,6 +128,12 @@ def test_release_configuration_selects_both_native_components(self) -> None: def test_libatrinik_and_ci_contract_changes_select_windows_tests(self) -> None: for path in ( "CMakeLists.txt", + "cmake/AtrinikLLVM.cmake", + "cmake/InstallLLVMRuntime.cmake", + "cmake/InstallWindowsRuntime.cmake", + "cmake/toolchains/llvm.cmake", + "tools/tests/test_llvm_toolchain.py", + "tools/tests/test_llvm_runtime_notices.py", "client/src/client/main.c", "libatrinik/path.c", "protocol/generated/c/commands.c", diff --git a/tools/tests/test_compiler_image.py b/tools/tests/test_compiler_image.py new file mode 100644 index 000000000..8725310d6 --- /dev/null +++ b/tools/tests/test_compiler_image.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + +from tools.release.resolve_compiler_image import LOCK, resolve_descriptor, resolve_image + + +OLD_IMAGE = "ghcr.io/atrinik/classic-build:2.0.0@sha256:" + "a" * 64 +CURRENT_IMAGE = "ghcr.io/atrinik/classic-build:2.1.0@sha256:" + "b" * 64 + + +class CompilerImageTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.git("init", "--quiet") + self.git("config", "user.name", "Compiler image fixture") + self.git("config", "user.email", "fixture@example.invalid") + + def git(self, *arguments: str) -> str: + return subprocess.run(["git", "-C", str(self.root), *arguments], check=True, + capture_output=True, text=True).stdout.strip() + + def commit(self, lock: object) -> str: + path = self.root / LOCK + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(lock)) + self.git("add", LOCK) + self.git("-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "fixture") + return self.git("rev-parse", "HEAD") + + def test_old_llvm_recovery_uses_tagged_lock_not_newer_pin_or_dirty_checkout(self) -> None: + old_revision = self.commit({"compiler": {"image": OLD_IMAGE}}) + self.commit({"compiler": {"image": CURRENT_IMAGE}}) + (self.root / LOCK).write_text('{"compiler":{"image":"malicious-floating-image"}}') + self.assertEqual(resolve_image(self.root, old_revision, CURRENT_IMAGE), OLD_IMAGE) + + def test_new_llvm_release_uses_current_locked_image(self) -> None: + revision = self.commit({"compiler": {"image": CURRENT_IMAGE}}) + self.assertEqual(resolve_image(self.root, revision, CURRENT_IMAGE), CURRENT_IMAGE) + + def test_preparation_descriptor_is_exact_tagged_identity_data(self) -> None: + compiler = {"image": OLD_IMAGE, "version": "23.1.3", "tools": ["clang", "ld.lld"]} + revision = self.commit({"compiler": compiler, "sources": [{"url": "candidate-not-used"}]}) + self.commit({"compiler": {"image": CURRENT_IMAGE}}) + self.assertEqual(resolve_descriptor(self.root, revision, CURRENT_IMAGE), compiler) + resolver = Path(__file__).resolve().parents[2] / "tools/release/resolve_compiler_image.py" + output = self.root / "descriptor.json" + subprocess.run(["python3", str(resolver), "--source-root", str(self.root), + "--revision", revision, "--current-image", CURRENT_IMAGE, + "--source-packaging-lock-output", str(output)], check=True, capture_output=True) + self.assertEqual(output.read_bytes(), subprocess.check_output( + ["git", "-C", str(self.root), "show", revision + ":" + LOCK])) + self.assertEqual(json.loads(output.read_text())["compiler"], compiler) + historical = self.commit({}) + subprocess.run(["python3", str(resolver), "--source-root", str(self.root), + "--revision", historical, "--current-image", CURRENT_IMAGE, + "--source-packaging-lock-output", str(output)], check=True, capture_output=True) + self.assertEqual(output.read_text(), "") + + def test_historical_gcc_descriptor_absence_uses_approved_current_pin(self) -> None: + revision = self.commit({"base_image": "historical-sdk"}) + self.assertEqual(resolve_image(self.root, revision, CURRENT_IMAGE), CURRENT_IMAGE) + + def test_existing_llvm_descriptor_never_falls_back(self) -> None: + for compiler in ({}, {"image": "ghcr.io/atrinik/classic-build:latest"}, None): + with self.subTest(compiler=compiler): + revision = self.commit({"compiler": compiler}) + with self.assertRaisesRegex(ValueError, "compiler (image|descriptor)"): + resolve_image(self.root, revision, CURRENT_IMAGE) + + def test_ambiguous_or_nonregular_tagged_lock_is_rejected(self) -> None: + path = self.root / LOCK + path.parent.mkdir(parents=True) + path.write_text('{"compiler":{"image":"' + OLD_IMAGE + + '"},"compiler":{"image":"' + CURRENT_IMAGE + '"}}') + self.git("add", LOCK) + self.git("-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "duplicate") + with self.assertRaisesRegex(ValueError, "duplicate source lock key"): + resolve_image(self.root, self.git("rev-parse", "HEAD"), CURRENT_IMAGE) + path.unlink() + path.symlink_to("outside-lock.json") + self.git("add", LOCK) + self.git("-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "symlink") + with self.assertRaisesRegex(ValueError, "regular Git blob"): + resolve_image(self.root, self.git("rev-parse", "HEAD"), CURRENT_IMAGE) + + def test_historical_finalizer_workflow_gets_no_unsupported_compiler_argument(self) -> None: + revision = self.commit({"base_image": "historical-sdk"}) + source_root = Path(__file__).resolve().parents[2] + workflow = (source_root / ".github/workflows/build-release-candidate.yml").read_text() + step = workflow.split(" - name: Validate and index the complete candidate", 1)[1].split(" - uses:", 1)[0] + script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + trusted = self.root / "build/ci-automation/tools/release" + trusted.mkdir(parents=True) + shutil.copy2(source_root / "tools/release/resolve_compiler_image.py", trusted) + historical = self.root / "tools/release" + historical.mkdir(parents=True) + (historical / "finalize_artifacts.py").write_text("""import argparse, json, pathlib, sys +parser = argparse.ArgumentParser() +for name in ('version', 'revision', 'source-epoch', 'directory'): + parser.add_argument('--' + name, required=True) +parser.parse_args() +pathlib.Path('received-arguments.json').write_text(json.dumps(sys.argv[1:])) +""") + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], + cwd=self.root, capture_output=True, text=True, + env={**os.environ, "GITHUB_WORKSPACE": str(self.root), "RUNNER_TEMP": str(self.root), + "RELEASE_VERSION": "5.1.0", "RELEASE_REVISION": revision, + "RELEASE_SOURCE_EPOCH": "42", "CLASSIC_LINUX_IMAGE": CURRENT_IMAGE}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("--compiler-image", json.loads((self.root / "received-arguments.json").read_text())) + # The same historical fixture cannot bypass a partial LLVM descriptor. + revision = self.commit({"compiler": {"version": "23.1.3"}}) + (self.root / "received-arguments.json").unlink() + environment = {**os.environ, "GITHUB_WORKSPACE": str(self.root), "RUNNER_TEMP": str(self.root), + "RELEASE_VERSION": "5.1.0", "RELEASE_REVISION": revision, + "RELEASE_SOURCE_EPOCH": "42", "CLASSIC_LINUX_IMAGE": CURRENT_IMAGE} + rejected = subprocess.run(["bash", "-euo", "pipefail", "-c", script], + cwd=self.root, capture_output=True, text=True, env=environment) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn("LLVM release lock requires its immutable compiler image", rejected.stderr) + self.assertFalse((self.root / "received-arguments.json").exists()) + + def test_branch_selector_and_unqualified_current_image_are_rejected(self) -> None: + revision = self.commit({}) + with self.assertRaisesRegex(ValueError, "exact lowercase"): + resolve_image(self.root, "HEAD", CURRENT_IMAGE) + with self.assertRaisesRegex(ValueError, "immutable published"): + resolve_image(self.root, revision, "ghcr.io/atrinik/classic-build:latest") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_linux_deb_workflow.py b/tools/tests/test_linux_deb_workflow.py index 4d4ba69ef..6b69f01e9 100644 --- a/tools/tests/test_linux_deb_workflow.py +++ b/tools/tests/test_linux_deb_workflow.py @@ -53,6 +53,7 @@ def test_candidate_automation_and_source_have_separate_authority(self) -> None: self.assertNotIn('github.token', job) preparation = job.split('name: Prepare pinned Debian', 1)[1].split(' - name:', 1)[0] self.assertNotIn('candidate-source', preparation) + self.assertEqual(preparation.count('--build-arg "ATRINIK_COMPILER_IMAGE=${CLASSIC_LINUX_IMAGE}"'), 2) self.assertIn('--file tools/ci/debian-client/Dockerfile tools/ci/debian-client', preparation) smoke = job.split('name: Install through APT', 1)[1].split(' - uses:', 1)[0] self.assertNotIn('candidate-source', smoke) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 3846c054c..0c5ee1000 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -104,6 +104,16 @@ def test_windows_font_baseline_is_separate_and_native_comparison_is_required(sel self.assertNotIn("continue-on-error", comparison) self.assertIn("if: always()", comparison) + def test_native_version_package_acceptance_runs_in_pinned_core_container(self) -> None: + core = self.text("check.yml").split(" core:\n", 1)[1].split(" windows-test-build:\n", 1)[0] + self.assertIn("CI= python3 -m coverage run --append", core) + native = core.split(" - name: Build and test core packages in the pinned environment", 1)[1].split(" - uses:", 1)[0] + self.assertIn('"${CLASSIC_LINUX_IMAGE}"', native) + self.assertIn("CI=1 python3 -m unittest -v", native) + self.assertIn("tools.tests.test_version_metadata.VersionMetadataTests.test_pathfinding_physical_tag_untagged_and_installed_package", native) + self.assertIn("set -euo pipefail", native) + self.assertNotIn("continue-on-error", native) + def test_windows_publisher_uses_the_staged_ctest_fixture(self) -> None: workflow = self.text("check.yml") jobs = dict( From d84e1344ae638c36ae6e96324894bef208cc8123 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:38:29 -0500 Subject: [PATCH 025/148] fix(build): resolve opt-in LLVM toolchains in scoped archives --- CMakeLists.txt | 7 + CMakePresets.json | 2 +- client/CMakeLists.txt | 9 ++ client/CMakePresets.json | 2 +- cmake/AtrinikLLVMOptIn.cmake | 39 ++++++ libatrinik/CMakeLists.txt | 15 +++ libatrinik/CMakePresets.json | 2 +- libatrinik/pathfinding/CMakeLists.txt | 15 +++ libatrinik/pathfinding/CMakePresets.json | 2 +- server/CMakeLists.txt | 9 ++ server/CMakePresets.json | 2 +- tools/release/package_sources.py | 3 + tools/tests/test_coverage_presets.py | 158 ++++++++++++++++++++++- tools/tests/test_package_sources.py | 6 + 14 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 cmake/AtrinikLLVMOptIn.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index ff743dcce..ce9b1b62f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,9 +1,16 @@ cmake_minimum_required(VERSION 3.21) include(cmake/AtrinikVersion.cmake) +if (ATRINIK_LLVM_TOOLCHAIN) + include(cmake/AtrinikLLVMOptIn.cmake) + atrinik_initialize_optional_llvm_toolchain() +endif () atrinik_resolve_version(ATRINIK_SOURCE_VERSION) project(atrinik-classic VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) +if (ATRINIK_LLVM_TOOLCHAIN) + atrinik_require_optional_llvm() +endif () atrinik_initialize_version_metadata() include(CTest) diff --git a/CMakePresets.json b/CMakePresets.json index 70dfa430b..c02edd409 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -34,7 +34,7 @@ "inherits": "linux-coverage", "binaryDir": "${sourceDir}/build/integrated-llvm-coverage", "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/cmake/toolchains/llvm.cmake" + "ATRINIK_LLVM_TOOLCHAIN": true } }, { diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index ac7e732bf..9da31dfa6 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -12,11 +12,20 @@ if (NOT ATRINIK_INTEGRATED_BUILD) "${ATRINIK_COMPONENT_SOURCE_DIR}/../cmake/AtrinikVersion.cmake") endif () include("${ATRINIK_VERSION_MODULE}") + if (ATRINIK_LLVM_TOOLCHAIN) + get_filename_component(ATRINIK_CMAKE_MODULE_DIR "${ATRINIK_VERSION_MODULE}" DIRECTORY) + include("${ATRINIK_CMAKE_MODULE_DIR}/AtrinikLLVMOptIn.cmake") + atrinik_initialize_optional_llvm_toolchain() + unset(ATRINIK_CMAKE_MODULE_DIR) + endif () unset(ATRINIK_VERSION_MODULE) unset(ATRINIK_COMPONENT_CMAKE_FILE) unset(ATRINIK_COMPONENT_SOURCE_DIR) atrinik_resolve_version(ATRINIK_SOURCE_VERSION) project(atrinik-client VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) + if (ATRINIK_LLVM_TOOLCHAIN) + atrinik_require_optional_llvm() + endif () atrinik_initialize_version_metadata() endif () diff --git a/client/CMakePresets.json b/client/CMakePresets.json index f8985d872..24553be03 100644 --- a/client/CMakePresets.json +++ b/client/CMakePresets.json @@ -36,7 +36,7 @@ "inherits": "linux-coverage", "binaryDir": "${sourceDir}/build/llvm-coverage", "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + "ATRINIK_LLVM_TOOLCHAIN": true } }, { diff --git a/cmake/AtrinikLLVMOptIn.cmake b/cmake/AtrinikLLVMOptIn.cmake new file mode 100644 index 000000000..e7747bf9f --- /dev/null +++ b/cmake/AtrinikLLVMOptIn.cmake @@ -0,0 +1,39 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +include_guard(GLOBAL) + +# This prerequisite retains the previous producer's defaults. The LLVM preset +# explicitly opts in; resolve from this packaged module rather than assuming +# that a component always has the monorepo's parent directory layout. +function(atrinik_initialize_optional_llvm_toolchain) + if (ATRINIK_LLVM_TOOLCHAIN AND NOT CMAKE_TOOLCHAIN_FILE) + set(CMAKE_TOOLCHAIN_FILE + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/toolchains/llvm.cmake" CACHE FILEPATH + "Pinned native LLVM toolchain") + endif () +endfunction() + +# Explicit toolchains retain their settings, but an LLVM opt-in must not +# silently configure with GCC or a mismatched LLVM cohort. +function(atrinik_require_optional_llvm) + if (NOT CMAKE_C_COMPILER_ID STREQUAL "Clang" OR + NOT CMAKE_C_COMPILER_VERSION VERSION_EQUAL "23.1.3") + message(FATAL_ERROR "LLVM opt-in requires Clang 23.1.3") + endif () + if (CMAKE_CXX_COMPILER_LOADED AND + (NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR + NOT CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL "23.1.3")) + message(FATAL_ERROR "LLVM opt-in C++ dependencies require Clang 23.1.3") + endif () + foreach (tool CMAKE_AR CMAKE_RANLIB CMAKE_LINKER) + execute_process(COMMAND "${${tool}}" --version + OUTPUT_VARIABLE tool_version ERROR_VARIABLE tool_error + RESULT_VARIABLE tool_result) + if (NOT tool_result EQUAL 0 OR + NOT tool_version MATCHES "LLVM version 23\\.1\\.3([^0-9.]|$)" AND + NOT tool_version MATCHES "LLD 23\\.1\\.3([^0-9.]|$)") + message(FATAL_ERROR "${tool} must be LLVM 23.1.3: ${tool_version}${tool_error}") + endif () + endforeach () +endfunction() diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index 6898b2959..9b5f65551 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -9,14 +9,29 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) get_filename_component(component_dir "${component_file}" DIRECTORY) if (EXISTS "${component_dir}/cmake/AtrinikVersion.cmake") include("${component_dir}/cmake/AtrinikVersion.cmake") + if (ATRINIK_LLVM_TOOLCHAIN) + include("${component_dir}/cmake/AtrinikLLVMOptIn.cmake") + endif () elseif (EXISTS "${component_dir}/../cmake/AtrinikVersion.cmake") include("${component_dir}/../cmake/AtrinikVersion.cmake") + if (ATRINIK_LLVM_TOOLCHAIN) + include("${component_dir}/../cmake/AtrinikLLVMOptIn.cmake") + endif () else () include("${component_dir}/../../cmake/AtrinikVersion.cmake") + if (ATRINIK_LLVM_TOOLCHAIN) + include("${component_dir}/../../cmake/AtrinikLLVMOptIn.cmake") + endif () + endif () + if (ATRINIK_LLVM_TOOLCHAIN) + atrinik_initialize_optional_llvm_toolchain() endif () atrinik_resolve_version(ATRINIK_SOURCE_VERSION) project(libatrinik VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) + if (ATRINIK_LLVM_TOOLCHAIN) + atrinik_require_optional_llvm() + endif () include(CTest) include(CMakePackageConfigHelpers) diff --git a/libatrinik/CMakePresets.json b/libatrinik/CMakePresets.json index 73ef2a90b..766d3c794 100644 --- a/libatrinik/CMakePresets.json +++ b/libatrinik/CMakePresets.json @@ -17,7 +17,7 @@ "inherits": "linux-coverage", "binaryDir": "${sourceDir}/build/llvm-coverage", "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + "ATRINIK_LLVM_TOOLCHAIN": true } }, { diff --git a/libatrinik/pathfinding/CMakeLists.txt b/libatrinik/pathfinding/CMakeLists.txt index cb76a00f1..84b294814 100644 --- a/libatrinik/pathfinding/CMakeLists.txt +++ b/libatrinik/pathfinding/CMakeLists.txt @@ -6,14 +6,29 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) get_filename_component(component_dir "${component_file}" DIRECTORY) if (EXISTS "${component_dir}/../cmake/AtrinikVersion.cmake") include("${component_dir}/../cmake/AtrinikVersion.cmake") + if (ATRINIK_LLVM_TOOLCHAIN) + include("${component_dir}/../cmake/AtrinikLLVMOptIn.cmake") + endif () elseif (EXISTS "${component_dir}/../../cmake/AtrinikVersion.cmake") include("${component_dir}/../../cmake/AtrinikVersion.cmake") + if (ATRINIK_LLVM_TOOLCHAIN) + include("${component_dir}/../../cmake/AtrinikLLVMOptIn.cmake") + endif () else () include("${component_dir}/../../../cmake/AtrinikVersion.cmake") + if (ATRINIK_LLVM_TOOLCHAIN) + include("${component_dir}/../../../cmake/AtrinikLLVMOptIn.cmake") + endif () + endif () + if (ATRINIK_LLVM_TOOLCHAIN) + atrinik_initialize_optional_llvm_toolchain() endif () atrinik_resolve_version(ATRINIK_SOURCE_VERSION) project(atrinik-pathfinding VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) + if (ATRINIK_LLVM_TOOLCHAIN) + atrinik_require_optional_llvm() + endif () include(CTest) include(CMakePackageConfigHelpers) option(ENABLE_COVERAGE "Enable native compiler coverage instrumentation" OFF) diff --git a/libatrinik/pathfinding/CMakePresets.json b/libatrinik/pathfinding/CMakePresets.json index 64bbe21f6..878bf49c8 100644 --- a/libatrinik/pathfinding/CMakePresets.json +++ b/libatrinik/pathfinding/CMakePresets.json @@ -9,7 +9,7 @@ "CMAKE_BUILD_TYPE": "Debug", "BUILD_TESTING": true, "ENABLE_COVERAGE": true, - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../../cmake/toolchains/llvm.cmake" + "ATRINIK_LLVM_TOOLCHAIN": true } } ], diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 18f3e7cb9..2a5809efa 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -12,11 +12,20 @@ if (NOT ATRINIK_INTEGRATED_BUILD) "${ATRINIK_COMPONENT_SOURCE_DIR}/../cmake/AtrinikVersion.cmake") endif () include("${ATRINIK_VERSION_MODULE}") + if (ATRINIK_LLVM_TOOLCHAIN) + get_filename_component(ATRINIK_CMAKE_MODULE_DIR "${ATRINIK_VERSION_MODULE}" DIRECTORY) + include("${ATRINIK_CMAKE_MODULE_DIR}/AtrinikLLVMOptIn.cmake") + atrinik_initialize_optional_llvm_toolchain() + unset(ATRINIK_CMAKE_MODULE_DIR) + endif () unset(ATRINIK_VERSION_MODULE) unset(ATRINIK_COMPONENT_CMAKE_FILE) unset(ATRINIK_COMPONENT_SOURCE_DIR) atrinik_resolve_version(ATRINIK_SOURCE_VERSION) project(atrinik-server VERSION "${ATRINIK_SOURCE_VERSION}" LANGUAGES C) + if (ATRINIK_LLVM_TOOLCHAIN) + atrinik_require_optional_llvm() + endif () atrinik_initialize_version_metadata() endif () diff --git a/server/CMakePresets.json b/server/CMakePresets.json index 8560bf2f2..708052dec 100644 --- a/server/CMakePresets.json +++ b/server/CMakePresets.json @@ -38,7 +38,7 @@ "inherits": "linux-coverage", "binaryDir": "${sourceDir}/build/llvm-coverage", "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/../cmake/toolchains/llvm.cmake" + "ATRINIK_LLVM_TOOLCHAIN": true } }, { diff --git a/tools/release/package_sources.py b/tools/release/package_sources.py index 841723a4c..1083e7142 100755 --- a/tools/release/package_sources.py +++ b/tools/release/package_sources.py @@ -27,6 +27,9 @@ "LICENSE.md", "ATTRIBUTIONS.md", "cmake/AtrinikVersion.cmake", + "cmake/AtrinikLLVMOptIn.cmake", + "cmake/toolchains/llvm.cmake", + "tools/ci/llvm_coverage.py", ) PROVENANCE_PREFIX = "docs/history/" diff --git a/tools/tests/test_coverage_presets.py b/tools/tests/test_coverage_presets.py index d027ac3b8..4692c92c2 100644 --- a/tools/tests/test_coverage_presets.py +++ b/tools/tests/test_coverage_presets.py @@ -3,12 +3,16 @@ import importlib.util import json +import platform from pathlib import Path import shutil import subprocess import tempfile +import tarfile import unittest +from tools.release import package_sources + ROOT = Path(__file__).resolve().parents[2] spec = importlib.util.spec_from_file_location("llvm_coverage", ROOT / "tools/ci/llvm_coverage.py") coverage = importlib.util.module_from_spec(spec) @@ -42,13 +46,162 @@ def test_opt_in_has_matching_configure_build_and_test_presets(self): tested = preset(data["testPresets"], "llvm-coverage") self.assertTrue(configured["cacheVariables"]["ENABLE_COVERAGE"]) self.assertEqual(configured["cacheVariables"]["CMAKE_BUILD_TYPE"], "Debug") - toolchain = configured["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"].replace("${sourceDir}", str(source)) - self.assertEqual(Path(toolchain).resolve(), ROOT / "cmake/toolchains/llvm.cmake") + # The entry point selects the shared toolchain by physical module + # location; a fixed preset path breaks scoped source archives. + self.assertNotIn("CMAKE_TOOLCHAIN_FILE", configured["cacheVariables"]) + self.assertNotIn("toolchainFile", configured) + self.assertTrue(configured["cacheVariables"]["ATRINIK_LLVM_TOOLCHAIN"]) self.assertEqual(built["configurePreset"], "llvm-coverage") self.assertEqual(tested["configurePreset"], "llvm-coverage") self.assertEqual(tested["environment"]["LLVM_PROFILE_FILE"], configured["binaryDir"] + "/llvm-profiles/%m-%p.profraw") + + def assert_configure_selects_toolchain(self, source, build, toolchain, preset_name="llvm-coverage", arguments=()): + # Run actual preset configuration and the actual authored CMake prefix. + # Abort at project() before language detection: this verifies selection + # without inventing compiler identity or permitting a product policy bypass. + build.mkdir(parents=True) + probe = build / "toolchain-selection-probe.cmake" + expected = toolchain.as_posix() if toolchain is not None else "" + probe.write_text(f''' +if (CMAKE_TOOLCHAIN_FILE) + file(REAL_PATH "${{CMAKE_TOOLCHAIN_FILE}}" selected_toolchain) +else () + set(selected_toolchain "") +endif () +if (NOT selected_toolchain STREQUAL "{expected}" OR + (selected_toolchain AND NOT EXISTS "${{selected_toolchain}}")) + message(FATAL_ERROR "Unexpected selected toolchain: ${{CMAKE_TOOLCHAIN_FILE}}") +endif () +if (NOT ENABLE_COVERAGE OR NOT BUILD_TESTING OR NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + message(FATAL_ERROR "Coverage preset options were not applied") +endif () +message(FATAL_ERROR "ATRINIK_TOOLCHAIN_SELECTION_PROBE_COMPLETED") +''', encoding="utf-8") + result = subprocess.run( + ["cmake", "--preset", preset_name, "-B", str(build), + "-DATRINIK_PACKAGE_VERSION=9.8.7", + "-DCMAKE_PROJECT_INCLUDE_BEFORE=" + str(probe), *arguments], + cwd=source, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("ATRINIK_TOOLCHAIN_SELECTION_PROBE_COMPLETED", + result.stdout + result.stderr) + + @unittest.skipUnless(shutil.which("cmake") and platform.system() == "Linux", + "Native Linux CMake selection probe unavailable") + def test_actual_checkout_presets_select_shared_native_toolchain(self): + with tempfile.TemporaryDirectory() as temporary: + for index, module in enumerate(MODULES): + with self.subTest(module=module): + self.assert_configure_selects_toolchain( + ROOT / module, Path(temporary) / str(index), + ROOT / "cmake/toolchains/llvm.cmake") + + @unittest.skipUnless(shutil.which("cmake") and platform.system() == "Linux", + "Native Linux CMake selection probe unavailable") + def test_actual_scoped_archive_presets_select_packaged_native_toolchain(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_tar = root / "source.tar" + # Package the actual authored entry points/presets and shared modules + # through the release archiver. Dependencies beyond project() are not + # needed for this deliberately bounded selection probe. + with tarfile.open(source_tar, "w") as archive: + for module in MODULES: + for name in ("CMakeLists.txt", "CMakePresets.json"): + relative = Path(module) / name + archive.add(ROOT / relative, arcname=relative.as_posix()) + archive.add(ROOT / "cmake", arcname="cmake") + for scope in ("root", "client", "server", "libatrinik"): + with self.subTest(scope=scope): + package = root / (scope + ".tar.gz") + package_sources.build_archive(source_tar, package, scope, + "9.8.7", 42, "f" * 40) + destination = root / scope + with tarfile.open(package) as archive: + archive.extractall(destination, filter="data") + source = next(destination.iterdir()) + toolchain = source / "cmake/toolchains/llvm.cmake" + self.assert_configure_selects_toolchain( + source, root / (scope + "-build"), toolchain) + pathfinding = source / ("libatrinik/pathfinding" if scope == "root" else + "pathfinding" if scope == "libatrinik" else + "dependencies/libatrinik/pathfinding") + self.assert_configure_selects_toolchain( + pathfinding, root / (scope + "-pathfinding-build"), toolchain) + + @unittest.skipUnless(shutil.which("cmake") and platform.system() == "Linux", + "Native Linux CMake selection probe unavailable") + def test_actual_legacy_presets_preserve_previous_producer_defaults(self): + with tempfile.TemporaryDirectory() as temporary: + for index, module in enumerate(MODULES[:-1]): + with self.subTest(module=module): + self.assert_configure_selects_toolchain( + ROOT / module, Path(temporary) / str(index), None, + preset_name="linux-coverage") + + @unittest.skipUnless(shutil.which("cmake") and platform.system() == "Linux", + "Native Linux CMake selection probe unavailable") + def test_opt_in_preserves_an_explicit_toolchain(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + toolchain = root / "explicit.cmake" + toolchain.write_text("# explicit target settings\n") + self.assert_configure_selects_toolchain( + ROOT / "client", root / "build", toolchain, + arguments=("-DCMAKE_TOOLCHAIN_FILE=" + str(toolchain),)) + + @unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") + def test_opt_in_rejects_nonmatching_compiler_identity(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for compiler, version in (("GNU", "23.1.3"), ("Clang", "22.1.0")): + with self.subTest(compiler=compiler, version=version): + probe = root / "validate.cmake" + probe.write_text(f''' +include("{(ROOT / "cmake/AtrinikLLVMOptIn.cmake").as_posix()}") +set(CMAKE_C_COMPILER_ID "{compiler}") +set(CMAKE_C_COMPILER_VERSION "{version}") +atrinik_require_optional_llvm() +''') + # Script-mode fixtures exercise the policy itself; they never + # impersonate a compiler during a product configure/build. + result = subprocess.run(["cmake", "-P", str(probe)], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("LLVM opt-in requires Clang 23.1.3", result.stderr) + + @unittest.skipUnless(shutil.which("cmake") and platform.system() == "Linux", + "Native Linux CMake policy probe unavailable") + def test_opt_in_rejects_missing_or_mismatched_llvm_utilities(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + valid = root / "llvm-tool" + valid.write_text("#!/bin/sh\necho 'LLVM version 23.1.3'\n") + valid.chmod(0o755) + wrong = root / "llvm-tool-old" + wrong.write_text("#!/bin/sh\necho 'LLVM version 23.1.2'\n") + wrong.chmod(0o755) + for variable in ("CMAKE_AR", "CMAKE_RANLIB", "CMAKE_LINKER"): + for selected in (wrong, root / "missing"): + with self.subTest(variable=variable, selected=selected.name): + probe = root / "validate.cmake" + settings = "\n".join( + f'set({name} "{selected if name == variable else valid}")' + for name in ("CMAKE_AR", "CMAKE_RANLIB", "CMAKE_LINKER")) + probe.write_text(f''' +include("{(ROOT / "cmake/AtrinikLLVMOptIn.cmake").as_posix()}") +set(CMAKE_C_COMPILER_ID Clang) +set(CMAKE_C_COMPILER_VERSION 23.1.3) +{settings} +atrinik_require_optional_llvm() +''') + result = subprocess.run(["cmake", "-P", str(probe)], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn(variable + " must be LLVM 23.1.3", result.stderr) + def test_native_profile_preparation_leaves_existing_gcc_cache_untouched(self): for module in MODULES[:-1]: with self.subTest(module=module), tempfile.TemporaryDirectory() as temporary: @@ -70,6 +223,7 @@ def test_native_profile_preparation_leaves_existing_gcc_cache_untouched(self): self.assertEqual(cache.read_text(), "CMAKE_C_COMPILER:FILEPATH=/usr/bin/gcc\n") self.assertEqual(raw.read_bytes(), b"retained GCC data") self.assertNotIn("CMAKE_TOOLCHAIN_FILE", legacy.get("cacheVariables", {})) + self.assertNotIn("ATRINIK_LLVM_TOOLCHAIN", legacy.get("cacheVariables", {})) self.assertNotIn("LLVM_PROFILE_FILE", preset(data["testPresets"], "linux-coverage").get("environment", {})) @unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") diff --git a/tools/tests/test_package_sources.py b/tools/tests/test_package_sources.py index e1fb54f71..ec3fab732 100644 --- a/tools/tests/test_package_sources.py +++ b/tools/tests/test_package_sources.py @@ -32,6 +32,9 @@ def setUp(self) -> None: "LICENSE.md": "license\n", "ATTRIBUTIONS.md": "notices\n", "cmake/AtrinikVersion.cmake": "# version contract\n", + "cmake/AtrinikLLVMOptIn.cmake": "# optional LLVM selection\n", + "cmake/toolchains/llvm.cmake": "# pinned LLVM toolchain\n", + "tools/ci/llvm_coverage.py": "# native LLVM coverage export\n", "docs/history/imports.json": "{}\n", "client/.releaserc.json": "{}\n", "client/main.c": "int main(void) { return 0; }\n", @@ -74,6 +77,9 @@ def test_scoped_archive_contains_shared_evidence_but_not_other_modules(self) -> ) with tarfile.open(output, "r:gz") as archive: names = set(archive.getnames()) + for helper in ("cmake/AtrinikLLVMOptIn.cmake", + "cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py"): + self.assertIn("atrinik-classic-client-5.6.0/" + helper, names) for relative in ("", "dependencies/protocol/", "dependencies/libatrinik/"): revision = archive.extractfile( f"atrinik-classic-client-5.6.0/{relative}SOURCE_REVISION" From a077263afa51cf66d8a81cd48d7315df3d767415 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:39:13 -0500 Subject: [PATCH 026/148] fix(release): bind recovered AppImage SDK to exact source lock --- tools/ci/appimage/prepare.py | 52 +++++++++++-- tools/ci/appimage/test_prepare.py | 124 ++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 6 deletions(-) diff --git a/tools/ci/appimage/prepare.py b/tools/ci/appimage/prepare.py index ffc2f2dfa..6a579f7df 100644 --- a/tools/ci/appimage/prepare.py +++ b/tools/ci/appimage/prepare.py @@ -8,6 +8,7 @@ import os from pathlib import Path import shutil +import stat import tempfile import urllib.request @@ -37,18 +38,56 @@ def download(entry, destination, cache): shutil.copyfile(cached, destination) +def select_packaging_lock(trusted_lock, image, source_lock_path=None): + """Preserve exact release lock bytes only for the supported SDK recipe.""" + if not re.fullmatch(r'ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}', image): + raise ValueError('--compiler-image must be an immutable published classic-build reference') + if source_lock_path is None: + if trusted_lock['compiler'].get('image') is not None and image != trusted_lock['compiler']['image']: + raise ValueError('--compiler-image differs from the trusted packaging lock') + return trusted_lock, None + source_fd = os.open(source_lock_path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(source_fd, 'rb') as stream: + identity = os.fstat(stream.fileno()) + if not stat.S_ISREG(identity.st_mode) or identity.st_size > 4 * 1024 * 1024: + raise ValueError('source packaging lock must be a bounded regular file') + data = stream.read(4 * 1024 * 1024 + 1) + if len(data) > 4 * 1024 * 1024: + raise ValueError('source packaging lock exceeds inspection limits') + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError('duplicate key in source packaging lock') + result[key] = value + return result + source_lock = json.loads(data, object_pairs_hook=pairs) + if not isinstance(source_lock, dict) or not isinstance(source_lock.get('compiler'), dict): + raise ValueError('source packaging lock requires a compiler descriptor') + if source_lock['compiler'].get('image') != image: + raise ValueError('source packaging lock compiler image differs from --compiler-image') + def recipe(lock): + return dict(lock, compiler={key: value for key, value in lock['compiler'].items() if key != 'image'}) + if json.dumps(recipe(source_lock), sort_keys=True) != json.dumps(recipe(trusted_lock), sort_keys=True): + raise ValueError('source packaging lock differs from the supported SDK recipe or LLVM cohort') + return source_lock, data + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--cache', type=Path) parser.add_argument('--compiler-image', required=True) + parser.add_argument('--source-packaging-lock', type=Path, + help='exact release lock bytes extracted by the trusted resolver') args = parser.parse_args() root = Path(__file__).resolve().parent - lock = json.loads((root / 'packaging.lock.json').read_text()) - if not re.fullmatch(r'ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}', args.compiler_image): - parser.error('--compiler-image must be an immutable published classic-build reference') - if lock['compiler'].get('image') is not None and args.compiler_image != lock['compiler']['image']: - parser.error('--compiler-image differs from the trusted packaging lock') + original_lock = (root / 'packaging.lock.json').read_text() + lock = json.loads(original_lock) + try: + lock, selected_lock_bytes = select_packaging_lock(lock, args.compiler_image, args.source_packaging_lock) + except (OSError, ValueError, UnicodeError) as error: + parser.error(str(error)) if args.output.resolve() == root: parser.error('--output must be a separate generated context directory') args.output.mkdir(parents=True, exist_ok=True) @@ -71,9 +110,10 @@ def main(): probe = root / name if probe.is_file() and probe.resolve() != (args.output / probe.name).resolve(): shutil.copyfile(probe, args.output / probe.name) - for name in ('build-dependencies.sh', 'compiler.py', 'packaging.lock.json'): + for name in ('build-dependencies.sh', 'compiler.py'): if (root / name).resolve() != (args.output / name).resolve(): shutil.copyfile(root / name, args.output / name) + (args.output / 'packaging.lock.json').write_bytes(selected_lock_bytes if selected_lock_bytes is not None else original_lock.encode()) (args.output / 'Dockerfile').write_text((root / 'Dockerfile').read_text().replace('FROM ${ATRINIK_COMPILER_IMAGE} AS compiler', f'FROM {args.compiler_image} AS compiler')) diff --git a/tools/ci/appimage/test_prepare.py b/tools/ci/appimage/test_prepare.py index 36ba422b0..881352227 100644 --- a/tools/ci/appimage/test_prepare.py +++ b/tools/ci/appimage/test_prepare.py @@ -4,6 +4,10 @@ import unittest import json import sys +import copy +import os +import contextlib +import io from unittest.mock import patch spec = importlib.util.spec_from_file_location('prepare', Path(__file__).with_name('prepare.py')) @@ -12,6 +16,126 @@ class IntegrityTests(unittest.TestCase): + def trusted_lock(self): + lock = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) + lock['compiler']['image'] = 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 + return lock + + def test_historical_lock_changes_only_approved_image_and_retains_exact_bytes(self): + trusted = self.trusted_lock() + image = 'ghcr.io/atrinik/classic-build:1.1.0@sha256:' + 'b' * 64 + historical = copy.deepcopy(trusted) + historical['compiler']['image'] = image + raw = (json.dumps(historical, indent=7) + '\n\n').encode() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'release-lock.json' + path.write_bytes(raw) + selected, selected_bytes = prepare.select_packaging_lock(trusted, image, path) + self.assertEqual(selected, historical) + self.assertEqual(selected_bytes, raw) + self.assertNotEqual(trusted['compiler']['image'], image) + with self.assertRaisesRegex(ValueError, 'trusted packaging lock'): + prepare.select_packaging_lock(trusted, image) + with self.assertRaisesRegex(ValueError, 'differs from --compiler-image'): + prepare.select_packaging_lock(trusted, trusted['compiler']['image'], path) + for section, key, value in (('source', 'url', 'https://example.invalid/other'), + ('defaults', 'runtime', 'libgcc'), + ('notices', 'compiler-rt-LICENSE.TXT', '0' * 64)): + changed = copy.deepcopy(historical) + changed['compiler'][section][key] = value + path.write_text(json.dumps(changed)) + with self.subTest(section=section), self.assertRaisesRegex(ValueError, 'SDK recipe or LLVM cohort'): + prepare.select_packaging_lock(trusted, image, path) + changed = copy.deepcopy(historical) + changed['sources'][0]['url'] = 'https://example.invalid/private-source' + path.write_text(json.dumps(changed)) + with self.assertRaisesRegex(ValueError, 'SDK recipe or LLVM cohort'): + prepare.select_packaging_lock(trusted, image, path) + changed = copy.deepcopy(historical) + changed['schema_version'] = float(changed['schema_version']) + path.write_text(json.dumps(changed)) + with self.assertRaisesRegex(ValueError, 'SDK recipe or LLVM cohort'): + prepare.select_packaging_lock(trusted, image, path) + + def test_historical_default_argv_and_present_missing_image_fail_closed(self): + trusted = self.trusted_lock() + image = trusted['compiler']['image'] + self.assertEqual(prepare.select_packaging_lock(trusted, image), (trusted, None)) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'release-lock.json' + historical = copy.deepcopy(trusted) + del historical['compiler']['image'] + path.write_text(json.dumps(historical)) + with self.assertRaisesRegex(ValueError, 'differs from --compiler-image'): + prepare.select_packaging_lock(trusted, image, path) + del historical['compiler'] + path.write_text(json.dumps(historical)) + with self.assertRaisesRegex(ValueError, 'requires a compiler descriptor'): + prepare.select_packaging_lock(trusted, image, path) + + def test_source_lock_paths_shapes_and_duplicate_keys_fail_before_materialization(self): + trusted = self.trusted_lock() + image = trusted['compiler']['image'] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / 'source-lock' + for data in ('[]', '{', 'null', '{"compiler":{},"compiler":{}}', ' ' * (4 * 1024 * 1024 + 1)): + path.write_text(data) + with self.subTest(data_length=len(data)), self.assertRaises(ValueError): + prepare.select_packaging_lock(trusted, image, path) + alias = root / 'alias' + alias.symlink_to(path) + with self.assertRaises(OSError): + prepare.select_packaging_lock(trusted, image, alias) + fifo = root / 'fifo' + os.mkfifo(fifo) + with self.assertRaisesRegex(ValueError, 'bounded regular file'): + prepare.select_packaging_lock(trusted, image, fifo) + + def test_recovery_context_retains_exact_source_lock_bytes_and_current_recipe(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source_lock = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) + image = 'ghcr.io/atrinik/classic-build:1.1.0@sha256:' + 'b' * 64 + historical = copy.deepcopy(source_lock) + historical['compiler']['image'] = image + raw = (json.dumps(historical, sort_keys=True, indent=7) + '\n\n').encode() + source_lock_path = root / 'release-lock.json' + source_lock_path.write_bytes(raw) + output = root / 'context' + def materialize(entry, destination, cache): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b'fixture') + args = ['prepare.py', '--output', str(output), '--compiler-image', image, + '--source-packaging-lock', str(source_lock_path)] + with patch.object(sys, 'argv', args), patch.object(prepare, 'download', side_effect=materialize): + prepare.main() + self.assertEqual((output / 'packaging.lock.json').read_bytes(), raw) + staged = json.loads((output / 'packaging.lock.json').read_text()) + self.assertEqual({key: value for key, value in staged.items() if key != 'compiler'}, + {key: value for key, value in source_lock.items() if key != 'compiler'}) + self.assertEqual(json.loads((output / 'dependency-inputs.lock.json').read_text())['compiler'], historical['compiler']) + self.assertIn(f'FROM {image} AS compiler', (output / 'Dockerfile').read_text()) + + def test_changed_release_url_is_rejected_before_download_or_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source_lock = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) + image = 'ghcr.io/atrinik/classic-build:1.1.0@sha256:' + 'b' * 64 + source_lock['compiler']['image'] = image + source_lock['sources'][0]['url'] = 'https://example.invalid/private-source' + path = root / 'release-lock.json' + path.write_text(json.dumps(source_lock)) + output = root / 'context' + args = ['prepare.py', '--output', str(output), '--compiler-image', image, + '--source-packaging-lock', str(path)] + with patch.object(sys, 'argv', args), patch.object(prepare, 'download') as download, contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as rejected: + prepare.main() + self.assertEqual(rejected.exception.code, 2) + download.assert_not_called() + self.assertFalse(output.exists()) + def test_context_embeds_the_exact_immutable_compiler_cohort(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) From 1fe85462f3152ea97960dc91f3e0860e98af0cd8 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:40:03 -0500 Subject: [PATCH 027/148] fix(ci): select native validation for macOS LLVM contracts --- tools/ci/classify_changes.py | 2 ++ tools/tests/test_classify_changes.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index 5ca8abbea..ef7836a40 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -26,6 +26,7 @@ "tools/tests/test_llvm_coverage.py", "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", + "tools/tests/test_macos_llvm_toolchain.py", "tools/tests/test_gpu_qualification_runtime.py", "LICENSE.md", "docs/history/component-release-map.json", @@ -48,6 +49,7 @@ WINDOWS_TEST_PATHS = { "CMakeLists.txt", "cmake/AtrinikLLVM.cmake", "cmake/InstallLLVMRuntime.cmake", "cmake/InstallWindowsRuntime.cmake", "cmake/toolchains/llvm.cmake", + "cmake/toolchains/llvm-macos.cmake", "tools/tests/test_macos_llvm_toolchain.py", "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", } BENCHMARK_CONTRACT_PATHS = { diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index 87a4f2e7b..124c62bbe 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -100,10 +100,12 @@ def test_shared_native_change_selects_both_components(self) -> None: self.assertTrue(result["codeql_core_cpp"]) def test_llvm_contract_changes_select_both_native_consumers(self) -> None: - for path in ("cmake/toolchains/llvm.cmake", "tools/ci/llvm_coverage.py", + for path in ("cmake/toolchains/llvm.cmake", "cmake/toolchains/llvm-macos.cmake", + "cmake/AtrinikLLVM.cmake", "tools/ci/llvm_coverage.py", "tools/tests/test_llvm_coverage.py", "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", + "tools/tests/test_macos_llvm_toolchain.py", "tools/tests/test_gpu_qualification_runtime.py"): with self.subTest(path=path): result = classify_changes.classify([path]) @@ -132,8 +134,10 @@ def test_libatrinik_and_ci_contract_changes_select_windows_tests(self) -> None: "cmake/InstallLLVMRuntime.cmake", "cmake/InstallWindowsRuntime.cmake", "cmake/toolchains/llvm.cmake", + "cmake/toolchains/llvm-macos.cmake", "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", + "tools/tests/test_macos_llvm_toolchain.py", "client/src/client/main.c", "libatrinik/path.c", "protocol/generated/c/commands.c", From 12b5f13e6ac74923053808869d4dbef5336e2932 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:41:31 -0500 Subject: [PATCH 028/148] fix(ci): separate LLVM and GCC validation caches --- tools/ci/run_linux_check.sh | 104 +++++++++++--------- tools/tests/test_workflow_contracts.py | 129 ++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 49 deletions(-) diff --git a/tools/ci/run_linux_check.sh b/tools/ci/run_linux_check.sh index 4d6785d4f..e22a8b815 100755 --- a/tools/ci/run_linux_check.sh +++ b/tools/ci/run_linux_check.sh @@ -36,11 +36,27 @@ test "$(jq -r '.platform' /usr/local/share/atrinik/classic-toolchain.json)" = li llvm_toolchain=${ATRINIK_LLVM_TOOLCHAIN:-0} coverage_preset=linux-coverage coverage_directory=build/linux-coverage +release_directory=build/linux-release +integrated_release_directory=build/integrated-linux-release +sanitizer_directory=build/linux-sanitizers +protocol_directory=build/check +library_release_directory=build/release +install_directory=build/install +consumer_directory=build/consumer if [[ ${llvm_toolchain} == 1 ]]; then test "$(jq -r '.tools.clang' /usr/local/share/atrinik/classic-toolchain.json)" = 23.1.3 export CC=clang CXX=clang++ coverage_preset=llvm-coverage coverage_directory=build/llvm-coverage + # CMake retains its compiler in the cache even when CC/CXX change. Keep every + # compiler-specific validation tree separate from the transitional GCC lane. + release_directory=build/llvm-release + integrated_release_directory=build/integrated-llvm-release + sanitizer_directory=build/llvm-sanitizers + protocol_directory=build/llvm-check + library_release_directory=build/llvm-release + install_directory=build/llvm-install + consumer_directory=build/llvm-consumer else # Transitional validation for the previously released immutable image. test "$(jq -r '.tools.gcc' /usr/local/share/atrinik/classic-toolchain.json)" = 15.2.0 @@ -123,10 +139,10 @@ ccache --zero-stats >/dev/null case "${component}" in core) - cmake -S "${source_root}/protocol" -B "${source_root}/protocol/build/check" \ + cmake -S "${source_root}/protocol" -B "${source_root}/protocol/${protocol_directory}" \ -G Ninja -DCMAKE_BUILD_TYPE=Release "${launcher[@]}" - cmake --build "${source_root}/protocol/build/check" --parallel "${jobs}" - ctest --test-dir "${source_root}/protocol/build/check" --output-on-failure + cmake --build "${source_root}/protocol/${protocol_directory}" --parallel "${jobs}" + ctest --test-dir "${source_root}/protocol/${protocol_directory}" --output-on-failure pushd "${source_root}/libatrinik" >/dev/null cmake --preset "${coverage_preset}" \ @@ -136,22 +152,22 @@ case "${component}" in prepare_coverage ctest --preset "${coverage_preset}" export_coverage libatrinik - cmake --preset linux-sanitizers \ + cmake --preset linux-sanitizers -B "${sanitizer_directory}" \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ "${launcher[@]}" - cmake --build --preset linux-sanitizers --parallel "${jobs}" + cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers - cmake -S . -B build/release -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_INSTALL_PREFIX="${PWD}/build/install" \ + ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" + cmake -S . -B "${library_release_directory}" -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${PWD}/${install_directory}" \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ "${launcher[@]}" - cmake --build build/release --parallel "${jobs}" - cmake --install build/release - cmake -S tests/consumer -B build/consumer \ - -DCMAKE_PREFIX_PATH="${PWD}/build/install" "${launcher[@]}" - cmake --build build/consumer --parallel "${jobs}" - build/consumer/libatrinik-consumer + cmake --build "${library_release_directory}" --parallel "${jobs}" + cmake --install "${library_release_directory}" + cmake -S tests/consumer -B "${consumer_directory}" \ + -DCMAKE_PREFIX_PATH="${PWD}/${install_directory}" "${launcher[@]}" + cmake --build "${consumer_directory}" --parallel "${jobs}" + "${consumer_directory}/libatrinik-consumer" popd >/dev/null ;; server) @@ -167,15 +183,15 @@ case "${component}" in prepare_coverage ctest --preset "${coverage_preset}" --parallel 4 -LE performance export_coverage server - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-release --parallel "${jobs}" - ctest --preset linux-release --parallel 4 -LE performance - cmake --preset linux-sanitizers \ + cmake --build "${release_directory}" --parallel "${jobs}" + ctest --preset linux-release --test-dir "${release_directory}" --parallel 4 -LE performance + cmake --preset linux-sanitizers -B "${sanitizer_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-sanitizers --parallel "${jobs}" + cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers --parallel 4 -LE performance + ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" --parallel 4 -LE performance popd >/dev/null ;; client) @@ -190,15 +206,15 @@ case "${component}" in prepare_coverage ctest --preset "${coverage_preset}" -LE performance export_coverage client - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-release --parallel "${jobs}" - ctest --preset linux-release -LE performance - cmake --preset linux-sanitizers \ + cmake --build "${release_directory}" --parallel "${jobs}" + ctest --preset linux-release --test-dir "${release_directory}" -LE performance + cmake --preset linux-sanitizers -B "${sanitizer_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-sanitizers --parallel "${jobs}" + cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers -LE performance + ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" -LE performance popd >/dev/null ;; client-benchmark) @@ -213,9 +229,9 @@ case "${component}" in python3 tools/dependencies.py verify env ATRINIK_BENCHMARK_REVISION="${ATRINIK_BENCHMARK_REVISION:-unknown}" \ ATRINIK_BENCHMARK_DIRTY=false \ - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-release --target atrinik --parallel "${jobs}" + cmake --build "${release_directory}" --target atrinik --parallel "${jobs}" if [[ ! -f ${source_root}/client/src/client/player_view.c ]]; then printf '%s\n' \ '{"schema_version":1,"skipped":true,"reason":"gpu-hardware-qualification-required"}' \ @@ -404,11 +420,11 @@ case "${component}" in --lock "${baseline_root}/client/dependencies.lock.json" env ATRINIK_BENCHMARK_REVISION="${benchmark_base_sha}" \ ATRINIK_BENCHMARK_DIRTY=false \ - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${baseline_root}/protocol" \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${baseline_root}/libatrinik" - cmake --build --preset linux-release --target atrinik --parallel "${jobs}" + cmake --build "${release_directory}" --target atrinik --parallel "${jobs}" popd >/dev/null if ! git -C "${baseline_root}" diff --quiet HEAD --; then echo "benchmark comparison build modified its immutable base source" >&2 @@ -423,10 +439,10 @@ case "${component}" in if [[ ${lighting_comparison} == true ]]; then command_status=0 python3 "${source_root}/client/tools/benchmark_lighting_regression.py" \ - --baseline-client "${baseline_root}/client/build/linux-release/atrinik" \ + --baseline-client "${baseline_root}/client/${release_directory}/atrinik" \ --baseline-manifest \ "${baseline_root}/client/src/tests/fixtures/player_view/colored-smooth.xml" \ - --candidate-client "${source_root}/client/build/linux-release/atrinik" \ + --candidate-client "${source_root}/client/${release_directory}/atrinik" \ --candidate-manifest \ "${source_root}/client/src/tests/fixtures/player_view/colored-smooth.xml" \ --samples 3 \ @@ -450,7 +466,7 @@ case "${component}" in probe_status=0 if [[ ${movement_action} != skip ]]; then python3 "${source_root}/client/tools/verify_movement_probe_control.py" \ - "${source_root}/client/build/linux-release/atrinik" \ + "${source_root}/client/${release_directory}/atrinik" \ "${source_root}/client/src/tests/fixtures/player_view/movement-lighting-isolated.xml" \ || probe_status=$? fi @@ -461,11 +477,11 @@ case "${component}" in command_status=0 if [[ ${movement_action} == compare ]]; then python3 "${source_root}/client/tools/benchmark_movement_regression.py" compare \ - --baseline-client "${baseline_root}/client/build/linux-release/atrinik" \ + --baseline-client "${baseline_root}/client/${release_directory}/atrinik" \ --baseline-manifest \ "${baseline_root}/client/src/tests/fixtures/player_view/movement-colored.xml" \ --baseline-schema "${movement_baseline_schema}" \ - --candidate-client "${source_root}/client/build/linux-release/atrinik" \ + --candidate-client "${source_root}/client/${release_directory}/atrinik" \ --candidate-manifest \ "${source_root}/client/src/tests/fixtures/player_view/movement-colored.xml" \ --discrete-manifest \ @@ -487,7 +503,7 @@ case "${component}" in movement_matrix_arguments+=(--full-matrix) fi python3 "${source_root}/client/tools/benchmark_movement_regression.py" candidate-only \ - --candidate-client "${source_root}/client/build/linux-release/atrinik" \ + --candidate-client "${source_root}/client/${release_directory}/atrinik" \ --candidate-manifest \ "${source_root}/client/src/tests/fixtures/player_view/movement-colored.xml" \ --candidate-revision "${ATRINIK_BENCHMARK_REVISION:-unknown}" \ @@ -524,8 +540,8 @@ case "${component}" in python3 tools/dependencies.py sync \ --cache "${dependency_downloads}" --refresh --offline python3 tools/dependencies.py verify - cmake --preset linux-release "${launcher[@]}" "${sibling_sources[@]}" - cmake --build --preset linux-release --parallel "${jobs}" + cmake --preset linux-release -B "${release_directory}" "${launcher[@]}" "${sibling_sources[@]}" + cmake --build "${release_directory}" --parallel "${jobs}" popd >/dev/null baseline_root="${source_root}/build/ci-server-benchmark-baseline" @@ -551,22 +567,22 @@ case "${component}" in --downloads "${dependency_downloads}" \ --offline) pushd "${baseline_root}/server" >/dev/null - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${baseline_root}/protocol" \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${baseline_root}/libatrinik" \ -DFETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP="${baseline_pcpnatpmp_source}" - cmake --build --preset linux-release --parallel "${jobs}" + cmake --build "${release_directory}" --parallel "${jobs}" popd >/dev/null if ! git -C "${baseline_root}" diff --quiet HEAD --; then echo "server benchmark build modified its immutable base source" >&2 exit 2 fi - ctest --test-dir "${baseline_root}/server/build/linux-release" \ + ctest --test-dir "${baseline_root}/server/${release_directory}" \ --verbose -R '^server-content-benchmark$' 2>&1 | \ tee "${source_root}/build/ci-evidence/server-content-benchmark-base.log" - ctest --test-dir "${source_root}/server/build/linux-release" \ + ctest --test-dir "${source_root}/server/${release_directory}" \ --verbose -R '^server-content-benchmark$' 2>&1 | \ tee "${source_root}/build/ci-evidence/server-content-benchmark-head.log" # Markdown code spans are intentionally literal. @@ -585,10 +601,10 @@ case "${component}" in ;; integrated) pushd "${source_root}" >/dev/null - cmake --preset linux-release \ + cmake --preset linux-release -B "${integrated_release_directory}" \ -DBUILD_TESTING=OFF \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-release --parallel "${jobs}" + cmake --build "${integrated_release_directory}" --parallel "${jobs}" popd >/dev/null ;; esac diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 3952d991d..a3ed3e667 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -4,6 +4,7 @@ import json import os import re +import shutil import subprocess import tempfile import textwrap @@ -892,10 +893,15 @@ def test_server_validation_exercises_release_ndebug(self) -> None: sanitizers = server.index("cmake --preset linux-sanitizers") self.assertLess(coverage, release) self.assertLess(release, sanitizers) - self.assertIn("cmake --build --preset linux-release --parallel", server) - for preset in ('"${coverage_preset}"', "linux-release", "linux-sanitizers"): - self.assertIn(f"ctest --preset {preset} --parallel 4", server) - self.assertIn(f"ctest --preset {preset} --parallel 4 -LE performance", server) + self.assertIn('cmake --build "${release_directory}" --parallel', server) + for preset, directory in ( + ('"${coverage_preset}"', ""), + ("linux-release", ' --test-dir "${release_directory}"'), + ("linux-sanitizers", ' --test-dir "${sanitizer_directory}"'), + ): + self.assertIn( + f"ctest --preset {preset}{directory} --parallel 4 -LE performance", server + ) presets = json.loads((ROOT / "server/CMakePresets.json").read_text()) self.assertEqual( @@ -931,7 +937,7 @@ def test_core_validation_runs_libatrinik_sanitizers(self) -> None: ) core = runner[runner.index(" core)") : runner.index(" server)")] self.assertIn("cmake --preset linux-sanitizers", core) - self.assertIn("cmake --build --preset linux-sanitizers --parallel", core) + self.assertIn('cmake --build "${sanitizer_directory}" --parallel', core) self.assertIn("ctest --preset linux-sanitizers", core) self.assertIn("ASAN_OPTIONS=detect_leaks=0:halt_on_error=1", core) self.assertIn("UBSAN_OPTIONS=halt_on_error=1", core) @@ -968,6 +974,119 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None self.assertIn("ctest --preset linux-release", component) self.assertIn("ctest --preset linux-sanitizers", component) + def test_linux_validation_routes_every_noncoverage_operation_to_selected_tree(self) -> None: + runner = (ROOT / "tools/ci/run_linux_check.sh").read_text() + self.assertNotIn("cmake --build --preset linux-release", runner) + self.assertNotIn("cmake --build --preset linux-sanitizers", runner) + for line in runner.splitlines(): + if "cmake --preset linux-release" in line: + self.assertRegex(line, r'-B "\$\{(?:integrated_)?release_directory\}"') + elif "cmake --preset linux-sanitizers" in line: + self.assertIn('-B "${sanitizer_directory}"', line) + elif "ctest --preset linux-release" in line: + self.assertIn('--test-dir "${release_directory}"', line) + elif "ctest --preset linux-sanitizers" in line: + self.assertIn('--test-dir "${sanitizer_directory}"', line) + for root in ("source_root", "baseline_root"): + self.assertIn(f'${{{root}}}/client/${{release_directory}}/atrinik', runner) + self.assertIn(f'${{{root}}}/server/${{release_directory}}', runner) + for operation in ( + 'cmake -S "${source_root}/protocol" -B "${source_root}/protocol/${protocol_directory}"', + 'cmake --build "${source_root}/protocol/${protocol_directory}"', + 'ctest --test-dir "${source_root}/protocol/${protocol_directory}"', + 'cmake -S . -B "${library_release_directory}" -DCMAKE_BUILD_TYPE=Release', + 'cmake --build "${library_release_directory}"', + 'cmake --install "${library_release_directory}"', + '-DCMAKE_INSTALL_PREFIX="${PWD}/${install_directory}"', + 'cmake -S tests/consumer -B "${consumer_directory}"', + '-DCMAKE_PREFIX_PATH="${PWD}/${install_directory}"', + 'cmake --build "${consumer_directory}"', + '"${consumer_directory}/libatrinik-consumer"', + 'cmake --build "${integrated_release_directory}"', + ): + self.assertIn(operation, runner) + + @unittest.skipUnless(shutil.which("cmake") and shutil.which("ctest") and shutil.which("cc") and shutil.which("ninja"), "CMake, CTest, Ninja and a C compiler required") + def test_linux_validation_isolates_actual_compiler_caches_in_both_directions(self) -> None: + runner = (ROOT / "tools/ci/run_linux_check.sh").read_text() + selection = runner[runner.index("llvm_toolchain="):runner.index("\nlauncher=(")] + names = ( + "release_directory", "integrated_release_directory", "sanitizer_directory", + "protocol_directory", "library_release_directory", "install_directory", + "consumer_directory", + ) + expected = { + "0": ("build/linux-release", "build/integrated-linux-release", "build/linux-sanitizers", + "build/check", "build/release", "build/install", "build/consumer"), + "1": ("build/llvm-release", "build/integrated-llvm-release", "build/llvm-sanitizers", + "build/llvm-check", "build/llvm-release", "build/llvm-install", "build/llvm-consumer"), + } + selected = {} + for mode in ("0", "1"): + result = subprocess.run( + ["bash", "-euc", 'jq() { case "$2" in .tools.clang) echo 23.1.3;; .tools.gcc) echo 15.2.0;; esac; }\n' + + selection + '\nprintf "%s\\n" ' + " ".join(f'"${{{name}}}"' for name in names)], + env={**os.environ, "ATRINIK_LLVM_TOOLCHAIN": mode}, + capture_output=True, check=True, + ) + selected[mode] = dict(zip(names, result.stdout.decode().splitlines())) + self.assertEqual(tuple(selected[mode].values()), expected[mode]) + + with tempfile.TemporaryDirectory() as temporary: + fixture = Path(temporary) + # Both fixture drivers delegate to the available host compiler. Distinct + # paths reproduce CMake's persistent compiler choice without requiring + # a production LLVM installation or compiling the Classic application. + compilers = {} + for mode in ("0", "1"): + driver = fixture / ("gcc-fixture" if mode == "0" else "clang-fixture") + driver.symlink_to(shutil.which("cc")) + compilers[mode] = driver + for order in (("0", "1", "0"), ("1", "0", "1")): + source = fixture / ("gcc-first" if order[0] == "0" else "llvm-first") + source.mkdir() + (source / "CMakeLists.txt").write_text( + "cmake_minimum_required(VERSION 3.20)\nproject(cache_fixture C)\n" + "enable_testing()\nadd_test(NAME cache_fixture COMMAND ${CMAKE_COMMAND} -E true)\n" + ) + (source / "CMakePresets.json").write_text( + (ROOT / "client/CMakePresets.json").read_text() + ) + for mode in order: + for name in names: + if name == "install_directory": + continue + build = source / name / selected[mode][name] + preset = ( + "linux-release" if name in ("release_directory", "integrated_release_directory") + else "linux-sanitizers" if name == "sanitizer_directory" else None + ) + configure = ( + ["cmake", "--preset", preset, "-B", str(build)] if preset + else ["cmake", "-S", str(source), "-B", str(build), "-G", "Ninja"] + ) + result = subprocess.run( + configure, cwd=source, + env={**os.environ, "CC": str(compilers[mode])}, + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + cache = (build / "CMakeCache.txt").read_text() + self.assertIn(f"CMAKE_C_COMPILER:FILEPATH={compilers[mode]}\n", cache) + if preset: + build_type = "Debug" if preset == "linux-sanitizers" else "Release" + self.assertIn(f"CMAKE_BUILD_TYPE:STRING={build_type}\n", cache) + result = subprocess.run( + ["cmake", "--build", str(build)], capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run( + ["ctest", "--preset", preset, "--test-dir", str(build), "--no-tests=error"], + cwd=source, capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("cache_fixture", result.stdout) + def test_movement_regression_compares_immutable_baselines_across_contracts(self) -> None: runner = (ROOT / "tools" / "ci" / "run_linux_check.sh").read_text( encoding="utf-8" From 5444ca4015c70a40aa625085a9bcb8b9cdb86a8d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 10 Oct 2026 23:41:13 -0500 Subject: [PATCH 029/148] fix(ci): isolate LLVM validation build caches --- tools/ci/run_linux_check.sh | 97 ++++++++++++++------------ tools/tests/test_workflow_contracts.py | 57 +++++++++++++-- 2 files changed, 105 insertions(+), 49 deletions(-) diff --git a/tools/ci/run_linux_check.sh b/tools/ci/run_linux_check.sh index 9b0cbd0bd..4be1aa791 100755 --- a/tools/ci/run_linux_check.sh +++ b/tools/ci/run_linux_check.sh @@ -35,6 +35,15 @@ test "$(jq -r '.target' /usr/local/share/atrinik/classic-toolchain.json)" = clas test "$(jq -r '.platform' /usr/local/share/atrinik/classic-toolchain.json)" = linux/amd64 test "$(jq -r '.tools.clang' /usr/local/share/atrinik/classic-toolchain.json)" = 23.1.3 export CC=clang CXX=clang++ +# CMake retains its compiler in the cache even when CC/CXX change. Keep every +# compiler-specific validation tree separate from prior GCC configurations. +release_directory=build/llvm-release +integrated_release_directory=build/integrated-llvm-release +sanitizer_directory=build/llvm-sanitizers +protocol_directory=build/llvm-check +library_release_directory=build/llvm-release +install_directory=build/llvm-install +consumer_directory=build/llvm-consumer launcher=( -DCMAKE_C_COMPILER_LAUNCHER=ccache @@ -97,10 +106,10 @@ ccache --zero-stats >/dev/null case "${component}" in core) - cmake -S "${source_root}/protocol" -B "${source_root}/protocol/build/check" \ + cmake -S "${source_root}/protocol" -B "${source_root}/protocol/${protocol_directory}" \ -G Ninja -DCMAKE_BUILD_TYPE=Release "${launcher[@]}" - cmake --build "${source_root}/protocol/build/check" --parallel "${jobs}" - ctest --test-dir "${source_root}/protocol/build/check" --output-on-failure + cmake --build "${source_root}/protocol/${protocol_directory}" --parallel "${jobs}" + ctest --test-dir "${source_root}/protocol/${protocol_directory}" --output-on-failure pushd "${source_root}/libatrinik" >/dev/null cmake --preset llvm-coverage \ @@ -110,22 +119,22 @@ case "${component}" in prepare_coverage ctest --preset llvm-coverage export_coverage libatrinik - cmake --preset linux-sanitizers \ + cmake --preset linux-sanitizers -B "${sanitizer_directory}" \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ "${launcher[@]}" - cmake --build --preset linux-sanitizers --parallel "${jobs}" + cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers - cmake -S . -B build/release -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_INSTALL_PREFIX="${PWD}/build/install" \ + ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" + cmake -S . -B "${library_release_directory}" -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${PWD}/${install_directory}" \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ "${launcher[@]}" - cmake --build build/release --parallel "${jobs}" - cmake --install build/release - cmake -S tests/consumer -B build/consumer \ - -DCMAKE_PREFIX_PATH="${PWD}/build/install" "${launcher[@]}" - cmake --build build/consumer --parallel "${jobs}" - build/consumer/libatrinik-consumer + cmake --build "${library_release_directory}" --parallel "${jobs}" + cmake --install "${library_release_directory}" + cmake -S tests/consumer -B "${consumer_directory}" \ + -DCMAKE_PREFIX_PATH="${PWD}/${install_directory}" "${launcher[@]}" + cmake --build "${consumer_directory}" --parallel "${jobs}" + "${consumer_directory}/libatrinik-consumer" popd >/dev/null ;; server) @@ -141,15 +150,15 @@ case "${component}" in prepare_coverage ctest --preset llvm-coverage --parallel 4 -LE performance export_coverage server - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-release --parallel "${jobs}" - ctest --preset linux-release --parallel 4 -LE performance - cmake --preset linux-sanitizers \ + cmake --build "${release_directory}" --parallel "${jobs}" + ctest --preset linux-release --test-dir "${release_directory}" --parallel 4 -LE performance + cmake --preset linux-sanitizers -B "${sanitizer_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-sanitizers --parallel "${jobs}" + cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers --parallel 4 -LE performance + ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" --parallel 4 -LE performance popd >/dev/null ;; client) @@ -164,15 +173,15 @@ case "${component}" in prepare_coverage ctest --preset llvm-coverage -LE performance export_coverage client - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-release --parallel "${jobs}" - ctest --preset linux-release -LE performance - cmake --preset linux-sanitizers \ + cmake --build "${release_directory}" --parallel "${jobs}" + ctest --preset linux-release --test-dir "${release_directory}" -LE performance + cmake --preset linux-sanitizers -B "${sanitizer_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-sanitizers --parallel "${jobs}" + cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers -LE performance + ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" -LE performance popd >/dev/null ;; client-benchmark) @@ -187,9 +196,9 @@ case "${component}" in python3 tools/dependencies.py verify env ATRINIK_BENCHMARK_REVISION="${ATRINIK_BENCHMARK_REVISION:-unknown}" \ ATRINIK_BENCHMARK_DIRTY=false \ - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-release --target atrinik --parallel "${jobs}" + cmake --build "${release_directory}" --target atrinik --parallel "${jobs}" if [[ ! -f ${source_root}/client/src/client/player_view.c ]]; then printf '%s\n' \ '{"schema_version":1,"skipped":true,"reason":"gpu-hardware-qualification-required"}' \ @@ -378,11 +387,11 @@ case "${component}" in --lock "${baseline_root}/client/dependencies.lock.json" env ATRINIK_BENCHMARK_REVISION="${benchmark_base_sha}" \ ATRINIK_BENCHMARK_DIRTY=false \ - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${baseline_root}/protocol" \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${baseline_root}/libatrinik" - cmake --build --preset linux-release --target atrinik --parallel "${jobs}" + cmake --build "${release_directory}" --target atrinik --parallel "${jobs}" popd >/dev/null if ! git -C "${baseline_root}" diff --quiet HEAD --; then echo "benchmark comparison build modified its immutable base source" >&2 @@ -397,10 +406,10 @@ case "${component}" in if [[ ${lighting_comparison} == true ]]; then command_status=0 python3 "${source_root}/client/tools/benchmark_lighting_regression.py" \ - --baseline-client "${baseline_root}/client/build/linux-release/atrinik" \ + --baseline-client "${baseline_root}/client/${release_directory}/atrinik" \ --baseline-manifest \ "${baseline_root}/client/src/tests/fixtures/player_view/colored-smooth.xml" \ - --candidate-client "${source_root}/client/build/linux-release/atrinik" \ + --candidate-client "${source_root}/client/${release_directory}/atrinik" \ --candidate-manifest \ "${source_root}/client/src/tests/fixtures/player_view/colored-smooth.xml" \ --samples 3 \ @@ -424,7 +433,7 @@ case "${component}" in probe_status=0 if [[ ${movement_action} != skip ]]; then python3 "${source_root}/client/tools/verify_movement_probe_control.py" \ - "${source_root}/client/build/linux-release/atrinik" \ + "${source_root}/client/${release_directory}/atrinik" \ "${source_root}/client/src/tests/fixtures/player_view/movement-lighting-isolated.xml" \ || probe_status=$? fi @@ -435,11 +444,11 @@ case "${component}" in command_status=0 if [[ ${movement_action} == compare ]]; then python3 "${source_root}/client/tools/benchmark_movement_regression.py" compare \ - --baseline-client "${baseline_root}/client/build/linux-release/atrinik" \ + --baseline-client "${baseline_root}/client/${release_directory}/atrinik" \ --baseline-manifest \ "${baseline_root}/client/src/tests/fixtures/player_view/movement-colored.xml" \ --baseline-schema "${movement_baseline_schema}" \ - --candidate-client "${source_root}/client/build/linux-release/atrinik" \ + --candidate-client "${source_root}/client/${release_directory}/atrinik" \ --candidate-manifest \ "${source_root}/client/src/tests/fixtures/player_view/movement-colored.xml" \ --discrete-manifest \ @@ -461,7 +470,7 @@ case "${component}" in movement_matrix_arguments+=(--full-matrix) fi python3 "${source_root}/client/tools/benchmark_movement_regression.py" candidate-only \ - --candidate-client "${source_root}/client/build/linux-release/atrinik" \ + --candidate-client "${source_root}/client/${release_directory}/atrinik" \ --candidate-manifest \ "${source_root}/client/src/tests/fixtures/player_view/movement-colored.xml" \ --candidate-revision "${ATRINIK_BENCHMARK_REVISION:-unknown}" \ @@ -498,8 +507,8 @@ case "${component}" in python3 tools/dependencies.py sync \ --cache "${dependency_downloads}" --refresh --offline python3 tools/dependencies.py verify - cmake --preset linux-release "${launcher[@]}" "${sibling_sources[@]}" - cmake --build --preset linux-release --parallel "${jobs}" + cmake --preset linux-release -B "${release_directory}" "${launcher[@]}" "${sibling_sources[@]}" + cmake --build "${release_directory}" --parallel "${jobs}" popd >/dev/null baseline_root="${source_root}/build/ci-server-benchmark-baseline" @@ -525,22 +534,22 @@ case "${component}" in --downloads "${dependency_downloads}" \ --offline) pushd "${baseline_root}/server" >/dev/null - cmake --preset linux-release \ + cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${baseline_root}/protocol" \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${baseline_root}/libatrinik" \ -DFETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP="${baseline_pcpnatpmp_source}" - cmake --build --preset linux-release --parallel "${jobs}" + cmake --build "${release_directory}" --parallel "${jobs}" popd >/dev/null if ! git -C "${baseline_root}" diff --quiet HEAD --; then echo "server benchmark build modified its immutable base source" >&2 exit 2 fi - ctest --test-dir "${baseline_root}/server/build/linux-release" \ + ctest --test-dir "${baseline_root}/server/${release_directory}" \ --verbose -R '^server-content-benchmark$' 2>&1 | \ tee "${source_root}/build/ci-evidence/server-content-benchmark-base.log" - ctest --test-dir "${source_root}/server/build/linux-release" \ + ctest --test-dir "${source_root}/server/${release_directory}" \ --verbose -R '^server-content-benchmark$' 2>&1 | \ tee "${source_root}/build/ci-evidence/server-content-benchmark-head.log" # Markdown code spans are intentionally literal. @@ -559,10 +568,10 @@ case "${component}" in ;; integrated) pushd "${source_root}" >/dev/null - cmake --preset linux-release \ + cmake --preset linux-release -B "${integrated_release_directory}" \ -DBUILD_TESTING=OFF \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" - cmake --build --preset linux-release --parallel "${jobs}" + cmake --build "${integrated_release_directory}" --parallel "${jobs}" popd >/dev/null ;; esac diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 0c5ee1000..1e8856440 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -971,10 +971,15 @@ def test_server_validation_exercises_release_ndebug(self) -> None: sanitizers = server.index("cmake --preset linux-sanitizers") self.assertLess(coverage, release) self.assertLess(release, sanitizers) - self.assertIn("cmake --build --preset linux-release --parallel", server) - for preset in ("llvm-coverage", "linux-release", "linux-sanitizers"): - self.assertIn(f"ctest --preset {preset} --parallel 4", server) - self.assertIn(f"ctest --preset {preset} --parallel 4 -LE performance", server) + self.assertIn('cmake --build "${release_directory}" --parallel', server) + for preset, directory in ( + ("llvm-coverage", ""), + ("linux-release", ' --test-dir "${release_directory}"'), + ("linux-sanitizers", ' --test-dir "${sanitizer_directory}"'), + ): + self.assertIn( + f"ctest --preset {preset}{directory} --parallel 4 -LE performance", server + ) presets = json.loads((ROOT / "server/CMakePresets.json").read_text()) self.assertEqual( @@ -1010,7 +1015,7 @@ def test_core_validation_runs_libatrinik_sanitizers(self) -> None: ) core = runner[runner.index(" core)") : runner.index(" server)")] self.assertIn("cmake --preset linux-sanitizers", core) - self.assertIn("cmake --build --preset linux-sanitizers --parallel", core) + self.assertIn('cmake --build "${sanitizer_directory}" --parallel', core) self.assertIn("ctest --preset linux-sanitizers", core) self.assertIn("ASAN_OPTIONS=detect_leaks=0:halt_on_error=1", core) self.assertIn("UBSAN_OPTIONS=halt_on_error=1", core) @@ -1047,6 +1052,48 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None self.assertIn("ctest --preset linux-release", component) self.assertIn("ctest --preset linux-sanitizers", component) + def test_linux_validation_routes_every_noncoverage_operation_to_selected_tree(self) -> None: + runner = (ROOT / "tools/ci/run_linux_check.sh").read_text() + for name, directory in ( + ("release_directory", "build/llvm-release"), + ("integrated_release_directory", "build/integrated-llvm-release"), + ("sanitizer_directory", "build/llvm-sanitizers"), + ("protocol_directory", "build/llvm-check"), + ("library_release_directory", "build/llvm-release"), + ("install_directory", "build/llvm-install"), + ("consumer_directory", "build/llvm-consumer"), + ): + self.assertIn(f"{name}={directory}\n", runner) + self.assertNotIn("cmake --build --preset linux-release", runner) + self.assertNotIn("cmake --build --preset linux-sanitizers", runner) + for line in runner.splitlines(): + if "cmake --preset linux-release" in line: + self.assertRegex(line, r'-B "\$\{(?:integrated_)?release_directory\}"') + elif "cmake --preset linux-sanitizers" in line: + self.assertIn('-B "${sanitizer_directory}"', line) + elif "ctest --preset linux-release" in line: + self.assertIn('--test-dir "${release_directory}"', line) + elif "ctest --preset linux-sanitizers" in line: + self.assertIn('--test-dir "${sanitizer_directory}"', line) + for root in ("source_root", "baseline_root"): + self.assertIn(f'${{{root}}}/client/${{release_directory}}/atrinik', runner) + self.assertIn(f'${{{root}}}/server/${{release_directory}}', runner) + for operation in ( + 'cmake -S "${source_root}/protocol" -B "${source_root}/protocol/${protocol_directory}"', + 'cmake --build "${source_root}/protocol/${protocol_directory}"', + 'ctest --test-dir "${source_root}/protocol/${protocol_directory}"', + 'cmake -S . -B "${library_release_directory}" -DCMAKE_BUILD_TYPE=Release', + 'cmake --build "${library_release_directory}"', + 'cmake --install "${library_release_directory}"', + '-DCMAKE_INSTALL_PREFIX="${PWD}/${install_directory}"', + 'cmake -S tests/consumer -B "${consumer_directory}"', + '-DCMAKE_PREFIX_PATH="${PWD}/${install_directory}"', + 'cmake --build "${consumer_directory}"', + '"${consumer_directory}/libatrinik-consumer"', + 'cmake --build "${integrated_release_directory}"', + ): + self.assertIn(operation, runner) + def test_movement_regression_compares_immutable_baselines_across_contracts(self) -> None: runner = (ROOT / "tools" / "ci" / "run_linux_check.sh").read_text( encoding="utf-8" From 683a7dc57f14258a49870f0cce75f6267ffae4c9 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:01:51 -0500 Subject: [PATCH 030/148] fix(ci): retain historical AppImage packaging lock bytes --- .github/workflows/build-release-candidate.yml | 10 ++--- docs/CI-LINUX-IMAGE.md | 5 +++ tools/release/resolve_compiler_image.py | 20 +++++---- tools/tests/test_appimage_workflow.py | 9 ++-- tools/tests/test_compiler_image.py | 41 ++++++++++++++++++- 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 77fbaef09..38842bed4 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -397,15 +397,15 @@ jobs: RELEASE_REVISION: ${{ needs.metadata.outputs.commit }} run: | mkdir -p build/appimage-evidence build/appimage-packages build/discord-config - descriptor_path=$(mktemp "${RUNNER_TEMP}/classic-compiler-descriptor.XXXXXX") - trap 'rm -f "${descriptor_path}"' EXIT + source_lock_path=$(mktemp "${RUNNER_TEMP}/classic-source-packaging-lock.XXXXXX") + trap 'rm -f "${source_lock_path}"' EXIT python3 tools/release/resolve_compiler_image.py \ --source-root "${GITHUB_WORKSPACE}/candidate-source" --revision "${RELEASE_REVISION}" \ - --current-image "${CLASSIC_LINUX_IMAGE}" --source-packaging-lock-output "${descriptor_path}" \ + --current-image "${CLASSIC_LINUX_IMAGE}" --source-packaging-lock-output "${source_lock_path}" \ > build/appimage-evidence/compiler-image.txt compiler_arguments=() - if test -s "${descriptor_path}"; then - compiler_arguments=(--source-packaging-lock "${descriptor_path}") + if test -s "${source_lock_path}"; then + compiler_arguments=(--source-packaging-lock "${source_lock_path}") fi python3 tools/ci/appimage/prepare.py --output build/appimage-context \ --compiler-image "${CLASSIC_LINUX_IMAGE}" "${compiler_arguments[@]}" \ diff --git a/docs/CI-LINUX-IMAGE.md b/docs/CI-LINUX-IMAGE.md index 83d146c6f..ecbe84f52 100644 --- a/docs/CI-LINUX-IMAGE.md +++ b/docs/CI-LINUX-IMAGE.md @@ -233,6 +233,11 @@ The trusted AppImage preparation script accepts the extracted source lock only when every field except the immutable compiler image matches its approved lock. It preserves the validated original lock bytes, including their source hash, and retains approved SDK recipes and dependency URLs. +Historical locks without a compiler descriptor also retain their exact bytes +for the source assembler. Preparation accepts only the approved legacy +projection: the current recipe with its compiler descriptor and six LLVM +notice entries removed. The execution dependency descriptor and installed +LLVM notices remain current; other historical recipe changes fail validation. The existing immutable 1.16.0 provider image pins are retained while the new LLVM producer is qualified and published. Those historical images do not diff --git a/tools/release/resolve_compiler_image.py b/tools/release/resolve_compiler_image.py index 9a758fcc8..5c6069a04 100644 --- a/tools/release/resolve_compiler_image.py +++ b/tools/release/resolve_compiler_image.py @@ -25,7 +25,7 @@ def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: return result -def resolve_descriptor(root: Path, revision: str, current_image: str) -> dict[str, object] | None: +def source_lock_bytes(root: Path, revision: str, current_image: str) -> bytes | None: if not re.fullmatch(r"[0-9a-f]{40}", revision): raise ValueError("release revision must be an exact lowercase commit SHA") if IMAGE.fullmatch(current_image) is None: @@ -42,7 +42,14 @@ def git(*arguments: str) -> bytes: fields = entry.decode().split() if fields[:2] != ["100644", "blob"]: raise ValueError("release packaging lock must be a regular Git blob") - locked = json.loads(git("show", revision + ":" + LOCK), object_pairs_hook=unique_object) + return git("show", revision + ":" + LOCK) + + +def resolve_descriptor(root: Path, revision: str, current_image: str) -> dict[str, object] | None: + lock_bytes = source_lock_bytes(root, revision, current_image) + if lock_bytes is None: + return None + locked = json.loads(lock_bytes, object_pairs_hook=unique_object) if not isinstance(locked, dict): raise ValueError("release packaging lock must be an object") if "compiler" not in locked: @@ -71,17 +78,14 @@ def main() -> int: parser.add_argument("--source-finalizer-arguments", action="store_true", help="emit only arguments supported by the selected source finalizer") parser.add_argument("--source-packaging-lock-output", type=Path, - help="write exact tagged LLVM source lock bytes for trusted SDK validation") + help="write exact tagged source lock bytes for trusted SDK validation") options = parser.parse_args() try: image = resolve_image(options.source_root, options.revision, options.current_image, source_finalizer=options.source_finalizer_arguments) if options.source_packaging_lock_output is not None: - descriptor = resolve_descriptor(options.source_root, options.revision, options.current_image) - lock_bytes = b"" if descriptor is None else subprocess.run( - ["git", "-C", str(options.source_root), "show", options.revision + ":" + LOCK], - check=True, capture_output=True, timeout=30).stdout - options.source_packaging_lock_output.write_bytes(lock_bytes) + lock_bytes = source_lock_bytes(options.source_root, options.revision, options.current_image) + options.source_packaging_lock_output.write_bytes(lock_bytes or b"") if image is not None: if options.source_finalizer_arguments: print("--compiler-image") diff --git a/tools/tests/test_appimage_workflow.py b/tools/tests/test_appimage_workflow.py index 107a43e23..272f10c4e 100644 --- a/tools/tests/test_appimage_workflow.py +++ b/tools/tests/test_appimage_workflow.py @@ -107,7 +107,7 @@ def test_trusted_preparation_and_smoke_do_not_mount_candidate_or_secrets(self) - preparation = job.split(' - name: Prepare pinned Ubuntu', 1)[1].split(' - name:', 1)[0] self.assertIn('tools/release/resolve_compiler_image.py', preparation) self.assertIn('--source-root "${GITHUB_WORKSPACE}/candidate-source"', preparation) - self.assertIn('compiler_arguments=(--source-packaging-lock "${descriptor_path}")', preparation) + self.assertIn('compiler_arguments=(--source-packaging-lock "${source_lock_path}")', preparation) self.assertIn('prepare.py --output build/appimage-context', preparation) self.assertIn('--file build/appimage-context/Dockerfile build/appimage-context', preparation) self.assertIn('cp build/appimage-context/packaging.lock.json build/appimage-evidence/', preparation) @@ -203,8 +203,11 @@ def test_preparation_captures_actual_builder_inventory_and_lock(self) -> None: parser = argparse.ArgumentParser() parser.add_argument('--output', type=pathlib.Path, required=True) parser.add_argument('--compiler-image', required=True) -parser.add_argument('--source-packaging-lock') -out = parser.parse_args().output +parser.add_argument('--source-packaging-lock', type=pathlib.Path) +args = parser.parse_args() +assert args.source_packaging_lock is not None +assert args.source_packaging_lock.read_bytes() == b'{}' +out = args.output out.mkdir(parents=True) (out / 'packaging.lock.json').write_text('{"fixture": "locked inputs"}\\n') (out / 'Dockerfile').write_text('fixture trusted Dockerfile\\n') diff --git a/tools/tests/test_compiler_image.py b/tools/tests/test_compiler_image.py index 8725310d6..bc637e718 100644 --- a/tools/tests/test_compiler_image.py +++ b/tools/tests/test_compiler_image.py @@ -5,6 +5,7 @@ import shutil from pathlib import Path import subprocess +import runpy import tempfile import textwrap import unittest @@ -64,7 +65,45 @@ def test_preparation_descriptor_is_exact_tagged_identity_data(self) -> None: subprocess.run(["python3", str(resolver), "--source-root", str(self.root), "--revision", historical, "--current-image", CURRENT_IMAGE, "--source-packaging-lock-output", str(output)], check=True, capture_output=True) - self.assertEqual(output.read_text(), "") + self.assertEqual(output.read_bytes(), b"{}") + self.git("rm", LOCK) + self.git("-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "no lock") + subprocess.run(["python3", str(resolver), "--source-root", str(self.root), + "--revision", self.git("rev-parse", "HEAD"), "--current-image", CURRENT_IMAGE, + "--source-packaging-lock-output", str(output)], check=True, capture_output=True) + self.assertEqual(output.read_bytes(), b"") + + def test_historical_assembler_accepts_exported_original_lock_and_rejects_new_lock(self) -> None: + root = Path(__file__).resolve().parents[2] + baseline = "fd4943252ac872fce2995c286b10a0c6d249aba3" + source = self.root / "historical/tools/ci/appimage" + source.mkdir(parents=True) + for name in ("assemble.py", "packaging.lock.json"): + content = subprocess.check_output( + ["git", "-C", str(root), "show", baseline + ":tools/ci/appimage/" + name]) + (source / name).write_bytes(content) + resolver = root / "tools/release/resolve_compiler_image.py" + prefix = self.root / "prefix" + prefix.mkdir() + exported = prefix / "packaging.lock.json" + subprocess.run(["python3", str(resolver), "--source-root", str(root), + "--revision", baseline, "--current-image", CURRENT_IMAGE, + "--source-packaging-lock-output", str(exported)], check=True, capture_output=True) + self.assertEqual(exported.read_bytes(), (source / "packaging.lock.json").read_bytes()) + historical = runpy.run_path(str(source / "assemble.py")) + appdir = self.root / "appdir" + (appdir / "usr/share/doc").mkdir(parents=True) + # The actual historical assembler passes its lock gate and reaches the + # first omitted payload fixture. No application build is needed. + with self.assertRaises(FileNotFoundError) as reached_payload: + historical["assemble"](appdir, prefix, "5.0.0", baseline) + self.assertIn("AppRun", str(reached_payload.exception)) + altered = json.loads(exported.read_bytes()) + altered["compiler"] = {"image": CURRENT_IMAGE} + exported.write_text(json.dumps(altered)) + with self.assertRaisesRegex(ValueError, "packaging lock differs from source lock"): + historical["assemble"](self.root / "rejected-appdir", prefix, "5.0.0", baseline) + self.assertFalse((self.root / "rejected-appdir").exists()) def test_historical_gcc_descriptor_absence_uses_approved_current_pin(self) -> None: revision = self.commit({"base_image": "historical-sdk"}) From 748b167c5e850e1c94097910a4155f1b87baa158 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:03:53 -0500 Subject: [PATCH 031/148] fix(release): preserve pre-LLVM AppImage recovery contracts --- tools/ci/appimage/prepare.py | 26 +++++- tools/ci/appimage/test_pre_llvm_recovery.py | 90 +++++++++++++++++++++ tools/ci/appimage/test_prepare.py | 50 ++++++++++++ tools/tests/test_appimage.py | 11 +++ 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 tools/ci/appimage/test_pre_llvm_recovery.py diff --git a/tools/ci/appimage/prepare.py b/tools/ci/appimage/prepare.py index 6a579f7df..a6ec334b8 100644 --- a/tools/ci/appimage/prepare.py +++ b/tools/ci/appimage/prepare.py @@ -12,6 +12,9 @@ import tempfile import urllib.request +LLVM_NOTICE_PATHS = tuple('usr/share/doc/atrinik/licenses/llvm/' + name + '-LICENSE.TXT' + for name in ('llvm', 'clang', 'clang-tools-extra', 'lld', 'compiler-rt', 'libunwind')) + def digest(path): with path.open('rb') as stream: @@ -62,7 +65,23 @@ def pairs(items): result[key] = value return result source_lock = json.loads(data, object_pairs_hook=pairs) - if not isinstance(source_lock, dict) or not isinstance(source_lock.get('compiler'), dict): + if not isinstance(source_lock, dict): + raise ValueError('source packaging lock requires a compiler descriptor') + if 'compiler' not in source_lock: + # Pre-LLVM schema-3 releases retain their publication lock verbatim. + # The execution recipe remains the approved current LLVM recipe; + # only the compiler record and its six newly required notice entries + # may be absent from the historical source contract. + if trusted_lock['compiler'].get('image') is not None and image != trusted_lock['compiler']['image']: + raise ValueError('--compiler-image differs from the trusted packaging lock') + if any(trusted_lock['notices'].count(path) != 1 for path in LLVM_NOTICE_PATHS): + raise ValueError('trusted SDK lacks the complete LLVM notice contract') + legacy = {key: value for key, value in trusted_lock.items() if key != 'compiler'} + legacy['notices'] = [path for path in trusted_lock['notices'] if path not in LLVM_NOTICE_PATHS] + if json.dumps(source_lock, sort_keys=True) != json.dumps(legacy, sort_keys=True): + raise ValueError('source packaging lock differs from the supported pre-LLVM recipe') + return source_lock, data + if not isinstance(source_lock['compiler'], dict): raise ValueError('source packaging lock requires a compiler descriptor') if source_lock['compiler'].get('image') != image: raise ValueError('source packaging lock compiler image differs from --compiler-image') @@ -83,11 +102,12 @@ def main(): args = parser.parse_args() root = Path(__file__).resolve().parent original_lock = (root / 'packaging.lock.json').read_text() - lock = json.loads(original_lock) + trusted_lock = json.loads(original_lock) try: - lock, selected_lock_bytes = select_packaging_lock(lock, args.compiler_image, args.source_packaging_lock) + publication_lock, selected_lock_bytes = select_packaging_lock(trusted_lock, args.compiler_image, args.source_packaging_lock) except (OSError, ValueError, UnicodeError) as error: parser.error(str(error)) + lock = publication_lock if 'compiler' in publication_lock else trusted_lock if args.output.resolve() == root: parser.error('--output must be a separate generated context directory') args.output.mkdir(parents=True, exist_ok=True) diff --git a/tools/ci/appimage/test_pre_llvm_recovery.py b/tools/ci/appimage/test_pre_llvm_recovery.py new file mode 100644 index 000000000..30a564a22 --- /dev/null +++ b/tools/ci/appimage/test_pre_llvm_recovery.py @@ -0,0 +1,90 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Exercise the pinned pre-LLVM assembler's lock and complete-notice boundary.""" +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from test_prepare import prepare + +ROOT = Path(__file__).resolve().parents[3] +HISTORICAL_COMMIT = 'fd4943252ac872fce2995c286b10a0c6d249aba3' + + +def historical_blob(name): + return subprocess.check_output(['git', '-C', str(ROOT), 'show', f'{HISTORICAL_COMMIT}:{name}']) + + +class StopAfterCompleteNotices(Exception): + pass + + +class HistoricalAssemblerTests(unittest.TestCase): + def test_prepared_historical_lock_passes_actual_assembler_and_keeps_llvm_notices(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / 'source' + for name in ('tools/ci/appimage/assemble.py', 'tools/ci/appimage/packaging.lock.json', + 'tools/ci/appimage/AppRun', 'tools/ci/appimage/openssl.cnf', 'client/ca-bundle.crt'): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(historical_blob(name)) + historical_lock = source / 'tools/ci/appimage/packaging.lock.json' + trusted = json.loads(Path(__file__).with_name('packaging.lock.json').read_text()) + image = trusted['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 + context = root / 'context' + def materialize(entry, destination, cache): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b'fixture') + args = ['prepare.py', '--output', str(context), '--compiler-image', image, + '--source-packaging-lock', str(historical_lock)] + with patch.object(sys, 'argv', args), patch.object(prepare, 'download', side_effect=materialize): + prepare.main() + self.assertEqual((context / 'packaging.lock.json').read_bytes(), historical_lock.read_bytes()) + execution = json.loads((context / 'dependency-inputs.lock.json').read_text()) + self.assertEqual(execution['compiler'], trusted['compiler']) + + prefix = root / 'prefix' + prefix.mkdir() + shutil.copyfile(context / 'packaging.lock.json', prefix / 'packaging.lock.json') + (prefix / 'bin').mkdir() + shutil.copyfile('/usr/bin/true', prefix / 'bin/openssl') + (prefix / 'lib/ossl-modules').mkdir(parents=True) + shutil.copyfile('/usr/bin/true', prefix / 'lib/ossl-modules/legacy.so') + notices = prefix / 'share/licenses/llvm' + notices.mkdir(parents=True) + for name in trusted['compiler']['notices']: + (notices / name).write_bytes((name + '\nComplete notice fixture\n').encode() * 100) + appdir = root / 'AppDir' + (appdir / 'usr/share/doc/atrinik').mkdir(parents=True) + (appdir / 'usr/bin').mkdir(parents=True) + shutil.copyfile('/usr/bin/true', appdir / 'usr/bin/atrinik') + spec = importlib.util.spec_from_file_location('pre_llvm_assembler', source / 'tools/ci/appimage/assemble.py') + assembler = importlib.util.module_from_spec(spec) + spec.loader.exec_module(assembler) + copytree = shutil.copytree + def copy_until_notices(source_path, destination, *args, **kwargs): + if Path(source_path) == Path('/usr/share/common-licenses'): + raise StopAfterCompleteNotices() + return copytree(source_path, destination, *args, **kwargs) + with patch.object(assembler.shutil, 'copytree', side_effect=copy_until_notices): + with self.assertRaises(StopAfterCompleteNotices): + assembler.assemble(appdir, prefix, '5.39.0', 'a' * 40) + # Stop before the platform library closure, after the real historical + # assembler passes its exact SHA gate and copies every LLVM notice. + for name in trusted['compiler']['notices']: + self.assertEqual((appdir / 'usr/share/doc/atrinik/licenses/llvm' / name).read_bytes(), + (notices / name).read_bytes()) + (prefix / 'packaging.lock.json').write_bytes(Path(__file__).with_name('packaging.lock.json').read_bytes()) + with self.assertRaisesRegex(ValueError, 'packaging lock differs from source lock'): + assembler.assemble(appdir, prefix, '5.39.0', 'a' * 40) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/ci/appimage/test_prepare.py b/tools/ci/appimage/test_prepare.py index 881352227..bb84341f1 100644 --- a/tools/ci/appimage/test_prepare.py +++ b/tools/ci/appimage/test_prepare.py @@ -70,8 +70,58 @@ def test_historical_default_argv_and_present_missing_image_fail_closed(self): prepare.select_packaging_lock(trusted, image, path) del historical['compiler'] path.write_text(json.dumps(historical)) + with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): + prepare.select_packaging_lock(trusted, image, path) + + def test_pre_llvm_projection_preserves_source_bytes_and_current_approved_image(self): + trusted = self.trusted_lock() + image = trusted['compiler']['image'] + legacy = {key: value for key, value in trusted.items() if key != 'compiler'} + legacy['notices'] = [path for path in trusted['notices'] if path not in prepare.LLVM_NOTICE_PATHS] + raw = (json.dumps(legacy, indent=7) + '\n\n').encode() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'legacy-lock.json' + path.write_bytes(raw) + self.assertEqual(prepare.select_packaging_lock(trusted, image, path), (legacy, raw)) + with self.assertRaisesRegex(ValueError, 'trusted packaging lock'): + prepare.select_packaging_lock(trusted, image.replace('a' * 64, 'b' * 64), path) + changed = copy.deepcopy(legacy) + changed['sources'][0]['url'] = 'https://example.invalid/private-source' + path.write_text(json.dumps(changed)) + with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): + prepare.select_packaging_lock(trusted, image, path) + changed = copy.deepcopy(legacy) + changed['compiler'] = None + path.write_text(json.dumps(changed)) with self.assertRaisesRegex(ValueError, 'requires a compiler descriptor'): prepare.select_packaging_lock(trusted, image, path) + changed = copy.deepcopy(legacy) + changed['notices'].pop() + path.write_text(json.dumps(changed)) + with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): + prepare.select_packaging_lock(trusted, image, path) + + def test_pre_llvm_context_keeps_execution_compiler_and_all_notice_installation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + trusted = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) + image = trusted['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 + legacy = {key: value for key, value in trusted.items() if key != 'compiler'} + legacy['notices'] = [path for path in trusted['notices'] if path not in prepare.LLVM_NOTICE_PATHS] + raw = (json.dumps(legacy, sort_keys=True, indent=7) + '\n\n').encode() + path = root / 'legacy-lock.json' + path.write_bytes(raw) + output = root / 'context' + def materialize(entry, destination, cache): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b'fixture') + args = ['prepare.py', '--output', str(output), '--compiler-image', image, + '--source-packaging-lock', str(path)] + with patch.object(sys, 'argv', args), patch.object(prepare, 'download', side_effect=materialize): + prepare.main() + self.assertEqual((output / 'packaging.lock.json').read_bytes(), raw) + inputs = json.loads((output / 'dependency-inputs.lock.json').read_text()) + self.assertEqual(inputs['compiler'], trusted['compiler']) def test_source_lock_paths_shapes_and_duplicate_keys_fail_before_materialization(self): trusted = self.trusted_lock() diff --git a/tools/tests/test_appimage.py b/tools/tests/test_appimage.py index 968a6acf6..0c0f148e0 100644 --- a/tools/tests/test_appimage.py +++ b/tools/tests/test_appimage.py @@ -635,6 +635,17 @@ def test_real_llvm_readelf_output_formats(self): self.assertEqual(assemble.dynamic(Path("fixture")), (["libc.so.6"], None)) self.assertEqual(run.call_args.args[0], "llvm-readelf") + def test_pre_llvm_inventory_preserves_all_llvm_notices_without_new_metadata(self): + self.assertNotIn("compiler", self.lock) + native_inputs = json.loads(json.dumps(self.metadata["native_inputs"])) + for name in ("llvm", "clang", "clang-tools-extra", "lld", "compiler-rt", "libunwind"): + self.write(f"usr/share/doc/atrinik/licenses/llvm/{name}-LICENSE.TXT", f"{name} complete fixture notice\n") + self.validate() + self.assertEqual(self.metadata["native_inputs"], native_inputs) + self.metadata["native_inputs"]["compiler"] = {"version": "23.1.3"} + with self.assertRaisesRegex(ValueError, "native inputs differ from trusted lock"): + self.validate() + def test_extended_attributes_and_trailing_payload_rejected(self): for mutation in ("xattrs", "trailing"): image = self.image() From 7f1bfd73c96b7f53e848ebda4bf2f625bac482df Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:06:48 -0500 Subject: [PATCH 032/148] fix(ci): recognize scoped source archives in LLVM coverage --- tools/ci/llvm_coverage.py | 37 ++++++-- tools/tests/test_llvm_coverage.py | 141 +++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 9 deletions(-) diff --git a/tools/ci/llvm_coverage.py b/tools/ci/llvm_coverage.py index 24c0badec..f9f4ad742 100644 --- a/tools/ci/llvm_coverage.py +++ b/tools/ci/llvm_coverage.py @@ -68,16 +68,37 @@ def coverage_objects(build_dir): return objects +def source_prefixes(root, component): + modules = ("client", "server", "libatrinik") if component == "all" else (component,) + projects = {"client": "atrinik-client", "server": "atrinik-server", "libatrinik": "libatrinik"} + # Generated release identity makes a flattened component root explicit. Check + # it before directory names, so a scoped archive cannot impersonate another + # component merely by containing a same-named subdirectory. + if (root / "VERSION").exists() or (root / "SOURCE_REVISION").exists(): + try: + version = (root / "VERSION").read_text().strip() + revision = (root / "SOURCE_REVISION").read_text().strip() + cmake = (root / "CMakeLists.txt").read_text() + except OSError as error: + raise ValueError("source root does not identify the requested Classic component") from error + names = [quoted or plain for quoted, plain in re.findall( + r'^\s*(?i:project)\s*\(\s*(?:"([^"]+)"|([\w-]+))(?=\s|\))', cmake, re.MULTILINE)] + if (not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) or + not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", revision) or len(names) != 1): + raise ValueError("source root does not identify the requested Classic component") + if names[0] != "atrinik-classic": + if component == "all" or names[0] != projects[component]: + raise ValueError("source root does not identify the requested Classic component") + return [(component, root)] + # A missing monorepo component must never widen the filter to the root. + if component != "all" and not (root / component).is_dir(): + raise ValueError("source root does not identify the requested Classic component") + return [(module, root / module) for module in modules] + + def filter_lcov(report, source_root, component): root = source_root.resolve() - modules = ("client", "server", "libatrinik") if component == "all" else (component,) - prefixes = [] - for module in modules: - prefix = root / module - # Scoped source archives retain their module layout at archive root. - if component != "all" and not prefix.is_dir(): - prefix = root - prefixes.append((module, prefix)) + prefixes = source_prefixes(root, component) records = [] for record in report.split("end_of_record"): match = re.search(r"^SF:(.+)$", record, re.MULTILINE) diff --git a/tools/tests/test_llvm_coverage.py b/tools/tests/test_llvm_coverage.py index ca1feb0fe..17aabd3fc 100644 --- a/tools/tests/test_llvm_coverage.py +++ b/tools/tests/test_llvm_coverage.py @@ -4,12 +4,17 @@ import contextlib import importlib.util import io +import os from pathlib import Path import subprocess import tempfile +import tarfile +import sys import unittest from unittest.mock import patch +from tools.release import package_sources + spec = importlib.util.spec_from_file_location( "llvm_coverage", Path(__file__).resolve().parents[1] / "ci" / "llvm_coverage.py") coverage = importlib.util.module_from_spec(spec) @@ -27,6 +32,8 @@ def setUp(self): self.root = Path(self.temporary.name) self.build = self.root / "server" / "build" / "linux-coverage" self.build.mkdir(parents=True) + (self.root / "client").mkdir() + (self.root / "libatrinik").mkdir() def fixture(self): coverage.prepare(self.build) @@ -76,7 +83,6 @@ def test_filters_component_sources_and_retains_branch_function_counts(self): self.assertEqual(actual, record(self.root / paths[0]) + record(self.root / paths[1])) def test_library_includes_standalone_pathfinding_without_tests(self): - (self.root / "libatrinik").mkdir() report = "".join(record(self.root / path) for path in ["libatrinik/socket.c", "libatrinik/pathfinding/pathfinding.c", "libatrinik/pathfinding/tests/test.c", "libatrinik/dependencies/source.c"]) @@ -84,9 +90,142 @@ def test_library_includes_standalone_pathfinding_without_tests(self): record(self.root / "libatrinik/socket.c") + record(self.root / "libatrinik/pathfinding/pathfinding.c")) + def test_scoped_filter_requires_matching_archive_identity(self): + scoped = self.root / "scoped" + scoped.mkdir() + (scoped / "server").mkdir() # A directory name cannot override archive owner identity. + cmake = scoped / "CMakeLists.txt" + version = scoped / "VERSION" + revision = scoped / "SOURCE_REVISION" + report = record(scoped / "src/server/main.c") + variants = ( + (None, "5.1.0", "a" * 40), + ("project(atrinik-classic LANGUAGES C)", "5.1.0", "a" * 40), + ("project(atrinik-client LANGUAGES C)", "5.1.0", "a" * 40), + ("project(atrinik-server LANGUAGES C)", "not-a-version", "a" * 40), + ("project(atrinik-server LANGUAGES C)", "5.1.0", "not-a-revision"), + ("project(atrinik-server LANGUAGES C)\nproject(atrinik-client)", "5.1.0", "a" * 40), + ) + for project, package_version, source_revision in variants: + with self.subTest(project=project, version=package_version, revision=source_revision): + if project is None: + cmake.unlink(missing_ok=True) + else: + cmake.write_text(project) + version.write_text(package_version) + revision.write_text(source_revision) + # An integrated archive identity keeps the narrower server/ + # prefix and rejects flattened src/ records at the filter stage. + error = "no intended production" if project and "atrinik-classic" in project else "does not identify" + with self.assertRaisesRegex(ValueError, error): + coverage.filter_lcov(report, scoped, "server") + # A renamed generated archive remains valid; recognition does not depend + # on its extraction directory's spelling. + cmake.write_text('project("atrinik-server" LANGUAGES C)') + version.write_text("5.1.0") + revision.write_text("a" * 40) + self.assertEqual(coverage.filter_lcov(report, scoped, "server"), report) + + def test_missing_monorepo_component_does_not_widen_filter_to_root(self): + (self.root / "libatrinik").rmdir() + (self.root / "CMakeLists.txt").write_text("project(atrinik-classic LANGUAGES C)") + (self.root / "VERSION").write_text("5.1.0") + (self.root / "SOURCE_REVISION").write_text("a" * 40) + with self.assertRaisesRegex(ValueError, "does not identify"): + coverage.filter_lcov(record(self.root / "server/src/server/main.c"), + self.root, "libatrinik") + + def test_all_keeps_monorepo_owner_prefixes(self): + production = ["client/src/client/main.c", "server/src/server/main.c", + "libatrinik/socket.c", "libatrinik/pathfinding/pathfinding.c"] + excluded = ["src/server/main.c", "client/src/tests/unit.c", + "server/build/generated.c", "libatrinik/dependencies/upstream.c"] + expected = "".join(record(self.root / name) for name in production) + self.assertEqual(coverage.filter_lcov( + expected + "".join(record(self.root / name) for name in excluded), self.root, "all"), + expected) + + def test_generated_scoped_archives_export_production_lcov_through_packaged_cli(self): + owner = Path(__file__).resolve().parents[2] + source_tar = self.root / "source.tar" + authored = ["tools/ci/llvm_coverage.py", "CMakeLists.txt", + "client/CMakeLists.txt", "server/CMakeLists.txt", "libatrinik/CMakeLists.txt", + "client/src/client/main.c", "server/src/server/main.c", + "libatrinik/socket.c", "libatrinik/pathfinding/pathfinding.c"] + with tarfile.open(source_tar, "w") as archive: + for name in authored: + archive.add(owner / name, arcname=name) + tool = self.root / "llvm-tool-fixture" + tool.write_text(f'''#!{sys.executable} +import os +from pathlib import Path +import sys +if sys.argv[1] == "--version": + print("LLVM version 23.1.3") +elif sys.argv[1] == "export": + sys.stdout.write(Path(os.environ["COVERAGE_FIXTURE_REPORT"]).read_text()) +''') + tool.chmod(0o755) + for scope in ("root", "client", "server", "libatrinik"): + with self.subTest(scope=scope): + archive = self.root / (scope + ".tar.gz") + package_sources.build_archive(source_tar, archive, scope, "5.1.0", 42, "a" * 40) + destination = self.root / (scope + "-archive") + with tarfile.open(archive) as source: + source.extractall(destination, filter="data") + package = next(destination.iterdir()) + build = package / "build/llvm-coverage" + build.mkdir(parents=True) + coverage.prepare(build) + (build / "llvm-profiles/module-123.profraw").write_bytes(b"profile fixture") + (build / "CMakeCache.txt").write_text("CMAKE_C_COMPILER:FILEPATH=" + str(tool) + "\n") + (build / "native-test").write_bytes(b"\x7fELFnative fixture__llvm_covmap") + production = (["client/src/client/main.c", "server/src/server/main.c", + "libatrinik/socket.c", "libatrinik/pathfinding/pathfinding.c"] + if scope == "root" else + ["socket.c", "pathfinding/pathfinding.c"] if scope == "libatrinik" + else [f"src/{scope}/main.c"]) + positive = "".join(record(package / name) for name in production) + # Relative source paths are also valid in LLVM LCOV output. + positive += record(production[0]) + negative = "".join(record(package / name) for name in ( + "src/tests/unit.c", "tests/unit.c", "build/generated.c", + "dependencies/libatrinik/socket.c", "_deps/upstream.c", + "third_party/upstream.c", "third-party/upstream.c", "header.h")) + negative += record(self.root / "client/src/client/main.c") + negative += record("../outside.c") + report = self.root / (scope + "-tool-report.lcov") + report.write_text(positive + negative) + output = package / "coverage.lcov" + command = [sys.executable, str(package / "tools/ci/llvm_coverage.py"), "export", + "--build-dir", str(build), "--source-root", str(package), + "--component", "all" if scope == "root" else scope, "--output", str(output)] + environment = {**os.environ, "LLVM_COV": str(tool), "LLVM_PROFDATA": str(tool), + "COVERAGE_FIXTURE_REPORT": str(report)} + result = subprocess.run(command, env=environment, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(output.read_text(), positive) + # Excluded-only output and malformed archive identity must fail + # collection and remove the previous report instead of uploading it. + report.write_text(negative) + result = subprocess.run(command, env=environment, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("no intended production", result.stderr) + self.assertFalse(output.exists()) + report.write_text(positive) + output.write_text("stale") + (package / "SOURCE_REVISION").write_text("malformed") + result = subprocess.run(command, env=environment, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not identify", result.stderr) + self.assertFalse(output.exists()) + def test_scoped_source_archive_uses_module_root_layout(self): scoped = self.root / "atrinik-classic-libatrinik-5.1.0" scoped.mkdir() + (scoped / "VERSION").write_text("5.1.0") + (scoped / "SOURCE_REVISION").write_text("a" * 40) + (scoped / "CMakeLists.txt").write_text("project(libatrinik LANGUAGES C)") report = record(scoped / "socket.c") + record(scoped / "pathfinding/pathfinding.c") self.assertEqual(coverage.filter_lcov(report, scoped, "libatrinik"), report) From ac67956dffa1f463492b8996eebe45405327c53a Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:10:27 -0500 Subject: [PATCH 033/148] fix(ci): select final LLVM test trees across CTest versions --- tools/ci/run_linux_check.sh | 13 +++-- tools/tests/test_workflow_contracts.py | 76 +++++++++++++++++++++----- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/tools/ci/run_linux_check.sh b/tools/ci/run_linux_check.sh index 4be1aa791..50327e831 100755 --- a/tools/ci/run_linux_check.sh +++ b/tools/ci/run_linux_check.sh @@ -51,6 +51,9 @@ launcher=( "-DCMAKE_TOOLCHAIN_FILE=${source_root}/cmake/toolchains/llvm.cmake" ) +# Noncoverage tests select their build tree directly: before CTest 3.30, a +# --preset ignores --test-dir. Keep their output/parallel settings explicit. + # CTest supplies binary/process-specific profiles for parallel tests and plugins. prepare_coverage() { python3 "${source_root}/tools/ci/llvm_coverage.py" prepare --build-dir build/llvm-coverage @@ -124,7 +127,7 @@ case "${component}" in "${launcher[@]}" cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" + ctest --test-dir "${sanitizer_directory}" --output-on-failure cmake -S . -B "${library_release_directory}" -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX="${PWD}/${install_directory}" \ -DATRINIK_PROTOCOL_SOURCE_DIR="${source_root}/protocol" \ @@ -153,12 +156,12 @@ case "${component}" in cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" cmake --build "${release_directory}" --parallel "${jobs}" - ctest --preset linux-release --test-dir "${release_directory}" --parallel 4 -LE performance + ctest --test-dir "${release_directory}" --output-on-failure --parallel 4 -LE performance cmake --preset linux-sanitizers -B "${sanitizer_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" --parallel 4 -LE performance + ctest --test-dir "${sanitizer_directory}" --output-on-failure --parallel 4 -LE performance popd >/dev/null ;; client) @@ -176,12 +179,12 @@ case "${component}" in cmake --preset linux-release -B "${release_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" cmake --build "${release_directory}" --parallel "${jobs}" - ctest --preset linux-release --test-dir "${release_directory}" -LE performance + ctest --test-dir "${release_directory}" --output-on-failure -LE performance cmake --preset linux-sanitizers -B "${sanitizer_directory}" \ "${launcher[@]}" "${sibling_sources[@]}" "${shader_arguments[@]}" cmake --build "${sanitizer_directory}" --parallel "${jobs}" env ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=halt_on_error=1 \ - ctest --preset linux-sanitizers --test-dir "${sanitizer_directory}" -LE performance + ctest --test-dir "${sanitizer_directory}" --output-on-failure -LE performance popd >/dev/null ;; client-benchmark) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 1df414054..921cb567f 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -973,13 +973,11 @@ def test_server_validation_exercises_release_ndebug(self) -> None: self.assertLess(coverage, release) self.assertLess(release, sanitizers) self.assertIn('cmake --build "${release_directory}" --parallel', server) - for preset, directory in ( - ("llvm-coverage", ""), - ("linux-release", ' --test-dir "${release_directory}"'), - ("linux-sanitizers", ' --test-dir "${sanitizer_directory}"'), - ): + self.assertIn("ctest --preset llvm-coverage --parallel 4 -LE performance", server) + for directory in ("release_directory", "sanitizer_directory"): self.assertIn( - f"ctest --preset {preset}{directory} --parallel 4 -LE performance", server + f'ctest --test-dir "${{{directory}}}" --output-on-failure --parallel 4 -LE performance', + server, ) presets = json.loads((ROOT / "server/CMakePresets.json").read_text()) @@ -1017,7 +1015,7 @@ def test_core_validation_runs_libatrinik_sanitizers(self) -> None: core = runner[runner.index(" core)") : runner.index(" server)")] self.assertIn("cmake --preset linux-sanitizers", core) self.assertIn('cmake --build "${sanitizer_directory}" --parallel', core) - self.assertIn("ctest --preset linux-sanitizers", core) + self.assertIn('ctest --test-dir "${sanitizer_directory}" --output-on-failure', core) self.assertIn("ASAN_OPTIONS=detect_leaks=0:halt_on_error=1", core) self.assertIn("UBSAN_OPTIONS=halt_on_error=1", core) @@ -1050,8 +1048,8 @@ def test_component_validation_compares_conventional_and_pch_builds(self) -> None else: self.assertNotIn("-DENABLE_PRECOMPILED_HEADERS=OFF", component) self.assertIn("ctest --preset llvm-coverage", component) - self.assertIn("ctest --preset linux-release", component) - self.assertIn("ctest --preset linux-sanitizers", component) + self.assertIn('ctest --test-dir "${release_directory}" --output-on-failure', component) + self.assertIn('ctest --test-dir "${sanitizer_directory}" --output-on-failure', component) def test_linux_validation_routes_every_noncoverage_operation_to_selected_tree(self) -> None: runner = (ROOT / "tools/ci/run_linux_check.sh").read_text() @@ -1067,15 +1065,13 @@ def test_linux_validation_routes_every_noncoverage_operation_to_selected_tree(se self.assertIn(f"{name}={directory}\n", runner) self.assertNotIn("cmake --build --preset linux-release", runner) self.assertNotIn("cmake --build --preset linux-sanitizers", runner) + self.assertNotIn("ctest --preset linux-release", runner) + self.assertNotIn("ctest --preset linux-sanitizers", runner) for line in runner.splitlines(): if "cmake --preset linux-release" in line: self.assertRegex(line, r'-B "\$\{(?:integrated_)?release_directory\}"') elif "cmake --preset linux-sanitizers" in line: self.assertIn('-B "${sanitizer_directory}"', line) - elif "ctest --preset linux-release" in line: - self.assertIn('--test-dir "${release_directory}"', line) - elif "ctest --preset linux-sanitizers" in line: - self.assertIn('--test-dir "${sanitizer_directory}"', line) for root in ("source_root", "baseline_root"): self.assertIn(f'${{{root}}}/client/${{release_directory}}/atrinik', runner) self.assertIn(f'${{{root}}}/server/${{release_directory}}', runner) @@ -1095,6 +1091,58 @@ def test_linux_validation_routes_every_noncoverage_operation_to_selected_tree(se ): self.assertIn(operation, runner) + @unittest.skipUnless(shutil.which("cmake") and shutil.which("ctest"), "CMake and CTest required") + def test_noncoverage_ctest_executes_selected_tree_with_stale_preset_tree_present(self) -> None: + runner = (ROOT / "tools/ci/run_linux_check.sh").read_text() + ctest = os.environ.get("ATRINIK_CTEST_COMPAT_EXECUTABLE", shutil.which("ctest")) + with tempfile.TemporaryDirectory() as temporary: + fixture = Path(temporary) + binaries = fixture / "bin" + binaries.mkdir() + (binaries / "ctest").symlink_to(ctest) + for component, module in (("core", "libatrinik"), ("server", "server"), ("client", "client")): + block = runner[runner.index(f" {component})"):] + block = block[:block.index("\n ;;")] + commands = [line.strip() for line in block.splitlines() + if 'ctest --test-dir "${release_directory}"' in line + or 'ctest --test-dir "${sanitizer_directory}"' in line] + self.assertEqual(len(commands), 1 if component == "core" else 2) + source = fixture / component + source.mkdir() + (source / "CMakePresets.json").write_text((ROOT / module / "CMakePresets.json").read_text()) + (source / "CMakeLists.txt").write_text( + "cmake_minimum_required(VERSION 3.21)\nproject(ctest_directory NONE)\n" + "enable_testing()\n" + "if(WRONG_TREE)\n" + " add_test(NAME wrong_preset_tree COMMAND ${CMAKE_COMMAND} -E false)\n" + "else()\n" + " add_test(NAME selected_tree COMMAND ${CMAKE_COMMAND} -E echo selected-tree-executed)\n" + " if(PERFORMANCE_FIXTURE)\n" + " add_test(NAME excluded_performance COMMAND ${CMAKE_COMMAND} -E false)\n" + " set_tests_properties(excluded_performance PROPERTIES LABELS performance)\n" + " endif()\nendif()\n" + ) + for command in commands: + variant = "release" if "release_directory" in command else "sanitizers" + for cohort in ("linux", "llvm"): + directory = source / "build" / f"{cohort}-{variant}" + configure = subprocess.run( + ["cmake", "-S", str(source), "-B", str(directory), + "-DWRONG_TREE=ON" if cohort == "linux" else "-DWRONG_TREE=OFF", + "-DPERFORMANCE_FIXTURE=OFF" if component == "core" else "-DPERFORMANCE_FIXTURE=ON"], + capture_output=True, text=True, + ) + self.assertEqual(configure.returncode, 0, configure.stdout + configure.stderr) + result = subprocess.run( + ["bash", "-euc", command], cwd=source, capture_output=True, text=True, + env={**os.environ, "PATH": str(binaries) + os.pathsep + os.environ["PATH"], + "release_directory": "build/llvm-release", "sanitizer_directory": "build/llvm-sanitizers"}, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("selected_tree", result.stdout) + self.assertNotIn("wrong_preset_tree", result.stdout) + self.assertNotIn("excluded_performance", result.stdout) + @unittest.skipUnless(shutil.which("cmake") and shutil.which("ctest") and shutil.which("cc") and shutil.which("ninja"), "CMake, CTest, Ninja and a C compiler required") def test_linux_validation_preserves_old_compiler_caches_in_both_orders(self) -> None: runner = (ROOT / "tools/ci/run_linux_check.sh").read_text() @@ -1161,7 +1209,7 @@ def test_linux_validation_preserves_old_compiler_caches_in_both_orders(self) -> ["cmake", "--build", str(build)], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) result = subprocess.run( - ["ctest", "--preset", preset, "--test-dir", str(build), "--no-tests=error"], + ["ctest", "--test-dir", str(build), "--output-on-failure", "--no-tests=error"], cwd=source, capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("cache_fixture", result.stdout) From fa68a48758f1a7611434e88986e8123dd9d220fe Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:28:42 -0500 Subject: [PATCH 034/148] ci(build): add manual native macOS LLVM CPU qualification --- .../workflows/llvm-macos-qualification.yml | 200 ++++++++++++++++++ docs/MACOS_TOOLCHAIN.md | 53 +++++ .../test_macos_qualification_workflow.py | 112 ++++++++++ 3 files changed, 365 insertions(+) create mode 100644 .github/workflows/llvm-macos-qualification.yml create mode 100644 tools/tests/test_macos_qualification_workflow.py diff --git a/.github/workflows/llvm-macos-qualification.yml b/.github/workflows/llvm-macos-qualification.yml new file mode 100644 index 000000000..7d5ae8c81 --- /dev/null +++ b/.github/workflows/llvm-macos-qualification.yml @@ -0,0 +1,200 @@ +name: Native macOS LLVM qualification + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: classic-llvm-macos-${{ github.sha }} + cancel-in-progress: false + +jobs: + shaders: + name: Prepare matching locked shader inputs + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Bind exact source and compile the locked shader cohort + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + tools/ci/prepare_gpu_shaders.sh \ + "${GITHUB_WORKSPACE}" "${RUNNER_TEMP}/macos-shader-downloads" \ + "${GITHUB_WORKSPACE}/build/macos-qualification-shaders" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: macos-llvm-shaders-${{ github.sha }} + path: build/macos-qualification-shaders + if-no-files-found: error + retention-days: 1 + + native-macos: + name: ARM64 SDK probes and Classic CPU tests + needs: shaders + # GitHub's macos-15 standard runner is ARM64; fail closed if that changes. + runs-on: macos-15 + timeout-minutes: 90 + env: + HOMEBREW_NO_INSTALL_CLEANUP: "1" + CMAKE_BUILD_PARALLEL_LEVEL: "2" + CTEST_PARALLEL_LEVEL: "2" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: macos-llvm-shaders-${{ github.sha }} + path: build/macos-qualification-shaders + - name: Record native host and source prerequisites + shell: bash + run: | + set -euo pipefail + test "$(uname -s)" = Darwin + test "$(uname -m)" = arm64 + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + test -z "$(git status --porcelain=v1 --untracked-files=normal)" + mkdir -p build/macos-qualification-evidence + { + git rev-parse HEAD + printf 'Runner image: %s %s\n' "${ImageOS:-unknown}" "${ImageVersion:-unknown}" + sw_vers + uname -a + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + xcrun --sdk macosx --show-sdk-version + cmake --version + ninja --version + brew config + df -h "${RUNNER_TEMP}" "${GITHUB_WORKSPACE}" + } > build/macos-qualification-evidence/host.txt + - name: Install the unchanged platform dependency features + shell: bash + run: | + set -euo pipefail + brew install --force-bottle \ + pkgconf flex check miniupnpc gd libidn2 curl openssl@3 zlib \ + libxml2 readline python@3.12 sdl3 sdl3_image sdl3_ttf sdl3_mixer \ + 2>&1 | tee build/macos-qualification-evidence/dependency-install.log + brew list --versions > build/macos-qualification-evidence/dependency-versions.txt + brew info --json=v2 --installed > build/macos-qualification-evidence/dependencies.json + echo "$(brew --prefix python@3.12)/libexec/bin" >> "${GITHUB_PATH}" + echo "$(brew --prefix flex)/bin" >> "${GITHUB_PATH}" + - name: Verify pinned LLVM and compile and run native C and C++ SDK probes + shell: bash + run: | + set -euo pipefail + tools/ci/prepare_macos_llvm.sh "${RUNNER_TEMP}/classic-llvm-macos" \ + 2>&1 | tee build/macos-qualification-evidence/llvm-sdk-probes.log + echo "${RUNNER_TEMP}/classic-llvm-macos/llvm/bin" >> "${GITHUB_PATH}" + cp "${RUNNER_TEMP}/classic-llvm-macos/stage/probe/CMakeCache.txt" \ + build/macos-qualification-evidence/probe-CMakeCache.txt + # These are exact task-owned downloads, already verified and installed. + # Free their space before building the native application graph. + rm "${RUNNER_TEMP}/classic-llvm-macos/stage/LLVM-23.1.3-macOS-ARM64.tar.xz" \ + "${RUNNER_TEMP}/classic-llvm-macos/stage/llvm-project-23.1.3.src.tar.xz" + - name: Acquire and verify the existing locked runtime and source inputs + shell: bash + run: | + set -euo pipefail + python3 client/tools/dependencies.py sync --root client --refresh + python3 client/tools/dependencies.py verify --root client + python3 server/tools/dependencies.py sync --root server --refresh + python3 server/tools/dependencies.py verify --root server + python3 client/tools/verify_gpu_fixture_provenance.py + pcp_source=$(python3 server/tools/dependencies.py source \ + --root server --source-lock server/cmake/immutable_sources.lock.json \ + --source-name libpcpnatpmp --cache "${RUNNER_TEMP}/macos-source-cache") + echo "ATRINIK_MACOS_PCP_SOURCE=${pcp_source}" >> "${GITHUB_ENV}" + - name: Configure the complete Classic Release graph + shell: bash + run: | + set -euo pipefail + prefix=$(brew --prefix) + python_prefix=$(brew --prefix python@3.12) + export PKG_CONFIG_PATH="${prefix}/lib/pkgconfig" + for formula in openssl@3 curl libxml2 zlib readline; do + formula_prefix=$(brew --prefix "${formula}") + PKG_CONFIG_PATH="${formula_prefix}/lib/pkgconfig:${PKG_CONFIG_PATH}" + done + cmake -S . -B build/macos-qualification -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="${GITHUB_WORKSPACE}/cmake/toolchains/llvm-macos.cmake" \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DPACKAGE_TYPE=none \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + "-DCMAKE_PREFIX_PATH=${prefix};$(brew --prefix openssl@3);$(brew --prefix curl);$(brew --prefix libxml2);$(brew --prefix zlib);$(brew --prefix readline);${python_prefix}" \ + "-DOPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" \ + "-DCURL_ROOT=$(brew --prefix curl)" \ + "-DPython_ROOT_DIR=${python_prefix}" -DPython_FIND_FRAMEWORK=LAST \ + "-DPython3_EXECUTABLE=${python_prefix}/bin/python3.12" \ + "-DFLEX_EXECUTABLE=$(brew --prefix flex)/bin/flex" \ + "-DFETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP=${ATRINIK_MACOS_PCP_SOURCE}" \ + "-DATRINIK_GPU_SHADER_DIRECTORY=${GITHUB_WORKSPACE}/build/macos-qualification-shaders" \ + 2>&1 | tee build/macos-qualification-evidence/configure.log + - name: Verify required production features were retained + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + cache = {} + for line in Path('build/macos-qualification/CMakeCache.txt').read_text().splitlines(): + if line and not line.startswith(('#', '//')) and '=' in line and ':' in line: + key, value = line.split('=', 1) + cache[key.split(':', 1)[0]] = value + for option in ('ATRINIK_BUILD_CLIENT', 'ATRINIK_BUILD_SERVER', 'ENABLE_WARNING_ERRORS', + 'ENABLE_PYTHON_PLUGIN', 'ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS'): + if cache.get(option) != 'ON': + raise SystemExit(f'required production feature absent: {option}') + if not cache.get('GD_LIBRARY') or cache['GD_LIBRARY'].endswith('-NOTFOUND'): + raise SystemExit('required world maker dependency absent: GD_LIBRARY') + PY + - name: Build the production client, server, library and test executables + shell: bash + run: | + set -euo pipefail + cmake --build build/macos-qualification --parallel 2 \ + 2>&1 | tee build/macos-qualification-evidence/build.log + - name: Verify CPU test discovery and run the non-GPU suites + shell: bash + env: + SDL_AUDIO_DRIVER: dummy + run: | + set -euo pipefail + ctest --test-dir build/macos-qualification --show-only=json-v1 \ + -LE 'gpu-conformance|performance' > build/macos-qualification-evidence/tests.json + python3 - <<'PY' + import json + from pathlib import Path + data = json.loads(Path('build/macos-qualification-evidence/tests.json').read_text()) + names = [test['name'] for test in data['tests']] + for prefix in ('protocol-', 'libatrinik-', 'atrinik-pathfinding', 'client-', 'server-unit-', 'server-plugin-python'): + if not any(name.startswith(prefix) for name in names): + raise SystemExit(f'missing required native CPU tests: {prefix}') + print(f'{len(names)} native CPU tests selected') + PY + ctest --test-dir build/macos-qualification --output-on-failure \ + --no-tests=error --parallel 2 -LE 'gpu-conformance|performance' \ + --output-junit "${GITHUB_WORKSPACE}/build/macos-qualification-evidence/tests.xml" \ + 2>&1 | tee build/macos-qualification-evidence/tests.log + - name: Retain exact native evidence and failures + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: macos-llvm-cpu-qualification-${{ github.sha }} + path: | + build/macos-qualification-evidence + build/macos-qualification/CMakeCache.txt + build/macos-qualification/compile_commands.json + build/macos-qualification/CMakeFiles/CMakeConfigureLog.yaml + build/macos-qualification/Testing/Temporary/LastTest.log + if-no-files-found: error + retention-days: 14 diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md index 133303f5c..b5693d8a8 100644 --- a/docs/MACOS_TOOLCHAIN.md +++ b/docs/MACOS_TOOLCHAIN.md @@ -50,3 +50,56 @@ build on each supported Mac architecture, followed by the unchanged Metal reference and minimum hardware qualification rows in `.github/workflows/gpu-qualification.yml`. These rows retain their GPU checks, stress runs, lifecycle recovery, production fixtures and human-approved goldens. + +## Hosted native CPU qualification + +`.github/workflows/llvm-macos-qualification.yml` supplies a bounded manual and +reusable CPU qualification lane. Its Linux job produces the existing locked +shader cohort for the exact selected source SHA; its native `macos-15` job +checks Darwin/ARM64 before preparing the pinned LLVM archive and running the +C/C++ SDK probes. GitHub currently documents this standard runner as an +[ARM64 M1 host with three CPUs and 7 GiB RAM](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). +The [runner inventory](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md) +provides CMake, Ninja, Homebrew and Xcode; the lane records the actual image, +OS, selected SDK and disk space instead of inferring them from the label. + +The native job installs ARM64 Homebrew bottles for the current application +prerequisites: SDL3, SDL3_image, SDL3_ttf, SDL3_mixer, OpenSSL 3, libxml2, +libidn2, curl, zlib, Readline, Flex, MiniUPnPc, libgd, Check and Python 3.12. +The official [SDL3](https://formulae.brew.sh/formula/sdl3), +[SDL3_mixer](https://formulae.brew.sh/formula/sdl3_mixer) and +[OpenSSL 3](https://formulae.brew.sh/formula/openssl@3) formulae currently supply +bottles meeting Classic's required versions. CMake remains the authority for +required versions at execution. Homebrew dependencies and hosted SDK images +can change; the exact installed versions and formula metadata are retained +with each qualification run. This lane does not create a released dependency +producer or qualify different dependency versions by inference. + +The complete integrated Release graph builds protocol, libatrinik, pathfinding, +client, server, the server Python plugin and all test executables through LLVM +23.1.3. Existing feature, warning, PCH and optimization defaults remain in use. +Builds and tests use two jobs. Locked sound, content, resources and libpcpnatpmp +inputs use their existing verification helpers. CTest excludes the +`gpu-conformance` and `performance` labels; native CPU test discovery must include +every component and the Python plugin before tests run. GPU test executables +still compile. The source-only GPU fixture provenance helper runs explicitly alongside +locked runtime acquisition, while hardware GPU tests, benchmarks and Metal +reference/minimum qualification remain separate acceptance requirements. + +Each run retains source identity, SDK/host and dependency inventory, native +probe/configure/build logs, CMake cache and compile commands, selected test +inventory, JUnit results and failure logs in the +`macos-llvm-cpu-qualification-SHA` artifact. Downloaded LLVM/source archives are +removed only after verified preparation and successful probes to recover the +runner's limited disk space. Missing prerequisites, failed probes, absent test +groups, build failures and test failures fail the lane. A source fixture pass +or workflow definition alone is not native acceptance. Intel macOS remains a +separate qualification target. + +A new manual workflow must exist on the repository's default branch before +GitHub accepts a direct `workflow_dispatch`, as documented in +[manual workflow execution](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow). +Before that, an existing manual workflow may call this local reusable workflow +from the selected source branch. The coordinator owns publication, caller +integration and authorized dispatch; this lane has only read permissions and +publishes no application packages. diff --git a/tools/tests/test_macos_qualification_workflow.py b/tools/tests/test_macos_qualification_workflow.py new file mode 100644 index 000000000..9da0c70bb --- /dev/null +++ b/tools/tests/test_macos_qualification_workflow.py @@ -0,0 +1,112 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from pathlib import Path +import json +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/llvm-macos-qualification.yml" + + +def embedded_python_script(index): + text = WORKFLOW.read_text() + matches = re.findall(r" python3 - <<'PY'\n(.*?) PY\n", text, re.S) + match = matches[index] if matches else None + if match is None: + raise AssertionError("Native CPU discovery gate is absent") + return "\n".join(line[10:] for line in match.splitlines()) + "\n" + + +class MacOSQualificationWorkflowTests(unittest.TestCase): + def test_discovery_requires_each_native_component_and_python_plugin(self): + names = [ + "protocol-generate-check", "libatrinik-smoke", "atrinik-pathfinding", + "client-help-parser", "server-unit-object", "server-plugin-python", + ] + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + evidence = root / "build/macos-qualification-evidence" + evidence.mkdir(parents=True) + script = root / "gate.py" + script.write_text(embedded_python_script(-1)) + for excluded in [None, *names]: + selected = [name for name in names if name != excluded] + (evidence / "tests.json").write_text(json.dumps({ + "tests": [{"name": name} for name in selected] + })) + result = subprocess.run( + [shutil.which("python3"), str(script)], cwd=root, + capture_output=True, text=True, + ) + with self.subTest(missing=excluded): + self.assertEqual(result.returncode == 0, excluded is None, result.stderr) + if excluded: + self.assertIn("missing required native CPU tests", result.stderr) + + def test_disabled_python_pch_and_missing_world_maker_fail_qualification(self): + enabled = { + "ATRINIK_BUILD_CLIENT": "ON", "ATRINIK_BUILD_SERVER": "ON", + "ENABLE_WARNING_ERRORS": "ON", "ENABLE_PYTHON_PLUGIN": "ON", + "ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS": "ON", "GD_LIBRARY": "/native/libgd.dylib", + } + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + build = root / "build/macos-qualification" + build.mkdir(parents=True) + script = root / "features.py" + script.write_text(embedded_python_script(0)) + for disabled in [None, *enabled]: + cache = dict(enabled) + if disabled: + cache[disabled] = "GD_LIBRARY-NOTFOUND" if disabled == "GD_LIBRARY" else "OFF" + (build / "CMakeCache.txt").write_text("".join( + f"{key}:STRING={value}\n" for key, value in cache.items() + )) + result = subprocess.run( + [shutil.which("python3"), str(script)], cwd=root, + capture_output=True, text=True, + ) + with self.subTest(disabled=disabled): + self.assertEqual(result.returncode == 0, disabled is None, result.stderr) + if disabled: + self.assertIn("absent", result.stderr) + + def test_manual_lane_keeps_production_features_and_gpu_boundary(self): + text = WORKFLOW.read_text() + self.assertIn(" workflow_call:\n workflow_dispatch:", text) + for trigger in (" pull_request:", " push:", " schedule:"): + self.assertNotIn(trigger, text) + self.assertIn('test "$(uname -m)" = arm64', text) + self.assertIn("contents: read", text) + self.assertIn("--no-tests=error", text) + self.assertIn("--output-junit", text) + self.assertIn("-LE 'gpu-conformance|performance'", text) + for option in ( + "ENABLE_PYTHON_PLUGIN", "ENABLE_PRECOMPILED_HEADERS", "ENABLE_WARNING_ERRORS", + "ATRINIK_BUILD_CLIENT", "ATRINIK_BUILD_SERVER", "ENABLE_COVERAGE", + "ENABLE_SANITIZERS", "CMAKE_INTERPROCEDURAL_OPTIMIZATION", + ): + self.assertNotIn("-D" + option, text) + self.assertNotIn("ATRINIK_GPU_CONFORMANCE_QUALIFIED_HARDWARE", text) + self.assertIn("prepare_macos_llvm.sh", text) + self.assertIn("-DCMAKE_BUILD_TYPE=Release", text) + self.assertIn("cmake --build build/macos-qualification --parallel 2", text) + + @unittest.skipUnless(shutil.which("bash"), "Bash unavailable") + def test_embedded_shell_recipes_are_valid(self): + text = WORKFLOW.read_text() + recipes = re.findall(r" run: \|\n((?: .*\n|\n)+)", text) + self.assertGreaterEqual(len(recipes), 8) + for recipe in recipes: + script = "\n".join(line[10:] for line in recipe.splitlines()) + "\n" + result = subprocess.run(["bash", "-n"], input=script, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() From 4f1fc8f7a06de447f6baebd14bf1140e264bdc57 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:38:02 -0500 Subject: [PATCH 035/148] ci(build): expose native macOS qualification through Check --- .github/workflows/check.yml | 12 ++++++++++++ tools/tests/test_macos_qualification_workflow.py | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 29fc43a26..8f7f17bb4 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -11,6 +11,11 @@ on: merge_group: workflow_dispatch: inputs: + native_macos_qualification: + description: Run native macOS LLVM CPU qualification on the selected ref + required: false + type: boolean + default: false gpu_qualification: description: Run the qualified GPU renderer matrix on the selected ref required: false @@ -76,6 +81,13 @@ jobs: if-no-files-found: error retention-days: 1 + native-macos-qualification: + name: Native macOS LLVM CPU qualification + if: github.event_name == 'workflow_dispatch' && inputs.native_macos_qualification + permissions: + contents: read + uses: ./.github/workflows/llvm-macos-qualification.yml + gpu-qualification: name: GPU renderer qualification if: github.event_name == 'workflow_dispatch' && inputs.gpu_qualification diff --git a/tools/tests/test_macos_qualification_workflow.py b/tools/tests/test_macos_qualification_workflow.py index 9da0c70bb..2d5218620 100644 --- a/tools/tests/test_macos_qualification_workflow.py +++ b/tools/tests/test_macos_qualification_workflow.py @@ -76,6 +76,17 @@ def test_disabled_python_pch_and_missing_world_maker_fail_qualification(self): if disabled: self.assertIn("absent", result.stderr) + def test_existing_check_dispatch_calls_native_lane_without_gpu_authority(self): + text = (ROOT / ".github/workflows/check.yml").read_text() + self.assertIn(" native_macos_qualification:\n", text) + block = text.split(" native-macos-qualification:\n", 1)[1].split("\n gpu-qualification:", 1)[0] + self.assertIn("github.event_name == 'workflow_dispatch' && inputs.native_macos_qualification", block) + self.assertIn("contents: read", block) + self.assertIn("uses: ./.github/workflows/llvm-macos-qualification.yml", block) + self.assertNotIn("secrets:", block) + self.assertNotIn("with:", block) + self.assertNotIn("gpu_qualification", block) + def test_manual_lane_keeps_production_features_and_gpu_boundary(self): text = WORKFLOW.read_text() self.assertIn(" workflow_call:\n workflow_dispatch:", text) From 55639279e1952e599855ae8cc2eea620c32abf21 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:32:29 -0500 Subject: [PATCH 036/148] fix(build): preserve native test interception on Darwin --- client/CMakeLists.txt | 16 ++++++++---- libatrinik/CMakeLists.txt | 32 +++++++++++++++++++++--- libatrinik/tests/path.c | 5 ++++ libatrinik/tests/sensitive_packet.c | 4 +++ libatrinik/tests/wrapped/access_code.c | 9 +++++++ libatrinik/tests/wrapped/packet.c | 8 ++++++ libatrinik/tests/wrapped/path.c | 11 ++++++++ libatrinik/tests/wrapped/socket_direct.c | 14 +++++++++++ 8 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 libatrinik/tests/wrapped/access_code.c create mode 100644 libatrinik/tests/wrapped/packet.c create mode 100644 libatrinik/tests/wrapped/path.c create mode 100644 libatrinik/tests/wrapped/socket_direct.c diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index f5326c68e..badfcbb23 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -431,6 +431,12 @@ if (MINGW) endif () if (BUILD_TESTING) + # Focused tests discard unrelated production handlers on each object format. + if (APPLE) + set(ATRINIK_TEST_DEAD_STRIP_OPTION "-Wl,-dead_strip") + else () + set(ATRINIK_TEST_DEAD_STRIP_OPTION "-Wl,--gc-sections") + endif () set(ATRINIK_PYTHON3_EXECUTABLE "${Python3_EXECUTABLE}") # Headless dependency equivalence probe, also built independently by the @@ -982,7 +988,7 @@ if (BUILD_TESTING) $<$:-ffunction-sections> $<$:-fdata-sections>) target_link_options(client-compound-packet-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:${ATRINIK_TEST_DEAD_STRIP_OPTION}>) target_compile_definitions(client-compound-packet-tests PRIVATE SDL_DISABLE_OLD_NAMES) target_include_directories(client-compound-packet-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include @@ -1003,7 +1009,7 @@ if (BUILD_TESTING) $<$:-ffunction-sections> $<$:-fdata-sections>) target_link_options(client-book-edit-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:${ATRINIK_TEST_DEAD_STRIP_OPTION}>) target_compile_definitions(client-book-edit-tests PRIVATE SDL_DISABLE_OLD_NAMES) target_include_directories(client-book-edit-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include @@ -1183,7 +1189,7 @@ if (BUILD_TESTING) target_compile_options(client-item-drag-tests PRIVATE $<$:-ffunction-sections>) target_link_options(client-item-drag-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:${ATRINIK_TEST_DEAD_STRIP_OPTION}>) target_include_directories(client-item-drag-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include ${CMAKE_CURRENT_SOURCE_DIR}/src/include @@ -1199,7 +1205,7 @@ if (BUILD_TESTING) target_compile_options(client-notification-tests PRIVATE $<$:-ffunction-sections>) target_link_options(client-notification-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:${ATRINIK_TEST_DEAD_STRIP_OPTION}>) target_include_directories(client-notification-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include ${CMAKE_CURRENT_SOURCE_DIR}/src/include @@ -1232,7 +1238,7 @@ if (BUILD_TESTING) target_compile_options(client-render-profiler-tests PRIVATE $<$:-ffunction-sections>) target_link_options(client-render-profiler-tests PRIVATE - $<$:-Wl,--gc-sections>) + $<$:${ATRINIK_TEST_DEAD_STRIP_OPTION}>) target_include_directories(client-render-profiler-tests BEFORE PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/src/include ${CMAKE_CURRENT_SOURCE_DIR}/src/include diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index 914fa45e3..fc4197a2c 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -521,12 +521,21 @@ endif () if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR ATRINIK_INTEGRATED_BUILD) AND BUILD_TESTING) + # Mach-O has no GNU --wrap. These test-only translation units compile the + # original implementation with selected calls redirected after system headers + # are loaded. Their definitions satisfy references before the normal static + # library is searched, so the original archive member is not extracted. add_executable(libatrinik-access-code tests/access_code.c) atrinik_configure_target(libatrinik-access-code) target_link_libraries(libatrinik-access-code PRIVATE Atrinik::Core) if (UNIX AND CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_definitions(libatrinik-access-code PRIVATE ACCESS_CODE_TEST_ENTROPY) - target_link_options(libatrinik-access-code PRIVATE "-Wl,--wrap=RAND_priv_bytes") + if (APPLE) + target_sources(libatrinik-access-code PRIVATE tests/wrapped/access_code.c) + target_link_libraries(libatrinik-access-code PRIVATE OpenSSL::Crypto) + else () + target_link_options(libatrinik-access-code PRIVATE "-Wl,--wrap=RAND_priv_bytes") + endif () endif () add_test(NAME libatrinik-access-code COMMAND libatrinik-access-code) @@ -540,7 +549,13 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR target_link_libraries(libatrinik-sensitive-packet PRIVATE Atrinik::Core) if (UNIX AND CMAKE_C_COMPILER_ID STREQUAL "Clang") target_compile_definitions(libatrinik-sensitive-packet PRIVATE PACKET_TEST_FREE) - target_link_options(libatrinik-sensitive-packet PRIVATE "-Wl,--wrap=free") + if (APPLE) + target_sources(libatrinik-sensitive-packet PRIVATE tests/wrapped/packet.c) + target_compile_definitions(libatrinik-sensitive-packet PRIVATE ATRINIK_TEST_SOURCE_WRAP) + target_link_libraries(libatrinik-sensitive-packet PRIVATE OpenSSL::Crypto ZLIB::ZLIB) + else () + target_link_options(libatrinik-sensitive-packet PRIVATE "-Wl,--wrap=free") + endif () endif () add_test(NAME libatrinik-sensitive-packet COMMAND libatrinik-sensitive-packet) @@ -583,6 +598,10 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR target_link_libraries(libatrinik-path PRIVATE Atrinik::Core) if (MINGW) target_link_options(libatrinik-path PRIVATE "-Wl,--wrap=path_windows_token_user") + elseif (APPLE) + target_sources(libatrinik-path PRIVATE tests/wrapped/path.c) + target_compile_definitions(libatrinik-path PRIVATE ATRINIK_TEST_SOURCE_WRAP) + target_link_libraries(libatrinik-path PRIVATE OpenSSL::Crypto) elseif (UNIX AND CMAKE_C_COMPILER_ID STREQUAL "Clang") target_link_options(libatrinik-path PRIVATE "-Wl,--wrap=fsync" "-Wl,--wrap=close") endif () @@ -614,8 +633,13 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR if (UNIX AND CMAKE_C_COMPILER_ID STREQUAL "Clang") add_executable(libatrinik-socket-websocket tests/socket_websocket.c) atrinik_configure_target(libatrinik-socket-websocket) - target_link_options(libatrinik-socket-websocket PRIVATE - "-Wl,--wrap=curl_ws_recv") + if (APPLE) + target_sources(libatrinik-socket-websocket PRIVATE tests/wrapped/socket_direct.c) + target_link_libraries(libatrinik-socket-websocket PRIVATE OpenSSL::Crypto) + else () + target_link_options(libatrinik-socket-websocket PRIVATE + "-Wl,--wrap=curl_ws_recv") + endif () target_link_libraries(libatrinik-socket-websocket PRIVATE Atrinik::Core) add_test(NAME libatrinik-socket-websocket COMMAND libatrinik-socket-websocket) endif () diff --git a/libatrinik/tests/path.c b/libatrinik/tests/path.c index 5a938e2c8..6e4ac83e6 100644 --- a/libatrinik/tests/path.c +++ b/libatrinik/tests/path.c @@ -31,8 +31,13 @@ static bool fail_parent_directory_close; static bool fail_next_parent_close; static unsigned int fsync_call_count; +#ifdef ATRINIK_TEST_SOURCE_WRAP +#define __real_fsync fsync +#define __real_close close +#else int __real_fsync(int fd); int __real_close(int fd); +#endif int __wrap_fsync(int fd) { if (fail_parent_directory_close && ++fsync_call_count == 2) { diff --git a/libatrinik/tests/sensitive_packet.c b/libatrinik/tests/sensitive_packet.c index 117f3a312..1bfbf2e1b 100644 --- a/libatrinik/tests/sensitive_packet.c +++ b/libatrinik/tests/sensitive_packet.c @@ -10,7 +10,11 @@ static void *watched; static size_t watched_size; static bool observed; +#ifdef ATRINIK_TEST_SOURCE_WRAP +#define __real_free free +#else void __real_free(void *ptr); +#endif void __wrap_free(void *ptr) { if (ptr != NULL && ptr == watched) { const unsigned char *p = ptr; diff --git a/libatrinik/tests/wrapped/access_code.c b/libatrinik/tests/wrapped/access_code.c new file mode 100644 index 000000000..a68cc8854 --- /dev/null +++ b/libatrinik/tests/wrapped/access_code.c @@ -0,0 +1,9 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ + +/* Load declarations before redirecting calls in the production implementation. */ +#include + +int __wrap_RAND_priv_bytes(unsigned char *out, int size); +#define RAND_priv_bytes __wrap_RAND_priv_bytes +#include "../../access_code.c" diff --git a/libatrinik/tests/wrapped/packet.c b/libatrinik/tests/wrapped/packet.c new file mode 100644 index 000000000..8138989dd --- /dev/null +++ b/libatrinik/tests/wrapped/packet.c @@ -0,0 +1,8 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ + +#include + +void __wrap_free(void *ptr); +#define free __wrap_free +#include "../../packet.c" diff --git a/libatrinik/tests/wrapped/path.c b/libatrinik/tests/wrapped/path.c new file mode 100644 index 000000000..ebfadc354 --- /dev/null +++ b/libatrinik/tests/wrapped/path.c @@ -0,0 +1,11 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ + +/* Keep Darwin's libc assembler aliases on the real declarations only. */ +#include + +int __wrap_fsync(int fd); +int __wrap_close(int fd); +#define fsync __wrap_fsync +#define close __wrap_close +#include "../../path.c" diff --git a/libatrinik/tests/wrapped/socket_direct.c b/libatrinik/tests/wrapped/socket_direct.c new file mode 100644 index 000000000..4a397aa77 --- /dev/null +++ b/libatrinik/tests/wrapped/socket_direct.c @@ -0,0 +1,14 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ + +#include + +#if LIBCURL_VERSION_NUM >= 0x075600 +CURLcode __wrap_curl_ws_recv(CURL *curl, + void *buffer, + size_t buflen, + size_t *recv, + const struct curl_ws_frame **meta); +#define curl_ws_recv __wrap_curl_ws_recv +#endif +#include "../../socket_direct.c" From 3edfbbc932a7c0fac1a54199bd38c0227092791f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:37:22 -0500 Subject: [PATCH 037/148] fix(libatrinik): declare optional toolkit diagnostic state --- libatrinik/toolkit.h | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libatrinik/toolkit.h b/libatrinik/toolkit.h index b8cc02b8a..8a9c519fb 100644 --- a/libatrinik/toolkit.h +++ b/libatrinik/toolkit.h @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -66,8 +66,10 @@ typedef struct toolkit_dependency { #define DEPENDS(__api_name) {toolkit_##__api_name##_init, true} #define IMPORTS(__api_name) {toolkit_##__api_name##_init, false} -#define TOOLKIT_API(...) \ - static bool _did_init_ = false; \ +/* Diagnostic state is optional: release builds and APIs without + * TOOLKIT_PROTECT() intentionally never read it. Keep its lifecycle unchanged. */ +#define TOOLKIT_API(...) \ + static bool _did_init_ __attribute__((unused)) = false; \ static toolkit_dependency_t _dependencies_[] = {{NULL, true}, ##__VA_ARGS__, {NULL, true}} /** From 4e32109df5e840ee1db22f21a14fbe486cc78889 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:39:09 -0500 Subject: [PATCH 038/148] docs(libatrinik): describe macOS entropy test wrappers --- libatrinik/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libatrinik/README.md b/libatrinik/README.md index 71a2c4aea..e8033c6d5 100644 --- a/libatrinik/README.md +++ b/libatrinik/README.md @@ -81,8 +81,8 @@ callers must cleanse every copy after use and never log codes or route capabilities. Store consumers persist only the index and verifier. The focused `libatrinik-access-code` test includes independently computed hash vectors, invalid alphabets/lengths, identity binding, and deterministic entropy failure -on supported Linux linkers. The helper alone does not provide admission or -route registration. +through linker wrappers on Linux and test-only source wrappers on macOS. +The helper alone does not provide admission or route registration. ## Direct connection and rendezvous API From 736a330adf007c431e77fa735b8c4024f948ef10 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:41:48 -0500 Subject: [PATCH 039/148] fix(build): bind Windows notices and isolate native worldmaker tools --- docs/WINDOWS_TOOLCHAIN.md | 5 +- server/tools/build-windows-package.sh | 33 +++- .../tests/test_windows_package_toolchains.py | 143 ++++++++++++++++++ tools/ci/stage_windows_runtime.py | 5 +- tools/tests/test_windows_trust_notices.py | 16 ++ 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 server/tools/tests/test_windows_package_toolchains.py diff --git a/docs/WINDOWS_TOOLCHAIN.md b/docs/WINDOWS_TOOLCHAIN.md index be2d0d386..46a72936e 100644 --- a/docs/WINDOWS_TOOLCHAIN.md +++ b/docs/WINDOWS_TOOLCHAIN.md @@ -22,7 +22,10 @@ dependency prefix. From the owning module directory, run `tools/build-windows-package.sh OUTPUT_DIRECTORY`. Server packaging additionally requires `ATRINIK_WINDOWS_PYTHON_INCLUDE_DIR`, `ATRINIK_WINDOWS_PYTHON_LIBRARY` and `ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR`, and -builds the host worldmaker separately before cross-compiling the server. +builds the host worldmaker separately with `/opt/llvm-23.1.3/bin` native tools +and Linux compiler-rt/libunwind before cross-compiling the server. The package +helper selects these native tools for that phase even when the caller's PATH +selects LLVM-MinGW; the cross phase uses `ATRINIK_WINDOWS_TOOLCHAIN_FILE`. Dependency, shader, sound and source-profile qualification inputs keep their existing verification contracts. diff --git a/server/tools/build-windows-package.sh b/server/tools/build-windows-package.sh index e54e277f9..846b8018c 100755 --- a/server/tools/build-windows-package.sh +++ b/server/tools/build-windows-package.sh @@ -89,6 +89,33 @@ python3 ../client/tools/verify_gpu_fixture_provenance.py --content-runtime runti dependency_arguments=() native_compiler_arguments=() +native_llvm_bin=/opt/llvm-23.1.3/bin +script_source_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +native_toolchain=${script_source_root}/cmake/toolchains/llvm.cmake +if [[ ! -f ${native_toolchain} ]]; then + native_toolchain=${script_source_root}/../cmake/toolchains/llvm.cmake +fi +if [[ ! -f ${native_toolchain} ]]; then + echo "The native LLVM worldmaker toolchain is missing" >&2 + exit 1 +fi +native_compiler_arguments+=("-DCMAKE_TOOLCHAIN_FILE=${native_toolchain}") +# The inherited PATH selects LLVM-MinGW for the later cross build. Its clang +# cannot link the Linux compiler-rt/libunwind used by the host worldmaker. +native_llvm() { + env PATH="${native_llvm_bin}:${PATH}" \ + CMAKE_TOOLCHAIN_FILE="${native_toolchain}" \ + CC="${native_llvm_bin}/clang" CXX="${native_llvm_bin}/clang++" \ + AR="${native_llvm_bin}/llvm-ar" RANLIB="${native_llvm_bin}/llvm-ranlib" \ + LD="${native_llvm_bin}/ld.lld" "$@" +} +for native_tool in C_COMPILER:clang CXX_COMPILER:clang++ AR:llvm-ar \ + RANLIB:llvm-ranlib LINKER:ld.lld NM:llvm-nm STRIP:llvm-strip \ + OBJCOPY:llvm-objcopy OBJDUMP:llvm-objdump READELF:llvm-readelf; do + native_compiler_arguments+=( + "-DCMAKE_${native_tool%%:*}=${native_llvm_bin}/${native_tool#*:}" + ) +done monorepo_root=$(git rev-parse --show-toplevel 2>/dev/null || true) if [[ -n ${monorepo_root} && -f ${monorepo_root}/protocol/CMakeLists.txt && -f ${monorepo_root}/libatrinik/CMakeLists.txt ]]; then @@ -137,14 +164,14 @@ cmake -E copy runtime/content/compatibility.json \ "${package_root}/compatibility.json" cmake -E copy runtime/content/manifest.json "${package_root}/manifest.json" -cmake -S . -B "${region_build}" -G Ninja \ +native_llvm cmake -S . -B "${region_build}" -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_TESTING=OFF \ -DPACKAGE_TYPE=none \ -DATRINIK_PACKAGE_VERSION="${version}" \ "${native_compiler_arguments[@]}" \ "${dependency_arguments[@]}" -cmake --build "${region_build}" \ +native_llvm cmake --build "${region_build}" \ --target atrinik-server plugin_arena plugin_python --parallel "$(nproc)" region_assets=${package_root}/region-assets @@ -158,7 +185,7 @@ cmake -E copy_directory install_data "${region_data}" repository_root=$(pwd) ( cd "${region_runtime}" - PYTHONDONTWRITEBYTECODE=1 ./atrinik-server \ + PYTHONDONTWRITEBYTECODE=1 native_llvm ./atrinik-server \ --worldmaker \ --libpath="${repository_root}/${package_root}/lib" \ --mapspath="${repository_root}/${package_root}/maps" \ diff --git a/server/tools/tests/test_windows_package_toolchains.py b/server/tools/tests/test_windows_package_toolchains.py new file mode 100644 index 000000000..a5d16865a --- /dev/null +++ b/server/tools/tests/test_windows_package_toolchains.py @@ -0,0 +1,143 @@ +"""Run the package helper with recorded build phases and disposable outputs.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + +DRIVER = r''' +import json, os, pathlib, shutil, sys +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +with open(os.environ["FIXTURE_CALLS"], "a") as output: + output.write(json.dumps({"tool": name, "args": args, + "path": os.environ["PATH"], + "toolchain": os.environ.get("CMAKE_TOOLCHAIN_FILE"), + "cc": os.environ.get("CC"), "cxx": os.environ.get("CXX")}) + "\n") +if name == "git": + print(os.environ["FIXTURE_REPOSITORY"]) +elif name == "cmake": + if args[0] == "-E": + action, paths = args[1], args[2:] + if action == "remove_directory": + shutil.rmtree(paths[0], ignore_errors=True) + elif action == "make_directory": + for path in paths: + pathlib.Path(path).mkdir(parents=True, exist_ok=True) + elif action == "copy_directory": + shutil.copytree(paths[0], paths[1], dirs_exist_ok=True) + elif action == "copy": + for path in paths[:-1]: + shutil.copy(path, paths[-1]) + else: + raise RuntimeError(action) + elif args[0] == "-S": + pathlib.Path(args[args.index("-B") + 1]).mkdir(parents=True, exist_ok=True) + elif args[0] == "--build" and args[1].endswith("windows-region-generator"): + build = pathlib.Path(args[1]) + (build / "atrinik-server").write_text(pathlib.Path(sys.argv[0]).read_text()) + (build / "atrinik-server").chmod(0o755) + for plugin in ("libplugin_arena.so", "libplugin_python.so"): + (build / plugin).write_bytes(b"fixture plugin") +elif name == "atrinik-server": + assets = next(arg.split("=", 1)[1] for arg in args if arg.startswith("--assetspath=")) + pathlib.Path(assets, "client-maps").mkdir(parents=True) +elif name == "cpack": + destination = pathlib.Path(args[args.index("-B") + 1]) + (destination / "atrinik-classic-server-0.0.0-windows-x86_64.zip").write_bytes(b"fixture package") +''' + + +class WindowsPackageToolchainTests(unittest.TestCase): + def test_native_worldmaker_does_not_inherit_cross_compiler_selection(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + server = repository / "server" + binaries = repository / "host-bin" + mingw = repository / "llvm-mingw/bin" + binaries.mkdir() + mingw.mkdir(parents=True) + for tool in ("cmake", "cpack", "python3", "git", "ccache", "llvm-readobj"): + executable = binaries / tool + executable.write_text(f"#!{sys.executable}\n" + DRIVER) + executable.chmod(0o755) + for module in ("protocol", "libatrinik"): + (repository / module).mkdir() + (repository / module / "CMakeLists.txt").touch() + for directory in ("runtime/content/maps", "runtime/content/lib", + "runtime/content/attribution", "install_data", "resources"): + (server / directory).mkdir(parents=True) + for name in ("runtime/content/compatibility.json", "runtime/content/manifest.json", + "server.cfg", "permissions.cfg"): + (server / name).write_text("fixture\n") + toolchain = repository / "mingw.cmake" + toolchain.touch() + cache = repository / "dependency-cache" + cache.mkdir() + log = repository / "calls.jsonl" + environment = {key: value for key, value in os.environ.items() + if not key.startswith(("ATRINIK_", "MXE_"))} + inherited_path = f"{mingw}:{binaries}:{os.environ['PATH']}" + environment.update({ + "PATH": inherited_path, "CC": str(mingw / "clang"), + "CXX": str(mingw / "clang++"), "FIXTURE_CALLS": str(log), + "CMAKE_TOOLCHAIN_FILE": str(toolchain), + "FIXTURE_REPOSITORY": str(repository), "ATRINIK_PACKAGE_VERSION": "0.0.0", + "ATRINIK_WINDOWS_TOOLCHAIN_FILE": str(toolchain), + "ATRINIK_WINDOWS_TARGET": "x86_64-w64-mingw32", + "ATRINIK_WINDOWS_RUNTIME_DIR": str(mingw), + "ATRINIK_WINDOWS_PYTHON_INCLUDE_DIR": str(mingw), + "ATRINIK_WINDOWS_PYTHON_LIBRARY": str(toolchain), + "ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR": str(mingw), + "ATRINIK_DEPENDENCY_CACHE_DIR": str(cache), + }) + result = subprocess.run(["bash", str(ROOT / "tools/build-windows-package.sh")], + cwd=server, env=environment, capture_output=True, text=True, + timeout=20, check=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + calls = [json.loads(line) for line in log.read_text().splitlines()] + configurations = [call for call in calls if call["tool"] == "cmake" + and call["args"][0] == "-S"] + self.assertEqual(len(configurations), 2) + native, cross = configurations + native_prefix = "/opt/llvm-23.1.3/bin" + self.assertEqual(native["path"], f"{native_prefix}:{inherited_path}") + self.assertEqual(native["cc"], f"{native_prefix}/clang") + self.assertEqual(native["cxx"], f"{native_prefix}/clang++") + native_toolchain = ROOT.parent / "cmake/toolchains/llvm.cmake" + self.assertEqual(Path(native["toolchain"]).resolve(), native_toolchain) + self.assertIn(f"-DCMAKE_TOOLCHAIN_FILE={native['toolchain']}", native["args"]) + for variable, tool in (("C_COMPILER", "clang"), ("LINKER", "ld.lld"), + ("AR", "llvm-ar"), ("READELF", "llvm-readelf")): + self.assertIn(f"-DCMAKE_{variable}={native_prefix}/{tool}", native["args"]) + self.assertEqual(cross["path"], inherited_path) + self.assertEqual(cross["cc"], str(mingw / "clang")) + self.assertEqual(cross["toolchain"], str(toolchain)) + self.assertIn(f"-DCMAKE_TOOLCHAIN_FILE={toolchain}", cross["args"]) + self.assertFalse(any(arg.startswith("-DCMAKE_C_COMPILER=") for arg in cross["args"])) + for configuration in configurations: + for argument in ("-DCMAKE_BUILD_TYPE=Release", "-DBUILD_TESTING=OFF", + f"-DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL={repository}/protocol", + f"-DFETCHCONTENT_SOURCE_DIR_LIBATRINIK={repository}/libatrinik", + f"-DATRINIK_DEPENDENCY_CACHE_DIR={cache}"): + self.assertIn(argument, configuration["args"]) + native_build = next(call for call in calls if call["tool"] == "cmake" + and call["args"][:2] == ["--build", "build/windows-region-generator"]) + self.assertEqual(native_build["path"], native["path"]) + self.assertIn("plugin_python", native_build["args"]) + worldmaker = next(call for call in calls if call["tool"] == "atrinik-server") + self.assertEqual(worldmaker["path"], native["path"]) + self.assertIn("--worldmaker", worldmaker["args"]) + cross_build = next(call for call in calls if call["tool"] == "cmake" + and call["args"][:2] == ["--build", "build/windows-release"]) + self.assertEqual(cross_build["path"], inherited_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci/stage_windows_runtime.py b/tools/ci/stage_windows_runtime.py index 7a7da5674..fd0e8f989 100644 --- a/tools/ci/stage_windows_runtime.py +++ b/tools/ci/stage_windows_runtime.py @@ -280,7 +280,10 @@ def producer_inventory(path: Path, runtime_dir: Path, safe_relative_path(dependency["name"]) for notice in notices: safe_relative_path(notice) - notice_file = runtime_dir.parent / "share/licenses" / dependency["name"] / notice.replace("/", "_") + installed_notice = dependency["name"] + "/" + notice.replace("/", "_") + if installed_notice not in notice_files: + raise StageError(f"dependency notice has no producer hash: {installed_notice}") + notice_file = runtime_dir.parent / "share/licenses" / installed_notice if notice_file.is_symlink() or not notice_file.is_file(): raise StageError(f"producer owner notice is missing: {dependency['name']}/{notice}") records = {} diff --git a/tools/tests/test_windows_trust_notices.py b/tools/tests/test_windows_trust_notices.py index f651522d5..753bd00d1 100644 --- a/tools/tests/test_windows_trust_notices.py +++ b/tools/tests/test_windows_trust_notices.py @@ -284,6 +284,22 @@ def install_result(self, root): return subprocess.run(["cmake", "--install", str(root / "build"), "--prefix", str(root / "installed")], capture_output=True, text=True) + def test_dependency_notice_missing_from_hashed_registry_fails_install(self): + for module in ("client", "server"): + with self.subTest(module=module): + root, result, _, _ = self.fixture(module, cares=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + inventory = root / "toolchain/share/atrinik/windows-dependencies/pe-closure.json" + manifest = json.loads(inventory.read_text()) + del manifest["notices"]["fixture-curl"]["fixture-curl/docs_NOTICE"] + inventory.write_text(json.dumps(manifest)) + # Mere existence cannot qualify a notice that staging will omit. + self.assertTrue((root / "toolchain/share/licenses/fixture-curl/docs_NOTICE").is_file()) + result = self.install_result(root) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("dependency notice has no producer hash: fixture-curl/docs_NOTICE", + " ".join((result.stdout + result.stderr).split())) + def test_pe_inventory_rejects_malformed_unowned_or_changed_dlls(self): for damage in ("malformed", "unowned", "bytes", "imports", "owner", "notice"): with self.subTest(damage=damage): From 1f39dec2f4cf07fb6bc3f9bd59a8942c9f65b92c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:42:06 -0500 Subject: [PATCH 040/148] test(release): name public packaging fixtures precisely --- tools/ci/appimage/test_pre_llvm_recovery.py | 10 +-- tools/ci/appimage/test_prepare.py | 70 ++++++++++----------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tools/ci/appimage/test_pre_llvm_recovery.py b/tools/ci/appimage/test_pre_llvm_recovery.py index 30a564a22..e13b9b79f 100644 --- a/tools/ci/appimage/test_pre_llvm_recovery.py +++ b/tools/ci/appimage/test_pre_llvm_recovery.py @@ -36,8 +36,8 @@ def test_prepared_historical_lock_passes_actual_assembler_and_keeps_llvm_notices path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(historical_blob(name)) historical_lock = source / 'tools/ci/appimage/packaging.lock.json' - trusted = json.loads(Path(__file__).with_name('packaging.lock.json').read_text()) - image = trusted['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 + approved_lock = json.loads(Path(__file__).with_name('packaging.lock.json').read_text()) + image = approved_lock['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 context = root / 'context' def materialize(entry, destination, cache): destination.parent.mkdir(parents=True, exist_ok=True) @@ -48,7 +48,7 @@ def materialize(entry, destination, cache): prepare.main() self.assertEqual((context / 'packaging.lock.json').read_bytes(), historical_lock.read_bytes()) execution = json.loads((context / 'dependency-inputs.lock.json').read_text()) - self.assertEqual(execution['compiler'], trusted['compiler']) + self.assertEqual(execution['compiler'], approved_lock['compiler']) prefix = root / 'prefix' prefix.mkdir() @@ -59,7 +59,7 @@ def materialize(entry, destination, cache): shutil.copyfile('/usr/bin/true', prefix / 'lib/ossl-modules/legacy.so') notices = prefix / 'share/licenses/llvm' notices.mkdir(parents=True) - for name in trusted['compiler']['notices']: + for name in approved_lock['compiler']['notices']: (notices / name).write_bytes((name + '\nComplete notice fixture\n').encode() * 100) appdir = root / 'AppDir' (appdir / 'usr/share/doc/atrinik').mkdir(parents=True) @@ -78,7 +78,7 @@ def copy_until_notices(source_path, destination, *args, **kwargs): assembler.assemble(appdir, prefix, '5.39.0', 'a' * 40) # Stop before the platform library closure, after the real historical # assembler passes its exact SHA gate and copies every LLVM notice. - for name in trusted['compiler']['notices']: + for name in approved_lock['compiler']['notices']: self.assertEqual((appdir / 'usr/share/doc/atrinik/licenses/llvm' / name).read_bytes(), (notices / name).read_bytes()) (prefix / 'packaging.lock.json').write_bytes(Path(__file__).with_name('packaging.lock.json').read_bytes()) diff --git a/tools/ci/appimage/test_prepare.py b/tools/ci/appimage/test_prepare.py index bb84341f1..e47045a48 100644 --- a/tools/ci/appimage/test_prepare.py +++ b/tools/ci/appimage/test_prepare.py @@ -16,28 +16,28 @@ class IntegrityTests(unittest.TestCase): - def trusted_lock(self): + def approved_lock_fixture(self): lock = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) lock['compiler']['image'] = 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 return lock def test_historical_lock_changes_only_approved_image_and_retains_exact_bytes(self): - trusted = self.trusted_lock() + approved_lock = self.approved_lock_fixture() image = 'ghcr.io/atrinik/classic-build:1.1.0@sha256:' + 'b' * 64 - historical = copy.deepcopy(trusted) + historical = copy.deepcopy(approved_lock) historical['compiler']['image'] = image raw = (json.dumps(historical, indent=7) + '\n\n').encode() with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'release-lock.json' path.write_bytes(raw) - selected, selected_bytes = prepare.select_packaging_lock(trusted, image, path) + selected, selected_bytes = prepare.select_packaging_lock(approved_lock, image, path) self.assertEqual(selected, historical) self.assertEqual(selected_bytes, raw) - self.assertNotEqual(trusted['compiler']['image'], image) + self.assertNotEqual(approved_lock['compiler']['image'], image) with self.assertRaisesRegex(ValueError, 'trusted packaging lock'): - prepare.select_packaging_lock(trusted, image) + prepare.select_packaging_lock(approved_lock, image) with self.assertRaisesRegex(ValueError, 'differs from --compiler-image'): - prepare.select_packaging_lock(trusted, trusted['compiler']['image'], path) + prepare.select_packaging_lock(approved_lock, approved_lock['compiler']['image'], path) for section, key, value in (('source', 'url', 'https://example.invalid/other'), ('defaults', 'runtime', 'libgcc'), ('notices', 'compiler-rt-LICENSE.TXT', '0' * 64)): @@ -45,69 +45,69 @@ def test_historical_lock_changes_only_approved_image_and_retains_exact_bytes(sel changed['compiler'][section][key] = value path.write_text(json.dumps(changed)) with self.subTest(section=section), self.assertRaisesRegex(ValueError, 'SDK recipe or LLVM cohort'): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) changed = copy.deepcopy(historical) changed['sources'][0]['url'] = 'https://example.invalid/private-source' path.write_text(json.dumps(changed)) with self.assertRaisesRegex(ValueError, 'SDK recipe or LLVM cohort'): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) changed = copy.deepcopy(historical) changed['schema_version'] = float(changed['schema_version']) path.write_text(json.dumps(changed)) with self.assertRaisesRegex(ValueError, 'SDK recipe or LLVM cohort'): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) def test_historical_default_argv_and_present_missing_image_fail_closed(self): - trusted = self.trusted_lock() - image = trusted['compiler']['image'] - self.assertEqual(prepare.select_packaging_lock(trusted, image), (trusted, None)) + approved_lock = self.approved_lock_fixture() + image = approved_lock['compiler']['image'] + self.assertEqual(prepare.select_packaging_lock(approved_lock, image), (approved_lock, None)) with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'release-lock.json' - historical = copy.deepcopy(trusted) + historical = copy.deepcopy(approved_lock) del historical['compiler']['image'] path.write_text(json.dumps(historical)) with self.assertRaisesRegex(ValueError, 'differs from --compiler-image'): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) del historical['compiler'] path.write_text(json.dumps(historical)) with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) def test_pre_llvm_projection_preserves_source_bytes_and_current_approved_image(self): - trusted = self.trusted_lock() - image = trusted['compiler']['image'] - legacy = {key: value for key, value in trusted.items() if key != 'compiler'} - legacy['notices'] = [path for path in trusted['notices'] if path not in prepare.LLVM_NOTICE_PATHS] + approved_lock = self.approved_lock_fixture() + image = approved_lock['compiler']['image'] + legacy = {key: value for key, value in approved_lock.items() if key != 'compiler'} + legacy['notices'] = [path for path in approved_lock['notices'] if path not in prepare.LLVM_NOTICE_PATHS] raw = (json.dumps(legacy, indent=7) + '\n\n').encode() with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'legacy-lock.json' path.write_bytes(raw) - self.assertEqual(prepare.select_packaging_lock(trusted, image, path), (legacy, raw)) + self.assertEqual(prepare.select_packaging_lock(approved_lock, image, path), (legacy, raw)) with self.assertRaisesRegex(ValueError, 'trusted packaging lock'): - prepare.select_packaging_lock(trusted, image.replace('a' * 64, 'b' * 64), path) + prepare.select_packaging_lock(approved_lock, image.replace('a' * 64, 'b' * 64), path) changed = copy.deepcopy(legacy) changed['sources'][0]['url'] = 'https://example.invalid/private-source' path.write_text(json.dumps(changed)) with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) changed = copy.deepcopy(legacy) changed['compiler'] = None path.write_text(json.dumps(changed)) with self.assertRaisesRegex(ValueError, 'requires a compiler descriptor'): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) changed = copy.deepcopy(legacy) changed['notices'].pop() path.write_text(json.dumps(changed)) with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) def test_pre_llvm_context_keeps_execution_compiler_and_all_notice_installation(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - trusted = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) - image = trusted['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 - legacy = {key: value for key, value in trusted.items() if key != 'compiler'} - legacy['notices'] = [path for path in trusted['notices'] if path not in prepare.LLVM_NOTICE_PATHS] + approved_lock = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) + image = approved_lock['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 + legacy = {key: value for key, value in approved_lock.items() if key != 'compiler'} + legacy['notices'] = [path for path in approved_lock['notices'] if path not in prepare.LLVM_NOTICE_PATHS] raw = (json.dumps(legacy, sort_keys=True, indent=7) + '\n\n').encode() path = root / 'legacy-lock.json' path.write_bytes(raw) @@ -121,26 +121,26 @@ def materialize(entry, destination, cache): prepare.main() self.assertEqual((output / 'packaging.lock.json').read_bytes(), raw) inputs = json.loads((output / 'dependency-inputs.lock.json').read_text()) - self.assertEqual(inputs['compiler'], trusted['compiler']) + self.assertEqual(inputs['compiler'], approved_lock['compiler']) def test_source_lock_paths_shapes_and_duplicate_keys_fail_before_materialization(self): - trusted = self.trusted_lock() - image = trusted['compiler']['image'] + approved_lock = self.approved_lock_fixture() + image = approved_lock['compiler']['image'] with tempfile.TemporaryDirectory() as directory: root = Path(directory) path = root / 'source-lock' for data in ('[]', '{', 'null', '{"compiler":{},"compiler":{}}', ' ' * (4 * 1024 * 1024 + 1)): path.write_text(data) with self.subTest(data_length=len(data)), self.assertRaises(ValueError): - prepare.select_packaging_lock(trusted, image, path) + prepare.select_packaging_lock(approved_lock, image, path) alias = root / 'alias' alias.symlink_to(path) with self.assertRaises(OSError): - prepare.select_packaging_lock(trusted, image, alias) + prepare.select_packaging_lock(approved_lock, image, alias) fifo = root / 'fifo' os.mkfifo(fifo) with self.assertRaisesRegex(ValueError, 'bounded regular file'): - prepare.select_packaging_lock(trusted, image, fifo) + prepare.select_packaging_lock(approved_lock, image, fifo) def test_recovery_context_retains_exact_source_lock_bytes_and_current_recipe(self): with tempfile.TemporaryDirectory() as directory: From 241f761ee68204948794aa0eeeaaf394f6954da0 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:44:41 -0500 Subject: [PATCH 041/148] test(build): verify worldmaker bytecode environment stays scoped --- server/tools/tests/test_windows_package_toolchains.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/tools/tests/test_windows_package_toolchains.py b/server/tools/tests/test_windows_package_toolchains.py index a5d16865a..131b0e83e 100644 --- a/server/tools/tests/test_windows_package_toolchains.py +++ b/server/tools/tests/test_windows_package_toolchains.py @@ -19,6 +19,7 @@ output.write(json.dumps({"tool": name, "args": args, "path": os.environ["PATH"], "toolchain": os.environ.get("CMAKE_TOOLCHAIN_FILE"), + "python_no_bytecode": os.environ.get("PYTHONDONTWRITEBYTECODE"), "cc": os.environ.get("CC"), "cxx": os.environ.get("CXX")}) + "\n") if name == "git": print(os.environ["FIXTURE_REPOSITORY"]) @@ -88,6 +89,7 @@ def test_native_worldmaker_does_not_inherit_cross_compiler_selection(self) -> No "PATH": inherited_path, "CC": str(mingw / "clang"), "CXX": str(mingw / "clang++"), "FIXTURE_CALLS": str(log), "CMAKE_TOOLCHAIN_FILE": str(toolchain), + "PYTHONDONTWRITEBYTECODE": "0", "FIXTURE_REPOSITORY": str(repository), "ATRINIK_PACKAGE_VERSION": "0.0.0", "ATRINIK_WINDOWS_TOOLCHAIN_FILE": str(toolchain), "ATRINIK_WINDOWS_TARGET": "x86_64-w64-mingw32", @@ -133,10 +135,13 @@ def test_native_worldmaker_does_not_inherit_cross_compiler_selection(self) -> No self.assertIn("plugin_python", native_build["args"]) worldmaker = next(call for call in calls if call["tool"] == "atrinik-server") self.assertEqual(worldmaker["path"], native["path"]) + self.assertEqual(worldmaker["python_no_bytecode"], "1") self.assertIn("--worldmaker", worldmaker["args"]) cross_build = next(call for call in calls if call["tool"] == "cmake" and call["args"][:2] == ["--build", "build/windows-release"]) self.assertEqual(cross_build["path"], inherited_path) + self.assertEqual(cross["python_no_bytecode"], "0") + self.assertEqual(cross_build["python_no_bytecode"], "0") if __name__ == "__main__": From 0771fc9dee5f0a278377fffadd817fb114588dbf Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:45:17 -0500 Subject: [PATCH 042/148] test(build): retain bytecode guard through native LLVM launcher --- tools/tests/test_workflow_contracts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 921cb567f..d29699d45 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -491,7 +491,7 @@ def test_windows_package_profile_sound_override_is_explicit_and_bounded(self) -> self.assertIn("validate_profile_tree ATRINIK_PROFILE_CONTENT_DIR", server_script) self.assertIn("validate_profile_tree ATRINIK_PROFILE_RESOURCES_DIR", server_script) self.assertIn( - "PYTHONDONTWRITEBYTECODE=1 ./atrinik-server", server_script + "PYTHONDONTWRITEBYTECODE=1 native_llvm ./atrinik-server", server_script ) self.assertIn('python3 tools/dependencies.py sync', server_script) self.assertIn('python3 tools/dependencies.py verify', server_script) From 12c5ef0a1922bdf5c07bccf8712f7b10ec24787a Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 00:51:39 -0500 Subject: [PATCH 043/148] fix(build): select libc++ headers from the macOS SDK --- cmake/toolchains/llvm-macos.cmake | 28 ++++++++++++----- docs/MACOS_TOOLCHAIN.md | 8 +++-- tools/tests/test_macos_llvm_toolchain.py | 40 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/cmake/toolchains/llvm-macos.cmake b/cmake/toolchains/llvm-macos.cmake index 680c15f42..4100809ba 100644 --- a/cmake/toolchains/llvm-macos.cmake +++ b/cmake/toolchains/llvm-macos.cmake @@ -39,13 +39,27 @@ if (NOT CMAKE_OSX_SYSROOT) endif () set(CMAKE_OSX_SYSROOT "${sdk}" CACHE PATH "Selected Xcode macOS SDK") endif () -execute_process(COMMAND "${ATRINIK_XCRUN}" --sdk macosx --find clang++ - OUTPUT_VARIABLE apple_cxx OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE apple_result) -get_filename_component(apple_bin "${apple_cxx}" DIRECTORY) -get_filename_component(apple_usr "${apple_bin}" DIRECTORY) -set(apple_cxx_headers "${apple_usr}/include/c++/v1") -if (NOT apple_result EQUAL 0 OR NOT IS_DIRECTORY "${apple_cxx_headers}") - message(FATAL_ERROR "The selected Xcode platform libc++ headers are required") +# Platform libc++ headers may live in the SDK or beside Apple Clang; +# both layouts remain platform-owned. +set(apple_sdk "${CMAKE_OSX_SYSROOT}") +if (NOT IS_DIRECTORY "${apple_sdk}") + # CMake also accepts a named SDK such as macosx instead of an absolute path. + execute_process(COMMAND "${ATRINIK_XCRUN}" --sdk "${CMAKE_OSX_SYSROOT}" --show-sdk-path + OUTPUT_VARIABLE apple_sdk OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE apple_sdk_result) + if (NOT apple_sdk_result EQUAL 0 OR NOT IS_DIRECTORY "${apple_sdk}") + message(FATAL_ERROR "A selected Xcode macOS SDK is required") + endif () +endif () +set(apple_cxx_headers "${apple_sdk}/usr/include/c++/v1") +if (NOT IS_DIRECTORY "${apple_cxx_headers}") + execute_process(COMMAND "${ATRINIK_XCRUN}" --sdk macosx --find clang++ + OUTPUT_VARIABLE apple_cxx OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE apple_result) + get_filename_component(apple_bin "${apple_cxx}" DIRECTORY) + get_filename_component(apple_usr "${apple_bin}" DIRECTORY) + set(apple_cxx_headers "${apple_usr}/include/c++/v1") + if (NOT apple_result EQUAL 0 OR NOT IS_DIRECTORY "${apple_cxx_headers}") + message(FATAL_ERROR "The selected Xcode platform libc++ headers are required") + endif () endif () # Avoid the downloaded cohort's newer libc++ headers with the system libc++ ABI. set(CMAKE_CXX_FLAGS_INIT "-nostdinc++ -isystem \"${apple_cxx_headers}\"") diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md index b5693d8a8..595b85a1d 100644 --- a/docs/MACOS_TOOLCHAIN.md +++ b/docs/MACOS_TOOLCHAIN.md @@ -38,8 +38,12 @@ builtins archive must exist. Complete LLVM/runtime notices accompany the install Darwin builds automatically select this toolchain before `project()` when no explicit toolchain was supplied. An explicit `CMAKE_OSX_SYSROOT`, deployment target and architecture list are preserved. Without an SDK override, `xcrun` -selects the macOS SDK. The platform libc++ headers come from the selected Xcode -toolchain rather than the downloaded LLVM cohort. Mach-O LLD is selected by its +selects the macOS SDK. The platform libc++ headers come first from +`usr/include/c++/v1` in the selected SDK, with the selected Apple Clang +toolchain's `include/c++/v1` as a +fallback for older Xcode layouts. Named SDKs resolve through `xcrun` without +overwriting the caller's SDK selection. The downloaded LLVM cohort's libc++ +headers are not used with the system libc++ runtime. Mach-O LLD is selected by its absolute path, following [LLD's native macOS interface](https://lld.llvm.org/MachO/index.html). Linux CMake-script fixtures verify selection, version rejection, platform-header diff --git a/tools/tests/test_macos_llvm_toolchain.py b/tools/tests/test_macos_llvm_toolchain.py index e11b51b4f..218568189 100644 --- a/tools/tests/test_macos_llvm_toolchain.py +++ b/tools/tests/test_macos_llvm_toolchain.py @@ -122,6 +122,46 @@ def test_macos_binds_macho_lld_sdk_and_system_cxx_headers(self): result = self.script(body) self.assertEqual(result.returncode, 0, result.stderr) + def test_modern_sdk_cpp_headers_work_without_legacy_xcode_header_directory(self): + headers = self.sdk / "usr/include/c++/v1" + headers.mkdir(parents=True) + (self.apple_usr / "include/c++/v1").rmdir() + result = self.script(self.settings() + f''' +if (NOT apple_cxx_headers STREQUAL "{headers}") + message(FATAL_ERROR "modern SDK headers not selected") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + + def test_explicit_sdk_cpp_headers_take_precedence_over_legacy_xcode_headers(self): + selected_sdk = self.root / "Older selected MacOSX.sdk" + headers = selected_sdk / "usr/include/c++/v1" + headers.mkdir(parents=True) + result = self.script(f'set(CMAKE_OSX_SYSROOT "{selected_sdk}")\n' + self.settings() + f''' +if (NOT apple_cxx_headers STREQUAL "{headers}" OR + NOT CMAKE_OSX_SYSROOT STREQUAL "{selected_sdk}") + message(FATAL_ERROR "explicit SDK selection changed") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + + def test_named_sdk_resolves_headers_without_overwriting_caller_selection(self): + headers = self.sdk / "usr/include/c++/v1" + headers.mkdir(parents=True) + result = self.script('set(CMAKE_OSX_SYSROOT macosx)\n' + self.settings() + f''' +if (NOT apple_cxx_headers STREQUAL "{headers}" OR + NOT CMAKE_OSX_SYSROOT STREQUAL "macosx") + message(FATAL_ERROR "named SDK selection changed") +endif () +''') + self.assertEqual(result.returncode, 0, result.stderr) + + def test_unresolved_sdk_rejected_even_with_legacy_headers_available(self): + self.xcrun.write_text('#!/bin/sh\nexit 1\n') + result = self.script('set(CMAKE_OSX_SYSROOT unavailable-sdk)\n' + self.settings()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("selected Xcode macOS SDK", result.stderr) + def test_explicit_sdk_deployment_and_architectures_are_preserved(self): result = self.script(''' set(CMAKE_OSX_SYSROOT explicit-sdk) From d68328050a16dbbdb80f7f85cb494734fc70a48f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:06:39 -0500 Subject: [PATCH 044/148] fix(build): retain LLVM tools in macOS compiler checks --- cmake/toolchains/llvm-macos.cmake | 3 +++ docs/MACOS_TOOLCHAIN.md | 2 ++ tools/tests/test_macos_llvm_toolchain.py | 34 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/cmake/toolchains/llvm-macos.cmake b/cmake/toolchains/llvm-macos.cmake index 4100809ba..83f9b210a 100644 --- a/cmake/toolchains/llvm-macos.cmake +++ b/cmake/toolchains/llvm-macos.cmake @@ -6,6 +6,9 @@ if (NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") message(FATAL_ERROR "This LLVM toolchain requires a native macOS host") endif () +# Keep caller-selected LLVM tool discovery available in compiler ABI checks. +list(APPEND CMAKE_TRY_COMPILE_PLATFORM_VARIABLES CMAKE_PROGRAM_PATH) + find_program(CMAKE_C_COMPILER NAMES clang REQUIRED) find_program(CMAKE_CXX_COMPILER NAMES clang++ REQUIRED) find_program(CMAKE_AR NAMES llvm-ar REQUIRED) diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md index 595b85a1d..f8a46d9fb 100644 --- a/docs/MACOS_TOOLCHAIN.md +++ b/docs/MACOS_TOOLCHAIN.md @@ -45,6 +45,8 @@ fallback for older Xcode layouts. Named SDKs resolve through `xcrun` without overwriting the caller's SDK selection. The downloaded LLVM cohort's libc++ headers are not used with the system libc++ runtime. Mach-O LLD is selected by its absolute path, following [LLD's native macOS interface](https://lld.llvm.org/MachO/index.html). +The caller's LLVM `CMAKE_PROGRAM_PATH` is propagated into CMake's nested compiler +checks, so those checks select the same pinned LLVM utilities. Linux CMake-script fixtures verify selection, version rejection, platform-header selection and missing-runtime failures. They do not prove that a release archive diff --git a/tools/tests/test_macos_llvm_toolchain.py b/tools/tests/test_macos_llvm_toolchain.py index 218568189..a5fff686e 100644 --- a/tools/tests/test_macos_llvm_toolchain.py +++ b/tools/tests/test_macos_llvm_toolchain.py @@ -75,6 +75,40 @@ def test_darwin_default_uses_native_macos_toolchain(self): ''') self.assertEqual(result.returncode, 0, result.stderr) + def test_nested_try_compile_retains_caller_llvm_program_path(self): + version_output = subprocess.run([shutil.which("cmake"), "--version"], + capture_output=True, text=True, check=True) + version = tuple(int(part) for part in version_output.stdout.split()[2].split(".")[:2]) + if version < (3, 25): + self.skipTest("modern whole-project try_compile fixture requires CMake 3.25") + # A real nested CMake configure exercises propagation independently of + # native SDK compilation with CMP0137 enabled. LLVM fixtures live + # outside the process PATH; older CMake retains the policy fixtures. + wrapper = self.root / "fixture-toolchain.cmake" + wrapper.write_text(f'''set(CMAKE_HOST_SYSTEM_NAME Darwin) +include("{TOOLCHAIN}") +''') + nested = self.root / "nested" + nested.mkdir() + (nested / "CMakeLists.txt").write_text('''cmake_minimum_required(VERSION 3.25) +project(NestedLLVMDiscovery LANGUAGES NONE) +''') + outer = self.root / "outer" + outer.mkdir() + (outer / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.25) +project(LLVMDiscovery LANGUAGES NONE) +try_compile(nested_ok PROJECT NestedLLVMDiscovery SOURCE_DIR "{nested}" + OUTPUT_VARIABLE nested_output) +if (NOT nested_ok) + message(FATAL_ERROR "nested LLVM discovery failed: ${{nested_output}}") +endif () +''') + result = subprocess.run([ + shutil.which("cmake"), "-S", str(outer), "-B", str(self.root / "build"), + f"-DCMAKE_TOOLCHAIN_FILE={wrapper}", f"-DCMAKE_PROGRAM_PATH={self.bin}", + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_explicit_darwin_cross_toolchain_is_preserved(self): result = self.script(''' set(CMAKE_HOST_SYSTEM_NAME Darwin) From a4164c88f81a61e33e8c222494490c93e72951ae Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:06:47 -0500 Subject: [PATCH 045/148] fix(build): resolve strict LLVM 23 native diagnostics --- client/src/client/gpu_map_renderer.c | 3 --- client/src/tests/face_request.c | 6 ++++++ server/src/loaders/map_header.l | 9 ++------- server/src/plugins/plugin_python/plugin_python.c | 13 ++----------- server/src/server/spell_effect.c | 2 +- server/src/server/time.c | 12 ------------ 6 files changed, 11 insertions(+), 34 deletions(-) diff --git a/client/src/client/gpu_map_renderer.c b/client/src/client/gpu_map_renderer.c index 9a59c8536..73c963a6a 100644 --- a/client/src/client/gpu_map_renderer.c +++ b/client/src/client/gpu_map_renderer.c @@ -264,7 +264,6 @@ static size_t projected_light_row_gpu_bytes; static SDL_GPUTexture *albedo_target; static SDL_GPUTexture *owner_target; static SDL_GPUTexture *final_target; -static SDL_Texture *wrapped_final_target; static gpu_map_target_set_t map_targets[2]; static size_t active_target_index; static SDL_GPUCommandBuffer *map_command_buffer; @@ -1032,7 +1031,6 @@ static void gpu_map_target_activate(size_t index) { albedo_target = target->albedo; owner_target = target->owner; final_target = target->final; - wrapped_final_target = target->wrapped_final; target_width = target->width; target_height = target->height; } @@ -1072,7 +1070,6 @@ static void gpu_map_targets_destroy(void) { albedo_target = NULL; owner_target = NULL; final_target = NULL; - wrapped_final_target = NULL; target_width = 0; target_height = 0; active_target_index = 0; diff --git a/client/src/tests/face_request.c b/client/src/tests/face_request.c index 2a9455669..0e4bd9d08 100644 --- a/client/src/tests/face_request.c +++ b/client/src/tests/face_request.c @@ -1258,13 +1258,19 @@ static void test_production_completion_budget(void) { TEST_CHECK(test_asset_active == 0); TEST_CHECK(face_loader_test_wait_results(TEST_BUDGET_FACE_COUNT, TEST_ASYNC_TIMEOUT_MS)); + size_t cache_enqueues_before = test_cache_enqueues; size_t redraws_before = test_book_redraws; + size_t interface_redraws_before = test_interface_redraws; + size_t widget_redraws_before = test_widget_redraws; size_t active_effect_redraws_before = test_active_effect_redraws; test_budget_clock_calls = 0; image_face_requests_test_clock_set(test_budget_clock); image_face_requests_service(); TEST_CHECK(test_loaded_budget_faces() == 1); + TEST_CHECK(test_cache_enqueues == cache_enqueues_before + 1); TEST_CHECK(test_book_redraws == redraws_before + 1); + TEST_CHECK(test_interface_redraws == interface_redraws_before + 1); + TEST_CHECK(test_widget_redraws == widget_redraws_before + 4); TEST_CHECK(test_active_effect_redraws == active_effect_redraws_before + 1); image_face_requests_test_clock_set(NULL); diff --git a/server/src/loaders/map_header.l b/server/src/loaders/map_header.l index 74276f1ce..07d59608a 100644 --- a/server/src/loaders/map_header.l +++ b/server/src/loaders/map_header.l @@ -2,7 +2,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2026 The Atrinik Project * + * Copyright 2009-2026 The Atrinik Project * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -35,7 +35,7 @@ static const char *yval(void); static bool parse_int(const char *value, int minimum, int maximum, int *result); static char msgbuf[HUGE_BUF]; -static int lex_error, msg_flag; +static int msg_flag; #define IVAL atoi(yval()) @@ -76,11 +76,6 @@ WS [ \t]* %% -%{ -/* Declare some local variables */ -lex_error = 0; -%} - ^name{S} FREE_AND_COPY_HASH(m->name, yval()); ^bg_music{S} FREE_AND_COPY_HASH(m->bg_music, yval()); ^weather{S} FREE_AND_COPY_HASH(m->weather, yval()); diff --git a/server/src/plugins/plugin_python/plugin_python.c b/server/src/plugins/plugin_python/plugin_python.c index 33ddce5a8..9f5586ad0 100644 --- a/server/src/plugins/plugin_python/plugin_python.c +++ b/server/src/plugins/plugin_python/plugin_python.c @@ -49,9 +49,7 @@ struct plugin_hooklist *hooks; /** A generic exception that we use for error messages. */ PyObject *AtrinikError; -/** The context stack. */ -static PythonContext *context_stack; -/** Current context. */ +/** Top of the context stack; each context links to its caller through down. */ PythonContext *current_context; /** * The global variables dictionary. @@ -508,7 +506,6 @@ static void clear_python_cache(void) { */ static void initContextStack(void) { current_context = NULL; - context_stack = NULL; } /** @@ -517,13 +514,7 @@ static void initContextStack(void) { * The context to push. */ static void pushContext(PythonContext *context) { - if (current_context == NULL) { - context_stack = context; - context->down = NULL; - } else { - context->down = current_context; - } - + context->down = current_context; current_context = context; } diff --git a/server/src/server/spell_effect.c b/server/src/server/spell_effect.c index bb87514c0..5106b6e85 100644 --- a/server/src/server/spell_effect.c +++ b/server/src/server/spell_effect.c @@ -1431,7 +1431,7 @@ int cast_transform_wealth(object *op) { /* Figure out our value of money to give to player. */ int64_t source_value; if (!shop_money_object_counted(op, marked) || !shop_money_object_value(marked, &source_value) || - source_value > INT64_MAX / TRANSFORM_WEALTH_SACRIFICE || !shop_coins_available()) { + source_value > (float)INT64_MAX / TRANSFORM_WEALTH_SACRIFICE || !shop_coins_available()) { free(name); return 0; } diff --git a/server/src/server/time.c b/server/src/server/time.c index c8a3011cc..412a42831 100644 --- a/server/src/server/time.c +++ b/server/src/server/time.c @@ -50,9 +50,7 @@ static long psaveind; static long process_max_utime = 0; /** Shortest cycle time. */ static long process_min_utime = 999999999; -static long process_tot_mtime; long pticks; -static long process_utime_long_count; /** Used for main loop timing. */ struct timeval last_time; @@ -153,7 +151,6 @@ void reset_sleep(void) { psaveind = 0; process_max_utime = 0; process_min_utime = 999999999; - process_tot_mtime = 0; pticks = 1; server_clock_init((uint64_t)max_time, (server_tick_t){(uint64_t)pticks}); @@ -177,8 +174,6 @@ static void log_time(long process_utime) { if (process_utime < process_min_utime) { process_min_utime = process_utime; } - - process_tot_mtime += process_utime / 1000; } /** Calculate the remaining time before the next non-transport pass. */ @@ -215,17 +210,10 @@ uint64_t sleep_delta_timeout_us(void) { /** Advance the next non-transport pass deadline without sleeping. */ void sleep_delta_complete(void) { struct timeval new_time; - long sleep_sec, sleep_usec; - GETTIMEOFDAY(&new_time); - sleep_delta_remaining(&new_time, &sleep_sec, &sleep_usec); log_time((new_time.tv_sec - last_time.tv_sec) * 1000000 + new_time.tv_usec - last_time.tv_usec); - if (sleep_sec < 0 || sleep_usec <= 0) { - process_utime_long_count++; - } - /* Set last_time to when we're expected to wake up: */ last_time.tv_usec += max_time / max_time_multiplier; From e4e84fb014a94c952cdbf9910a144e2266eea304 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:09:39 -0500 Subject: [PATCH 046/148] fix(tests): preserve escape-byte shutdown fixture under LLVM 23 --- server/src/tests/unit/server/admin_shutdown.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/tests/unit/server/admin_shutdown.c b/server/src/tests/unit/server/admin_shutdown.c index 241c8b9ad..32801b296 100644 --- a/server/src/tests/unit/server/admin_shutdown.c +++ b/server/src/tests/unit/server/admin_shutdown.c @@ -35,7 +35,7 @@ START_TEST(test_reject_invalid_requests) { "ATRINIK-ADMIN/1 SHUTDOWN 0123456789abcdef0123456789abcdef 60 \n", "ATRINIK-ADMIN/1 SHUTDOWN 0123456789abcdef0123456789abcdef 60 update", "ATRINIK-ADMIN/1 SHUTDOWN 0123456789abcdef0123456789abcdef 60 update\nnext\n", - "ATRINIK-ADMIN/1 SHUTDOWN 0123456789abcdef0123456789abcdef 60 up\x1b" "date\n", + "ATRINIK-ADMIN/1 SHUTDOWN 0123456789abcdef0123456789abcdef 60 up\033date\n", "ATRINIK-ADMIN/1 SHUTDOWN 0123456789abcdef0123456789abcdef 60 \xc0\xaf\n", "ATRINIK-ADMIN/1 SHUTDOWN 0123456789abcdef0123456789abcdef 60 \xed\xa0\x80\n", "ATRINIK-ADMIN/1 SHUTDOWN 0123456789abcdef0123456789abcdef 60 \xf4\x90\x80\x80\n", From ad7f427a52bb8e98e854d42e844967116d975881 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:28:16 -0500 Subject: [PATCH 047/148] fix(ci): qualify supported Classic macOS targets --- .../workflows/llvm-macos-qualification.yml | 37 +++++++++---------- docs/MACOS_TOOLCHAIN.md | 24 ++++++++---- .../test_macos_qualification_workflow.py | 33 +++++++++++++---- 3 files changed, 58 insertions(+), 36 deletions(-) diff --git a/.github/workflows/llvm-macos-qualification.yml b/.github/workflows/llvm-macos-qualification.yml index 7d5ae8c81..1596a06be 100644 --- a/.github/workflows/llvm-macos-qualification.yml +++ b/.github/workflows/llvm-macos-qualification.yml @@ -82,13 +82,12 @@ jobs: run: | set -euo pipefail brew install --force-bottle \ - pkgconf flex check miniupnpc gd libidn2 curl openssl@3 zlib \ + pkgconf libidn2 curl openssl@3 zlib \ libxml2 readline python@3.12 sdl3 sdl3_image sdl3_ttf sdl3_mixer \ 2>&1 | tee build/macos-qualification-evidence/dependency-install.log brew list --versions > build/macos-qualification-evidence/dependency-versions.txt brew info --json=v2 --installed > build/macos-qualification-evidence/dependencies.json echo "$(brew --prefix python@3.12)/libexec/bin" >> "${GITHUB_PATH}" - echo "$(brew --prefix flex)/bin" >> "${GITHUB_PATH}" - name: Verify pinned LLVM and compile and run native C and C++ SDK probes shell: bash run: | @@ -108,14 +107,8 @@ jobs: set -euo pipefail python3 client/tools/dependencies.py sync --root client --refresh python3 client/tools/dependencies.py verify --root client - python3 server/tools/dependencies.py sync --root server --refresh - python3 server/tools/dependencies.py verify --root server python3 client/tools/verify_gpu_fixture_provenance.py - pcp_source=$(python3 server/tools/dependencies.py source \ - --root server --source-lock server/cmake/immutable_sources.lock.json \ - --source-name libpcpnatpmp --cache "${RUNNER_TEMP}/macos-source-cache") - echo "ATRINIK_MACOS_PCP_SOURCE=${pcp_source}" >> "${GITHUB_ENV}" - - name: Configure the complete Classic Release graph + - name: Configure the supported macOS Classic Release targets shell: bash run: | set -euo pipefail @@ -129,14 +122,12 @@ jobs: cmake -S . -B build/macos-qualification -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="${GITHUB_WORKSPACE}/cmake/toolchains/llvm-macos.cmake" \ -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DPACKAGE_TYPE=none \ + -DATRINIK_BUILD_CLIENT=ON -DATRINIK_BUILD_SERVER=OFF \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ "-DCMAKE_PREFIX_PATH=${prefix};$(brew --prefix openssl@3);$(brew --prefix curl);$(brew --prefix libxml2);$(brew --prefix zlib);$(brew --prefix readline);${python_prefix}" \ "-DOPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" \ "-DCURL_ROOT=$(brew --prefix curl)" \ - "-DPython_ROOT_DIR=${python_prefix}" -DPython_FIND_FRAMEWORK=LAST \ "-DPython3_EXECUTABLE=${python_prefix}/bin/python3.12" \ - "-DFLEX_EXECUTABLE=$(brew --prefix flex)/bin/flex" \ - "-DFETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP=${ATRINIK_MACOS_PCP_SOURCE}" \ "-DATRINIK_GPU_SHADER_DIRECTORY=${GITHUB_WORKSPACE}/build/macos-qualification-shaders" \ 2>&1 | tee build/macos-qualification-evidence/configure.log - name: Verify required production features were retained @@ -150,14 +141,18 @@ jobs: if line and not line.startswith(('#', '//')) and '=' in line and ':' in line: key, value = line.split('=', 1) cache[key.split(':', 1)[0]] = value - for option in ('ATRINIK_BUILD_CLIENT', 'ATRINIK_BUILD_SERVER', 'ENABLE_WARNING_ERRORS', - 'ENABLE_PYTHON_PLUGIN', 'ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS'): - if cache.get(option) != 'ON': - raise SystemExit(f'required production feature absent: {option}') - if not cache.get('GD_LIBRARY') or cache['GD_LIBRARY'].endswith('-NOTFOUND'): - raise SystemExit('required world maker dependency absent: GD_LIBRARY') + required = { + 'ATRINIK_BUILD_CLIENT': 'ON', 'ATRINIK_BUILD_SERVER': 'OFF', + 'ENABLE_WARNING_ERRORS': 'ON', 'BUILD_TESTING': 'ON', + 'CMAKE_BUILD_TYPE': 'Release', + } + for option, value in required.items(): + if cache.get(option) != value: + raise SystemExit(f'required macOS build setting absent: {option}={value}') + if not cache.get('READLINE_LIBRARY') or cache['READLINE_LIBRARY'].endswith('-NOTFOUND'): + raise SystemExit('required toolkit dependency absent: READLINE_LIBRARY') PY - - name: Build the production client, server, library and test executables + - name: Build the production client, shared libraries and test executables shell: bash run: | set -euo pipefail @@ -176,9 +171,11 @@ jobs: from pathlib import Path data = json.loads(Path('build/macos-qualification-evidence/tests.json').read_text()) names = [test['name'] for test in data['tests']] - for prefix in ('protocol-', 'libatrinik-', 'atrinik-pathfinding', 'client-', 'server-unit-', 'server-plugin-python'): + for prefix in ('protocol-', 'libatrinik-', 'atrinik-pathfinding', 'client-'): if not any(name.startswith(prefix) for name in names): raise SystemExit(f'missing required native CPU tests: {prefix}') + if any(name.startswith('server-') for name in names): + raise SystemExit('unsupported server tests included in macOS qualification') print(f'{len(names)} native CPU tests selected') PY ctest --test-dir build/macos-qualification --output-on-failure \ diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md index f8a46d9fb..12dd27a5e 100644 --- a/docs/MACOS_TOOLCHAIN.md +++ b/docs/MACOS_TOOLCHAIN.md @@ -71,7 +71,10 @@ OS, selected SDK and disk space instead of inferring them from the label. The native job installs ARM64 Homebrew bottles for the current application prerequisites: SDL3, SDL3_image, SDL3_ttf, SDL3_mixer, OpenSSL 3, libxml2, -libidn2, curl, zlib, Readline, Flex, MiniUPnPc, libgd, Check and Python 3.12. +libidn2, curl, zlib, Readline and Python 3.12. Python supplies the protocol, +client and validation tooling interpreter. Readline retains the toolkit's +interactive console feature. Flex, MiniUPnPc, libgd, Check and libpcpnatpmp +belong to the server build and are not acquired by this Mac lane. The official [SDL3](https://formulae.brew.sh/formula/sdl3), [SDL3_mixer](https://formulae.brew.sh/formula/sdl3_mixer) and [OpenSSL 3](https://formulae.brew.sh/formula/openssl@3) formulae currently supply @@ -81,13 +84,18 @@ can change; the exact installed versions and formula metadata are retained with each qualification run. This lane does not create a released dependency producer or qualify different dependency versions by inference. -The complete integrated Release graph builds protocol, libatrinik, pathfinding, -client, server, the server Python plugin and all test executables through LLVM -23.1.3. Existing feature, warning, PCH and optimization defaults remain in use. -Builds and tests use two jobs. Locked sound, content, resources and libpcpnatpmp -inputs use their existing verification helpers. CTest excludes the -`gpu-conformance` and `performance` labels; native CPU test discovery must include -every component and the Python plugin before tests run. GPU test executables +The integrated root selects `ATRINIK_BUILD_CLIENT=ON` and +`ATRINIK_BUILD_SERVER=OFF`, building the supported macOS protocol, libatrinik, +pathfinding, client and test targets through LLVM 23.1.3. The server access +backend deliberately supports Linux and Windows only; this lane preserves that +restriction. Server, worldmaker and server Python-plugin acceptance remains +required in the Linux and Windows lanes. Existing client/library feature, +warning and optimization defaults remain in use, including Release optimization +and warnings as errors. Builds and tests use two jobs. Locked sound, content and +resources use their existing verification helpers. CTest excludes the +`gpu-conformance` and `performance` labels; native CPU discovery must include +protocol, library, pathfinding and client tests and reject server tests before +tests run. GPU test executables still compile. The source-only GPU fixture provenance helper runs explicitly alongside locked runtime acquisition, while hardware GPU tests, benchmarks and Metal reference/minimum qualification remain separate acceptance requirements. diff --git a/tools/tests/test_macos_qualification_workflow.py b/tools/tests/test_macos_qualification_workflow.py index 2d5218620..7176b4801 100644 --- a/tools/tests/test_macos_qualification_workflow.py +++ b/tools/tests/test_macos_qualification_workflow.py @@ -23,10 +23,10 @@ def embedded_python_script(index): class MacOSQualificationWorkflowTests(unittest.TestCase): - def test_discovery_requires_each_native_component_and_python_plugin(self): + def test_discovery_requires_each_supported_native_component(self): names = [ "protocol-generate-check", "libatrinik-smoke", "atrinik-pathfinding", - "client-help-parser", "server-unit-object", "server-plugin-python", + "client-help-parser", ] with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -48,11 +48,19 @@ def test_discovery_requires_each_native_component_and_python_plugin(self): if excluded: self.assertIn("missing required native CPU tests", result.stderr) - def test_disabled_python_pch_and_missing_world_maker_fail_qualification(self): + (evidence / "tests.json").write_text(json.dumps({ + "tests": [{"name": name} for name in [*names, "server-unit-object"]] + })) + result = subprocess.run([shutil.which("python3"), str(script)], cwd=root, + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unsupported server tests", result.stderr) + + def test_incorrect_platform_scope_or_disabled_required_features_fail(self): enabled = { - "ATRINIK_BUILD_CLIENT": "ON", "ATRINIK_BUILD_SERVER": "ON", - "ENABLE_WARNING_ERRORS": "ON", "ENABLE_PYTHON_PLUGIN": "ON", - "ATRINIK_SERVER_ENABLE_PRECOMPILED_HEADERS": "ON", "GD_LIBRARY": "/native/libgd.dylib", + "ATRINIK_BUILD_CLIENT": "ON", "ATRINIK_BUILD_SERVER": "OFF", + "ENABLE_WARNING_ERRORS": "ON", "BUILD_TESTING": "ON", + "CMAKE_BUILD_TYPE": "Release", "READLINE_LIBRARY": "/native/libreadline.dylib", } with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -63,7 +71,10 @@ def test_disabled_python_pch_and_missing_world_maker_fail_qualification(self): for disabled in [None, *enabled]: cache = dict(enabled) if disabled: - cache[disabled] = "GD_LIBRARY-NOTFOUND" if disabled == "GD_LIBRARY" else "OFF" + cache[disabled] = { + "ATRINIK_BUILD_SERVER": "ON", "CMAKE_BUILD_TYPE": "Debug", + "READLINE_LIBRARY": "READLINE_LIBRARY-NOTFOUND", + }.get(disabled, "OFF") (build / "CMakeCache.txt").write_text("".join( f"{key}:STRING={value}\n" for key, value in cache.items() )) @@ -97,9 +108,10 @@ def test_manual_lane_keeps_production_features_and_gpu_boundary(self): self.assertIn("--no-tests=error", text) self.assertIn("--output-junit", text) self.assertIn("-LE 'gpu-conformance|performance'", text) + self.assertIn("-DATRINIK_BUILD_CLIENT=ON -DATRINIK_BUILD_SERVER=OFF", text) for option in ( "ENABLE_PYTHON_PLUGIN", "ENABLE_PRECOMPILED_HEADERS", "ENABLE_WARNING_ERRORS", - "ATRINIK_BUILD_CLIENT", "ATRINIK_BUILD_SERVER", "ENABLE_COVERAGE", + "ENABLE_COVERAGE", "ENABLE_SANITIZERS", "CMAKE_INTERPROCEDURAL_OPTIMIZATION", ): self.assertNotIn("-D" + option, text) @@ -107,6 +119,11 @@ def test_manual_lane_keeps_production_features_and_gpu_boundary(self): self.assertIn("prepare_macos_llvm.sh", text) self.assertIn("-DCMAKE_BUILD_TYPE=Release", text) self.assertIn("cmake --build build/macos-qualification --parallel 2", text) + self.assertNotIn("server/tools/dependencies.py", text) + self.assertNotIn("FETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP", text) + self.assertNotIn("server-plugin-python", text) + self.assertNotIn("complete Classic Release graph", text) + self.assertNotIn("GD_LIBRARY", text) @unittest.skipUnless(shutil.which("bash"), "Bash unavailable") def test_embedded_shell_recipes_are_valid(self): From a6a8a8dbc1104cb75e7a159254393d77b41e1da2 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:41:44 -0500 Subject: [PATCH 048/148] fix(ci): collect reachable native macOS build errors --- .../workflows/llvm-macos-qualification.yml | 2 +- .../test_macos_qualification_workflow.py | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/llvm-macos-qualification.yml b/.github/workflows/llvm-macos-qualification.yml index 1596a06be..6c6f0bfc9 100644 --- a/.github/workflows/llvm-macos-qualification.yml +++ b/.github/workflows/llvm-macos-qualification.yml @@ -156,7 +156,7 @@ jobs: shell: bash run: | set -euo pipefail - cmake --build build/macos-qualification --parallel 2 \ + cmake --build build/macos-qualification --parallel 2 -- -k 0 \ 2>&1 | tee build/macos-qualification-evidence/build.log - name: Verify CPU test discovery and run the non-GPU suites shell: bash diff --git a/tools/tests/test_macos_qualification_workflow.py b/tools/tests/test_macos_qualification_workflow.py index 7176b4801..85a991d5a 100644 --- a/tools/tests/test_macos_qualification_workflow.py +++ b/tools/tests/test_macos_qualification_workflow.py @@ -118,13 +118,41 @@ def test_manual_lane_keeps_production_features_and_gpu_boundary(self): self.assertNotIn("ATRINIK_GPU_CONFORMANCE_QUALIFIED_HARDWARE", text) self.assertIn("prepare_macos_llvm.sh", text) self.assertIn("-DCMAKE_BUILD_TYPE=Release", text) - self.assertIn("cmake --build build/macos-qualification --parallel 2", text) + self.assertIn("cmake --build build/macos-qualification --parallel 2 -- -k 0", text) self.assertNotIn("server/tools/dependencies.py", text) self.assertNotIn("FETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP", text) self.assertNotIn("server-plugin-python", text) self.assertNotIn("complete Classic Release graph", text) self.assertNotIn("GD_LIBRARY", text) + @unittest.skipUnless(shutil.which("bash"), "Bash unavailable") + def test_build_collects_reachable_errors_and_preserves_failure_through_tee(self): + text = WORKFLOW.read_text() + build_step = text.split(" - name: Build the production client, shared libraries and test executables\n", 1)[1] + recipe = build_step.split(" run: |\n", 1)[1].split(" - name:", 1)[0] + script = "\n".join(line[10:] for line in recipe.splitlines()) + "\n" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "build/macos-qualification-evidence").mkdir(parents=True) + fake_bin = root / "bin" + fake_bin.mkdir() + fake_cmake = fake_bin / "cmake" + fake_cmake.write_text('''#!/usr/bin/env bash +printf '%s\\n' "$@" > arguments.txt +echo 'reachable native compile failure' +exit 7 +''') + fake_cmake.chmod(0o755) + script = f'export PATH="{fake_bin}:$PATH"\n' + script + result = subprocess.run(["bash"], input=script, cwd=root, + capture_output=True, text=True) + self.assertEqual(result.returncode, 7, result.stderr) + self.assertEqual((root / "arguments.txt").read_text().splitlines(), [ + "--build", "build/macos-qualification", "--parallel", "2", "--", "-k", "0", + ]) + self.assertIn("reachable native compile failure", + (root / "build/macos-qualification-evidence/build.log").read_text()) + @unittest.skipUnless(shutil.which("bash"), "Bash unavailable") def test_embedded_shell_recipes_are_valid(self): text = WORKFLOW.read_text() From 138a4c1a32bce58605488baa2d5c1f79c9da59d2 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:42:49 -0500 Subject: [PATCH 049/148] fix(libatrinik): include POSIX case comparison declarations --- libatrinik/porting.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libatrinik/porting.h b/libatrinik/porting.h index 1c7bbcf30..1b7583c02 100644 --- a/libatrinik/porting.h +++ b/libatrinik/porting.h @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -59,6 +59,9 @@ #include #include #include +#ifndef WIN32 +#include +#endif #include #include #include From 1b7d22e4fe3c4598b2b55e7d6b6e25ed3a3b9727 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:45:21 -0500 Subject: [PATCH 050/148] fix(client): guard test-only map allocation counters --- client/src/gui/widgets/map.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/src/gui/widgets/map.c b/client/src/gui/widgets/map.c index fa9010935..c1a1c263a 100644 --- a/client/src/gui/widgets/map.c +++ b/client/src/gui/widgets/map.c @@ -182,8 +182,10 @@ typedef struct map_cell_header { static map_cell_store_t *cells; static map_cell_store_t *level_cells[MAP2_LEVELS]; +#ifdef ATRINIK_WIDGET_TESTS static uint64_t map_cell_allocation_count; static uint64_t map_cell_allocation_bytes; +#endif static uint64_t map_cell_retained_bytes; static uint64_t map_cell_peak_retained_bytes; static uint64_t map_cell_next_painter_identity = 1; @@ -264,8 +266,10 @@ void map_animation_redraw_consume(void) { } void map_benchmark_statistics_reset(void) { +#ifdef ATRINIK_WIDGET_TESTS map_cell_allocation_count = 0; map_cell_allocation_bytes = 0; +#endif map_cell_peak_retained_bytes = map_cell_retained_bytes; map_benchmark_statistics = (map_benchmark_statistics_t){0}; map_benchmark_animation_command_digest = 0; @@ -300,8 +304,10 @@ static void map_clear_expired_visibility_layer(map_cell_t *cell, int sub_layer, static void *map_cell_record_allocate(size_t size) { void *record = xcalloc(1, size); +#ifdef ATRINIK_WIDGET_TESTS map_cell_allocation_count++; map_cell_allocation_bytes += size; +#endif map_cell_retained_bytes += size; map_cell_peak_retained_bytes = MAX(map_cell_peak_retained_bytes, map_cell_retained_bytes); return record; @@ -1055,8 +1061,10 @@ static map_cell_store_t *map_cell_store_create(size_t count) { store->headers = xcalloc(count, sizeof(*store->headers)); store->count = count; size_t bytes = sizeof(*store) + count * (sizeof(*store->slots) + sizeof(*store->headers)); +#ifdef ATRINIK_WIDGET_TESTS map_cell_allocation_count += 3; map_cell_allocation_bytes += bytes; +#endif map_cell_retained_bytes += bytes; map_cell_peak_retained_bytes = MAX(map_cell_peak_retained_bytes, map_cell_retained_bytes); return store; From f3cdb57db031cd9cff651dad5c99b7eb297e2311 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:47:55 -0500 Subject: [PATCH 051/148] fix(build): reject unsupported Windows Clang ABIs --- cmake/AtrinikLLVM.cmake | 32 ++++++++++++ tools/tests/test_llvm_toolchain.py | 82 +++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/cmake/AtrinikLLVM.cmake b/cmake/AtrinikLLVM.cmake index f6f9f8483..fca626c10 100644 --- a/cmake/AtrinikLLVM.cmake +++ b/cmake/AtrinikLLVM.cmake @@ -32,6 +32,38 @@ function(atrinik_require_llvm) NOT CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL "23.1.3")) message(FATAL_ERROR "Classic C++ build dependencies require Clang 23.1.3") endif () + if (CMAKE_SYSTEM_NAME STREQUAL "Windows") + if (NOT MINGW OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR "Classic Windows builds require 64-bit LLVM-MinGW") + endif () + set(windows_languages C) + if (CMAKE_CXX_COMPILER_LOADED) + list(APPEND windows_languages CXX) + endif () + foreach (language IN LISTS windows_languages) + if (NOT CMAKE_${language}_COMPILER_FRONTEND_VARIANT STREQUAL "GNU" OR + CMAKE_${language}_SIMULATE_ID STREQUAL "MSVC") + message(FATAL_ERROR "Classic Windows builds require the LLVM-MinGW GNU frontend") + endif () + set(target_arguments) + if (CMAKE_${language}_COMPILER_ARG1) + separate_arguments(target_arguments NATIVE_COMMAND "${CMAKE_${language}_COMPILER_ARG1}") + endif () + if (CMAKE_${language}_COMPILER_TARGET) + list(APPEND target_arguments "--target=${CMAKE_${language}_COMPILER_TARGET}") + endif () + execute_process(COMMAND "${CMAKE_${language}_COMPILER}" ${target_arguments} -dumpmachine + OUTPUT_VARIABLE windows_target ERROR_VARIABLE target_error + RESULT_VARIABLE target_result OUTPUT_STRIP_TRAILING_WHITESPACE) + # LLVM-MinGW's mingw32 driver alias reports the canonical Windows + # GNU triple. Check the selected compiler, not its executable name. + if (NOT target_result EQUAL 0 OR + NOT windows_target MATCHES "^x86_64-w64-(mingw32|windows-gnu)$") + message(FATAL_ERROR + "Classic Windows builds require the x86_64-w64-mingw32 target: ${windows_target}${target_error}") + endif () + endforeach () + endif () foreach (tool CMAKE_AR CMAKE_RANLIB CMAKE_LINKER CMAKE_NM CMAKE_STRIP CMAKE_OBJCOPY CMAKE_OBJDUMP CMAKE_READELF) execute_process(COMMAND "${${tool}}" --version diff --git a/tools/tests/test_llvm_toolchain.py b/tools/tests/test_llvm_toolchain.py index 997286a88..b78f414ec 100644 --- a/tools/tests/test_llvm_toolchain.py +++ b/tools/tests/test_llvm_toolchain.py @@ -23,7 +23,9 @@ def setUp(self): self.root = Path(self.temporary.name) self.capture = self.root / "options.txt" self.tool = self.root / "llvm-tool" - self.tool.write_text("#!/bin/sh\nprintf 'LLVM version 23.1.3\\n'\n") + self.tool.write_text("#!/bin/sh\nif test \"$1\" = -dumpmachine; then\n" + " printf 'x86_64-w64-windows-gnu\\n'\n" + "else\n printf 'LLVM version 23.1.3\\n'\nfi\n") self.tool.chmod(0o755) def script(self, body): @@ -35,6 +37,7 @@ def settings(self, platform="Linux", compiler="Clang", version="23.1.3"): return f''' set(CMAKE_C_COMPILER_ID "{compiler}") set(CMAKE_C_COMPILER_VERSION "{version}") +set(CMAKE_C_COMPILER "{self.tool}") set(CMAKE_SYSTEM_NAME "{platform}") set(CMAKE_AR "{self.tool}") set(CMAKE_RANLIB "{self.tool}") @@ -47,7 +50,10 @@ def settings(self, platform="Linux", compiler="Clang", version="23.1.3"): function(add_link_options) file(WRITE "{self.capture}" "${{ARGV}}") endfunction() -''' +''' + ('''set(MINGW TRUE) +set(CMAKE_SIZEOF_VOID_P 8) +set(CMAKE_C_COMPILER_FRONTEND_VARIANT GNU) +''' if platform == "Windows" else "") def test_native_default_selects_pinned_toolchain_from_module_directory(self): result = self.script(f''' @@ -91,6 +97,78 @@ def test_gcc_and_mismatched_clang_are_rejected(self): self.assertNotEqual(result.returncode, 0) self.assertIn("require Clang 23.1.3", result.stderr) + def test_windows_rejects_msvc_frontend_and_non_mingw_configurations(self): + for selection, message in ( + ("set(MINGW FALSE)", "64-bit LLVM-MinGW"), + ("set(CMAKE_SIZEOF_VOID_P 4)", "64-bit LLVM-MinGW"), + ("set(CMAKE_C_COMPILER_FRONTEND_VARIANT MSVC)", "GNU frontend"), + ("set(CMAKE_C_SIMULATE_ID MSVC)", "GNU frontend"), + ("unset(CMAKE_C_COMPILER_FRONTEND_VARIANT)", "GNU frontend"), + ): + with self.subTest(selection=selection): + result = self.script(self.settings("Windows") + selection + "\natrinik_require_llvm()\n") + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + + def test_windows_checks_the_compiler_target_instead_of_its_name(self): + # A correctly named wrapper and matching LLVM version are insufficient. + compiler = self.root / "x86_64-w64-mingw32-clang" + for target, accepted in (("x86_64-w64-windows-gnu", True), + ("x86_64-w64-mingw32", True), + ("x86_64-pc-windows-msvc", False), + ("x86_64-pc-windows-gnu", False), + ("aarch64-w64-windows-gnu", False), + ("i686-w64-windows-gnu", False), + ("x86_64-unknown-linux-gnu", False), + ("", False)): + compiler.write_text(f"#!/bin/sh\nprintf '%s\\n' '{target}'\n") + compiler.chmod(0o755) + with self.subTest(target=target): + result = self.script(self.settings("Windows") + + f'set(CMAKE_C_COMPILER "{compiler}")\natrinik_require_llvm()\n') + self.assertEqual(result.returncode == 0, accepted, result.stderr) + if not accepted: + self.assertIn("x86_64-w64-mingw32 target", result.stderr) + + def test_windows_explicit_target_and_driver_arguments_are_checked(self): + compiler = self.root / "driver" + compiler.write_text('''#!/bin/sh +test "$1" = --driver-mode=gcc || exit 1 +case $2 in + --target=x86_64-w64-mingw32) echo x86_64-w64-windows-gnu ;; + --target=x86_64-pc-windows-msvc) echo x86_64-pc-windows-msvc ;; + *) exit 1 ;; +esac +''') + compiler.chmod(0o755) + for target, accepted in (("x86_64-w64-mingw32", True), ("x86_64-pc-windows-msvc", False)): + with self.subTest(target=target): + result = self.script(self.settings("Windows") + f''' +set(CMAKE_C_COMPILER "{compiler}") +set(CMAKE_C_COMPILER_ARG1 --driver-mode=gcc) +set(CMAKE_C_COMPILER_TARGET "{target}") +atrinik_require_llvm() +''') + self.assertEqual(result.returncode == 0, accepted, result.stderr) + + def test_windows_loaded_cpp_compiler_must_use_the_same_supported_abi(self): + for frontend, target, accepted in (("GNU", "x86_64-w64-windows-gnu", True), + ("MSVC", "x86_64-w64-windows-gnu", False), + ("GNU", "x86_64-pc-windows-msvc", False)): + compiler = self.root / "clang++" + compiler.write_text(f"#!/bin/sh\nprintf '%s\\n' '{target}'\n") + compiler.chmod(0o755) + with self.subTest(frontend=frontend, target=target): + result = self.script(self.settings("Windows") + f''' +set(CMAKE_CXX_COMPILER_LOADED TRUE) +set(CMAKE_CXX_COMPILER_ID Clang) +set(CMAKE_CXX_COMPILER_VERSION 23.1.3) +set(CMAKE_CXX_COMPILER_FRONTEND_VARIANT "{frontend}") +set(CMAKE_CXX_COMPILER "{compiler}") +atrinik_require_llvm() +''') + self.assertEqual(result.returncode == 0, accepted, result.stderr) + def test_mismatched_archive_tools_are_rejected(self): self.tool.write_text("#!/bin/sh\nprintf 'GNU ar 2.45\\n'\n") result = self.script(self.settings() + "atrinik_require_llvm()\n") From 3a1211f9557f3ebac112a51005fc94f3f9ba4495 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:50:34 -0500 Subject: [PATCH 052/148] fix(build): validate Windows targets for every enabled configuration --- cmake/AtrinikLLVM.cmake | 39 ++++++++++++++++++++++-------- tools/tests/test_llvm_toolchain.py | 37 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/cmake/AtrinikLLVM.cmake b/cmake/AtrinikLLVM.cmake index fca626c10..07106e829 100644 --- a/cmake/AtrinikLLVM.cmake +++ b/cmake/AtrinikLLVM.cmake @@ -40,6 +40,13 @@ function(atrinik_require_llvm) if (CMAKE_CXX_COMPILER_LOADED) list(APPEND windows_languages CXX) endif () + if (CMAKE_CONFIGURATION_TYPES) + set(windows_configurations ${CMAKE_CONFIGURATION_TYPES}) + elseif (CMAKE_BUILD_TYPE) + set(windows_configurations "${CMAKE_BUILD_TYPE}") + else () + set(windows_configurations __default) + endif () foreach (language IN LISTS windows_languages) if (NOT CMAKE_${language}_COMPILER_FRONTEND_VARIANT STREQUAL "GNU" OR CMAKE_${language}_SIMULATE_ID STREQUAL "MSVC") @@ -52,16 +59,28 @@ function(atrinik_require_llvm) if (CMAKE_${language}_COMPILER_TARGET) list(APPEND target_arguments "--target=${CMAKE_${language}_COMPILER_TARGET}") endif () - execute_process(COMMAND "${CMAKE_${language}_COMPILER}" ${target_arguments} -dumpmachine - OUTPUT_VARIABLE windows_target ERROR_VARIABLE target_error - RESULT_VARIABLE target_result OUTPUT_STRIP_TRAILING_WHITESPACE) - # LLVM-MinGW's mingw32 driver alias reports the canonical Windows - # GNU triple. Check the selected compiler, not its executable name. - if (NOT target_result EQUAL 0 OR - NOT windows_target MATCHES "^x86_64-w64-(mingw32|windows-gnu)$") - message(FATAL_ERROR - "Classic Windows builds require the x86_64-w64-mingw32 target: ${windows_target}${target_error}") - endif () + separate_arguments(common_arguments NATIVE_COMMAND "${CMAKE_${language}_FLAGS}") + list(APPEND target_arguments ${common_arguments}) + foreach (configuration IN LISTS windows_configurations) + set(configuration_arguments) + if (NOT configuration STREQUAL __default) + string(TOUPPER "${configuration}" configuration_upper) + separate_arguments(configuration_arguments NATIVE_COMMAND + "${CMAKE_${language}_FLAGS_${configuration_upper}}") + endif () + execute_process(COMMAND "${CMAKE_${language}_COMPILER}" + ${target_arguments} ${configuration_arguments} -dumpmachine + OUTPUT_VARIABLE windows_target ERROR_VARIABLE target_error + RESULT_VARIABLE target_result OUTPUT_STRIP_TRAILING_WHITESPACE) + # LLVM-MinGW's alias reports the canonical Windows GNU triple. + # Include flags in compile order: each enabled configuration + # can override the default target or pointer architecture. + if (NOT target_result EQUAL 0 OR + NOT windows_target MATCHES "^x86_64-w64-(mingw32|windows-gnu)$") + message(FATAL_ERROR + "Classic Windows builds require the x86_64-w64-mingw32 target (${language}/${configuration}): ${windows_target}${target_error}") + endif () + endforeach () endforeach () endif () foreach (tool CMAKE_AR CMAKE_RANLIB CMAKE_LINKER CMAKE_NM CMAKE_STRIP diff --git a/tools/tests/test_llvm_toolchain.py b/tools/tests/test_llvm_toolchain.py index b78f414ec..c501e70c2 100644 --- a/tools/tests/test_llvm_toolchain.py +++ b/tools/tests/test_llvm_toolchain.py @@ -169,6 +169,43 @@ def test_windows_loaded_cpp_compiler_must_use_the_same_supported_abi(self): ''') self.assertEqual(result.returncode == 0, accepted, result.stderr) + def test_windows_common_and_enabled_configuration_flags_cannot_change_abi(self): + compiler = self.root / "flag-driver" + compiler.write_text('''#!/bin/sh +target=x86_64-w64-windows-gnu +for argument do + case $argument in + --target=*) target=${argument#--target=} ;; + -m32) target=i686-w64-windows-gnu ;; + esac +done +printf '%s\\n' "$target" +''') + compiler.chmod(0o755) + for language in ("C", "CXX"): + for selection, accepted in ( + (f'set(CMAKE_{language}_FLAGS "-O2 --target=x86_64-pc-windows-msvc")', False), + (f'set(CMAKE_{language}_FLAGS "-m32")', False), + (f'set(CMAKE_BUILD_TYPE Release)\nset(CMAKE_{language}_FLAGS_RELEASE "--target=x86_64-pc-windows-msvc")', False), + (f'set(CMAKE_CONFIGURATION_TYPES "Debug;Release")\nset(CMAKE_{language}_FLAGS_RELEASE "-m32")', False), + (f'set(CMAKE_BUILD_TYPE Release)\nset(CMAKE_{language}_COMPILER_TARGET x86_64-pc-windows-msvc)\n' + f'set(CMAKE_{language}_FLAGS_RELEASE "--target=x86_64-w64-windows-gnu")', True), + (f'set(CMAKE_CONFIGURATION_TYPES "Debug;Release")\nset(CMAKE_{language}_FLAGS "-Wall -O2")\n' + f'set(CMAKE_{language}_FLAGS_RELEASE "-DNDEBUG")', True), + ): + with self.subTest(language=language, selection=selection): + result = self.script(self.settings("Windows") + f''' +set(CMAKE_C_COMPILER "{compiler}") +set(CMAKE_CXX_COMPILER_LOADED TRUE) +set(CMAKE_CXX_COMPILER_ID Clang) +set(CMAKE_CXX_COMPILER_VERSION 23.1.3) +set(CMAKE_CXX_COMPILER_FRONTEND_VARIANT GNU) +set(CMAKE_CXX_COMPILER "{compiler}") +{selection} +atrinik_require_llvm() +''') + self.assertEqual(result.returncode == 0, accepted, result.stderr) + def test_mismatched_archive_tools_are_rejected(self): self.tool.write_text("#!/bin/sh\nprintf 'GNU ar 2.45\\n'\n") result = self.script(self.settings() + "atrinik_require_llvm()\n") From 4af4f6b20de32279615a0325825c53f7c00d0663 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:12:39 -0500 Subject: [PATCH 053/148] fix(libatrinik): expose Darwin APIs and protect fixture writes --- libatrinik/porting.h | 7 ++++++ libatrinik/process.c | 3 ++- libatrinik/tests/curl.c | 53 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/libatrinik/porting.h b/libatrinik/porting.h index 1b7583c02..872ea3a21 100644 --- a/libatrinik/porting.h +++ b/libatrinik/porting.h @@ -34,6 +34,13 @@ #define _GNU_SOURCE #endif +/* Darwin's POSIX-only namespace hides BSD socket/timer flags and newer + * descriptor-relative file APIs used by the toolkit. Select its full namespace + * before any system header; _GNU_SOURCE only provides this policy on Linux. */ +#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE) +#define _DARWIN_C_SOURCE +#endif + #ifdef __GNUC__ #define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0) diff --git a/libatrinik/process.c b/libatrinik/process.c index aff95a7a9..fcf393164 100644 --- a/libatrinik/process.c +++ b/libatrinik/process.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -36,6 +36,7 @@ #include "socket.h" #ifndef WIN32 +#include #include #include #include diff --git a/libatrinik/tests/curl.c b/libatrinik/tests/curl.c index 3f69039a5..07276f362 100644 --- a/libatrinik/tests/curl.c +++ b/libatrinik/tests/curl.c @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -23,6 +25,52 @@ typedef struct http_fixture { } http_fixture_t; static char captured_log[HUGE_BUF]; +static ssize_t http_fixture_send(int client, const char *response, size_t size) { +#ifdef MSG_NOSIGNAL + return send(client, response, size, MSG_NOSIGNAL); +#elif defined(SO_NOSIGPIPE) + const int enabled = 1; + if (setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)) != 0) { + return -1; + } + return send(client, response, size, 0); +#else +#error "HTTP fixtures require a socket-local SIGPIPE suppression mechanism" +#endif +} + +static int test_fixture_closed_peer(void) { + /* Run before toolkit initialization or threads: an ignored process-wide + * SIGPIPE disposition must not hide a broken fixture safeguard. */ + pid_t child = fork(); + if (child < 0) { + return 1; + } + if (child == 0) { + sigset_t unblocked; + if (sigemptyset(&unblocked) != 0 || sigaddset(&unblocked, SIGPIPE) != 0 || + signal(SIGPIPE, SIG_DFL) == SIG_ERR || + sigprocmask(SIG_UNBLOCK, &unblocked, NULL) != 0) { + _exit(1); + } + int sockets[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) { + _exit(1); + } + close(sockets[1]); + ssize_t sent = http_fixture_send(sockets[0], "x", 1); + int failed = sent != -1 || errno != EPIPE; + close(sockets[0]); + _exit(failed); + } + int status; + pid_t waited; + do { + waited = waitpid(child, &status, 0); + } while (waited < 0 && errno == EINTR); + return waited != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0; +} + static void capture_curl_log(const char *message) { if (strstr(message, "HTTP request origin=") != NULL) { snprintf(captured_log, sizeof(captured_log), "%s", message); @@ -68,7 +116,7 @@ static void *http_fixture_run(void *user_data) { usleep(fixture->stall_us); } if (fixture->response != NULL) { - (void)send(client, fixture->response, strlen(fixture->response), MSG_NOSIGNAL); + (void)http_fixture_send(client, fixture->response, strlen(fixture->response)); } close(client); return NULL; @@ -815,6 +863,9 @@ static int test_endpoint_alias_diagnostics(void) { } int main(void) { + if (test_fixture_closed_peer() != 0) { + return 1; + } toolkit_import(path); toolkit_import(curl); int failed = test_response_code_survives_body_limit() || From 072f043c9ad7667ded399d2cbcdc2cef9045c75b Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:12:39 -0500 Subject: [PATCH 054/148] fix(client): saturate telemetry at an exact floating boundary --- client/src/client/telemetry.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/src/client/telemetry.c b/client/src/client/telemetry.c index 4bf0bbdd9..f40b1e476 100644 --- a/client/src/client/telemetry.c +++ b/client/src/client/telemetry.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2026 Atrinik Development Team * + * Copyright 2009-2026 The Atrinik Project * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -86,7 +86,9 @@ uint64_t telemetry_exp_per_hour(void) { } long double hourly = (long double)telemetry.exp_gained * 3600000000.0L / elapsed_us; - return hourly >= UINT64_MAX ? UINT64_MAX : (uint64_t)hourly; + /* 2^64 is exact even when long double has only double precision (Darwin + * ARM64). Saturate before conversion outside the uint64_t range. */ + return hourly >= 0x1p64L ? UINT64_MAX : (uint64_t)hourly; } void telemetry_game_time_sync(uint64_t game_seconds, uint32_t millis_per_game_minute) { From ed4dd63327879a7d226afa7535e5de9613335dfb Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:13:57 -0500 Subject: [PATCH 055/148] fix(build): expand snprintf buffer arguments for fortified stdio --- client/src/client/asset.c | 2 +- client/src/client/asset_source.c | 10 +- client/src/client/commands.c | 8 +- client/src/client/face_loader.c | 2 +- client/src/client/gpu_player_view.c | 48 ++--- client/src/client/image.c | 2 +- client/src/client/item.c | 2 +- client/src/client/main.c | 4 +- client/src/client/menu.c | 2 +- client/src/client/metaserver.c | 2 +- client/src/client/metaserver_directory.c | 2 +- client/src/client/region_map.c | 22 +-- client/src/client/server_files.c | 2 +- client/src/client/server_settings.c | 4 +- client/src/client/sprite.c | 2 +- client/src/client/wrapper.c | 2 +- client/src/gui/misc/game_news.c | 2 +- client/src/gui/misc/intro.c | 4 +- client/src/gui/popups/connection_preference.c | 2 +- client/src/gui/popups/help.c | 8 +- client/src/gui/toolkit/widget.c | 2 +- client/src/gui/widgets/game_time.c | 6 +- client/src/gui/widgets/inventory.c | 10 +- client/src/gui/widgets/map.c | 24 +-- client/src/gui/widgets/network_graph.c | 4 +- client/src/gui/widgets/party.c | 2 +- client/src/gui/widgets/playerinfo.c | 2 +- client/src/gui/widgets/quickslots.c | 8 +- client/src/gui/widgets/skills.c | 4 +- client/src/gui/widgets/stat.c | 6 +- client/src/gui/widgets/xp_tracker.c | 8 +- client/src/tests/audio_log.c | 4 +- client/src/tests/compound_packet.c | 2 +- client/src/tests/face_cache.c | 2 +- client/src/tests/face_request.c | 34 ++-- client/src/tests/face_stream.c | 2 +- client/src/tests/sound_command.c | 6 +- client/src/tests/sound_playback.c | 4 +- client/src/tests/user_data.c | 2 +- libatrinik/clioptions.c | 2 +- libatrinik/curl.c | 8 +- libatrinik/logger.c | 24 +-- libatrinik/mempool.c | 4 +- libatrinik/metaserver_publisher.c | 10 +- libatrinik/metaserver_publisher_sequence.c | 8 +- libatrinik/metaserver_url.c | 10 +- libatrinik/path.c | 8 +- libatrinik/process.c | 6 +- libatrinik/signals.c | 6 +- libatrinik/socket.c | 4 +- libatrinik/socket_direct.c | 16 +- libatrinik/socket_quic.c | 8 +- libatrinik/tests/metaserver_publisher.c | 24 +-- libatrinik/tests/path.c | 64 +++---- server/src/commands/permission/metrics.c | 2 +- server/src/loaders/map_header.l | 2 +- server/src/loaders/random_map.l | 24 +-- server/src/modules/world_maker.c | 6 +- .../src/plugins/plugin_python/atrinik_map.c | 2 +- .../plugins/plugin_python/atrinik_object.c | 4 +- .../src/plugins/plugin_python/plugin_python.c | 4 +- server/src/server/access_server.c | 4 +- server/src/server/account.c | 8 +- server/src/server/arch.c | 4 +- server/src/server/artifact.c | 4 +- server/src/server/ban.c | 6 +- server/src/server/bank.c | 2 +- server/src/server/celestial_structure.c | 8 +- server/src/server/exp.c | 2 +- server/src/server/faction.c | 4 +- server/src/server/gameplay_journal.c | 38 ++-- server/src/server/hiscore.c | 10 +- server/src/server/init.c | 66 +++---- server/src/server/main.c | 2 +- server/src/server/map.c | 14 +- server/src/server/material.c | 6 +- server/src/server/object.c | 26 +-- server/src/server/party.c | 2 +- server/src/server/quest.c | 10 +- server/src/server/readable.c | 4 +- server/src/server/resources.c | 4 +- server/src/server/shop.c | 10 +- server/src/server/swap.c | 2 +- server/src/server/treasure.c | 2 +- server/src/server/walking_route.c | 2 +- server/src/socket/assets.c | 14 +- server/src/socket/init.c | 6 +- server/src/socket/metaserver.c | 28 +-- server/src/socket/metaserver_policy.c | 6 +- server/src/socket/network_metrics.c | 2 +- server/src/socket/port_mapping.c | 4 +- server/src/socket/server.c | 16 +- server/src/tests/check.c | 6 +- server/src/tests/unit/server/account.c | 26 +-- server/src/tests/unit/server/account_async.c | 34 ++-- server/src/tests/unit/server/assets.c | 10 +- .../tests/unit/server/celestial_structure.c | 176 +++++++++--------- .../src/tests/unit/server/content_benchmark.c | 2 +- .../src/tests/unit/server/gameplay_journal.c | 100 +++++----- server/src/tests/unit/server/light.c | 4 +- server/src/tests/unit/server/metrics.c | 12 +- .../src/tests/unit/server/random_map_style.c | 12 +- server/src/tests/unit/server/region.c | 8 +- server/src/tests/unit/server/request.c | 10 +- server/src/tests/unit/server/swap.c | 16 +- server/src/tests/unit/server/todclock.c | 22 +-- .../tests/unit/server/transport_benchmark.c | 26 +-- server/src/tests/unit/socket/item.c | 4 +- server/src/tests/unit/toolkit/socket_asset.c | 24 +-- server/src/tests/unit/toolkit/string.c | 2 +- server/src/types/disease.c | 6 +- server/src/types/monster/monster_guard.c | 6 +- server/src/types/player.c | 20 +- server/src/types/savebed.c | 6 +- 114 files changed, 679 insertions(+), 679 deletions(-) diff --git a/client/src/client/asset.c b/client/src/client/asset.c index a5f2df84c..d8b1ae4aa 100644 --- a/client/src/client/asset.c +++ b/client/src/client/asset.c @@ -272,7 +272,7 @@ static asset_request_t *asset_request_start_internal(const char *path, } char key[MAX_BUF + 32]; - snprintf(VS(key), "%c:%" PRIu64 ":%s", metadata_only ? 'M' : 'D', (uint64_t)max_size, path); + snprintf((key), sizeof((key)), "%c:%" PRIu64 ":%s", metadata_only ? 'M' : 'D', (uint64_t)max_size, path); if (!asset_lock()) { return NULL; } diff --git a/client/src/client/asset_source.c b/client/src/client/asset_source.c index 5a0f519f4..cd883c5cd 100644 --- a/client/src/client/asset_source.c +++ b/client/src/client/asset_source.c @@ -54,7 +54,7 @@ static bool asset_source_url(const char *asset_path, char *url, size_t size) { char path[HUGE_BUF]; if (ok) { int length = snprintf( - VS(path), + (path), sizeof((path)), "%s%s%s", base_path != NULL ? base_path : "", base_path != NULL && *base_path != '\0' && base_path[strlen(base_path) - 1] != '/' ? "/" @@ -120,7 +120,7 @@ asset_source_t *asset_source_start(const char *asset_path, const char *cache_pat } if (!asset_source_start_inband(source)) { source->state = ASSET_SOURCE_ERROR; - snprintf(VS(source->error), "No available asset transport"); + snprintf((source->error), sizeof((source->error)), "No available asset transport"); } return source; } @@ -154,7 +154,7 @@ asset_source_state_t asset_source_get_state(asset_source_t *source) { return source->state; } source->state = ASSET_SOURCE_ERROR; - snprintf(VS(source->error), "Authenticated asset metadata is unavailable"); + snprintf((source->error), sizeof((source->error)), "Authenticated asset metadata is unavailable"); return source->state; } @@ -208,7 +208,7 @@ asset_source_state_t asset_source_get_state(asset_source_t *source) { return source->state; } source->state = ASSET_SOURCE_ERROR; - snprintf(VS(source->error), "HTTP failed and QUIC fallback is unavailable"); + snprintf((source->error), sizeof((source->error)), "HTTP failed and QUIC fallback is unavailable"); return source->state; } @@ -218,7 +218,7 @@ asset_source_state_t asset_source_get_state(asset_source_t *source) { source->state = ASSET_SOURCE_COMPLETE; } else if (state == ASSET_REQUEST_ERROR) { source->state = ASSET_SOURCE_ERROR; - snprintf(VS(source->error), "In-band asset transfer failed"); + snprintf((source->error), sizeof((source->error)), "In-band asset transfer failed"); } } return source->state; diff --git a/client/src/client/commands.c b/client/src/client/commands.c index fe7e22692..9d5bf316a 100644 --- a/client/src/client/commands.c +++ b/client/src/client/commands.c @@ -876,8 +876,8 @@ static void map_pending_effects_commit(void) { bool footstep = map_pending_effects.footstep; char bg_music[HUGE_BUF]; char weather_name[MAX_BUF]; - snprintf(VS(bg_music), "%s", map_pending_effects.bg_music); - snprintf(VS(weather_name), "%s", map_pending_effects.weather_name); + snprintf((bg_music), sizeof((bg_music)), "%s", map_pending_effects.bg_music); + snprintf((weather_name), sizeof((weather_name)), "%s", map_pending_effects.weather_name); map_pending_effects_abort(); if (rich_presence) { @@ -1037,9 +1037,9 @@ static void socket_command_map_apply(uint8_t *data, size_t len, size_t pos) { snprintf(MapData.name_new, sizeof(MapData.name_new), "%s", mapname); map_pending_effects.rich_presence = true; - snprintf(VS(map_pending_effects.bg_music), "%s", bg_music); + snprintf((map_pending_effects.bg_music), sizeof((map_pending_effects.bg_music)), "%s", bg_music); map_pending_effects.music = true; - snprintf(VS(map_pending_effects.weather_name), "%s", weather); + snprintf((map_pending_effects.weather_name), sizeof((map_pending_effects.weather_name)), "%s", weather); map_pending_effects.weather = true; update_map_height_diff(height_diff); update_map_region_name(region_name); diff --git a/client/src/client/face_loader.c b/client/src/client/face_loader.c index 75a18c02b..c152cb7d5 100644 --- a/client/src/client/face_loader.c +++ b/client/src/client/face_loader.c @@ -348,7 +348,7 @@ static face_loader_result_t *face_loader_process_local(face_loader_job_t *job, result->sprite = face_loader_decode(data, size, checksum); if (result->sprite != NULL) { char override[MAX_BUF]; - int length = snprintf(VS(override), DIRECTORY_GFX_USER "/%s", job->name); + int length = snprintf((override), sizeof((override)), DIRECTORY_GFX_USER "/%s", job->name); if (length > 0 && (size_t)length < sizeof(override)) { result->override_name = xstrdup(override); result->override_checksum = checksum; diff --git a/client/src/client/gpu_player_view.c b/client/src/client/gpu_player_view.c index ab0959791..86429c672 100644 --- a/client/src/client/gpu_player_view.c +++ b/client/src/client/gpu_player_view.c @@ -883,42 +883,42 @@ static bool player_view_manifest_parse(const char *manifest_path, (mono_font == NULL || manifest->mono_font_path != NULL); } if (success) { - snprintf(VS(manifest->settings_digest), "%s", settings_digest); - snprintf(VS(manifest->archdef_digest), "%s", archdef_digest); - snprintf(VS(manifest->snapshot_digest), "%s", snapshot_digest); + snprintf((manifest->settings_digest), sizeof((manifest->settings_digest)), "%s", settings_digest); + snprintf((manifest->archdef_digest), sizeof((manifest->archdef_digest)), "%s", archdef_digest); + snprintf((manifest->snapshot_digest), sizeof((manifest->snapshot_digest)), "%s", snapshot_digest); if (next_snapshot != NULL) { - snprintf(VS(manifest->next_snapshot_digest), "%s", next_snapshot_digest); + snprintf((manifest->next_snapshot_digest), sizeof((manifest->next_snapshot_digest)), "%s", next_snapshot_digest); } if (transition_snapshot != NULL) { - snprintf(VS(manifest->transition_snapshot_digest), "%s", transition_snapshot_digest); + snprintf((manifest->transition_snapshot_digest), sizeof((manifest->transition_snapshot_digest)), "%s", transition_snapshot_digest); } if (interface_file != NULL) { - snprintf(VS(manifest->interface_digest), "%s", interface_digest); + snprintf((manifest->interface_digest), sizeof((manifest->interface_digest)), "%s", interface_digest); } if (layout != NULL) { - snprintf(VS(manifest->layout_digest), "%s", layout_digest); + snprintf((manifest->layout_digest), sizeof((manifest->layout_digest)), "%s", layout_digest); } if (font != NULL) { - snprintf(VS(manifest->font_digest), "%s", font_digest); + snprintf((manifest->font_digest), sizeof((manifest->font_digest)), "%s", font_digest); } if (mono_font != NULL) { - snprintf(VS(manifest->mono_font_digest), "%s", mono_font_digest); + snprintf((manifest->mono_font_digest), sizeof((manifest->mono_font_digest)), "%s", mono_font_digest); } if (expected_ui != NULL) { - snprintf(VS(manifest->expected_ui_pixels_digest), "%s", expected_ui); + snprintf((manifest->expected_ui_pixels_digest), sizeof((manifest->expected_ui_pixels_digest)), "%s", expected_ui); } if (archived_software_ui != NULL) { - snprintf(VS(manifest->archived_software_ui_pixels_digest), "%s", archived_software_ui); + snprintf((manifest->archived_software_ui_pixels_digest), sizeof((manifest->archived_software_ui_pixels_digest)), "%s", archived_software_ui); } if (archived_software != NULL) { - snprintf(VS(manifest->archived_software_pixels_digest), "%s", archived_software); + snprintf((manifest->archived_software_pixels_digest), sizeof((manifest->archived_software_pixels_digest)), "%s", archived_software); } if (expected_standard_checkpoint != NULL) { - snprintf(VS(manifest->expected_standard_checkpoint_digest), + snprintf((manifest->expected_standard_checkpoint_digest), sizeof((manifest->expected_standard_checkpoint_digest)), "%s", expected_standard_checkpoint); } - snprintf(VS(manifest->expected_pixels_digest), "%s", expected); + snprintf((manifest->expected_pixels_digest), sizeof((manifest->expected_pixels_digest)), "%s", expected); } for (xmlNode *node = success ? root->children : NULL; node != NULL; node = node->next) { @@ -960,7 +960,7 @@ static bool player_view_manifest_parse(const char *manifest_path, success = asset->path != NULL; } if (success) { - snprintf(VS(asset->digest), "%s", digest); + snprintf((asset->digest), sizeof((asset->digest)), "%s", digest); manifest->assets_num++; } free(face); @@ -1959,9 +1959,9 @@ static bool gpu_player_view_ui_region_map_prepare(void) { region->def->maps = xcalloc(1, sizeof(*region->def->maps)); region->def->maps[0].path = xstrdup("/gpu-ui-closure"); region->fow->bitmap = xcalloc(1, RM_MAP_FOW_BITMAP_SIZE(region)); - snprintf(VS(MapData.map_path), "%s", region->def->maps[0].path); - snprintf(VS(MapData.region_name), "%s", "gpu-ui-closure"); - snprintf(VS(MapData.region_longname), "%s", "GPU UI Closure Region"); + snprintf((MapData.map_path), sizeof((MapData.map_path)), "%s", region->def->maps[0].path); + snprintf((MapData.region_name), sizeof((MapData.region_name)), "%s", "gpu-ui-closure"); + snprintf((MapData.region_longname), sizeof((MapData.region_longname)), "%s", "GPU UI Closure Region"); MapData.posx = 75; MapData.posy = 50; region_map_fow_update(region); @@ -1983,7 +1983,7 @@ static bool gpu_player_view_ui_models_prepare(void) { item->face = 3; item->itype = TYPE_MISC_OBJECT; item->nrof = 12; - snprintf(VS(item->s_name), "%s", "Retained GPU qualification tokens"); + snprintf((item->s_name), sizeof((item->s_name)), "%s", "Retained GPU qualification tokens"); draw_info(COLOR_GREEN, "The complete retained GPU interface is active."); draw_info(COLOR_GREEN, "Root object markup: [obj=477 32 32 0]"); @@ -4790,7 +4790,7 @@ int gpu_player_view_main(int argc, char *argv[]) { return 3; } } - snprintf(VS(gpu_player_view_review_prefix), "%s", fixture_id); + snprintf((gpu_player_view_review_prefix), sizeof((gpu_player_view_review_prefix)), "%s", fixture_id); uint8_t *snapshot = NULL; size_t snapshot_size = 0; @@ -4855,8 +4855,8 @@ int gpu_player_view_main(int argc, char *argv[]) { wrapper_test_user_data_isolated_set(true); widget_mplayer_test_isolated_set(true); widget_render_profiler_test_isolated_set(true); - snprintf(VS(cpl.account), "%s", "renderer-fixture"); - snprintf(VS(cpl.name), "%s", "Renderer Maintainer"); + snprintf((cpl.account), sizeof((cpl.account)), "%s", "renderer-fixture"); + snprintf((cpl.name), sizeof((cpl.name)), "%s", "Renderer Maintainer"); #endif bool settings_ready = settings_init_read_only(manifest.settings_path); bool sdl_ready = false; @@ -4975,8 +4975,8 @@ int gpu_player_view_main(int argc, char *argv[]) { if (manifest.target_ui) { cpl.target_code = CMD_TARGET_ENEMY; cpl.target_hp = 64; - snprintf(VS(cpl.target_color), "%s", "ffffff"); - snprintf(VS(cpl.target_name), "%s", "Local Player"); + snprintf((cpl.target_color), sizeof((cpl.target_color)), "%s", "ffffff"); + snprintf((cpl.target_name), sizeof((cpl.target_name)), "%s", "Local Player"); } memset(&MapData, 0, sizeof(MapData)); memset(FaceList, 0, sizeof(FaceList)); diff --git a/client/src/client/image.c b/client/src/client/image.c index e4579cca3..851341652 100644 --- a/client/src/client/image.c +++ b/client/src/client/image.c @@ -672,7 +672,7 @@ void finish_face_cmd(int facenum, uint32_t checksum, const char *face) { return; } char name[MAX_BUF]; - int name_length = snprintf(VS(name), "%s.png", face); + int name_length = snprintf((name), sizeof((name)), "%s.png", face); if (name_length < 0 || (size_t)name_length >= sizeof(name)) { image_missing_faces = true; return; diff --git a/client/src/client/item.c b/client/src/client/item.c index aba7155b2..be38f28ce 100644 --- a/client/src/client/item.c +++ b/client/src/client/item.c @@ -695,7 +695,7 @@ void object_show_centered(SDL_Surface *surface, object *tmp, int x, int y, int w sprite_effects_t effects; memset(&effects, 0, sizeof(effects)); - snprintf(VS(effects.glow), "%s", tmp->glow); + snprintf((effects.glow), sizeof((effects.glow)), "%s", tmp->glow); effects.glow_speed = tmp->glow_speed; effects.glow_state = tmp->glow_state; effects.zoom_x = zoom_x * 100.0; diff --git a/client/src/client/main.c b/client/src/client/main.c index 658723a65..0993d6d8d 100644 --- a/client/src/client/main.c +++ b/client/src/client/main.c @@ -345,7 +345,7 @@ static int game_status_chain(void) { connection_preference_get(selected_server))) { char failure_message[160]; if (!client_connection_failure_format(&csocket.failure, VS(failure_message))) { - snprintf(VS(failure_message), "Connection failed; please try again."); + snprintf((failure_message), sizeof((failure_message)), "Connection failed; please try again."); } draw_info(COLOR_RED, failure_message); client_access_attempt_clear(&selected_server->access_attempt); @@ -1302,7 +1302,7 @@ int main(int argc, char *argv[]) { resources_init(); char buf[HUGE_BUF]; - snprintf(VS(buf), "Welcome to Atrinik version %s", version); + snprintf((buf), sizeof((buf)), "Welcome to Atrinik version %s", version); draw_info(COLOR_HGOLD, buf); settings_apply(); diff --git a/client/src/client/menu.c b/client/src/client/menu.c index ecae2cd7d..6edd37470 100644 --- a/client/src/client/menu.c +++ b/client/src/client/menu.c @@ -161,7 +161,7 @@ int client_command_check(const char *cmd) { if (cpl.partyjoin[0] != '\0') { char buf[HUGE_BUF]; - snprintf(VS(buf), "/party join %s\t%s", cpl.partyjoin, cmd); + snprintf((buf), sizeof((buf)), "/party join %s\t%s", cpl.partyjoin, cmd); send_command(buf); } diff --git a/client/src/client/metaserver.c b/client/src/client/metaserver.c index 8280db583..db65b954d 100644 --- a/client/src/client/metaserver.c +++ b/client/src/client/metaserver.c @@ -154,7 +154,7 @@ static char *metaserver_cache_path(const client_metaserver_endpoint_t *endpoint) return NULL; } char relative[HUGE_BUF]; - if (snprintf(VS(relative), DIRECTORY_CACHE "/metaserver-v6-%s.xml", scope) >= + if (snprintf((relative), sizeof((relative)), DIRECTORY_CACHE "/metaserver-v6-%s.xml", scope) >= (int)sizeof(relative)) { return NULL; } diff --git a/client/src/client/metaserver_directory.c b/client/src/client/metaserver_directory.c index d8beaaf7b..685a7daaa 100644 --- a/client/src/client/metaserver_directory.c +++ b/client/src/client/metaserver_directory.c @@ -124,7 +124,7 @@ static bool directory_parse_server(xmlNodePtr node, metaserver_directory_entry_t metaserver_hostname_valid(hostname) && directory_decimal(port, 1, UINT16_MAX, &port_number); if (ok) { - snprintf(VS(entry->hostname), "%s", hostname); + snprintf((entry->hostname), sizeof((entry->hostname)), "%s", hostname); entry->port = (uint16_t)port_number; entry->has_endpoint = true; } diff --git a/client/src/client/region_map.c b/client/src/client/region_map.c index f6eb8b18e..1d8c995bf 100644 --- a/client/src/client/region_map.c +++ b/client/src/client/region_map.c @@ -197,29 +197,29 @@ void region_map_update(region_map_t *region_map, const char *region_name) { region_name[i] == '-'; } if (!valid_region) { - snprintf(VS(region_map->error), "The server sent an invalid region."); + snprintf((region_map->error), sizeof((region_map->error)), "The server sent an invalid region."); LOG(ERROR, "Refusing unsafe region map name"); return; } - snprintf(VS(region_map->download_name), "%s", region_name); + snprintf((region_map->download_name), sizeof((region_map->download_name)), "%s", region_name); - if (snprintf(VS(buf), "client-maps/%s.png", region_name) >= (int)sizeof(buf)) { - snprintf(VS(region_map->error), "The server sent an invalid region."); + if (snprintf((buf), sizeof((buf)), "client-maps/%s.png", region_name) >= (int)sizeof(buf)) { + snprintf((region_map->error), sizeof((region_map->error)), "The server sent an invalid region."); return; } path = file_path_server(buf); if (path == NULL) { - snprintf(VS(region_map->error), "The server sent an invalid region."); + snprintf((region_map->error), sizeof((region_map->error)), "The server sent an invalid region."); return; } region_map->source_png = asset_source_start(buf, path); free(path); - snprintf(VS(buf), "client-maps/%s.def", region_name); + snprintf((buf), sizeof((buf)), "client-maps/%s.def", region_name); path = file_path_server(buf); if (path == NULL) { asset_source_free(region_map->source_png); region_map->source_png = NULL; - snprintf(VS(region_map->error), "The server sent an invalid region."); + snprintf((region_map->error), sizeof((region_map->error)), "The server sent an invalid region."); return; } region_map->source_def = asset_source_start(buf, path); @@ -265,7 +265,7 @@ bool region_map_ready(region_map_t *region_map) { body_png = asset_source_get_data(region_map->source_png, &body_png_size); body_def = asset_source_get_data(region_map->source_def, NULL); } else { - snprintf(VS(region_map->error), + snprintf((region_map->error), sizeof((region_map->error)), "The server does not provide region map '%s'.", region_map->download_name); LOG(ERROR, "%s", region_map->error); @@ -277,7 +277,7 @@ bool region_map_ready(region_map_t *region_map) { } if (body_png_size > INT_MAX) { - snprintf(VS(region_map->error), "Region map '%s' is too large.", region_map->download_name); + snprintf((region_map->error), sizeof((region_map->error)), "Region map '%s' is too large.", region_map->download_name); LOG(ERROR, "%s", region_map->error); return false; } @@ -286,7 +286,7 @@ bool region_map_ready(region_map_t *region_map) { SDL_IOStream *rw = SDL_IOFromConstMem(body_png, (int)body_png_size); img = rw != NULL ? image_codec_load_io(rw, true) : NULL; if (img == NULL) { - snprintf(VS(region_map->error), + snprintf((region_map->error), sizeof((region_map->error)), "Could not decode region map '%s': %s", region_map->download_name, SDL_GetError()); @@ -297,7 +297,7 @@ bool region_map_ready(region_map_t *region_map) { region_map->surface = surface_to_display(img); SDL_DestroySurface(img); if (region_map->surface == NULL) { - snprintf(VS(region_map->error), + snprintf((region_map->error), sizeof((region_map->error)), "Could not prepare region map '%s': %s", region_map->download_name, SDL_GetError()); diff --git a/client/src/client/server_files.c b/client/src/client/server_files.c index 836993557..7b1192985 100644 --- a/client/src/client/server_files.c +++ b/client/src/client/server_files.c @@ -307,7 +307,7 @@ static int server_file_process(server_files_struct *tmp) { if (tmp->update == 1) { char asset[MAX_BUF]; - snprintf(VS(asset), "data/%s.zz", tmp->name); + snprintf((asset), sizeof((asset)), "data/%s.zz", tmp->name); tmp->source = asset_source_start(asset, NULL); tmp->update = -1; return 1; diff --git a/client/src/client/server_settings.c b/client/src/client/server_settings.c index c6c51af1e..fbe1c6265 100644 --- a/client/src/client/server_settings.c +++ b/client/src/client/server_settings.c @@ -54,13 +54,13 @@ void server_settings_test_init(void) { } for (size_t index = 0; index < arraysize(s_settings->protection_groups); index++) { char value[32]; - snprintf(VS(value), "Protection group %" PRIu64, (uint64_t)(index + 1U)); + snprintf((value), sizeof((value)), "Protection group %" PRIu64, (uint64_t)(index + 1U)); s_settings->protection_groups[index] = xstrdup(value); } for (size_t index = 0; index < arraysize(s_settings->protection_letters); index++) { s_settings->protection_letters[index] = xstrdup("PR"); char value[32]; - snprintf(VS(value), "Protection %" PRIu64, (uint64_t)(index + 1U)); + snprintf((value), sizeof((value)), "Protection %" PRIu64, (uint64_t)(index + 1U)); s_settings->protection_full[index] = xstrdup(value); } for (size_t index = 0; index < arraysize(s_settings->spell_paths); index++) { diff --git a/client/src/client/sprite.c b/client/src/client/sprite.c index 3cf23abc8..b571dfe06 100644 --- a/client/src/client/sprite.c +++ b/client/src/client/sprite.c @@ -1112,7 +1112,7 @@ void surface_show_effects(SDL_Surface *surface, /* Construct a cache entry string. */ char name[HUGE_BUF]; - snprintf(VS(name), + snprintf((name), sizeof((name)), "%p;%u;%u;%s;%u;%u;%d;%d;%d;%s;%s;%u;%u", src, effects->flags, diff --git a/client/src/client/wrapper.c b/client/src/client/wrapper.c index d83e3ef4b..37d62a078 100644 --- a/client/src/client/wrapper.c +++ b/client/src/client/wrapper.c @@ -399,7 +399,7 @@ static StringBuffer *file_path_server_internal(void) { } if (identity != NULL) { - snprintf(VS(scope), "%s", identity); + snprintf((scope), sizeof((scope)), "%s", identity); string_tolower(scope); } else { SOFT_ASSERT_RC(!string_isempty(selected_server->hostname), diff --git a/client/src/gui/misc/game_news.c b/client/src/gui/misc/game_news.c index 045706bdd..132851a14 100644 --- a/client/src/gui/misc/game_news.c +++ b/client/src/gui/misc/game_news.c @@ -283,7 +283,7 @@ void game_news_open(const char *title) { CURL *curl = curl_easy_init(); char *news_id = curl_easy_escape(curl, title, 0); char url[HUGE_BUF]; - snprintf(VS(url), "%s?news=%s", clioption_settings.game_news_url, news_id); + snprintf((url), sizeof((url)), "%s?news=%s", clioption_settings.game_news_url, news_id); curl_free(news_id); curl_easy_cleanup(curl); diff --git a/client/src/gui/misc/intro.c b/client/src/gui/misc/intro.c index 93f71ec62..950e5ef5d 100644 --- a/client/src/gui/misc/intro.c +++ b/client/src/gui/misc/intro.c @@ -320,9 +320,9 @@ void intro_show(void) { list_add(list_servers, i, 0, node->name); if (node->port > 0) { - snprintf(VS(buf), "%d", node->port); + snprintf((buf), sizeof((buf)), "%d", node->port); } else { - snprintf(VS(buf), "-"); + snprintf((buf), sizeof((buf)), "-"); } list_add(list_servers, i, 1, buf); diff --git a/client/src/gui/popups/connection_preference.c b/client/src/gui/popups/connection_preference.c index 1c7e3edf5..6dfcd70dd 100644 --- a/client/src/gui/popups/connection_preference.c +++ b/client/src/gui/popups/connection_preference.c @@ -102,7 +102,7 @@ static int popup_draw(popup_struct *popup) { &box); char buf[HUGE_BUF]; - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "Choose which direct route %s should try first. If it is " "unavailable, the client continues with Automatic fallback.", preference_server->name); diff --git a/client/src/gui/popups/help.c b/client/src/gui/popups/help.c index bf2493a43..2c0ec1c74 100644 --- a/client/src/gui/popups/help.c +++ b/client/src/gui/popups/help.c @@ -374,7 +374,7 @@ void help_show(const char *name) { if (hfile == NULL) { char buf[HUGE_BUF]; - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "[book]Help not found[/book][title]\n[center]The " "specified help file could not be found.[/center]" "[/title]"); @@ -428,7 +428,7 @@ void help_handle_tabulator(text_input_struct *text_input) { utarray_sort(command_matches, command_match_cmp); command_index = 0; - snprintf(VS(command_buf), "%s", text_input->str); + snprintf((command_buf), sizeof((command_buf)), "%s", text_input->str); } len = utarray_len(command_matches); @@ -437,9 +437,9 @@ void help_handle_tabulator(text_input_struct *text_input) { return; } - snprintf(VS(buf), "/%s ", *((char **)utarray_eltptr(command_matches, command_index))); + snprintf((buf), sizeof((buf)), "/%s ", *((char **)utarray_eltptr(command_matches, command_index))); text_input_set(text_input, buf); - snprintf(VS(command_buf), "%s", buf); + snprintf((command_buf), sizeof((command_buf)), "%s", buf); command_index++; diff --git a/client/src/gui/toolkit/widget.c b/client/src/gui/toolkit/widget.c index 86e34fba4..28dc957b4 100644 --- a/client/src/gui/toolkit/widget.c +++ b/client/src/gui/toolkit/widget.c @@ -556,7 +556,7 @@ void widget_menu_standard_items(widgetdata *widget, widgetdata *menu) { static void widget_texture_create(widgetdata *widget) { char buf[HUGE_BUF]; - snprintf(VS(buf), "rectangle:%d,%d;", widget->w, widget->h); + snprintf((buf), sizeof((buf)), "rectangle:%d,%d;", widget->w, widget->h); if (widget->texture_type == WIDGET_TEXTURE_TYPE_NORMAL) { snprintfcat(VS(buf), "[bar=%s]", widget->bg[0] != '\0' ? widget->bg : "widget_bg"); diff --git a/client/src/gui/widgets/game_time.c b/client/src/gui/widgets/game_time.c index c770efa7c..88f7bde9d 100644 --- a/client/src/gui/widgets/game_time.c +++ b/client/src/gui/widgets/game_time.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2026 Atrinik Development Team * + * Copyright 2009-2026 The Atrinik Project * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -32,7 +32,7 @@ static void widget_draw(widgetdata *widget) { if (telemetry_game_time_get(&game_minutes, &millis_per_game_minute)) { unsigned hour = game_minutes / 60 % 24; unsigned minute = game_minutes % 60; - snprintf(VS(text), "Game time: %02u:%02u", hour, minute); + snprintf((text), sizeof((text)), "Game time: %02u:%02u", hour, minute); } text_show(widget->surface, @@ -62,7 +62,7 @@ static int widget_event(widgetdata *widget, SDL_Event *event) { uint32_t millis_per_game_minute; if (telemetry_game_time_get(&game_minutes, &millis_per_game_minute)) { char buf[160]; - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "Day %" PRIu64 ", %02" PRIu64 ":%02" PRIu64 "\nOne game minute passes every %.2f real seconds.", game_minutes / (UINT64_C(24) * 60) + 1, diff --git a/client/src/gui/widgets/inventory.c b/client/src/gui/widgets/inventory.c index a86b18290..87c490847 100644 --- a/client/src/gui/widgets/inventory.c +++ b/client/src/gui/widgets/inventory.c @@ -280,7 +280,7 @@ inventory_render_object(widgetdata *widget, object *ob, uint32_t i, uint32_t *r, if (cpl.inventory_focus != widget) { alpha /= 2; } - snprintf(VS(buf), "[alpha=%d][center]", alpha); + snprintf((buf), sizeof((buf)), "[alpha=%d][center]", alpha); /* Construct the name */ if (ob->nrof > 1) { @@ -311,10 +311,10 @@ inventory_render_object(widgetdata *widget, object *ob, uint32_t i, uint32_t *r, if (ob->item_skill_tag && (skill = object_find(ob->item_skill_tag)) && skill_find_object(skill, &skill_id)) { level = skill_get(skill_id)->level; - snprintf(VS(buf2), "level %d %s", ob->item_level, skill->s_name); + snprintf((buf2), sizeof((buf2)), "level %d %s", ob->item_level, skill->s_name); } else { level = cpl.stats.level; - snprintf(VS(buf2), "level %d", ob->item_level); + snprintf((buf2), sizeof((buf2)), "level %d", ob->item_level); } /* If the player or the player's skill level is too low, @@ -975,7 +975,7 @@ void menu_inventory_drop(widgetdata *widget, widgetdata *menuitem, SDL_Event *ev "/droptag %" PRIu32 " %" PRIu32 " ", loc, ob->tag); - snprintf(VS(buf), "%" PRIu32, nrof); + snprintf((buf), sizeof((buf)), "%" PRIu32, nrof); text_input_set(&input->text_input, buf); input->text_input.character_check_func = text_input_number_character_check; text_input_set_history(&input->text_input, NULL); @@ -1075,7 +1075,7 @@ void menu_inventory_get(widgetdata *widget, widgetdata *menuitem, SDL_Event *eve "/gettag %" PRIu32 " %" PRIu32 " ", loc, ob->tag); - snprintf(VS(buf), "%" PRIu32, nrof); + snprintf((buf), sizeof((buf)), "%" PRIu32, nrof); text_input_set(&input->text_input, buf); input->text_input.character_check_func = text_input_number_character_check; text_input_set_history(&input->text_input, NULL); diff --git a/client/src/gui/widgets/map.c b/client/src/gui/widgets/map.c index c1a1c263a..b27696a0d 100644 --- a/client/src/gui/widgets/map.c +++ b/client/src/gui/widgets/map.c @@ -818,7 +818,7 @@ bool widget_map_sparse_state_test(void) { map_cell_layer_record(record, GET_MAP_LAYER(LAYER_LIVING, 0), true)->face = 4; map_cell_actor_record_t *actor = map_cell_actor_record(record, 0, true); actor->target_object_count = (uint32_t)tile + 1U; - snprintf(VS(actor->name), "actor-%" PRIu64, (uint64_t)tile); + snprintf((actor->name), sizeof((actor->name)), "actor-%" PRIu64, (uint64_t)tile); layer_records++; actor_records++; } @@ -2219,7 +2219,7 @@ void update_map_region_name(const char *region_name) { return; } - snprintf(VS(MapData.region_name), "%s", region_name); + snprintf((MapData.region_name), sizeof((MapData.region_name)), "%s", region_name); if (!map_state_transaction.active) { region_map_update(MapData.region_map, region_name); } @@ -2231,7 +2231,7 @@ void update_map_region_name(const char *region_name) { * New region long name. */ void update_map_region_longname(const char *region_longname) { - snprintf(VS(MapData.region_longname), "%s", region_longname); + snprintf((MapData.region_longname), sizeof((MapData.region_longname)), "%s", region_longname); } /** @@ -2240,7 +2240,7 @@ void update_map_region_longname(const char *region_longname) { * New map path. */ void update_map_path(const char *map_path) { - snprintf(VS(MapData.map_path), "%s", map_path); + snprintf((MapData.map_path), sizeof((MapData.map_path)), "%s", map_path); } /** @@ -2941,7 +2941,7 @@ void map_set_data(int x, } layer_record->quick_pos = quick_pos; - snprintf(VS(layer_record->glow), "%s", glow); + snprintf((layer_record->glow), sizeof((layer_record->glow)), "%s", glow); layer_record->height = height; layer_record->zoom_x = zoom_x; layer_record->zoom_y = zoom_y; @@ -2981,8 +2981,8 @@ void map_set_data(int x, actor_record->probe = probe; actor_record->target_object_count = target_object_count; actor_record->target_is_friend = target_is_friend; - snprintf(VS(actor_record->color), "%s", name_color); - snprintf(VS(actor_record->name), "%s", name); + snprintf((actor_record->color), sizeof((actor_record->color)), "%s", name_color); + snprintf((actor_record->name), sizeof((actor_record->name)), "%s", name); if (anim_flags & ANIM_FLAG_ATTACKING && !(actor_record->anim_flags & ANIM_FLAG_ATTACKING)) { layer_record->anim_state = 0; @@ -4175,7 +4175,7 @@ static void draw_map_object(SDL_Surface *surface, map_render_data_t *data) { xl += map_cell_layer_record_read(data->cell, map_layer)->align; - snprintf(VS(effects.glow), "%s", map_cell_layer_record_read(data->cell, map_layer)->glow); + snprintf((effects.glow), sizeof((effects.glow)), "%s", map_cell_layer_record_read(data->cell, map_layer)->glow); effects.glow_speed = map_cell_layer_record_read(data->cell, map_layer)->glow_speed; effects.glow_state = map_cell_layer_record_read(data->cell, map_layer)->glow_state; @@ -7308,7 +7308,7 @@ static void map_render_command_draw_exit(SDL_Surface *surface, effects.zoom_x = command->effects.zoom_x; effects.zoom_y = command->effects.zoom_y; effects.rotate = command->effects.rotate; - snprintf(VS(effects.outline), "%s", MAP_OUTLINE_COLOR); + snprintf((effects.outline), sizeof((effects.outline)), "%s", MAP_OUTLINE_COLOR); surface_show_effects(surface, command->x, command->y, NULL, command->source, &effects); if (command->draw_double) { surface_show_effects(surface, command->x, command->y - 22, NULL, command->source, &effects); @@ -7433,7 +7433,7 @@ static void map_render_commands(SDL_Surface *surface, effects.zoom_x = command->effects.zoom_x; effects.zoom_y = command->effects.zoom_y; effects.rotate = command->effects.rotate; - snprintf(VS(effects.outline), "%s", MAP_OUTLINE_COLOR); + snprintf((effects.outline), sizeof((effects.outline)), "%s", MAP_OUTLINE_COLOR); gpu_renderer_map_set_instance_identity(command->record_identity, ((uint32_t)command->record_layer << 2U) | 2U); @@ -11410,7 +11410,7 @@ void map_anims_play(void) { map_animation_test_damage_draws++; } #endif - snprintf(VS(buf), "%d", abs(anim->value)); + snprintf((buf), sizeof((buf)), "%d", abs(anim->value)); int wd = text_get_width(FONT_MONO10, buf, TEXT_OUTLINE); const char *color = anim->value < 0 ? COLOR_GREEN : COLOR_ORANGE; @@ -11430,7 +11430,7 @@ void map_anims_play(void) { map_animation_test_kill_draws++; } #endif - snprintf(VS(buf), "%d", anim->value); + snprintf((buf), sizeof((buf)), "%d", anim->value); int wd = text_get_width(FONT_MONO10, buf, TEXT_OUTLINE); int ht = text_get_height(FONT_MONO10, buf, 0); #ifdef ATRINIK_WIDGET_TESTS diff --git a/client/src/gui/widgets/network_graph.c b/client/src/gui/widgets/network_graph.c index d7dff7bf8..4509f3eda 100644 --- a/client/src/gui/widgets/network_graph.c +++ b/client/src/gui/widgets/network_graph.c @@ -292,7 +292,7 @@ static int widget_event(widgetdata *widget, SDL_Event *event) { return 0; } - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "Maximum: %.3f ms", (double)(series->max / 1000.0L)); if (network_graph_series_is_valid(series, (size_t)x)) { @@ -308,7 +308,7 @@ static int widget_event(widgetdata *widget, SDL_Event *event) { return 0; } - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "Maximum: %" PRIu64 " Bytes/s (%" PRIu64 " kB/s)", (uint64_t)data->max, (uint64_t)data->max / 1000); diff --git a/client/src/gui/widgets/party.c b/client/src/gui/widgets/party.c index caba2e3af..375ba8f86 100644 --- a/client/src/gui/widgets/party.c +++ b/client/src/gui/widgets/party.c @@ -52,7 +52,7 @@ /** Macro to create the stat bar markup. */ #define PARTY_STAT_BAR() \ - snprintf(VS(bars), \ + snprintf((bars), sizeof((bars)), \ "[bar=#000000 %d 6][bar=#cb0202 %d 6]" \ "[border=#909090 %d 6][y=6][bar=#000000 %d 6]" \ "[bar=#1818a4 %d 6][y=-1][border=#909090 %d 7]", \ diff --git a/client/src/gui/widgets/playerinfo.c b/client/src/gui/widgets/playerinfo.c index 9c76f6fc7..c340431ee 100644 --- a/client/src/gui/widgets/playerinfo.c +++ b/client/src/gui/widgets/playerinfo.c @@ -71,7 +71,7 @@ static void widget_draw(widgetdata *widget) { cpl.name); char buf[32]; - snprintf(VS(buf), "[b]%d[/b]", cpl.stats.level); + snprintf((buf), sizeof((buf)), "[b]%d[/b]", cpl.stats.level); int wd = text_get_width(FONT_SERIF14, buf, TEXT_MARKUP); const char *color = cpl.stats.level == s_settings->max_level ? COLOR_HGOLD : COLOR_WHITE; text_show_format(widget->surface, diff --git a/client/src/gui/widgets/quickslots.c b/client/src/gui/widgets/quickslots.c index dd866cda7..bf8bda5c6 100644 --- a/client/src/gui/widgets/quickslots.c +++ b/client/src/gui/widgets/quickslots.c @@ -92,7 +92,7 @@ static void quickslots_set(widgetdata *widget, uint32_t row, uint32_t col, tag_t if (tag != 0) { char buf[MAX_BUF]; - snprintf(VS(buf), "%" PRIu32, tag); + snprintf((buf), sizeof((buf)), "%" PRIu32, tag); tmp->list->text[row][col] = xstrdup(buf); } } @@ -184,7 +184,7 @@ static int quickslots_trigger(widgetdata *widget, uint32_t row, uint32_t col) { spell_entry_struct *spell = spell_get(spell_path, spell_id); if (spell != NULL && spell->flags & SPELL_DESC_SELF) { char buf[MAX_BUF]; - snprintf(VS(buf), "/cast %s", ob->s_name); + snprintf((buf), sizeof((buf)), "/cast %s", ob->s_name); send_command_check(buf); return 1; } @@ -265,7 +265,7 @@ static void list_post_column(list_struct *list, uint32_t row, uint32_t col) { object_show_inventory(list->surface, tmp, x, y); char buf[MAX_BUF]; - snprintf(VS(buf), "?QUICKSLOT_%" PRIu32, col + 1); + snprintf((buf), sizeof((buf)), "?QUICKSLOT_%" PRIu32, col + 1); keybind_struct *keybind = keybind_find_by_command(buf); if (keybind != NULL) { @@ -421,7 +421,7 @@ void socket_command_quickslots(uint8_t *data, size_t len, size_t pos) { uint8_t slot = packet_reader_read_uint8(&reader); tag_t tag = packet_reader_read_uint32(&reader); char buf[MAX_BUF]; - snprintf(VS(buf), "%" PRIu32, tag); + snprintf((buf), sizeof((buf)), "%" PRIu32, tag); for (widgetdata *widget = cur_widget[QUICKSLOT_ID]; widget != NULL; widget = widget->type_next) { diff --git a/client/src/gui/widgets/skills.c b/client/src/gui/widgets/skills.c index 65aa86dd9..a1e34f593 100644 --- a/client/src/gui/widgets/skills.c +++ b/client/src/gui/widgets/skills.c @@ -126,7 +126,7 @@ static void list_post_column(list_struct *list, uint32_t row, uint32_t col) { border_create_color(list->surface, &box, 1, "ff0000"); char buf[MAX_BUF]; - snprintf(VS(buf), "%s", skill_list[skill_id]->skill->s_name); + snprintf((buf), sizeof((buf)), "%s", skill_list[skill_id]->skill->s_name); string_title(buf); box.w = 160; @@ -285,7 +285,7 @@ void skills_update(object *op, uint8_t level, int64_t xp, const char *msg) { skill->level = level; skill->exp = xp; - snprintf(VS(skill->msg), "%s", msg); + snprintf((skill->msg), sizeof((skill->msg)), "%s", msg); skill_list_reload(); } diff --git a/client/src/gui/widgets/stat.c b/client/src/gui/widgets/stat.c index bae6300ce..bf4b72ba8 100644 --- a/client/src/gui/widgets/stat.c +++ b/client/src/gui/widgets/stat.c @@ -120,7 +120,7 @@ static void widget_draw(widgetdata *widget) { SDL_Rect box; if (strcmp(stat_widget->texture, "text") == 0) { char buf[MAX_BUF]; - snprintf(VS(buf), "%s: %" PRId64 "/%" PRId64, widget->id, curr, max); + snprintf((buf), sizeof((buf)), "%s: %" PRId64 "/%" PRId64, widget->id, curr, max); string_title(buf); if (regen > 0.0) { @@ -202,7 +202,7 @@ static void widget_draw(widgetdata *widget) { } char buf[MAX_BUF]; - snprintf(VS(buf), "stat_bar_%s", widget->id); + snprintf((buf), sizeof((buf)), "stat_bar_%s", widget->id); surface_show_fill(widget->surface, box.x, box.y, NULL, TEXTURE_CLIENT(buf), &box); } } @@ -223,7 +223,7 @@ static int widget_event(widgetdata *widget, SDL_Event *event) { if (regen > 0.0) { char buf[MAX_BUF]; - snprintf(VS(buf), "%s", widget->id); + snprintf((buf), sizeof((buf)), "%s", widget->id); string_title(buf); snprintfcat(VS(buf), ": %" PRId64 "/%" PRId64 "\nRegen: %2.1f/s", curr, max, regen); tooltip_create(event_mouse_x(event), event_mouse_y(event), FONT_ARIAL11, buf); diff --git a/client/src/gui/widgets/xp_tracker.c b/client/src/gui/widgets/xp_tracker.c index e342fdc1f..13cffd97f 100644 --- a/client/src/gui/widgets/xp_tracker.c +++ b/client/src/gui/widgets/xp_tracker.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2026 Atrinik Development Team * + * Copyright 2009-2026 The Atrinik Project * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -27,7 +27,7 @@ static void widget_draw(widgetdata *widget) { SDL_Rect box = {.w = widget->w, .h = widget->h}; char hourly[64]; - snprintf(VS(hourly), "%s", string_format_number_comma(telemetry_exp_per_hour())); + snprintf((hourly), sizeof((hourly)), "%s", string_format_number_comma(telemetry_exp_per_hour())); text_show_format(widget->surface, FONT_ARIAL11, @@ -52,10 +52,10 @@ static void widget_background(widgetdata *widget, int draw) { static int widget_event(widgetdata *widget, SDL_Event *event) { if (event->type == SDL_EVENT_MOUSE_MOTION) { char gained[64]; - snprintf(VS(gained), "%s", string_format_number_comma(telemetry_exp_gained())); + snprintf((gained), sizeof((gained)), "%s", string_format_number_comma(telemetry_exp_gained())); uint64_t elapsed = telemetry_exp_elapsed_seconds(); char buf[256]; - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "Experience gained: %s\nElapsed: %02" PRIu64 ":%02" PRIu64 ":%02" PRIu64 "\nRight-click to reset.", gained, diff --git a/client/src/tests/audio_log.c b/client/src/tests/audio_log.c index cd7b3253a..489fa3f5b 100644 --- a/client/src/tests/audio_log.c +++ b/client/src/tests/audio_log.c @@ -26,7 +26,7 @@ static char captured[HUGE_BUF]; static void capture_print(const char *text) { - snprintf(VS(captured), "%s", text); + snprintf((captured), sizeof((captured)), "%s", text); } static char *read_file(const char *path) { @@ -60,7 +60,7 @@ int main(void) { audio_log_asset_escape("ignored", escaped, 0); char path[HUGE_BUF]; - snprintf(VS(path), "%s/audio-observability.log", ATRINIK_TEST_BINARY_DIR); + snprintf((path), sizeof((path)), "%s/audio-observability.log", ATRINIK_TEST_BINARY_DIR); logger_open_log(path); audio_log_effect_started("server-positional", diff --git a/client/src/tests/compound_packet.c b/client/src/tests/compound_packet.c index 55870ba79..a27ca5f14 100644 --- a/client/src/tests/compound_packet.c +++ b/client/src/tests/compound_packet.c @@ -295,7 +295,7 @@ static void test_name_count_update(void) { base.prev = &prev; base.env = &env; base.inv = &inv; - snprintf(VS(base.s_name), "torch"); + snprintf((base.s_name), sizeof((base.s_name)), "torch"); packet_struct *packet = make_name_count_update("torches", 2); packet_reader_t reader; diff --git a/client/src/tests/face_cache.c b/client/src/tests/face_cache.c index bafd0fedb..50fe42d61 100644 --- a/client/src/tests/face_cache.c +++ b/client/src/tests/face_cache.c @@ -30,7 +30,7 @@ int main(void) { toolkit_import(logger); toolkit_import(path); - snprintf(VS(test_directory), "/tmp/atrinik-face-cache-XXXXXX"); + snprintf((test_directory), sizeof((test_directory)), "/tmp/atrinik-face-cache-XXXXXX"); if (mkdtemp(test_directory) == NULL || !face_cache_start() || !face_cache_start()) { return 1; } diff --git a/client/src/tests/face_request.c b/client/src/tests/face_request.c index 0e4bd9d08..2f4810313 100644 --- a/client/src/tests/face_request.c +++ b/client/src/tests/face_request.c @@ -382,7 +382,7 @@ void widget_redraw_all(int widget_type_id) { static void test_png_load(void) { char source[HUGE_BUF]; - int length = snprintf(VS(source), "%s/textures/loading_off.png", ATRINIK_TEST_SOURCE_DIR); + int length = snprintf((source), sizeof((source)), "%s/textures/loading_off.png", ATRINIK_TEST_SOURCE_DIR); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(source)); @@ -457,34 +457,34 @@ static void test_png_write(const char *path) { static void test_cache_prepare(void) { TEST_CHECK(getcwd(VS(test_original_directory)) != NULL); - snprintf(VS(test_directory), "/tmp/atrinik-face-request-XXXXXX"); + snprintf((test_directory), sizeof((test_directory)), "/tmp/atrinik-face-request-XXXXXX"); TEST_CHECK(mkdtemp(test_directory) != NULL); - int length = snprintf(VS(test_cache_directory), "%s/%s", test_directory, DIRECTORY_CACHE); + int length = snprintf((test_cache_directory), sizeof((test_cache_directory)), "%s/%s", test_directory, DIRECTORY_CACHE); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(test_cache_directory)); TEST_CHECK(mkdir(test_cache_directory, 0700) == 0); - length = snprintf(VS(test_data_directory), "%s/data", test_directory); + length = snprintf((test_data_directory), sizeof((test_data_directory)), "%s/data", test_directory); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(test_data_directory)); TEST_CHECK(mkdir(test_data_directory, 0700) == 0); - length = snprintf(VS(test_user_gfx_directory), "%s/user-gfx", test_directory); + length = snprintf((test_user_gfx_directory), sizeof((test_user_gfx_directory)), "%s/user-gfx", test_directory); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(test_user_gfx_directory)); TEST_CHECK(mkdir(test_user_gfx_directory, 0700) == 0); - length = snprintf(VS(test_current_gfx_directory), "%s/%s", test_directory, DIRECTORY_GFX_USER); + length = snprintf((test_current_gfx_directory), sizeof((test_current_gfx_directory)), "%s/%s", test_directory, DIRECTORY_GFX_USER); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(test_current_gfx_directory)); TEST_CHECK(mkdir(test_current_gfx_directory, 0700) == 0); - length = snprintf(VS(test_installed_directory), "%s/installed", test_directory); + length = snprintf((test_installed_directory), sizeof((test_installed_directory)), "%s/installed", test_directory); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(test_installed_directory)); TEST_CHECK(mkdir(test_installed_directory, 0700) == 0); - length = snprintf(VS(test_installed_gfx_directory), + length = snprintf((test_installed_gfx_directory), sizeof((test_installed_gfx_directory)), "%s/%s", test_installed_directory, DIRECTORY_GFX_USER); @@ -492,7 +492,7 @@ static void test_cache_prepare(void) { TEST_CHECK((size_t)length < sizeof(test_installed_gfx_directory)); TEST_CHECK(mkdir(test_installed_gfx_directory, 0700) == 0); - length = snprintf(VS(test_pack_path), "%s/atrinik.p0", test_data_directory); + length = snprintf((test_pack_path), sizeof((test_pack_path)), "%s/atrinik.p0", test_data_directory); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(test_pack_path)); FILE *fp = fopen(test_pack_path, "wb"); @@ -506,12 +506,12 @@ static void test_cache_prepare(void) { TEST_CHECK(fclose(fp) == 0); length = - snprintf(VS(test_warm_cache), "%s/face-%03u.png", test_cache_directory, TEST_WARM_FACE); + snprintf((test_warm_cache), sizeof((test_warm_cache)), "%s/face-%03u.png", test_cache_directory, TEST_WARM_FACE); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(test_warm_cache)); test_png_write(test_warm_cache); - length = snprintf(VS(test_occupied_warm_cache), + length = snprintf((test_occupied_warm_cache), sizeof((test_occupied_warm_cache)), "%s/face-%03u.png", test_cache_directory, TEST_OCCUPIED_LOCAL_FACE); @@ -519,7 +519,7 @@ static void test_cache_prepare(void) { TEST_CHECK((size_t)length < sizeof(test_occupied_warm_cache)); test_png_write(test_occupied_warm_cache); - length = snprintf(VS(test_offline_warm_cache), + length = snprintf((test_offline_warm_cache), sizeof((test_offline_warm_cache)), "%s/face-%03u.png", test_cache_directory, TEST_OFFLINE_BACKGROUND_FACE); @@ -527,7 +527,7 @@ static void test_cache_prepare(void) { TEST_CHECK((size_t)length < sizeof(test_offline_warm_cache)); test_png_write(test_offline_warm_cache); - length = snprintf(VS(test_current_gfx_face), + length = snprintf((test_current_gfx_face), sizeof((test_current_gfx_face)), "%s/face-%03u.png", test_current_gfx_directory, TEST_CURRENT_GFX_FACE); @@ -535,7 +535,7 @@ static void test_cache_prepare(void) { TEST_CHECK((size_t)length < sizeof(test_current_gfx_face)); test_png_write(test_current_gfx_face); - length = snprintf(VS(test_installed_gfx_face), + length = snprintf((test_installed_gfx_face), sizeof((test_installed_gfx_face)), "%s/face-%03u.png", test_installed_gfx_directory, TEST_INSTALLED_GFX_FACE); @@ -549,7 +549,7 @@ static void test_cache_prepare(void) { test_installed_gfx_directory, }; for (size_t i = 0; i < arraysize(precedence_directories); i++) { - length = snprintf(VS(test_user_precedence_faces[i]), + length = snprintf((test_user_precedence_faces[i]), sizeof((test_user_precedence_faces[i])), "%s/face-%03u.png", precedence_directories[i], TEST_USER_GFX_FACE); @@ -746,7 +746,7 @@ static void test_loader_restart_and_gfx_roots(void) { test_wait_for_face(TEST_USER_GFX_FACE); TEST_CHECK(FaceList[TEST_USER_GFX_FACE].checksum == test_user_png_crc); char expected[MAX_BUF]; - int length = snprintf(VS(expected), DIRECTORY_GFX_USER "/face-%03u.png", TEST_USER_GFX_FACE); + int length = snprintf((expected), sizeof((expected)), DIRECTORY_GFX_USER "/face-%03u.png", TEST_USER_GFX_FACE); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(expected)); TEST_CHECK(strcmp(FaceList[TEST_USER_GFX_FACE].name, expected) == 0); @@ -1284,7 +1284,7 @@ static void test_production_completion_budget(void) { static void test_malformed_network_png_is_rejected(void) { char name[MAX_BUF]; - int length = snprintf(VS(name), "face-%03u", TEST_MALFORMED_FACE); + int length = snprintf((name), sizeof((name)), "face-%03u", TEST_MALFORMED_FACE); TEST_CHECK(length > 0); TEST_CHECK((size_t)length < sizeof(name)); finish_face_cmd(TEST_MALFORMED_FACE, test_malformed_png_crc, name); diff --git a/client/src/tests/face_stream.c b/client/src/tests/face_stream.c index aecc6c189..a6ca8e9a9 100644 --- a/client/src/tests/face_stream.c +++ b/client/src/tests/face_stream.c @@ -303,7 +303,7 @@ int main(void) { return 1; } char identity[HUGE_BUF]; - snprintf(VS(identity), "%s/identity.pem", directory); + snprintf((identity), sizeof((identity)), "%s/identity.pem", directory); test_server_t server = {0}; for (size_t i = 0; i < sizeof(server.body); i++) { diff --git a/client/src/tests/sound_command.c b/client/src/tests/sound_command.c index 1c9d13e9e..7d22ba2c0 100644 --- a/client/src/tests/sound_command.c +++ b/client/src/tests/sound_command.c @@ -35,7 +35,7 @@ static int8_t played_y; static uint8_t played_type; void sound_command_play_effect(const char *filename, int volume, int loop, int8_t x, int8_t y) { - snprintf(VS(played_filename), "%s", filename); + snprintf((played_filename), sizeof((played_filename)), "%s", filename); played_volume = volume; played_loop = loop; played_x = x; @@ -44,14 +44,14 @@ void sound_command_play_effect(const char *filename, int volume, int loop, int8_ } void sound_command_play_background(const char *filename, int volume, int loop) { - snprintf(VS(played_filename), "%s", filename); + snprintf((played_filename), sizeof((played_filename)), "%s", filename); played_volume = volume; played_loop = loop; played_type = CMD_SOUND_BACKGROUND; } void sound_command_play_absolute(const char *filename, int volume, int loop) { - snprintf(VS(played_filename), "%s", filename); + snprintf((played_filename), sizeof((played_filename)), "%s", filename); played_volume = volume; played_loop = loop; played_type = CMD_SOUND_ABSOLUTE; diff --git a/client/src/tests/sound_playback.c b/client/src/tests/sound_playback.c index 1a3833416..b8f94aed7 100644 --- a/client/src/tests/sound_playback.c +++ b/client/src/tests/sound_playback.c @@ -81,7 +81,7 @@ static void write_u32(FILE *fp, uint32_t value) { } static void write_test_wav(void) { - snprintf(VS(audio_path), "%s/audio-playback.wav", ATRINIK_TEST_BINARY_DIR); + snprintf((audio_path), sizeof((audio_path)), "%s/audio-playback.wav", ATRINIK_TEST_BINARY_DIR); FILE *fp = fopen(audio_path, "wb"); TEST_CHECK(fp != NULL); const uint32_t samples = 80000; @@ -171,7 +171,7 @@ static void test_server_and_ambient_sources(void) { socket_command_sound(packet->data, packet->len, 0); TEST_CHECK(strstr(captured, "source=server-positional") != NULL); char expected[MAX_BUF]; - snprintf(VS(expected), + snprintf((expected), sizeof((expected)), "requested=\"%s\" effective=\"%s\"", hurt_effects[i], hurt_effects[i]); diff --git a/client/src/tests/user_data.c b/client/src/tests/user_data.c index f8da4e6bd..78110ba00 100644 --- a/client/src/tests/user_data.c +++ b/client/src/tests/user_data.c @@ -61,7 +61,7 @@ static bool remove_tree(const char *path) { } if (strcmp(entry->d_name, ".") != 0 && strcmp(entry->d_name, "..") != 0) { char child[HUGE_BUF]; - if (snprintf(VS(child), "%s/%s", path, entry->d_name) >= (int)sizeof(child) || + if (snprintf((child), sizeof((child)), "%s/%s", path, entry->d_name) >= (int)sizeof(child) || !remove_tree(child)) { complete = false; } diff --git a/libatrinik/clioptions.c b/libatrinik/clioptions.c index 5259169f4..35ce7bd73 100644 --- a/libatrinik/clioptions.c +++ b/libatrinik/clioptions.c @@ -597,7 +597,7 @@ bool clioptions_load(const char *path, const char *category) { } if (string_startswith(cp, "[") && string_endswith(cp, "]")) { - snprintf(VS(category_cur), "%s", cp); + snprintf((category_cur), sizeof((category_cur)), "%s", cp); } else if (category == NULL || strcasecmp(category, category_cur) == 0) { char *errmsg = NULL; if (!clioptions_load_str(cp, &errmsg)) { diff --git a/libatrinik/curl.c b/libatrinik/curl.c index 7e83d4cc3..cce6975d8 100644 --- a/libatrinik/curl.c +++ b/libatrinik/curl.c @@ -535,7 +535,7 @@ static char *curl_load_etag(curl_request_t *request) { } char path[HUGE_BUF]; - snprintf(VS(path), "%s.etag", request->path); + snprintf((path), sizeof((path)), "%s.etag", request->path); FILE *fp = path_fopen(path, "r"); char *etag = NULL; @@ -713,7 +713,7 @@ static bool curl_write_cache(curl_request_t *request) { } char etag_path[HUGE_BUF]; - snprintf(VS(etag_path), "%s.etag", request->path); + snprintf((etag_path), sizeof((etag_path)), "%s.etag", request->path); if (!path_write_atomic(etag_path, etag, strlen(etag), 0600)) { LOG(ERROR, "Failed to open %s for saving: %d (%s)", etag_path, errno, strerror(errno)); (void)unlink(etag_path); @@ -1384,7 +1384,7 @@ static bool curl_verify_cert_chain(curl_request_t *request) { } char path[HUGE_BUF]; - snprintf(VS(path), "%s/certchains/%s", curl_data_dir, digest_ascii); + snprintf((path), sizeof((path)), "%s/certchains/%s", curl_data_dir, digest_ascii); pthread_mutex_lock(&certchains_mutex); FILE *fp = path_fopen(path, "rb"); @@ -1763,7 +1763,7 @@ void *curl_request_do_get(void *user_data) { char *etag = curl_load_etag(request); if (etag != NULL) { char header[MAX_BUF]; - snprintf(VS(header), "If-None-Match: %s", etag); + snprintf((header), sizeof((header)), "If-None-Match: %s", etag); free(etag); chunk = curl_slist_append(chunk, header); curl_easy_setopt(request->handle, CURLOPT_HTTPHEADER, chunk); diff --git a/libatrinik/logger.c b/libatrinik/logger.c index e2960a397..b4aa566c5 100644 --- a/libatrinik/logger.c +++ b/libatrinik/logger.c @@ -119,16 +119,16 @@ TOOLKIT_INIT_FUNC(logger) { logger_set_filter_logfile("all,-dumptx,-dumprx"); if (logger_term_has_ansi_colors()) { - snprintf(VS(LOGGER_ESC_SEQ(BOLD)), "%s", "\033[1m"); - snprintf(VS(LOGGER_ESC_SEQ(BLACK)), "%s", "\033[30m"); - snprintf(VS(LOGGER_ESC_SEQ(RED)), "%s", "\033[31m"); - snprintf(VS(LOGGER_ESC_SEQ(GREEN)), "%s", "\033[32m"); - snprintf(VS(LOGGER_ESC_SEQ(YELLOW)), "%s", "\033[33m"); - snprintf(VS(LOGGER_ESC_SEQ(BLUE)), "%s", "\033[34m"); - snprintf(VS(LOGGER_ESC_SEQ(MAGENTA)), "%s", "\033[35m"); - snprintf(VS(LOGGER_ESC_SEQ(CYAN)), "%s", "\033[36m"); - snprintf(VS(LOGGER_ESC_SEQ(WHITE)), "%s", "\033[37m"); - snprintf(VS(LOGGER_ESC_SEQ(END)), "%s", "\033[0m"); + snprintf((LOGGER_ESC_SEQ(BOLD)), sizeof((LOGGER_ESC_SEQ(BOLD))), "%s", "\033[1m"); + snprintf((LOGGER_ESC_SEQ(BLACK)), sizeof((LOGGER_ESC_SEQ(BLACK))), "%s", "\033[30m"); + snprintf((LOGGER_ESC_SEQ(RED)), sizeof((LOGGER_ESC_SEQ(RED))), "%s", "\033[31m"); + snprintf((LOGGER_ESC_SEQ(GREEN)), sizeof((LOGGER_ESC_SEQ(GREEN))), "%s", "\033[32m"); + snprintf((LOGGER_ESC_SEQ(YELLOW)), sizeof((LOGGER_ESC_SEQ(YELLOW))), "%s", "\033[33m"); + snprintf((LOGGER_ESC_SEQ(BLUE)), sizeof((LOGGER_ESC_SEQ(BLUE))), "%s", "\033[34m"); + snprintf((LOGGER_ESC_SEQ(MAGENTA)), sizeof((LOGGER_ESC_SEQ(MAGENTA))), "%s", "\033[35m"); + snprintf((LOGGER_ESC_SEQ(CYAN)), sizeof((LOGGER_ESC_SEQ(CYAN))), "%s", "\033[36m"); + snprintf((LOGGER_ESC_SEQ(WHITE)), sizeof((LOGGER_ESC_SEQ(WHITE))), "%s", "\033[37m"); + snprintf((LOGGER_ESC_SEQ(END)), sizeof((LOGGER_ESC_SEQ(END))), "%s", "\033[0m"); } } TOOLKIT_INIT_FUNC_FINISH @@ -326,13 +326,13 @@ void logger_print(logger_level level, char timebuf2[MAX_BUF]; strftime(VS(timebuf2), "%Y/%m/%d %H:%M:%S", tm); - snprintf(VS(timebuf), "[%s.%06" PRIu64 "] ", timebuf2, (uint64_t)tv.tv_usec); + snprintf((timebuf), sizeof((timebuf)), "[%s.%06" PRIu64 "] ", timebuf2, (uint64_t)tv.tv_usec); } else { timebuf[0] = '\0'; } if ((1U << level) & logger_filter_stdout) { - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "%s%s%s" "%s%-6s%s " "%s[%s:%" PRIu64 "]%s " diff --git a/libatrinik/mempool.c b/libatrinik/mempool.c index 5a7a90553..9a1918370 100644 --- a/libatrinik/mempool.c +++ b/libatrinik/mempool.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -274,7 +274,7 @@ static void mempool_leak_info(mempool_struct *pool, StringBuffer *sb) { #else { #endif - snprintf(VS(buf), "no debug information available"); + snprintf((buf), sizeof((buf)), "no debug information available"); } for (i = 0; i < MEMPOOL_NROF_FREELISTS; i++) { diff --git a/libatrinik/metaserver_publisher.c b/libatrinik/metaserver_publisher.c index 12aaa4297..2e68a86b4 100644 --- a/libatrinik/metaserver_publisher.c +++ b/libatrinik/metaserver_publisher.c @@ -207,7 +207,7 @@ bool metaserver_publisher_classic_body(const metaserver_publisher_classic_payloa } char endpoint[320] = {0}; if (has_hostname) { - int endpoint_length = snprintf(VS(endpoint), + int endpoint_length = snprintf((endpoint), sizeof((endpoint)), ",\"hostname\":\"%s\",\"port\":%" PRIu16, payload->hostname, payload->port); @@ -358,14 +358,14 @@ bool metaserver_publisher_build(metaserver_publisher_profile_t profile, metaserver_publisher_hex_lower(nonce_hex); int path_length = - snprintf(VS(components->path), + snprintf((components->path), sizeof((components->path)), "%s/%s/%s", prefix, server_id, profile == METASERVER_PUBLISHER_ACCESS_CLASSIC_V1 ? "routes" : "publish"); - int digest_length = snprintf(VS(components->content_digest), "sha-256=:%s:", digest_base64); + int digest_length = snprintf((components->content_digest), sizeof((components->content_digest)), "sha-256=:%s:", digest_base64); int input_length = - snprintf(VS(components->signature_input), + snprintf((components->signature_input), sizeof((components->signature_input)), "atrinik=" METASERVER_PUBLISH_COMPONENTS ";created=%" PRIu64 ";expires=%" PRIu64 ";nonce=\"%s\";alg=\"ecdsa-p256-sha256\";keyid=\"%s\"" ";tag=\"%s\"", @@ -374,7 +374,7 @@ bool metaserver_publisher_build(metaserver_publisher_profile_t profile, nonce_hex, server_id, tag); - int base_length = snprintf(VS(components->signature_base), + int base_length = snprintf((components->signature_base), sizeof((components->signature_base)), "\"@method\": POST\n" "\"@authority\": %s\n" "\"@path\": %s\n" diff --git a/libatrinik/metaserver_publisher_sequence.c b/libatrinik/metaserver_publisher_sequence.c index db92d4079..d93ab907e 100644 --- a/libatrinik/metaserver_publisher_sequence.c +++ b/libatrinik/metaserver_publisher_sequence.c @@ -28,7 +28,7 @@ static bool metaserver_publish_sequence_read(const char *data_path, } *highwater = 0; for (size_t i = 0; i < 2; i++) { - if (snprintf(VS(slots[i].path), + if (snprintf((slots[i].path), sizeof((slots[i].path)), "%s/%s-%s.%u", data_path, METASERVER_PUBLISH_SEQUENCE_FILE, @@ -45,7 +45,7 @@ static bool metaserver_publish_sequence_read(const char *data_path, char canonical[21]; if (error != PATH_SECRET_OK || permissive || !string_parse_uint64(value, 10, 1, UINT64_MAX, &slots[i].value) || - snprintf(VS(canonical), "%" PRIu64, slots[i].value) >= (int)sizeof(canonical) || + snprintf((canonical), sizeof((canonical)), "%" PRIu64, slots[i].value) >= (int)sizeof(canonical) || strcmp(value, canonical) != 0) { return false; } @@ -85,7 +85,7 @@ metaserver_publish_sequence_persist(const char *data_path, } char value[22]; - int length = snprintf(VS(value), "%" PRIu64 "\n", desired); + int length = snprintf((value), sizeof((value)), "%" PRIu64 "\n", desired); if (length <= 0 || (size_t)length >= sizeof(value)) { return METASERVER_PUBLISH_SEQUENCE_ERROR; } @@ -184,7 +184,7 @@ bool metaserver_publish_replay_parse(const char *body, return false; } char canonical[21]; - if (snprintf(VS(canonical), "%" PRIu64, parsed) != (int)digits_size || + if (snprintf((canonical), sizeof((canonical)), "%" PRIu64, parsed) != (int)digits_size || memcmp(canonical, digits, digits_size) != 0) { return false; } diff --git a/libatrinik/metaserver_url.c b/libatrinik/metaserver_url.c index 79b0b48db..30ae854b3 100644 --- a/libatrinik/metaserver_url.c +++ b/libatrinik/metaserver_url.c @@ -281,15 +281,15 @@ bool metaserver_url_rendezvous(const char *origin, size_t path_length = strlen(parsed.path); char path[MAX_BUF]; bool root = strcmp(parsed.path, "/") == 0; - int path_result = root ? snprintf(VS(path), "/servers/%s", server_id) - : snprintf(VS(path), + int path_result = root ? snprintf((path), sizeof((path)), "/servers/%s", server_id) + : snprintf((path), sizeof((path)), "%s%sservers/%s", parsed.path, parsed.path[path_length - 1] == '/' ? "" : "/", server_id); bool ok = path_result > 0 && path_result < (int)sizeof(path); char query[32]; - ok = ok && snprintf(VS(query), "role=%s", role) < (int)sizeof(query); + ok = ok && snprintf((query), sizeof((query)), "role=%s", role) < (int)sizeof(query); const char *websocket_scheme = strcmp(parsed.scheme, "https") == 0 ? "wss" : "ws"; char *rendered = NULL; ok = ok && curl_url_set(parsed.handle, CURLUPART_PATH, path, 0) == CURLUE_OK && @@ -326,8 +326,8 @@ bool metaserver_url_access(const char *origin, (!websocket || metaserver_identity_valid(server_id)); char path[128]; if (ok) { - int n = websocket ? snprintf(VS(path), "/v1/access/rendezvous/classic/%s", server_id) - : snprintf(VS(path), "/v1/access/resolve"); + int n = websocket ? snprintf((path), sizeof((path)), "/v1/access/rendezvous/classic/%s", server_id) + : snprintf((path), sizeof((path)), "/v1/access/resolve"); ok = n > 0 && (size_t)n < sizeof(path); } char *rendered = NULL; diff --git a/libatrinik/path.c b/libatrinik/path.c index b80080283..273eb0633 100644 --- a/libatrinik/path.c +++ b/libatrinik/path.c @@ -275,7 +275,7 @@ void path_ensure_directories(const char *path) { return; } - snprintf(VS(buf), "%s", path); + snprintf((buf), sizeof((buf)), "%s", path); cp = buf; while ((cp = strchr(cp + 1, '/')) != NULL) { @@ -473,7 +473,7 @@ bool path_write_atomic_existing(const char *path, HARD_ASSERT(data != NULL || size == 0); char temporary[HUGE_BUF]; - if (snprintf(VS(temporary), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary)) { + if (snprintf((temporary), sizeof((temporary)), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary)) { return false; } @@ -1126,7 +1126,7 @@ path_secret_create_atomic(const char *path, const void *data, size_t size) { } char temporary[HUGE_BUF]; - if (snprintf(VS(temporary), "%s.tmp.%s", path, suffix) >= (int)sizeof(temporary)) { + if (snprintf((temporary), sizeof((temporary)), "%s.tmp.%s", path, suffix) >= (int)sizeof(temporary)) { free(directory); free(basename); free(normalized_path); @@ -1272,7 +1272,7 @@ path_secret_create_atomic(const char *path, const void *data, size_t size) { char temporary[64]; int temporary_fd = -1; if (directory_fd != -1 && - snprintf(VS(temporary), ".atrinik-secret-%s", suffix) < (int)sizeof(temporary)) { + snprintf((temporary), sizeof((temporary)), ".atrinik-secret-%s", suffix) < (int)sizeof(temporary)) { int flags = O_WRONLY | O_CREAT | O_EXCL; #ifdef O_CLOEXEC flags |= O_CLOEXEC; diff --git a/libatrinik/process.c b/libatrinik/process.c index fcf393164..e086a37e6 100644 --- a/libatrinik/process.c +++ b/libatrinik/process.c @@ -241,9 +241,9 @@ const char *process_get_str(process_t *process) { static char buf[HUGE_BUF]; #ifndef WIN32 - snprintf(VS(buf), "PID %d", process->pid); + snprintf((buf), sizeof((buf)), "PID %d", process->pid); #else - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "PID %" PRIu64 " (handle: %p)", (uint64_t)process->pi.dwProcessId, process->pi.hProcess); @@ -416,7 +416,7 @@ bool process_start(process_t *process) { sa.lpSecurityDescriptor = NULL; char pipe_name[MAX_BUF]; - snprintf(VS(pipe_name), "\\\\.\\pipe\\%u\\%d", process->uid, i); + snprintf((pipe_name), sizeof((pipe_name)), "\\\\.\\pipe\\%u\\%d", process->uid, i); process_pipe_t *pipe_rx_tmp = CreateNamedPipe(pipe_name, diff --git a/libatrinik/signals.c b/libatrinik/signals.c index a7c4ebc45..f10f47c2a 100644 --- a/libatrinik/signals.c +++ b/libatrinik/signals.c @@ -234,12 +234,12 @@ static void signal_handler(int sig, siginfo_t *siginfo, void *context) strftime(VS(date), "%Y_%m_%d_%H-%M-%S", tm); fp = NULL; if (homedir != NULL && homedir[0] != '\0') { - snprintf(VS(path), "%s/.atrinik/%s-traceback-%s.txt", homedir, traceback_prefix, date); + snprintf((path), sizeof((path)), "%s/.atrinik/%s-traceback-%s.txt", homedir, traceback_prefix, date); fp = fopen(path, "a"); } if (fp == NULL) { - snprintf(VS(path), "%s-traceback-%s.txt", traceback_prefix, date); + snprintf((path), sizeof((path)), "%s-traceback-%s.txt", traceback_prefix, date); fp = fopen(path, "a"); if (fp == NULL) { @@ -609,7 +609,7 @@ TOOLKIT_DEINIT_FUNC(signals) { TOOLKIT_DEINIT_FUNC_FINISH void signals_set_traceback_prefix(const char *prefix) { - snprintf(VS(traceback_prefix), "%s", prefix); + snprintf((traceback_prefix), sizeof((traceback_prefix)), "%s", prefix); } void signals_enable_graceful_termination(void) { diff --git a/libatrinik/socket.c b/libatrinik/socket.c index ed6cf4c2c..ff9d7b9f2 100644 --- a/libatrinik/socket.c +++ b/libatrinik/socket.c @@ -157,7 +157,7 @@ socket_t *socket_create(const char *host, uint16_t port, socket_role_t role, boo #ifdef HAVE_GETADDRINFO char port_str[6]; - snprintf(VS(port_str), "%" PRIu16, port); + snprintf((port_str), sizeof((port_str)), "%" PRIu16, port); struct addrinfo hints, *res; memset(&hints, 0, sizeof(hints)); @@ -282,7 +282,7 @@ socket_t *socket_create(const char *host, uint16_t port, socket_role_t role, boo char *socket_get_addr(socket_t *sc) { static char buf[MAX_BUF]; if (socket_addr2host(&sc->addr, VS(buf)) == NULL) { - snprintf(VS(buf), ""); + snprintf((buf), sizeof((buf)), ""); } return buf; } diff --git a/libatrinik/socket_direct.c b/libatrinik/socket_direct.c index 6742fd497..bbe62d9bd 100644 --- a/libatrinik/socket_direct.c +++ b/libatrinik/socket_direct.c @@ -167,8 +167,8 @@ static struct addrinfo *socket_stun_resolve_until(const char *host, return NULL; } context->hints = *hints; - snprintf(VS(context->host), "%s", host); - snprintf(VS(context->service), "%s", service); + snprintf((context->host), sizeof((context->host)), "%s", host); + snprintf((context->service), sizeof((context->service)), "%s", service); if (pthread_mutex_init(&context->mutex, NULL) != 0) { socket_stun_resolver_release(); free(context); @@ -333,7 +333,7 @@ static bool socket_rendezvous_client_candidate_parse_raw(const char *message, !string_parse_uint64(port_text, 10, 1, UINT16_MAX, &parsed_port) || !socket_rendezvous_host_valid(parsed_host) || !socket_rendezvous_ticket_valid(parsed_ticket) || strlen(parsed_host) >= host_size || - snprintf(VS(canonical), + snprintf((canonical), sizeof((canonical)), "{\"type\":\"client_candidate\",\"host\":\"%s\"," "\"port\":%u,\"ticket\":\"%s\"}", parsed_host, @@ -371,7 +371,7 @@ static bool socket_rendezvous_server_candidate_parse_raw(const char *message, message[consumed] != '\0' || !string_parse_uint64(port_text, 10, 1, UINT16_MAX, &port) || strcmp(ticket, expected_ticket) != 0 || !socket_rendezvous_host_valid(host) || !socket_candidate_kind_parse(kind, &parsed_kind) || - snprintf(VS(canonical), + snprintf((canonical), sizeof((canonical)), "{\"type\":\"server_candidate\",\"host\":\"%s\"," "\"port\":%u,\"kind\":\"%s\",\"ticket\":\"%s\"}", host, @@ -381,7 +381,7 @@ static bool socket_rendezvous_server_candidate_parse_raw(const char *message, strcmp(message, canonical) != 0) { return false; } - snprintf(VS(candidate->host), "%s", host); + snprintf((candidate->host), sizeof((candidate->host)), "%s", host); candidate->port = (uint16_t)port; candidate->kind = parsed_kind; return true; @@ -797,7 +797,7 @@ static bool socket_candidate_add(socket_direct_candidate_t *candidates, return false; } - snprintf(VS(candidates[*count].host), "%s", host); + snprintf((candidates[*count].host), sizeof((candidates[*count].host)), "%s", host); candidates[*count].port = port; candidates[*count].kind = kind; (*count)++; @@ -984,7 +984,7 @@ bool socket_stun_discover_until(socket_t *sc, } memcpy(stun_host, endpoint, stun_host_length); stun_host[stun_host_length] = '\0'; - snprintf(VS(stun_port), "%s", separator + 1); + snprintf((stun_port), sizeof((stun_port)), "%s", separator + 1); struct addrinfo hints, *addresses = NULL; memset(&hints, 0, sizeof(hints)); @@ -1167,7 +1167,7 @@ bool socket_udp_punch(socket_t *sc, const char *host, uint16_t port) { HARD_ASSERT(host != NULL); char port_string[6]; - snprintf(VS(port_string), "%" PRIu16, port); + snprintf((port_string), sizeof((port_string)), "%" PRIu16, port); struct addrinfo hints, *addresses = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = ((struct sockaddr *)&sc->addr)->sa_family; diff --git a/libatrinik/socket_quic.c b/libatrinik/socket_quic.c index b22df4e31..4d42e442f 100644 --- a/libatrinik/socket_quic.c +++ b/libatrinik/socket_quic.c @@ -281,7 +281,7 @@ socket_t *socket_quic_server_create(const char *host, } char port_str[6]; - snprintf(VS(port_str), "%" PRIu16, port); + snprintf((port_str), sizeof((port_str)), "%" PRIu16, port); struct addrinfo hints, *addresses = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; @@ -420,7 +420,7 @@ static socket_t *socket_quic_client_socket(const char *host, struct sockaddr_storage *address, socklen_t *address_length) { char port_str[6]; - snprintf(VS(port_str), "%" PRIu16, port); + snprintf((port_str), sizeof((port_str)), "%" PRIu16, port); struct addrinfo hints, *addresses = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; @@ -767,7 +767,7 @@ static bool socket_quic_candidate_address(const socket_direct_candidate_t *candi } char service[6]; - snprintf(VS(service), "%" PRIu16, candidate->port); + snprintf((service), sizeof((service)), "%" PRIu16, candidate->port); struct addrinfo hints, *addresses = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; @@ -894,7 +894,7 @@ socket_t *socket_quic_client_create(const char *host, } } if (has_directory_address) { - snprintf(VS(candidates[count].host), "%s", host); + snprintf((candidates[count].host), sizeof((candidates[count].host)), "%s", host); candidates[count].port = port; candidates[count].kind = SOCKET_CANDIDATE_DIRECTORY; count++; diff --git a/libatrinik/tests/metaserver_publisher.c b/libatrinik/tests/metaserver_publisher.c index 971c0f125..4551ce74e 100644 --- a/libatrinik/tests/metaserver_publisher.c +++ b/libatrinik/tests/metaserver_publisher.c @@ -54,7 +54,7 @@ static char *fixture_read(const char *path) { static bool fixture_string(const char *json, const char *key, char *value, size_t size) { char marker[96]; - if (snprintf(VS(marker), "\n \"%s\": \"", key) >= (int)sizeof(marker)) { + if (snprintf((marker), sizeof((marker)), "\n \"%s\": \"", key) >= (int)sizeof(marker)) { return false; } const char *cursor = strstr(json, marker); @@ -89,7 +89,7 @@ static bool fixture_string(const char *json, const char *key, char *value, size_ static bool fixture_uint64(const char *json, const char *key, uint64_t *value) { char marker[96]; - if (snprintf(VS(marker), "\n \"%s\": ", key) >= (int)sizeof(marker)) { + if (snprintf((marker), sizeof((marker)), "\n \"%s\": ", key) >= (int)sizeof(marker)) { return false; } const char *cursor = strstr(json, marker); @@ -143,8 +143,8 @@ static bool fixture_nested_string(const char *json, char *value, size_t size) { char object_marker[96], field_marker[96]; - if (snprintf(VS(object_marker), "\n \"%s\": {", object) >= (int)sizeof(object_marker) || - snprintf(VS(field_marker), "\n \"%s\": \"", key) >= (int)sizeof(field_marker)) { + if (snprintf((object_marker), sizeof((object_marker)), "\n \"%s\": {", object) >= (int)sizeof(object_marker) || + snprintf((field_marker), sizeof((field_marker)), "\n \"%s\": \"", key) >= (int)sizeof(field_marker)) { return false; } const char *object_start = strstr(json, object_marker); @@ -270,13 +270,13 @@ static void sequence_persistence_test(const char *server_id) { char temporary_root[HUGE_BUF]; DWORD length = GetTempPathA(sizeof(temporary_root), temporary_root); require(length > 0 && length < sizeof(temporary_root)); - require(snprintf(VS(directory), + require(snprintf((directory), sizeof((directory)), "%satrinik-publisher-%lu", temporary_root, (unsigned long)GetCurrentProcessId()) < (int)sizeof(directory)); require(CreateDirectoryA(directory, NULL)); #else - snprintf(VS(directory), "/tmp/atrinik-publisher-XXXXXX"); + snprintf((directory), sizeof((directory)), "/tmp/atrinik-publisher-XXXXXX"); require(mkdtemp(directory) != NULL); #endif @@ -291,7 +291,7 @@ static void sequence_persistence_test(const char *server_id) { for (unsigned int i = 0; i < 2; i++) { char slot[HUGE_BUF], value[22]; bool permissive = false; - require(snprintf(VS(slot), + require(snprintf((slot), sizeof((slot)), "%s/metaserver-publish-sequence-%s.%u", directory, server_id, @@ -323,18 +323,18 @@ static void sequence_persistence_test(const char *server_id) { require(sequence == 101); char alternate_id[65]; - snprintf(VS(alternate_id), "%s", server_id); + snprintf((alternate_id), sizeof((alternate_id)), "%s", server_id); alternate_id[0] = alternate_id[0] == '0' ? '1' : '0'; require(metaserver_publish_sequence_reserve(directory, alternate_id, 1, &sequence) == METASERVER_PUBLISH_SEQUENCE_OK); require(sequence == 1); char slot0[HUGE_BUF], slot1[HUGE_BUF], alternate_slot0[HUGE_BUF]; - require(snprintf(VS(slot0), "%s/metaserver-publish-sequence-%s.0", directory, server_id) < + require(snprintf((slot0), sizeof((slot0)), "%s/metaserver-publish-sequence-%s.0", directory, server_id) < (int)sizeof(slot0)); - require(snprintf(VS(slot1), "%s/metaserver-publish-sequence-%s.1", directory, server_id) < + require(snprintf((slot1), sizeof((slot1)), "%s/metaserver-publish-sequence-%s.1", directory, server_id) < (int)sizeof(slot1)); - require(snprintf(VS(alternate_slot0), + require(snprintf((alternate_slot0), sizeof((alternate_slot0)), "%s/metaserver-publish-sequence-%s.0", directory, alternate_id) < (int)sizeof(alternate_slot0)); @@ -554,7 +554,7 @@ int main(int argc, char **argv) { signature)); char mutated[METASERVER_PUBLISH_SIGNATURE_BASE_MAX]; - snprintf(VS(mutated), "%s", classic.signature_base); + snprintf((mutated), sizeof((mutated)), "%s", classic.signature_base); mutated[0] ^= 1; require(!metaserver_publisher_verify(certificate, certificate_size, diff --git a/libatrinik/tests/path.c b/libatrinik/tests/path.c index 6e4ac83e6..8b4bec95f 100644 --- a/libatrinik/tests/path.c +++ b/libatrinik/tests/path.c @@ -189,36 +189,36 @@ int main(int argc, char **argv) { char temporary_root[HUGE_BUF]; DWORD length = GetTempPathA(sizeof(temporary_root), temporary_root); require(length > 0 && length < sizeof(temporary_root)); - require(snprintf(VS(directory), + require(snprintf((directory), sizeof((directory)), "%satrinik-path-%lu", temporary_root, (unsigned long)GetCurrentProcessId()) < (int)sizeof(directory)); require(CreateDirectoryA(directory, NULL)); #else - snprintf(VS(directory), "/tmp/atrinik-path-XXXXXX"); + snprintf((directory), sizeof((directory)), "/tmp/atrinik-path-XXXXXX"); require(mkdtemp(directory) != NULL); #endif char prepared[HUGE_BUF]; - require(snprintf(VS(prepared), "%s/prepared", directory) < (int)sizeof(prepared)); + require(snprintf((prepared), sizeof((prepared)), "%s/prepared", directory) < (int)sizeof(prepared)); require(path_ensure_real_directory(prepared, 0700) == PATH_DIRECTORY_OK); require(path_ensure_real_directory(prepared, 0700) == PATH_DIRECTORY_OK); struct stat prepared_metadata; require(stat(prepared, &prepared_metadata) == 0 && S_ISDIR(prepared_metadata.st_mode)); char not_directory[HUGE_BUF]; - require(snprintf(VS(not_directory), "%s/not-directory", directory) < + require(snprintf((not_directory), sizeof((not_directory)), "%s/not-directory", directory) < (int)sizeof(not_directory)); setup_file(not_directory, "not a directory\n"); require(path_ensure_real_directory(not_directory, 0700) == PATH_DIRECTORY_UNSAFE); char missing_directory_parent[HUGE_BUF]; - require(snprintf(VS(missing_directory_parent), "%s/missing-parent/child", directory) < + require(snprintf((missing_directory_parent), sizeof((missing_directory_parent)), "%s/missing-parent/child", directory) < (int)sizeof(missing_directory_parent)); require(path_ensure_real_directory(missing_directory_parent, 0700) == PATH_DIRECTORY_ERROR); char directory_link[HUGE_BUF]; - require(snprintf(VS(directory_link), "%s/directory-link", directory) < + require(snprintf((directory_link), sizeof((directory_link)), "%s/directory-link", directory) < (int)sizeof(directory_link)); #ifdef WIN32 wchar_t *prepared_wide = path_to_wide(prepared); @@ -242,7 +242,7 @@ int main(int argc, char **argv) { if (directory_link_created) { require(path_ensure_real_directory(directory_link, 0700) == PATH_DIRECTORY_UNSAFE); char linked_secret[HUGE_BUF]; - require(snprintf(VS(linked_secret), "%s/linked-secret", directory_link) < + require(snprintf((linked_secret), sizeof((linked_secret)), "%s/linked-secret", directory_link) < (int)sizeof(linked_secret)); static const char linked_secret_data[] = "must-not-follow\n"; require(path_secret_create_atomic(linked_secret, @@ -251,13 +251,13 @@ int main(int argc, char **argv) { PATH_SECRET_CREATE_ERROR); char nested_directory[HUGE_BUF]; - require(snprintf(VS(nested_directory), "%s/nested", prepared) < + require(snprintf((nested_directory), sizeof((nested_directory)), "%s/nested", prepared) < (int)sizeof(nested_directory)); wchar_t *nested_directory_wide = path_to_wide(nested_directory); require(CreateDirectoryW(nested_directory_wide, NULL)); char ancestor_link[HUGE_BUF]; - require(snprintf(VS(ancestor_link), "%s/ancestor-link", directory) < + require(snprintf((ancestor_link), sizeof((ancestor_link)), "%s/ancestor-link", directory) < (int)sizeof(ancestor_link)); wchar_t *ancestor_link_wide = path_to_wide(ancestor_link); bool ancestor_link_created = @@ -276,7 +276,7 @@ int main(int argc, char **argv) { require(ancestor_link_created || ancestor_link_error == ERROR_PRIVILEGE_NOT_HELD); if (ancestor_link_created) { char ancestor_secret[HUGE_BUF]; - require(snprintf(VS(ancestor_secret), + require(snprintf((ancestor_secret), sizeof((ancestor_secret)), "%s/nested/ancestor-secret", ancestor_link) < (int)sizeof(ancestor_secret)); static const char ancestor_secret_data[] = "must-not-follow\n"; @@ -286,7 +286,7 @@ int main(int argc, char **argv) { PATH_SECRET_CREATE_ERROR); char real_secret[HUGE_BUF]; - require(snprintf(VS(real_secret), + require(snprintf((real_secret), sizeof((real_secret)), "%s/ancestor-secret", nested_directory) < (int)sizeof(real_secret)); require(path_exists(real_secret) == 0); @@ -329,7 +329,7 @@ int main(int argc, char **argv) { require(unlink(directory_link) == 0); char dangling_link[HUGE_BUF]; - require(snprintf(VS(dangling_link), "%s/dangling-link", directory) < + require(snprintf((dangling_link), sizeof((dangling_link)), "%s/dangling-link", directory) < (int)sizeof(dangling_link)); require(symlink("missing-target", dangling_link) == 0); require(path_ensure_real_directory(dangling_link, 0700) == PATH_DIRECTORY_UNSAFE); @@ -337,7 +337,7 @@ int main(int argc, char **argv) { #endif char path[HUGE_BUF]; - require(snprintf(VS(path), "%s/invite", directory) < (int)sizeof(path)); + require(snprintf((path), sizeof((path)), "%s/invite", directory) < (int)sizeof(path)); static const char secret_data[] = "secret-value\n"; require(path_secret_create_atomic(path, secret_data, sizeof(secret_data) - 1U) == PATH_SECRET_CREATE_OK); @@ -348,7 +348,7 @@ int main(int argc, char **argv) { require(strcmp(secret, "secret-value") == 0 && !permissive); #ifdef WIN32 char backslash_path[HUGE_BUF]; - require(snprintf(VS(backslash_path), "%s\\backslash-invite", directory) < + require(snprintf((backslash_path), sizeof((backslash_path)), "%s\\backslash-invite", directory) < (int)sizeof(backslash_path)); static const char backslash_secret[] = "backslash-secret\n"; require(path_secret_create_atomic(backslash_path, @@ -386,7 +386,7 @@ int main(int argc, char **argv) { memset(long_name, 'x', sizeof(long_name) - 1U); long_name[sizeof(long_name) - 1U] = 0; char long_path[HUGE_BUF]; - require(snprintf(VS(long_path), "%s/%s", directory, long_name) < (int)sizeof(long_path)); + require(snprintf((long_path), sizeof((long_path)), "%s/%s", directory, long_name) < (int)sizeof(long_path)); static const char long_secret[] = "long-secret"; require(path_secret_create_atomic(long_path, long_secret, sizeof(long_secret) - 1U) == PATH_SECRET_CREATE_OK); @@ -394,7 +394,7 @@ int main(int argc, char **argv) { require(path_read_secret(long_path, VS(secret), &permissive) == PATH_SECRET_OK); require(strcmp(secret, "long-secret") == 0 && !permissive); char extended_path[HUGE_BUF]; - require(snprintf(VS(extended_path), "%s", long_path) < (int)sizeof(extended_path)); + require(snprintf((extended_path), sizeof((extended_path)), "%s", long_path) < (int)sizeof(extended_path)); for (char *cp = extended_path; *cp != 0; cp++) { if (*cp == '/') { *cp = '\\'; @@ -412,7 +412,7 @@ int main(int argc, char **argv) { free(long_path_wide); char broad[HUGE_BUF]; - require(snprintf(VS(broad), "%s/broad", directory) < (int)sizeof(broad)); + require(snprintf((broad), sizeof((broad)), "%s/broad", directory) < (int)sizeof(broad)); static const char broad_secret[] = "broad-secret\n"; require(path_secret_create_atomic(broad, broad_secret, sizeof(broad_secret) - 1U) == PATH_SECRET_CREATE_OK); @@ -422,7 +422,7 @@ int main(int argc, char **argv) { require(strcmp(secret, "broad-secret") == 0 && permissive); char link_path[HUGE_BUF]; - require(snprintf(VS(link_path), "%s/link", directory) < (int)sizeof(link_path)); + require(snprintf((link_path), sizeof((link_path)), "%s/link", directory) < (int)sizeof(link_path)); wchar_t *target_wide = path_to_wide(path); wchar_t *link_wide = path_to_wide(link_path); #ifndef SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE @@ -449,14 +449,14 @@ int main(int argc, char **argv) { #endif char missing[HUGE_BUF]; - require(snprintf(VS(missing), "%s/missing", directory) < (int)sizeof(missing)); + require(snprintf((missing), sizeof((missing)), "%s/missing", directory) < (int)sizeof(missing)); memset(secret, 'x', sizeof(secret)); require(path_read_secret(missing, VS(secret), NULL) == PATH_SECRET_NOT_FOUND); static const char cleared[sizeof(secret)]; require(CRYPTO_memcmp(secret, cleared, sizeof(secret)) == 0); char too_long[HUGE_BUF]; - require(snprintf(VS(too_long), "%s/too-long", directory) < (int)sizeof(too_long)); + require(snprintf((too_long), sizeof((too_long)), "%s/too-long", directory) < (int)sizeof(too_long)); static const char too_long_secret[] = "12345678"; require(path_secret_create_atomic(too_long, too_long_secret, sizeof(too_long_secret) - 1U) == PATH_SECRET_CREATE_OK); @@ -470,7 +470,7 @@ int main(int argc, char **argv) { require(CRYPTO_memcmp(small, small_cleared, sizeof(small)) == 0); char trailing[HUGE_BUF]; - require(snprintf(VS(trailing), "%s/trailing", directory) < (int)sizeof(trailing)); + require(snprintf((trailing), sizeof((trailing)), "%s/trailing", directory) < (int)sizeof(trailing)); static const char trailing_secret[] = "secret\nother\n"; require(path_secret_create_atomic(trailing, trailing_secret, sizeof(trailing_secret) - 1U) == PATH_SECRET_CREATE_OK); @@ -482,7 +482,7 @@ int main(int argc, char **argv) { require(CRYPTO_memcmp(secret, cleared, sizeof(secret)) == 0); char embedded_nul[HUGE_BUF]; - require(snprintf(VS(embedded_nul), "%s/embedded-nul", directory) < + require(snprintf((embedded_nul), sizeof((embedded_nul)), "%s/embedded-nul", directory) < (int)sizeof(embedded_nul)); static const char embedded_nul_data[] = "secret\0wrong\n"; require(path_secret_create_atomic(embedded_nul, @@ -504,7 +504,7 @@ int main(int argc, char **argv) { require(strcmp(path_secret_error_string(PATH_SECRET_READ_ERROR), "cannot read the file") == 0); char boundary_trailing[HUGE_BUF]; - require(snprintf(VS(boundary_trailing), "%s/boundary-trailing", directory) < + require(snprintf((boundary_trailing), sizeof((boundary_trailing)), "%s/boundary-trailing", directory) < (int)sizeof(boundary_trailing)); char boundary_data[MAX_BUF + 1]; memset(boundary_data, 'a', MAX_BUF); @@ -524,7 +524,7 @@ int main(int argc, char **argv) { sizeof(boundary_secret)) == 0); char atomic[HUGE_BUF]; - require(snprintf(VS(atomic), "%s/atomic", directory) < (int)sizeof(atomic)); + require(snprintf((atomic), sizeof((atomic)), "%s/atomic", directory) < (int)sizeof(atomic)); static const char atomic_data[] = "atomic-data"; require(path_write_atomic_existing(atomic, atomic_data, sizeof(atomic_data) - 1U, 0600)); char *atomic_contents = path_file_contents(atomic); @@ -533,7 +533,7 @@ int main(int argc, char **argv) { #ifndef WIN32 char unsynced[HUGE_BUF]; - require(snprintf(VS(unsynced), "%s/unsynced", directory) < (int)sizeof(unsynced)); + require(snprintf((unsynced), sizeof((unsynced)), "%s/unsynced", directory) < (int)sizeof(unsynced)); fail_parent_directory_fsync = true; fsync_call_count = 0; require(!path_write_atomic_existing(unsynced, atomic_data, sizeof(atomic_data) - 1U, 0600)); @@ -542,7 +542,7 @@ int main(int argc, char **argv) { unlink(unsynced); char unclosed[HUGE_BUF]; - require(snprintf(VS(unclosed), "%s/unclosed", directory) < (int)sizeof(unclosed)); + require(snprintf((unclosed), sizeof((unclosed)), "%s/unclosed", directory) < (int)sizeof(unclosed)); fail_parent_directory_close = true; fsync_call_count = 0; require(!path_write_atomic_existing(unclosed, atomic_data, sizeof(atomic_data) - 1U, 0600)); @@ -552,7 +552,7 @@ int main(int argc, char **argv) { #endif char missing_parent[HUGE_BUF]; - require(snprintf(VS(missing_parent), "%s/missing/atomic", directory) < + require(snprintf((missing_parent), sizeof((missing_parent)), "%s/missing/atomic", directory) < (int)sizeof(missing_parent)); require( !path_write_atomic_existing(missing_parent, atomic_data, sizeof(atomic_data) - 1U, 0600)); @@ -565,19 +565,19 @@ int main(int argc, char **argv) { require(chmod(path, 0600) == 0); char link_path[HUGE_BUF]; - require(snprintf(VS(link_path), "%s/link", directory) < (int)sizeof(link_path)); + require(snprintf((link_path), sizeof((link_path)), "%s/link", directory) < (int)sizeof(link_path)); require(symlink(path, link_path) == 0); memset(secret, 'x', sizeof(secret)); require(path_read_secret(link_path, VS(secret), NULL) == PATH_SECRET_UNSAFE_LINK); require(CRYPTO_memcmp(secret, cleared, sizeof(secret)) == 0); char fifo_path[HUGE_BUF]; - require(snprintf(VS(fifo_path), "%s/fifo", directory) < (int)sizeof(fifo_path)); + require(snprintf((fifo_path), sizeof((fifo_path)), "%s/fifo", directory) < (int)sizeof(fifo_path)); require(mkfifo(fifo_path, 0600) == 0); require(path_read_secret(fifo_path, VS(secret), NULL) == PATH_SECRET_NOT_REGULAR); char wrong_owner[HUGE_BUF]; - require(snprintf(VS(wrong_owner), "%s/wrong-owner", directory) < (int)sizeof(wrong_owner)); + require(snprintf((wrong_owner), sizeof((wrong_owner)), "%s/wrong-owner", directory) < (int)sizeof(wrong_owner)); setup_file(wrong_owner, "secret\n"); require(chmod(wrong_owner, 0600) == 0); bool changed_owner = geteuid() == 0 && chown(wrong_owner, 1, (gid_t)-1) == 0; @@ -586,10 +586,10 @@ int main(int argc, char **argv) { } char orphan[HUGE_BUF]; - require(snprintf(VS(orphan), "%s/.atrinik-secret-orphan", directory) < (int)sizeof(orphan)); + require(snprintf((orphan), sizeof((orphan)), "%s/.atrinik-secret-orphan", directory) < (int)sizeof(orphan)); setup_file(orphan, "orphan"); char independent[HUGE_BUF]; - require(snprintf(VS(independent), "%s/independent", directory) < (int)sizeof(independent)); + require(snprintf((independent), sizeof((independent)), "%s/independent", directory) < (int)sizeof(independent)); require(path_secret_create_atomic(independent, secret_data, sizeof(secret_data) - 1U) == PATH_SECRET_CREATE_OK); diff --git a/server/src/commands/permission/metrics.c b/server/src/commands/permission/metrics.c index 6fc6c0096..549a6453b 100644 --- a/server/src/commands/permission/metrics.c +++ b/server/src/commands/permission/metrics.c @@ -112,7 +112,7 @@ void command_metrics(object *op, const char *command, char *params) { string_get_word(args, &pos, ' ', VS(scope), 0); string_get_word(args, &pos, ' ', VS(category), 0); if (*scope == '\0') { - snprintf(VS(scope), "all"); + snprintf((scope), sizeof((scope)), "all"); } if (strcasecmp(scope, "character") != 0 && strcasecmp(scope, "account") != 0 && strcasecmp(scope, "all") != 0) { diff --git a/server/src/loaders/map_header.l b/server/src/loaders/map_header.l index 07d59608a..4499eecc7 100644 --- a/server/src/loaders/map_header.l +++ b/server/src/loaders/map_header.l @@ -118,7 +118,7 @@ WS [ \t]* ^journal_run{S} { const char *run_id = yval(); if (string_is_hex_fixed(run_id, 32, true)) { - snprintf(VS(m->journal_run_id), "%s", run_id); + snprintf((m->journal_run_id), sizeof((m->journal_run_id)), "%s", run_id); } } ^journal_sequence{S} { diff --git a/server/src/loaders/random_map.l b/server/src/loaders/random_map.l index 6a299b8f6..ab8791459 100644 --- a/server/src/loaders/random_map.l +++ b/server/src/loaders/random_map.l @@ -2,7 +2,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -58,13 +58,13 @@ WS [ \ t]* rmap_lex_error = 0; %} -^wallstyle{S} snprintf(VS(RP->wallstyle), "%s", rmap_yval()); -^floorstyle{S} snprintf(VS(RP->floorstyle), "%s", rmap_yval()); -^monsterstyle{S} snprintf(VS(RP->monsterstyle), "%s", rmap_yval()); -^layoutstyle{S} snprintf(VS(RP->layoutstyle), "%s", rmap_yval()); -^doorstyle{S} snprintf(VS(RP->doorstyle), "%s", rmap_yval()); -^decorstyle{S} snprintf(VS(RP->decorstyle), "%s", rmap_yval()); -^dungeon_name{S} snprintf(VS(RP->dungeon_name), "%s", rmap_yval()); +^wallstyle{S} snprintf((RP->wallstyle), sizeof((RP->wallstyle)), "%s", rmap_yval()); +^floorstyle{S} snprintf((RP->floorstyle), sizeof((RP->floorstyle)), "%s", rmap_yval()); +^monsterstyle{S} snprintf((RP->monsterstyle), sizeof((RP->monsterstyle)), "%s", rmap_yval()); +^layoutstyle{S} snprintf((RP->layoutstyle), sizeof((RP->layoutstyle)), "%s", rmap_yval()); +^doorstyle{S} snprintf((RP->doorstyle), sizeof((RP->doorstyle)), "%s", rmap_yval()); +^decorstyle{S} snprintf((RP->decorstyle), sizeof((RP->decorstyle)), "%s", rmap_yval()); +^dungeon_name{S} snprintf((RP->dungeon_name), sizeof((RP->dungeon_name)), "%s", rmap_yval()); ^xsize{S} RP->Xsize = IVAL; ^ysize{S} RP->Ysize = IVAL; ^expand2x{S} RP->expand2x = IVAL; @@ -74,19 +74,19 @@ rmap_lex_error = 0; ^symmetry{S} RP->symmetry = IVAL; ^difficulty{S} RP->difficulty = IVAL; ^decorchance{S} RP->decorchance = IVAL; -^exitstyle{S} snprintf(VS(RP->exitstyle), "%s", rmap_yval()); +^exitstyle{S} snprintf((RP->exitstyle), sizeof((RP->exitstyle)), "%s", rmap_yval()); ^dungeon_level{S} RP->dungeon_level = IVAL; ^dungeon_depth{S} RP->dungeon_depth = IVAL; -^final_map{S} snprintf(VS(RP->final_map), "%s", rmap_yval()); +^final_map{S} snprintf((RP->final_map), sizeof((RP->final_map)), "%s", rmap_yval()); ^orientation{S} RP->orientation = IVAL; ^origin_x{S} RP->origin_x = IVAL; ^origin_y{S} RP->origin_y = IVAL; -^origin_map{S} snprintf(VS(RP->origin_map), "%s", rmap_yval()); +^origin_map{S} snprintf((RP->origin_map), sizeof((RP->origin_map)), "%s", rmap_yval()); ^random_seed{S} RP->random_seed = IVAL; ^num_monsters{S} RP->num_monsters = IVAL; ^darkness{S} RP->darkness = IVAL; ^level_increment{S} RP->level_increment = IVAL; -^bg_music{S} snprintf(VS(RP->bg_music), "%s", rmap_yval()); +^bg_music{S} snprintf((RP->bg_music), sizeof((RP->bg_music)), "%s", rmap_yval()); <*>(^{WS}$)|\n {} diff --git a/server/src/modules/world_maker.c b/server/src/modules/world_maker.c index b5ed55471..a74cd5146 100644 --- a/server/src/modules/world_maker.c +++ b/server/src/modules/world_maker.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -507,7 +507,7 @@ void world_maker(void) { /* Initialize the region. */ wm_r = xcalloc(1, sizeof(wm_region)); /* Open the definitions file. */ - snprintf(VS(buf), "%s/client-maps/%s.def", settings.assetspath, r->name); + snprintf((buf), sizeof((buf)), "%s/client-maps/%s.def", settings.assetspath, r->name); path_ensure_directories(buf); def_fp = fopen(buf, "w"); @@ -522,7 +522,7 @@ void world_maker(void) { /* Parse the maps recursively. */ region_add_rec(wm_r, m, r->name); - snprintf(VS(buf), "%s/client-maps/%s.png", settings.assetspath, r->name); + snprintf((buf), sizeof((buf)), "%s/client-maps/%s.png", settings.assetspath, r->name); path_ensure_directories(buf); /* Store defaults in the definitions file. */ diff --git a/server/src/plugins/plugin_python/atrinik_map.c b/server/src/plugins/plugin_python/atrinik_map.c index d22fd8325..4595e941d 100644 --- a/server/src/plugins/plugin_python/atrinik_map.c +++ b/server/src/plugins/plugin_python/atrinik_map.c @@ -1175,7 +1175,7 @@ static void Atrinik_Map_dealloc(Atrinik_Map *self) { */ static PyObject *Atrinik_Map_str(Atrinik_Map *self) { char buf[HUGE_BUF]; - snprintf(VS(buf), "[%s \"%s\"]", self->map->path, self->map->name); + snprintf((buf), sizeof((buf)), "[%s \"%s\"]", self->map->path, self->map->name); return Py_BuildValue("s", buf); } diff --git a/server/src/plugins/plugin_python/atrinik_object.c b/server/src/plugins/plugin_python/atrinik_object.c index 4c3e21cdf..6816ad430 100644 --- a/server/src/plugins/plugin_python/atrinik_object.c +++ b/server/src/plugins/plugin_python/atrinik_object.c @@ -1078,7 +1078,7 @@ static PyObject *Atrinik_Object_Say(Atrinik_Object *self, PyObject *args) { OBJEXISTCHECK(self); char *name = hooks->stringbuffer_finish(hooks->object_get_name(self->obj, NULL, NULL)); - snprintf(VS(buf), "%s says: %s", name, message); + snprintf((buf), sizeof((buf)), "%s says: %s", name, message); xfree(name); hooks->draw_info_map(CHAT_TYPE_GAME, NULL, @@ -3827,7 +3827,7 @@ int Atrinik_Object_init(PyObject *module) { PyGetSetDef *def = &getseters[i++]; char buf[MAX_BUF]; - snprintf(VS(buf), "f_%s", hooks->object_flag_names[flagno]); + snprintf((buf), sizeof((buf)), "f_%s", hooks->object_flag_names[flagno]); def->name = strdup(buf); def->get = (getter)Object_GetFlag; diff --git a/server/src/plugins/plugin_python/plugin_python.c b/server/src/plugins/plugin_python/plugin_python.c index 9f5586ad0..c5d378cbf 100644 --- a/server/src/plugins/plugin_python/plugin_python.c +++ b/server/src/plugins/plugin_python/plugin_python.c @@ -905,7 +905,7 @@ static void command_custom_python(object *op, const char *command, char *params) context->returnvalue = 0; char path[MAX_BUF]; - snprintf(VS(path), "/python/commands/%s.py", command); + snprintf((path), sizeof((path)), "/python/commands/%s.py", command); if (!do_script(context, path, NULL)) { freeContext(context); return; @@ -2401,7 +2401,7 @@ static PyObject *PyInit_Atrinik(void) { */ static PyObject *module_create(PyObject *parent, const char *name) { char tmp[MAX_BUF]; - snprintf(VS(tmp), "Atrinik_%s", name); + snprintf((tmp), sizeof((tmp)), "Atrinik_%s", name); PyObject *module = PyModule_New(tmp); PyDict_SetItemString(PyModule_GetDict(parent), name, module); return module; diff --git a/server/src/server/access_server.c b/server/src/server/access_server.c index bff30cff0..0088f23a5 100644 --- a/server/src/server/access_server.c +++ b/server/src/server/access_server.c @@ -216,8 +216,8 @@ bool access_server_init(const char identity_hex[65]) { char path[HUGE_BUF]; bool relative_store = settings.access_store[0] == '\0' && initialization_data_descriptor() >= 0; if (settings.access_store[0] != '\0') - snprintf(VS(path), "%s", settings.access_store); - else if (snprintf(VS(path), "%s/access-tokens", settings.datapath) >= (int)sizeof(path)) + snprintf((path), sizeof((path)), "%s", settings.access_store); + else if (snprintf((path), sizeof((path)), "%s/access-tokens", settings.datapath) >= (int)sizeof(path)) return false; int data = initialization_data_descriptor(); bool absent = diff --git a/server/src/server/account.c b/server/src/server/account.c index 5b43a63cd..526f1fc46 100644 --- a/server/src/server/account.c +++ b/server/src/server/account.c @@ -492,8 +492,8 @@ bool account_provision(const char *name, memset(&account, 0, sizeof(account)); metrics_store_init(&account.metrics, METRIC_SCOPE_ACCOUNT, 0); account.metrics_valid = true; - snprintf(VS(account_name), "%s", name); - snprintf(VS(character_name), "%s", character); + snprintf((account_name), sizeof((account_name)), "%s", name); + snprintf((character_name), sizeof((character_name)), "%s", character); size_t account_length = strlen(account_name); size_t password_length = strlen(password); @@ -647,8 +647,8 @@ bool account_provision_from_file(const char *name, HARD_ASSERT(error != NULL); HARD_ASSERT(error_size > 0); error[0] = '\0'; - snprintf(VS(account_name), "%s", name); - snprintf(VS(character_name), "%s", character); + snprintf((account_name), sizeof((account_name)), "%s", name); + snprintf((character_name), sizeof((character_name)), "%s", character); string_tolower(account_name); string_title(character_name); diff --git a/server/src/server/arch.c b/server/src/server/arch.c index 5f27f5c40..6327d7b6e 100644 --- a/server/src/server/arch.c +++ b/server/src/server/arch.c @@ -286,7 +286,7 @@ static void arch_pass_second(FILE *fp, const char *filename) { } char filename2[HUGE_BUF]; - snprintf(VS(filename2), "%s/artifacts", settings.libpath); + snprintf((filename2), sizeof((filename2)), "%s/artifacts", settings.libpath); fp = fopen(filename2, "rb"); if (fp == NULL) { @@ -372,7 +372,7 @@ static void arch_load(void) { TIMER_START(1); char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/archetypes", settings.libpath); + snprintf((filename), sizeof((filename)), "%s/archetypes", settings.libpath); FILE *fp = fopen(filename, "rb"); if (fp == NULL) { diff --git a/server/src/server/artifact.c b/server/src/server/artifact.c index 8001bf26f..29985bee3 100644 --- a/server/src/server/artifact.c +++ b/server/src/server/artifact.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -122,7 +122,7 @@ static void artifact_list_free(artifact_list_t *al) { */ void artifact_load(void) { char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/artifacts", settings.libpath); + snprintf((filename), sizeof((filename)), "%s/artifacts", settings.libpath); FILE *fp = fopen(filename, "rb"); if (fp == NULL) { LOG(ERROR, "Can't open %s.", filename); diff --git a/server/src/server/ban.c b/server/src/server/ban.c index 262bfefb5..8dbe79f6c 100644 --- a/server/src/server/ban.c +++ b/server/src/server/ban.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -74,7 +74,7 @@ TOOLKIT_API(IMPORTS(shstr), IMPORTS(string)); TOOLKIT_INIT_FUNC(ban) { char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/" BANFILE, settings.datapath); + snprintf((filename), sizeof((filename)), "%s/" BANFILE, settings.datapath); FILE *fp = fopen(filename, "r"); if (fp == NULL) { @@ -114,7 +114,7 @@ TOOLKIT_DEINIT_FUNC_FINISH */ static void ban_save(void) { char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/" BANFILE, settings.datapath); + snprintf((filename), sizeof((filename)), "%s/" BANFILE, settings.datapath); FILE *fp = fopen(filename, "w"); if (fp == NULL) { diff --git a/server/src/server/bank.c b/server/src/server/bank.c index 7f0346a39..c3485c20d 100644 --- a/server/src/server/bank.c +++ b/server/src/server/bank.c @@ -295,7 +295,7 @@ bank_insert_coins(object *op, archetype_t *at, uint32_t nrof, const char *transa tmp->nrof = nrof; if (transaction_id != NULL && transaction_id[0] != '\0') { char lineage[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(lineage), "currency:%s", transaction_id); + snprintf((lineage), sizeof((lineage)), "currency:%s", transaction_id); tmp->custody_lineage = add_string(lineage); } object_insert_into(tmp, op, 0); diff --git a/server/src/server/celestial_structure.c b/server/src/server/celestial_structure.c index 65864bd2a..c01ede169 100644 --- a/server/src/server/celestial_structure.c +++ b/server/src/server/celestial_structure.c @@ -2689,7 +2689,7 @@ static bool private_map_roster_owner(const char *directory, continue; } char canonical_character[MAX_BUF]; - snprintf(VS(canonical_character), "%s", parts[1]); + snprintf((canonical_character), sizeof((canonical_character)), "%s", parts[1]); string_title(canonical_character); if (strcmp(canonical_character, character) != 0) { continue; @@ -2758,7 +2758,7 @@ static bool preflight_private_map_file(const char *path, char *error, size_t err char reason[HUGE_BUF] = ""; char source_path[HUGE_BUF]; if (!celestial_private_source_from_path(path, source_path)) { - snprintf(VS(reason), "invalid $-demangled authored source"); + snprintf((reason), sizeof((reason)), "invalid $-demangled authored source"); } if (reason[0] == '\0') { @@ -2775,11 +2775,11 @@ static bool preflight_private_map_file(const char *path, char *error, size_t err FREE_AND_COPY_HASH(map->path, path); map->map_flags |= MAP_FLAG_UNIQUE; if (fp == NULL || !load_map_header(map, fp)) { - snprintf(VS(reason), "malformed private map header"); + snprintf((reason), sizeof((reason)), "malformed private map header"); } else if (map->celestial_schema != 1 || !celestial_structure_validate_header(map, VS(reason))) { if (reason[0] == '\0') { - snprintf(VS(reason), "private map is not a valid celestial-v1 map"); + snprintf((reason), sizeof((reason)), "private map is not a valid celestial-v1 map"); } } } diff --git a/server/src/server/exp.c b/server/src/server/exp.c index 2a394e8af..a674c5cb4 100644 --- a/server/src/server/exp.c +++ b/server/src/server/exp.c @@ -581,7 +581,7 @@ static bool progression_level_begin(object *who, char transaction[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]) { char subject[GAMEPLAY_JOURNAL_ID_MAX + 1]; if (op->type == PLAYER) { - snprintf(VS(subject), "level:character"); + snprintf((subject), sizeof((subject)), "level:character"); } else { const char *skill_id = skill_id_from_index(op->stats.sp); if (skill_id == NULL || !metrics_format_content_id(VS(subject), "skill", skill_id)) { diff --git a/server/src/server/faction.c b/server/src/server/faction.c index 59a2ee5ad..3311e3a1c 100644 --- a/server/src/server/faction.c +++ b/server/src/server/faction.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -142,7 +142,7 @@ TOOLKIT_API(DEPENDS(shstr)); TOOLKIT_INIT_FUNC(faction) { char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/factions", settings.libpath); + snprintf((filename), sizeof((filename)), "%s/factions", settings.libpath); FILE *fp = fopen(filename, "r"); diff --git a/server/src/server/gameplay_journal.c b/server/src/server/gameplay_journal.c index 7f1548093..9c077f5e0 100644 --- a/server/src/server/gameplay_journal.c +++ b/server/src/server/gameplay_journal.c @@ -239,7 +239,7 @@ static bool journal_item_snapshot_valid(const gameplay_journal_change_t *change) return false; } char prefix[GAMEPLAY_JOURNAL_ID_MAX + 1]; - int prefix_length = snprintf(VS(prefix), + int prefix_length = snprintf((prefix), sizeof((prefix)), "arch=%s;type=%" PRId32 ";nrof=", change->archetype, change->object_type); @@ -271,7 +271,7 @@ static bool journal_item_snapshot_valid(const gameplay_journal_change_t *change) return false; } char expected[GAMEPLAY_JOURNAL_ID_MAX + 1]; - int expected_length = snprintf(VS(expected), + int expected_length = snprintf((expected), sizeof((expected)), "%s%" PRIuMAX ";value=%" PRIdMAX ";weight=%" PRIuMAX, prefix, nrof, @@ -430,7 +430,7 @@ static bool journal_sync_directory(void) { static bool journal_lock(void) { char path[HUGE_BUF]; - if (snprintf(VS(path), "%s/journal.lock", journal.directory) >= (int)sizeof(path)) { + if (snprintf((path), sizeof((path)), "%s/journal.lock", journal.directory) >= (int)sizeof(path)) { return false; } #ifdef WIN32 @@ -511,7 +511,7 @@ static bool journal_counter_bootstrap(uint64_t *sequence) { continue; } char path[HUGE_BUF]; - if (snprintf(VS(path), "%s/%s", journal.directory, entry->d_name) >= (int)sizeof(path)) { + if (snprintf((path), sizeof((path)), "%s/%s", journal.directory, entry->d_name) >= (int)sizeof(path)) { ok = false; break; } @@ -611,7 +611,7 @@ static bool journal_counter_reserve(void) { } uint64_t sequence = journal.sequence + 1; char value[32]; - int length = snprintf(VS(value), "%" PRIu64 "\n", sequence); + int length = snprintf((value), sizeof((value)), "%" PRIu64 "\n", sequence); if (length <= 0 || length >= (int)sizeof(value) || !journal_counter_seek_start()) { return false; } @@ -642,7 +642,7 @@ static bool journal_enforce_retention(void) { continue; } retained_file_t file; - if (snprintf(VS(file.path), "%s/%s", journal.directory, entry->d_name) >= + if (snprintf((file.path), sizeof((file.path)), "%s/%s", journal.directory, entry->d_name) >= (int)sizeof(file.path)) { ok = false; break; @@ -743,7 +743,7 @@ static bool journal_open_file(void) { return false; } char path[HUGE_BUF]; - if (snprintf(VS(path), + if (snprintf((path), sizeof((path)), "%s/journal-%s-%04" PRIu32 ".jsonl", journal.directory, journal.run_id, @@ -891,9 +891,9 @@ static bool journal_profile_copy(const gameplay_journal_profile_t *profile) { !journal_token_valid(profile->effective_axes, false)) { return false; } - snprintf(VS(journal.profile_id), "%s", profile->id); - snprintf(VS(journal.profile_digest), "%s", profile->digest); - snprintf(VS(journal.profile_axes), "%s", profile->effective_axes); + snprintf((journal.profile_id), sizeof((journal.profile_id)), "%s", profile->id); + snprintf((journal.profile_digest), sizeof((journal.profile_digest)), "%s", profile->digest); + snprintf((journal.profile_axes), sizeof((journal.profile_axes)), "%s", profile->effective_axes); journal.profile = (gameplay_journal_profile_t){.id = journal.profile_id, .schema = profile->schema, .digest = journal.profile_digest, @@ -908,7 +908,7 @@ bool gameplay_journal_init(const char *datapath, journal.required = true; if (datapath == NULL || !journal_token_valid(server_id, false) || !journal_profile_copy(profile) || - snprintf(VS(journal.directory), "%s/%s", datapath, JOURNAL_DIRECTORY) >= + snprintf((journal.directory), sizeof((journal.directory)), "%s/%s", datapath, JOURNAL_DIRECTORY) >= (int)sizeof(journal.directory) || path_ensure_real_directory(journal.directory, SAVE_MODE_DIR) != PATH_DIRECTORY_OK) { journal.failed = true; @@ -925,7 +925,7 @@ bool gameplay_journal_init(const char *datapath, journal.failed = true; return false; } - snprintf(VS(journal.server_id), "%s", server_id); + snprintf((journal.server_id), sizeof((journal.server_id)), "%s", server_id); if (!journal_open_file()) { journal.failed = true; return false; @@ -1072,7 +1072,7 @@ bool gameplay_journal_track_player(const char *transaction_id, object *player_ob if (!journal_append_domain(transaction_id, "player", id)) { return false; } - snprintf(VS(pending->player_ids[pending->player_count]), "%s", id); + snprintf((pending->player_ids[pending->player_count]), sizeof((pending->player_ids[pending->player_count])), "%s", id); pending->players[pending->player_count] = player_ob; pending->player_counts[pending->player_count] = player_ob->count; pending->player_count++; @@ -1107,7 +1107,7 @@ static bool journal_track_map(const char *transaction_id, mapstruct *map, bool u id)) { return false; } - snprintf(VS(pending->map_ids[pending->map_count]), "%s", id); + snprintf((pending->map_ids[pending->map_count]), sizeof((pending->map_ids[pending->map_count])), "%s", id); pending->maps[pending->map_count] = map; pending->map_counts[pending->map_count] = map->count; pending->map_unique[pending->map_count] = unique_component; @@ -1260,9 +1260,9 @@ bool gameplay_journal_begin(const gameplay_journal_subject_t *subject, } pending_transaction_t *pending = &journal.pending[journal.pending_count]; memset(pending, 0, sizeof(*pending)); - snprintf(VS(pending->id), "%s", transaction_id); - snprintf(VS(pending->reason), "%s", reason); - snprintf(VS(pending->player_ids[0]), "%s", player_domain); + snprintf((pending->id), sizeof((pending->id)), "%s", transaction_id); + snprintf((pending->reason), sizeof((pending->reason)), "%s", reason); + snprintf((pending->player_ids[0]), sizeof((pending->player_ids[0])), "%s", player_domain); pending->player_count = 1; journal.pending_count++; return true; @@ -1329,7 +1329,7 @@ static bool journal_finish(const char *transaction_id, const char *phase, const if (player_ob != NULL && OBJECT_VALID(player_ob, pending->player_counts[i]) && player_ob->type == PLAYER && CONTR(player_ob) != NULL) { player *pl = CONTR(player_ob); - snprintf(VS(pl->journal_run_id), "%s", journal.run_id); + snprintf((pl->journal_run_id), sizeof((pl->journal_run_id)), "%s", journal.run_id); pl->journal_sequence = journal.sequence; } } @@ -1350,7 +1350,7 @@ static bool journal_finish(const char *transaction_id, const char *phase, const for (size_t i = 0; i < JOURNAL_PENDING_LIMIT; i++) { if (journal_test_counts[i].reason[0] == '\0' || strcmp(journal_test_counts[i].reason, pending->reason) == 0) { - snprintf(VS(journal_test_counts[i].reason), "%s", pending->reason); + snprintf((journal_test_counts[i].reason), sizeof((journal_test_counts[i].reason)), "%s", pending->reason); journal_test_counts[i].count++; break; } diff --git a/server/src/server/hiscore.c b/server/src/server/hiscore.c index 6da1bf35a..3345402e3 100644 --- a/server/src/server/hiscore.c +++ b/server/src/server/hiscore.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -364,12 +364,12 @@ void hiscore_check(object *op, int quiet) { return; } - snprintf(VS(new_score.name), "%s", op->name); - snprintf(VS(new_score.title), "%s", op->race); + snprintf((new_score.name), sizeof((new_score.name)), "%s", op->name); + snprintf((new_score.title), sizeof((new_score.title)), "%s", op->race); const char *killer = player_get_killer(CONTR(op)); if (killer != NULL) { - snprintf(VS(new_score.killer), "%s", killer); + snprintf((new_score.killer), sizeof((new_score.killer)), "%s", killer); } else { *new_score.killer = '\0'; } @@ -379,7 +379,7 @@ void hiscore_check(object *op, int quiet) { if (op->map == NULL) { *new_score.maplevel = '\0'; } else { - snprintf(VS(new_score.maplevel), + snprintf((new_score.maplevel), sizeof((new_score.maplevel)), "%s", op->map->name != NULL ? op->map->name : op->map->path); diff --git a/server/src/server/init.c b/server/src/server/init.c index 44e2cf00f..dbe20c653 100644 --- a/server/src/server/init.c +++ b/server/src/server/init.c @@ -278,7 +278,7 @@ static bool clioptions_option_plugin_unit(const char *arg, char **errmsg) { settings.plugin_unit_tests = 1; if (arg != NULL) { - snprintf(VS(settings.plugin_unit_test), "%s", arg); + snprintf((settings.plugin_unit_test), sizeof((settings.plugin_unit_test)), "%s", arg); } return true; @@ -306,35 +306,35 @@ static bool clioptions_option_provision_scenario(const char *arg, char **errmsg) static const char *clioptions_option_provision_account_desc = "Account name for --provision_scenario."; static bool clioptions_option_provision_account(const char *arg, char **errmsg) { - snprintf(VS(settings.provision_account), "%s", arg); + snprintf((settings.provision_account), sizeof((settings.provision_account)), "%s", arg); return true; } static const char *clioptions_option_provision_character_desc = "Character name for --provision_scenario."; static bool clioptions_option_provision_character(const char *arg, char **errmsg) { - snprintf(VS(settings.provision_character), "%s", arg); + snprintf((settings.provision_character), sizeof((settings.provision_character)), "%s", arg); return true; } static const char *clioptions_option_provision_archetype_desc = "Player archetype for --provision_scenario."; static bool clioptions_option_provision_archetype(const char *arg, char **errmsg) { - snprintf(VS(settings.provision_archetype), "%s", arg); + snprintf((settings.provision_archetype), sizeof((settings.provision_archetype)), "%s", arg); return true; } static const char *clioptions_option_provision_preset_desc = "Server-owned deterministic preset for --provision_scenario."; static bool clioptions_option_provision_preset(const char *arg, char **errmsg) { - snprintf(VS(settings.provision_preset), "%s", arg); + snprintf((settings.provision_preset), sizeof((settings.provision_preset)), "%s", arg); return true; } static const char *clioptions_option_provision_password_file_desc = "Protected password file for --provision_scenario."; static bool clioptions_option_provision_password_file(const char *arg, char **errmsg) { - snprintf(VS(settings.provision_password_file), "%s", arg); + snprintf((settings.provision_password_file), sizeof((settings.provision_password_file)), "%s", arg); return true; } @@ -354,7 +354,7 @@ static bool clioptions_option_content_benchmark(const char *arg, char **errmsg) } settings.content_benchmark = true; - snprintf(VS(settings.content_benchmark_maps), "%s", arg); + snprintf((settings.content_benchmark_maps), sizeof((settings.content_benchmark_maps)), "%s", arg); return true; } @@ -373,7 +373,7 @@ static bool clioptions_option_content_benchmark_route(const char *arg, char **er return false; } settings.content_benchmark = true; - snprintf(VS(settings.content_benchmark_maps), "%s", arg); + snprintf((settings.content_benchmark_maps), sizeof((settings.content_benchmark_maps)), "%s", arg); logger_set_print_func(content_route_log_stderr); return true; } @@ -405,7 +405,7 @@ static bool clioptions_option_celestial_inventory(const char *arg, char **errmsg return false; } settings.celestial_inventory = true; - snprintf(VS(settings.celestial_inventory_maps), "%s", arg); + snprintf((settings.celestial_inventory_maps), sizeof((settings.celestial_inventory_maps)), "%s", arg); return true; } @@ -435,7 +435,7 @@ static bool clioptions_option_admin_shutdown_socket(const char *arg, char **errm string_fmt(*errmsg, "%s", "Local administrative shutdown is supported only on Linux"); return false; #else - snprintf(VS(settings.admin_shutdown_socket), "%s", arg); + snprintf((settings.admin_shutdown_socket), sizeof((settings.admin_shutdown_socket)), "%s", arg); return true; #endif } @@ -487,7 +487,7 @@ static const char *clioptions_option_libpath_desc = "archetypes etc reside."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_libpath(const char *arg, char **errmsg) { - snprintf(VS(settings.libpath), "%s", arg); + snprintf((settings.libpath), sizeof((settings.libpath)), "%s", arg); return true; } @@ -498,7 +498,7 @@ static const char *clioptions_option_datapath_desc = "Where to read and write player data, unique maps, etc."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_datapath(const char *arg, char **errmsg) { - snprintf(VS(settings.datapath), "%s", arg); + snprintf((settings.datapath), sizeof((settings.datapath)), "%s", arg); return true; } @@ -534,7 +534,7 @@ static bool clioptions_option_datapath_fd(const char *arg, char **errmsg) { static const char *clioptions_option_mapspath_desc = "Where the maps, Python scripts, etc reside."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_mapspath(const char *arg, char **errmsg) { - snprintf(VS(settings.mapspath), "%s", arg); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%s", arg); return true; } @@ -553,7 +553,7 @@ static bool clioptions_option_assetspath(const char *arg, char **errmsg) { return false; } - snprintf(VS(settings.assetspath), "%s", arg); + snprintf((settings.assetspath), sizeof((settings.assetspath)), "%s", arg); return true; } @@ -575,7 +575,7 @@ static const char *clioptions_option_resourcespath_desc = "The server must have read access to this directory."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_resourcespath(const char *arg, char **errmsg) { - snprintf(VS(settings.resourcespath), "%s", arg); + snprintf((settings.resourcespath), sizeof((settings.resourcespath)), "%s", arg); return true; } @@ -591,7 +591,7 @@ static bool clioptions_option_metaserver_publish_origin(const char *arg, char ** string_fmt(*errmsg, "%s", "metaserver publisher origin is not canonical"); return false; } - snprintf(VS(settings.metaserver_publish_origin), "%s", arg); + snprintf((settings.metaserver_publish_origin), sizeof((settings.metaserver_publish_origin)), "%s", arg); return true; } @@ -604,7 +604,7 @@ static bool clioptions_option_metaserver_hostname(const char *arg, char **errmsg *errmsg = xstrdup("Expected a canonical lowercase DNS hostname"); return false; } - snprintf(VS(settings.metaserver_hostname), "%s", arg); + snprintf((settings.metaserver_hostname), sizeof((settings.metaserver_hostname)), "%s", arg); return true; } @@ -620,7 +620,7 @@ static bool clioptions_option_metaserver_rendezvous_origin(const char *arg, char string_fmt(*errmsg, "%s", "metaserver rendezvous origin is not canonical"); return false; } - snprintf(VS(settings.metaserver_rendezvous_origin), "%s", arg); + snprintf((settings.metaserver_rendezvous_origin), sizeof((settings.metaserver_rendezvous_origin)), "%s", arg); return true; } @@ -656,7 +656,7 @@ static const char *clioptions_option_stun_server_desc = "STUN server as hostname:port for public UDP candidate discovery, or off."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_stun_server(const char *arg, char **errmsg) { - snprintf(VS(settings.stun_server), "%s", arg); + snprintf((settings.stun_server), sizeof((settings.stun_server)), "%s", arg); return true; } @@ -671,7 +671,7 @@ static bool clioptions_option_port_mapping(const char *arg, char **errmsg) { *errmsg = xstrdup("Expected auto or off"); return false; } - snprintf(VS(settings.port_mapping), "%s", arg); + snprintf((settings.port_mapping), sizeof((settings.port_mapping)), "%s", arg); return true; } @@ -729,7 +729,7 @@ static bool clioptions_option_access_store(const char *arg, char **errmsg) { *errmsg = xstrdup("Expected a bounded absolute directory"); return false; } - snprintf(VS(settings.access_store), "%s", arg); + snprintf((settings.access_store), sizeof((settings.access_store)), "%s", arg); return true; } /* Recognize removed settings solely to fail startup, never fall back to open. */ @@ -786,7 +786,7 @@ static bool clioptions_option_server_host(const char *arg, char **errmsg) { return false; } - snprintf(VS(settings.server_host), "%s", arg); + snprintf((settings.server_host), sizeof((settings.server_host)), "%s", arg); return true; } @@ -798,7 +798,7 @@ static const char *clioptions_option_server_name_desc = "servers (the metaserver)"; /** @copydoc clioptions_handler_func */ static bool clioptions_option_server_name(const char *arg, char **errmsg) { - snprintf(VS(settings.server_name), "%s", arg); + snprintf((settings.server_name), sizeof((settings.server_name)), "%s", arg); return true; } @@ -809,7 +809,7 @@ static const char *clioptions_option_server_desc_desc = "Description of the server. This should describe the server to players."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_server_desc(const char *arg, char **errmsg) { - snprintf(VS(settings.server_desc), "%s", arg); + snprintf((settings.server_desc), sizeof((settings.server_desc)), "%s", arg); return true; } @@ -1032,7 +1032,7 @@ static const char *clioptions_option_control_player_desc = "The first player is used if this option is not specified."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_control_player(const char *arg, char **errmsg) { - snprintf(VS(settings.control_player), "%s", arg); + snprintf((settings.control_player), sizeof((settings.control_player)), "%s", arg); return true; } @@ -1060,7 +1060,7 @@ static const char *clioptions_option_http_url_desc = "will fall back to QUIC when available."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_http_url(const char *arg, char **errmsg) { - snprintf(VS(settings.http_url), "%s", strcmp(arg, "off") == 0 ? "" : arg); + snprintf((settings.http_url), sizeof((settings.http_url)), "%s", strcmp(arg, "off") == 0 ? "" : arg); return true; } @@ -1104,7 +1104,7 @@ static const char *clioptions_option_network_stack_desc = "tunneling (this may not be supported on all systems)."; /** @copydoc clioptions_handler_func */ static bool clioptions_option_network_stack(const char *arg, char **errmsg) { - snprintf(VS(settings.network_stack), "%s", arg); + snprintf((settings.network_stack), sizeof((settings.network_stack)), "%s", arg); return true; } @@ -1149,11 +1149,11 @@ static void init_library(int argc, char *argv[]) { * the server or a Windows one. */ char user_agent[MAX_BUF]; #if defined(WIN32) - snprintf(VS(user_agent), "Atrinik Server (Win32)/%s (%d)", PACKAGE_VERSION, SOCKET_VERSION); + snprintf((user_agent), sizeof((user_agent)), "Atrinik Server (Win32)/%s (%d)", PACKAGE_VERSION, SOCKET_VERSION); #elif defined(__GNUC__) - snprintf(VS(user_agent), "Atrinik Server (GNU/Linux)/%s (%d)", PACKAGE_VERSION, SOCKET_VERSION); + snprintf((user_agent), sizeof((user_agent)), "Atrinik Server (GNU/Linux)/%s (%d)", PACKAGE_VERSION, SOCKET_VERSION); #else - snprintf(VS(user_agent), "Atrinik Server (Unknown)/%s (%d)", PACKAGE_VERSION, SOCKET_VERSION); + snprintf((user_agent), sizeof((user_agent)), "Atrinik Server (Unknown)/%s (%d)", PACKAGE_VERSION, SOCKET_VERSION); #endif curl_set_user_agent(user_agent); @@ -1331,7 +1331,7 @@ static void init_library(int argc, char *argv[]) { exit(EXIT_FAILURE); } char expected[MAX_BUF]; - snprintf(VS(expected), "/proc/self/fd/%d", settings.datapath_fd); + snprintf((expected), sizeof((expected)), "/proc/self/fd/%d", settings.datapath_fd); struct stat generation; access_generation_descriptor = open(".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (access_generation_descriptor < 0 || @@ -1522,7 +1522,7 @@ static void init_dynamic(void) { archetype_t *at, *tmp; HASH_ITER(hh, arch_table, at, tmp) { if (at->clone.type == MAP && EXIT_PATH(&at->clone) != NULL) { - snprintf(VS(first_map_path), "%s", EXIT_PATH(&at->clone)); + snprintf((first_map_path), sizeof((first_map_path)), "%s", EXIT_PATH(&at->clone)); first_map_x = at->clone.stats.hp; first_map_y = at->clone.stats.sp; return; @@ -1543,7 +1543,7 @@ static bool write_todclock_atomic(void) { char filename[HUGE_BUF]; char contents[64]; - if (snprintf(VS(filename), "%s/clockdata", settings.datapath) >= (int)sizeof(filename)) { + if (snprintf((filename), sizeof((filename)), "%s/clockdata", settings.datapath) >= (int)sizeof(filename)) { return false; } diff --git a/server/src/server/main.c b/server/src/server/main.c index 45fdfb914..06b66feee 100644 --- a/server/src/server/main.c +++ b/server/src/server/main.c @@ -117,7 +117,7 @@ static void shutdown_signal_handler(int signum) { void version(object *op) { char buf[HUGE_BUF]; - snprintf(VS(buf), "This is Atrinik v%s", PACKAGE_VERSION); + snprintf((buf), sizeof((buf)), "This is Atrinik v%s", PACKAGE_VERSION); if (op != NULL) { draw_info(COLOR_WHITE, op, buf); diff --git a/server/src/server/map.c b/server/src/server/map.c index 68bb29f00..b7b319cc0 100644 --- a/server/src/server/map.c +++ b/server/src/server/map.c @@ -1172,7 +1172,7 @@ static void derive_map_filename_tiles(mapstruct *m, const char *pathname, int fl continue; } - snprintf(VS(path), + snprintf((path), sizeof((path)), "%s_%d_%d", cp, m->coords[0] + map_tiled_coords[i][0], @@ -1399,7 +1399,7 @@ static mapstruct *load_temporary_map(mapstruct *m, int flags) { m->path, m->tmpname, error); - snprintf(VS(buf), "%s", m->path); + snprintf((buf), sizeof((buf)), "%s", m->path); free_map(m, 1); delete_map(m); m = load_original_map(buf, NULL, 0); @@ -1417,7 +1417,7 @@ static mapstruct *load_temporary_map(mapstruct *m, int flags) { LOG(BUG, "Invalid celestial structure in temporary map %s; falling back to original.", m->path); - snprintf(VS(buf), "%s", m->path); + snprintf((buf), sizeof((buf)), "%s", m->path); delete_map(m); return load_original_map(buf, NULL, 0); } @@ -1492,7 +1492,7 @@ static bool load_unique_objects(mapstruct *m) { uintmax_t sequence = parsed == 2 ? strtoumax(raw_sequence, &end, 10) : 0; if (parsed == 2 && string_is_hex_fixed(run, 32, true) && errno == 0 && end != raw_sequence && *end == '\0' && sequence <= UINT64_MAX) { - snprintf(VS(m->journal_unique_run_id), "%s", run); + snprintf((m->journal_unique_run_id), sizeof((m->journal_unique_run_id)), "%s", run); m->journal_unique_sequence = (uint64_t)sequence; } else { (void)fseek(fp, position, SEEK_SET); @@ -1523,8 +1523,8 @@ typedef struct map_atomic_file { static bool map_atomic_open(map_atomic_file_t *file, const char *target) { memset(file, 0, sizeof(*file)); - if (snprintf(VS(file->target), "%s", target) >= (int)sizeof(file->target) || - snprintf(VS(file->temporary), "%s.new.XXXXXX", target) >= (int)sizeof(file->temporary)) { + if (snprintf((file->target), sizeof((file->target)), "%s", target) >= (int)sizeof(file->target) || + snprintf((file->temporary), sizeof((file->temporary)), "%s.new.XXXXXX", target) >= (int)sizeof(file->temporary)) { return false; } int fd = mkstemp(file->temporary); @@ -1658,7 +1658,7 @@ int new_save_map(mapstruct *m, int flag) { close(fd); } - snprintf(VS(filename), "%s", m->tmpname); + snprintf((filename), sizeof((filename)), "%s", m->tmpname); } if (m->celestial_schema == 1 && celestial_structure_v1_runtime_active() && !flag) { diff --git a/server/src/server/material.c b/server/src/server/material.c index f1500c0d5..c8a77dbaf 100644 --- a/server/src/server/material.c +++ b/server/src/server/material.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -70,7 +70,7 @@ void material_init(void) { } char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/materials", settings.libpath); + snprintf((filename), sizeof((filename)), "%s/materials", settings.libpath); FILE *fp = fopen(filename, "r"); if (fp == NULL) { @@ -116,7 +116,7 @@ void material_init(void) { } if (*name != '\0') { - snprintf(VS(materials_real[i].name), + snprintf((materials_real[i].name), sizeof((materials_real[i].name)), "%.*s ", (int)sizeof(materials_real[i].name) - 2, name); diff --git a/server/src/server/object.c b/server/src/server/object.c index e4ee8e7e3..73bc7a3b5 100644 --- a/server/src/server/object.c +++ b/server/src/server/object.c @@ -3928,20 +3928,20 @@ bool object_custody_begin_parties(const object *op, if (op->arch == NULL || !object_custody_actor(actor_ob)) { return false; } - snprintf(VS(transaction->actor), "%s", actor_ob->custody_actor); + snprintf((transaction->actor), sizeof((transaction->actor)), "%s", actor_ob->custody_actor); if (op->custody_lineage != NULL) { - snprintf(VS(transaction->lineage), "%s", op->custody_lineage); + snprintf((transaction->lineage), sizeof((transaction->lineage)), "%s", op->custody_lineage); } else { char id[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]; if (!object_custody_random_id(id)) { return false; } - snprintf(VS(transaction->lineage), "item:%s", id); + snprintf((transaction->lineage), sizeof((transaction->lineage)), "item:%s", id); } - snprintf(VS(transaction->first_after), + snprintf((transaction->first_after), sizeof((transaction->first_after)), "%s", op->custody_first != NULL ? op->custody_first : acquirer); - snprintf(VS(transaction->last_after), + snprintf((transaction->last_after), sizeof((transaction->last_after)), "%s", relinquisher[0] != '\0' ? relinquisher : (op->custody_last != NULL ? op->custody_last : "")); @@ -3949,7 +3949,7 @@ bool object_custody_begin_parties(const object *op, transaction->relinquish = relinquisher[0] != '\0'; char snapshot[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(snapshot), + snprintf((snapshot), sizeof((snapshot)), "arch=%s;type=%u;nrof=%" PRIu32 ";value=%" PRId64 ";weight=%" PRIu32, op->arch != NULL ? op->arch->name : "unknown", op->type, @@ -3958,11 +3958,11 @@ bool object_custody_begin_parties(const object *op, op->weight); char provenance_before[GAMEPLAY_JOURNAL_ID_MAX + 1]; char provenance_after[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(provenance_before), + snprintf((provenance_before), sizeof((provenance_before)), "first=%.112s;last=%.112s", op->custody_first != NULL ? op->custody_first : "", op->custody_last != NULL ? op->custody_last : ""); - snprintf(VS(provenance_after), + snprintf((provenance_after), sizeof((provenance_after)), "first=%.112s;last=%.112s", transaction->first_after, transaction->last_after); @@ -4510,14 +4510,14 @@ bool object_enter_map(object *op, object *exit, mapstruct *m, int x, int y, bool rp.origin_x = exit->x; rp.origin_y = exit->y; - snprintf(VS(rp.origin_map), "%s", op->map->path); + snprintf((rp.origin_map), sizeof((rp.origin_map)), "%s", op->map->path); rp.origin_map_ptr = op->map; /* Pick a new pathname for the new map. Currently, we just use a * static variable and increment the counter by one each time. */ static uint64_t reference_number = 0; char newmap_name[HUGE_BUF]; - snprintf(VS(newmap_name), "/random/%" PRIu64, reference_number++); + snprintf((newmap_name), sizeof((newmap_name)), "/random/%" PRIu64, reference_number++); /* Now to generate the actual map. */ m = generate_random_map(newmap_name, &rp); @@ -4553,7 +4553,7 @@ bool object_enter_map(object *op, object *exit, mapstruct *m, int x, int y, bool /* If exit is damned, update player's savebed position. */ if (QUERY_FLAG(exit, FLAG_DAMNED) && op->type == PLAYER) { - snprintf(VS(CONTR(op)->savebed_map), "%s", m->path); + snprintf((CONTR(op)->savebed_map), sizeof((CONTR(op)->savebed_map)), "%s", m->path); CONTR(op)->bed_x = x; CONTR(op)->bed_y = y; player_save(op); @@ -4633,7 +4633,7 @@ bool object_enter_map(object *op, object *exit, mapstruct *m, int x, int y, bool /* Do some action special for players after we have inserted them. */ if (op->type == PLAYER) { if (CONTR(op) != NULL) { - snprintf(VS(CONTR(op)->maplevel), "%s", m->path); + snprintf((CONTR(op)->maplevel), sizeof((CONTR(op)->maplevel)), "%s", m->path); CONTR(op)->count = 0; metrics_character_visit(CONTR(op), m, oldmap != NULL && oldmap != m); } @@ -4835,7 +4835,7 @@ int object_blocked(object *op, mapstruct *m, int x, int y) { */ object *object_create_singularity(const char *name) { char buf[MAX_BUF]; - snprintf(VS(buf), "singularity"); + snprintf((buf), sizeof((buf)), "singularity"); if (name != NULL) { snprintfcat(VS(buf), " (%s)", name); } diff --git a/server/src/server/party.c b/server/src/server/party.c index 0e7e85a8a..f0cde7917 100644 --- a/server/src/server/party.c +++ b/server/src/server/party.c @@ -399,7 +399,7 @@ static void party_loot_random(object *pl, object *corpse) { } if (transaction[0] != '\0') { char lineage[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(lineage), "currency:%s", transaction); + snprintf((lineage), sizeof((lineage)), "currency:%s", transaction); FREE_AND_COPY_HASH(tmp->custody_lineage, lineage); } object_remove(tmp, 0); diff --git a/server/src/server/quest.c b/server/src/server/quest.c index 96522304e..9b6b29956 100644 --- a/server/src/server/quest.c +++ b/server/src/server/quest.c @@ -240,10 +240,10 @@ static void quest_check_item_drop(object *op, object *quest, object *quest_pl, o metrics_character_quest_status(CONTR(op), quest->name, QUEST_STATUS_COMPLETED); - snprintf(VS(buf), "You solved the one drop quest %s!\n", QUEST_NAME(quest_pl)); + snprintf((buf), sizeof((buf)), "You solved the one drop quest %s!\n", QUEST_NAME(quest_pl)); } else { char *name = object_get_short_name_s(clone, op); - snprintf(VS(buf), "You found the special drop %s!\n", name); + snprintf((buf), sizeof((buf)), "You found the special drop %s!\n", name); free(name); } @@ -300,7 +300,7 @@ static void quest_check_kill(object *op, object *quest, object *quest_pl, object if (quest_pl->last_sp <= quest_pl->last_grace) { char buf[MAX_BUF]; - snprintf(VS(buf), "Quest [b]%s[/b]", QUEST_NAME(quest_pl)); + snprintf((buf), sizeof((buf)), "Quest [b]%s[/b]", QUEST_NAME(quest_pl)); if (quest_pl->last_sp == quest_pl->last_grace) { play_sound_player_only(CONTR(op), CMD_SOUND_EFFECT, "event01.ogg", 0, 0, 0, 0); @@ -362,7 +362,7 @@ static void quest_check_item(object *op, object *quest, object *quest_pl, object CLEAR_FLAG(clone, FLAG_SYS_OBJECT); char *name = object_get_base_name_s(clone, op); - snprintf(VS(buf), "Quest [b]%s[/b]: You found the quest item %s", QUEST_NAME(quest_pl), name); + snprintf((buf), sizeof((buf)), "Quest [b]%s[/b]: You found the quest item %s", QUEST_NAME(quest_pl), name); free(name); if (quest_pl->last_grace > 1) { @@ -377,7 +377,7 @@ static void quest_check_item(object *op, object *quest, object *quest_pl, object char transaction[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE] = ""; if (before == 0 || after >= quest_pl->last_grace) { char objective[GAMEPLAY_JOURNAL_ID_MAX + 1]; - int written = snprintf(VS(objective), "objective:item:%s", clone->arch->name); + int written = snprintf((objective), sizeof((objective)), "objective:item:%s", clone->arch->name); if (written < 0 || (size_t)written >= sizeof(objective) || !quest_milestone_begin(op, quest, diff --git a/server/src/server/readable.c b/server/src/server/readable.c index 4498a34c6..50e26006b 100644 --- a/server/src/server/readable.c +++ b/server/src/server/readable.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -776,7 +776,7 @@ void tailor_readable_ob(object *book, int msg_type) { case MSGTYPE_MSGFILE: default: - snprintf(VS(msgbuf), "%s", msgfile_msg(BOOK_BUF)); + snprintf((msgbuf), sizeof((msgbuf)), "%s", msgfile_msg(BOOK_BUF)); break; } diff --git a/server/src/server/resources.c b/server/src/server/resources.c index 0550897ea..2b059921d 100644 --- a/server/src/server/resources.c +++ b/server/src/server/resources.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -64,7 +64,7 @@ static void resources_traverse(DIR *dir, const char *path) { } char path_curr[HUGE_BUF]; - snprintf(VS(path_curr), "%s/%s", path, d->d_name); + snprintf((path_curr), sizeof((path_curr)), "%s/%s", path, d->d_name); DIR *dir_curr = opendir(path_curr); if (dir_curr == NULL) { if (errno != ENOTDIR) { diff --git a/server/src/server/shop.c b/server/src/server/shop.c index 73b763b95..5c36b4116 100644 --- a/server/src/server/shop.c +++ b/server/src/server/shop.c @@ -190,7 +190,7 @@ const char *shop_get_cost_string(int64_t cost) { int64_t num = cost / coin->clone.value; cost -= num * coin->clone.value; - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "%" PRId64 " %s%s%s", num, materials_real[coin->clone.material_real].name, @@ -848,7 +848,7 @@ static void shop_insert_coin_stacks(object *op, coin->nrof = chunk; if (transaction_id != NULL && transaction_id[0] != '\0') { char lineage[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(lineage), "currency:%s", transaction_id); + snprintf((lineage), sizeof((lineage)), "currency:%s", transaction_id); coin->custody_lineage = add_string(lineage); } if (on_floor) { @@ -897,7 +897,7 @@ void shop_currency_tag_retire(object *op, const char *transaction_id) { return; } char lineage[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(lineage), "currency:%s", transaction_id); + snprintf((lineage), sizeof((lineage)), "currency:%s", transaction_id); shop_currency_tag_retire_inventory(op, lineage); if (op->map != NULL) { FOR_MAP_PREPARE(op->map, op->x, op->y, tmp) { @@ -919,7 +919,7 @@ static object *shop_currency_tag_retire_one(object *coin, const char *transactio return coin; } char lineage[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(lineage), "currency:%s", transaction_id); + snprintf((lineage), sizeof((lineage)), "currency:%s", transaction_id); if (coin->custody_lineage == NULL || strcmp(coin->custody_lineage, lineage) != 0) { return NULL; } @@ -1209,7 +1209,7 @@ object_semantic_result_t shop_insert_coin_object_reason(object *coin, } if (transaction[0] != '\0') { char lineage[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(lineage), "currency:%s", transaction); + snprintf((lineage), sizeof((lineage)), "currency:%s", transaction); FREE_AND_COPY_HASH(coin->custody_lineage, lineage); } *inserted_out = object_insert_into(coin, where, 0); diff --git a/server/src/server/swap.c b/server/src/server/swap.c index 3619959df..94151c5c7 100644 --- a/server/src/server/swap.c +++ b/server/src/server/swap.c @@ -42,7 +42,7 @@ */ bool write_map_log_checked(void) { char path[HUGE_BUF]; - if (snprintf(VS(path), "%s/temp.maps", settings.datapath) >= (int)sizeof(path)) { + if (snprintf((path), sizeof((path)), "%s/temp.maps", settings.datapath) >= (int)sizeof(path)) { return false; } long current_time = time(NULL); diff --git a/server/src/server/treasure.c b/server/src/server/treasure.c index 98de4403c..850852310 100644 --- a/server/src/server/treasure.c +++ b/server/src/server/treasure.c @@ -569,7 +569,7 @@ treasure_load_more(treasure_t **treasure, FILE *fp, const char *filename, uint64 */ static void treasure_load(void) { char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/treasures", settings.libpath); + snprintf((filename), sizeof((filename)), "%s/treasures", settings.libpath); FILE *fp = fopen(filename, "r"); if (fp == NULL) { diff --git a/server/src/server/walking_route.c b/server/src/server/walking_route.c index b04c7c54f..c84d5de06 100644 --- a/server/src/server/walking_route.c +++ b/server/src/server/walking_route.c @@ -280,7 +280,7 @@ int walking_route_export(void) { } for (int m = 0; m < WALKING_ROUTE_MAPS; m++) { char path[MAX_BUF]; - snprintf(VS(path), "/shattered_islands/world_%d_%d", m % 4, m / 4 + 66); + snprintf((path), sizeof((path)), "/shattered_islands/world_%d_%d", m % 4, m / 4 + 66); maps[m] = ready_map_name(path, NULL, MAP_FLUSH | MAP_NO_DYNAMIC); if (maps[m] == NULL) { LOG(ERROR, "Walking route could not load %s.", path); diff --git a/server/src/socket/assets.c b/server/src/socket/assets.c index 1c1081bf5..a541a2df6 100644 --- a/server/src/socket/assets.c +++ b/server/src/socket/assets.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -229,7 +229,7 @@ static bool asset_cache_add(const char *name, const char *path) { static void asset_cache_directory(const char *root, const char *relative, const char *prefix, bool recursive) { char directory[HUGE_BUF]; - int length = snprintf(VS(directory), "%s%s%s", root, *relative != '\0' ? "/" : "", relative); + int length = snprintf((directory), sizeof((directory)), "%s%s%s", root, *relative != '\0' ? "/" : "", relative); if (length < 0 || (size_t)length >= sizeof(directory)) { return; } @@ -244,7 +244,7 @@ asset_cache_directory(const char *root, const char *relative, const char *prefix continue; } char child_relative[HUGE_BUF]; - length = snprintf(VS(child_relative), + length = snprintf((child_relative), sizeof((child_relative)), "%s%s%s", relative, *relative != '\0' ? "/" : "", @@ -253,7 +253,7 @@ asset_cache_directory(const char *root, const char *relative, const char *prefix continue; } char path[HUGE_BUF]; - length = snprintf(VS(path), "%s/%s", root, child_relative); + length = snprintf((path), sizeof((path)), "%s/%s", root, child_relative); if (length < 0 || (size_t)length >= sizeof(path)) { continue; } @@ -270,7 +270,7 @@ asset_cache_directory(const char *root, const char *relative, const char *prefix } char name[HUGE_BUF]; - length = snprintf(VS(name), "%s/%s", prefix, child_relative); + length = snprintf((name), sizeof((name)), "%s/%s", prefix, child_relative); if (length < 0 || (size_t)length >= sizeof(name)) { continue; } @@ -284,10 +284,10 @@ asset_cache_directory(const char *root, const char *relative, const char *prefix void socket_assets_init(void) { char path[HUGE_BUF]; - snprintf(VS(path), "%s/data", settings.assetspath); + snprintf((path), sizeof((path)), "%s/data", settings.assetspath); asset_cache_directory(path, "", "data", false); asset_cache_directory(settings.resourcespath, "", "resources", true); - snprintf(VS(path), "%s/client-maps", settings.assetspath); + snprintf((path), sizeof((path)), "%s/client-maps", settings.assetspath); asset_cache_directory(path, "", "client-maps", false); LOG(INFO, "Cached %" PRIu64 " bytes of game assets in memory", asset_cache_size); server_metrics_asset_cache(asset_cache_rss); diff --git a/server/src/socket/init.c b/server/src/socket/init.c index f3faabc9a..a6bf0886e 100644 --- a/server/src/socket/init.c +++ b/server/src/socket/init.c @@ -279,7 +279,7 @@ static bool asset_staging_inherited_directory(const char *path, bool *recognized errno = 0; descriptor = strtoul(value, &end, 10); if (errno != 0 || *end != '\0' || descriptor > INT_MAX || - snprintf(VS(canonical), "%lu", descriptor) < 0 || strcmp(value, canonical) != 0) { + snprintf((canonical), sizeof((canonical)), "%lu", descriptor) < 0 || strcmp(value, canonical) != 0) { return false; } @@ -354,7 +354,7 @@ static void create_server_settings(void) { */ static void create_server_animations(void) { char buf[HUGE_BUF]; - snprintf(VS(buf), "%s/anims", settings.datapath); + snprintf((buf), sizeof((buf)), "%s/anims", settings.datapath); FILE *fp = fopen(buf, "wb"); if (fp == NULL) { @@ -362,7 +362,7 @@ static void create_server_animations(void) { exit(1); } - snprintf(VS(buf), "%s/animations", settings.libpath); + snprintf((buf), sizeof((buf)), "%s/animations", settings.libpath); FILE *fp2 = fopen(buf, "rb"); if (fp2 == NULL) { LOG(ERROR, "Couldn't open %s.", buf); diff --git a/server/src/socket/metaserver.c b/server/src/socket/metaserver.c index 7ca5914c4..c910808df 100644 --- a/server/src/socket/metaserver.c +++ b/server/src/socket/metaserver.c @@ -270,8 +270,8 @@ static bool metaserver_rendezvous_punch_schedule(rendezvous_punch_job_t *jobs, return false; } - snprintf(VS(available->host), "%s", host); - snprintf(VS(available->ticket), "%s", ticket); + snprintf((available->host), sizeof((available->host)), "%s", host); + snprintf((available->ticket), sizeof((available->ticket)), "%s", ticket); available->port = port; available->punches_sent = 0; socket_punch_pacer_start(&available->pacer, datetime_monotonic_ms(), RENDEZVOUS_PUNCH_GRACE_MS); @@ -338,7 +338,7 @@ static bool metaserver_rendezvous_retry(const rendezvous_args_t *args, static bool metaserver_rendezvous_message_type(const char *message, const char *type) { char prefix[64]; - int length = snprintf(VS(prefix), "{\"type\":\"%s\"", type); + int length = snprintf((prefix), sizeof((prefix)), "{\"type\":\"%s\"", type); return length > 0 && (size_t)length < sizeof(prefix) && strncmp(message, prefix, (size_t)length) == 0; } @@ -380,7 +380,7 @@ static void *metaserver_rendezvous_thread(void *data) { if (curl == NULL) { LOG(ERROR, "Cannot allocate a rendezvous connection"); } else { - snprintf(VS(authorization), "Authorization: Bearer %s", args->token); + snprintf((authorization), sizeof((authorization)), "Authorization: Bearer %s", args->token); headers = curl_slist_append(NULL, authorization); if (headers == NULL) { LOG(ERROR, "Cannot allocate rendezvous request headers"); @@ -690,7 +690,7 @@ static bool metaserver_rendezvous_url(char *url, size_t url_size) { static void metaserver_rendezvous_start(const char *token) { rendezvous_args_t *args = xcalloc(1, sizeof(*args)); - snprintf(VS(args->token), "%s", token); + snprintf((args->token), sizeof((args->token)), "%s", token); args->authorization_required = settings.access_required; if (!metaserver_rendezvous_url(VS(args->url))) { OPENSSL_cleanse(args->token, sizeof(args->token)); @@ -786,10 +786,10 @@ void metaserver_public_snapshot(metaserver_public_snapshot_t *snapshot) { HARD_ASSERT(snapshot != NULL); memset(snapshot, 0, sizeof(*snapshot)); - snprintf(VS(snapshot->name), "%s", settings.server_name); - snprintf(VS(snapshot->description), "%s", settings.server_desc); + snprintf((snapshot->name), sizeof((snapshot->name)), "%s", settings.server_name); + snprintf((snapshot->description), sizeof((snapshot->description)), "%s", settings.server_desc); if (settings.server_public && *settings.metaserver_hostname != '\0') { - snprintf(VS(snapshot->hostname), "%s", settings.metaserver_hostname); + snprintf((snapshot->hostname), sizeof((snapshot->hostname)), "%s", settings.metaserver_hostname); snapshot->port = settings.port_quic; } snapshot->is_public = settings.server_public; @@ -1158,7 +1158,7 @@ static curl_request_t *metaserver_publish_request_create( !metaserver_publisher_identity_sign(identity, components.signature_base, signature_header) || - snprintf(VS(sequence_header), "%" PRIu64, sequence) >= (int)sizeof(sequence_header) || + snprintf((sequence_header), sizeof((sequence_header)), "%" PRIu64, sequence) >= (int)sizeof(sequence_header) || !metaserver_url_publish(settings.metaserver_publish_origin, components.path, VS(url), @@ -1386,18 +1386,18 @@ static access_outcome_t metaserver_access_operation(const access_route_t *route, if (route->has_expiry) { if (route->expires_at <= 0) goto out; - snprintf(VS(expiry), "\"%" PRId64 "\"", route->expires_at); + snprintf((expiry), sizeof((expiry)), "\"%" PRId64 "\"", route->expires_at); } if (reservation[0] != 0) { if (!string_is_hex_fixed(reservation, 32, true)) goto out; - snprintf(VS(handle), "\"%s\"", reservation); + snprintf((handle), sizeof((handle)), "\"%s\"", reservation); } identity = socket_server_quic_publisher_identity(); if (identity == NULL) goto out; int n = snprintf( - VS(body), + (body), sizeof((body)), "{\"schema\":\"atrinik-access-route-v1\",\"profile\":\"classic\",\"serverId\":\"%s\"," "\"certificate\":\"%s\",\"operation\":\"%s\",\"requestId\":\"%s\",\"tokenId\":\"%s\"," "\"tokenRevision\":\"%" PRIu64 "\",\"index\":\"%s\",\"reservationId\":%s,\"expiresAt\":%s}", @@ -1441,7 +1441,7 @@ static access_outcome_t metaserver_access_operation(const access_route_t *route, "Atrinik-Server-ID", "Atrinik-Publish-Sequence"}; char sequence_text[21]; - snprintf(VS(sequence_text), "%" PRIu64, sequence); + snprintf((sequence_text), sizeof((sequence_text)), "%" PRIu64, sequence); const char *values[] = {"application/json", components.content_digest, components.signature_input, @@ -1450,7 +1450,7 @@ static access_outcome_t metaserver_access_operation(const access_route_t *route, sequence_text}; for (size_t i = 0; i < arraysize(names); i++) { char line[1200]; - int length = snprintf(VS(line), "%s: %s", names[i], values[i]); + int length = snprintf((line), sizeof((line)), "%s: %s", names[i], values[i]); if (length <= 0 || (size_t)length >= sizeof(line)) goto out; struct curl_slist *next = curl_slist_append(headers, line); diff --git a/server/src/socket/metaserver_policy.c b/server/src/socket/metaserver_policy.c index 274de8e5c..db7f2c877 100644 --- a/server/src/socket/metaserver_policy.c +++ b/server/src/socket/metaserver_policy.c @@ -210,7 +210,7 @@ static bool metaserver_publish_error_code_parse(const char *body, return false; } char canonical_sequence[24]; - return snprintf(VS(canonical_sequence), "%" PRIu64, minimum_next_sequence) == + return snprintf((canonical_sequence), sizeof((canonical_sequence)), "%" PRIu64, minimum_next_sequence) == (int)strlen(sequence) && strcmp(canonical_sequence, sequence) == 0; } @@ -740,7 +740,7 @@ metaserver_rendezvous_auth_claim(metaserver_rendezvous_auth_job_t *jobs, } memset(available, 0, sizeof(*available)); - snprintf(VS(available->ticket), "%s", ticket); + snprintf((available->ticket), sizeof((available->ticket)), "%s", ticket); available->deadline_ms = deadline_ms; available->state = RENDEZVOUS_SERVER_AUTH_NEW; available->active = true; @@ -818,7 +818,7 @@ access_outcome_t metaserver_access_response_parse(const char *input, consumed < 0 || (size_t)consumed != body_size || strcmp(reply_id, request_id) != 0) return ACCESS_PENDING; char expected_revision[21]; - snprintf(VS(expected_revision), "%" PRIu64, token_revision); + snprintf((expected_revision), sizeof((expected_revision)), "%" PRIu64, token_revision); if (strcmp(revision, expected_revision) != 0) return ACCESS_PENDING; bool null_handle = strcmp(reply_handle, "null") == 0; diff --git a/server/src/socket/network_metrics.c b/server/src/socket/network_metrics.c index 1eaed0212..d3174ef5c 100644 --- a/server/src/socket/network_metrics.c +++ b/server/src/socket/network_metrics.c @@ -167,7 +167,7 @@ void server_metrics_asset_stream(int active_delta, size_t bytes, bool rejected) void server_metrics_mapping(const char *method, bool open_failed, bool renewal_failed) { pthread_mutex_lock(&metrics_lock); if (method != NULL) { - snprintf(VS(metrics.mapping_method), "%s", method); + snprintf((metrics.mapping_method), sizeof((metrics.mapping_method)), "%s", method); } if (open_failed) { metrics.mapping_open_failures++; diff --git a/server/src/socket/port_mapping.c b/server/src/socket/port_mapping.c index 80c85d175..37f87c842 100644 --- a/server/src/socket/port_mapping.c +++ b/server/src/socket/port_mapping.c @@ -153,7 +153,7 @@ static bool socket_port_mapping_upnp(void *data, return false; } - snprintf(VS(mapping_upnp_port), "%" PRIu16, port); + snprintf((mapping_upnp_port), sizeof((mapping_upnp_port)), "%" PRIu16, port); char external_address[65]; int address_result = UPNP_GetExternalIPAddress(mapping_upnp_urls.controlURL, mapping_upnp_data.first.servicetype, @@ -184,7 +184,7 @@ static bool socket_port_mapping_upnp(void *data, snprintf(host, host_size, "%s", external_address); *external_port = port; - snprintf(VS(mapping_upnp_lan_address), "%s", lan_address); + snprintf((mapping_upnp_lan_address), sizeof((mapping_upnp_lan_address)), "%s", lan_address); mapping_upnp_renew_at_ms = datetime_monotonic_ms() + (uint64_t)PORT_MAPPING_LIFETIME * 500; mapping_upnp_active = true; return true; diff --git a/server/src/socket/server.c b/server/src/socket/server.c index a19c760af..f482c65ec 100644 --- a/server/src/socket/server.c +++ b/server/src/socket/server.c @@ -265,8 +265,8 @@ TOOLKIT_INIT_FUNC(socket_server) { bool v6_seen; } stack_setting; memset(&stack_setting, 0, sizeof(stack_setting)); - snprintf(VS(stack_setting.v4_host), "%s", "0.0.0.0"); - snprintf(VS(stack_setting.v6_host), "%s", "::"); + snprintf((stack_setting.v4_host), sizeof((stack_setting.v4_host)), "%s", "0.0.0.0"); + snprintf((stack_setting.v6_host), sizeof((stack_setting.v6_host)), "%s", "::"); char word[MAX_BUF]; size_t pos = 0; @@ -372,7 +372,7 @@ TOOLKIT_INIT_FUNC(socket_server) { exit(1); } char identity_path[HUGE_BUF]; - snprintf(VS(identity_path), "%s/quic-identity.pem", settings.datapath); + snprintf((identity_path), sizeof((identity_path)), "%s/quic-identity.pem", settings.datapath); bool dual = BIT_QUERY(stack_setting.type, STACK_DUAL); bool bind_v4 = dual || BIT_QUERY(stack_setting.type, STACK_IPV4); if (bind_v4) { @@ -438,7 +438,7 @@ TOOLKIT_INIT_FUNC(socket_server) { "Router mapping is not globally routable; retaining it as an intermediate " "candidate"); } - snprintf(VS(quic_candidates[quic_candidate_count].host), "%s", mapped_host); + snprintf((quic_candidates[quic_candidate_count].host), sizeof((quic_candidates[quic_candidate_count].host)), "%s", mapped_host); quic_candidates[quic_candidate_count].port = mapped_port; quic_candidates[quic_candidate_count].kind = SOCKET_CANDIDATE_MAPPED; quic_candidate_count++; @@ -457,7 +457,7 @@ TOOLKIT_INIT_FUNC(socket_server) { bool duplicate = quic_candidate_count != 0 && quic_candidates[0].port == stun_port && strcmp(quic_candidates[0].host, stun_host) == 0; if (!duplicate && quic_candidate_count < arraysize(quic_candidates)) { - snprintf(VS(quic_candidates[quic_candidate_count].host), "%s", stun_host); + snprintf((quic_candidates[quic_candidate_count].host), sizeof((quic_candidates[quic_candidate_count].host)), "%s", stun_host); quic_candidates[quic_candidate_count].port = stun_port; quic_candidates[quic_candidate_count].kind = SOCKET_CANDIDATE_SRFLX; quic_candidate_count++; @@ -468,7 +468,7 @@ TOOLKIT_INIT_FUNC(socket_server) { settings.stun_server, VS(stun_host), &stun_port)) { - snprintf(VS(quic_candidates[quic_candidate_count].host), "%s", stun_host); + snprintf((quic_candidates[quic_candidate_count].host), sizeof((quic_candidates[quic_candidate_count].host)), "%s", stun_host); quic_candidates[quic_candidate_count].port = stun_port; quic_candidates[quic_candidate_count].kind = SOCKET_CANDIDATE_IPV6; quic_candidate_count++; @@ -1519,8 +1519,8 @@ bool socket_server_process(void) { if (heartbeat_last == 0 || now - heartbeat_last >= 5) { char path[HUGE_BUF]; char heartbeat[64]; - snprintf(VS(path), "%s/tmp/server-heartbeat", settings.datapath); - int length = snprintf(VS(heartbeat), "%" PRIu64 "\n", (uint64_t)now); + snprintf((path), sizeof((path)), "%s/tmp/server-heartbeat", settings.datapath); + int length = snprintf((heartbeat), sizeof((heartbeat)), "%" PRIu64 "\n", (uint64_t)now); if (length > 0 && (size_t)length < sizeof(heartbeat) && path_write_atomic(path, heartbeat, (size_t)length, 0600)) { heartbeat_last = now; diff --git a/server/src/tests/check.c b/server/src/tests/check.c index bbd2c4a78..e9b7d3c60 100644 --- a/server/src/tests/check.c +++ b/server/src/tests/check.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -126,9 +126,9 @@ void check_run_suite(Suite *suite, const char *file) { sub = string_sub(sub, 0, -2); path_ensure_directories(sub); - snprintf(VS(buf2), "%s.xml", sub); + snprintf((buf2), sizeof((buf2)), "%s.xml", sub); srunner_set_xml(srunner, buf2); - snprintf(VS(buf), "%s.out", sub); + snprintf((buf), sizeof((buf)), "%s.out", sub); srunner_set_log(srunner, buf); free(sub); diff --git a/server/src/tests/unit/server/account.c b/server/src/tests/unit/server/account.c index 499cb3a9e..8260d9b1e 100644 --- a/server/src/tests/unit/server/account.c +++ b/server/src/tests/unit/server/account.c @@ -102,7 +102,7 @@ START_TEST(test_account_provision_password_file_permissions) { const char *character_name = "Scenario File"; char error[HUGE_BUF]; char password_path[HUGE_BUF]; - snprintf(VS(password_path), "%s/scenario-password", settings.datapath); + snprintf((password_path), sizeof((password_path)), "%s/scenario-password", settings.datapath); char *account_path = account_make_path(account_name); char *player_path = player_make_path(character_name, "player.dat"); @@ -140,7 +140,7 @@ START_TEST(test_account_provision_password_file_permissions) { ck_assert_ptr_nonnull(strstr(error, "exactly one line")); char missing_password_path[HUGE_BUF]; - snprintf(VS(missing_password_path), "%s/scenario-password-missing", settings.datapath); + snprintf((missing_password_path), sizeof((missing_password_path)), "%s/scenario-password-missing", settings.datapath); unlink(missing_password_path); ck_assert(!account_provision_from_file(account_name, missing_password_path, @@ -198,7 +198,7 @@ START_TEST(test_account_provision_lighting_preset) { const char *character_name_canonical = "Scenario Lighting"; char error[HUGE_BUF]; char password_path[HUGE_BUF]; - snprintf(VS(password_path), "%s/scenario-lighting-password", settings.datapath); + snprintf((password_path), sizeof((password_path)), "%s/scenario-lighting-password", settings.datapath); char *account_path = account_make_path(account_name_canonical); char *player_path = player_make_path(character_name_canonical, "player.dat"); @@ -254,7 +254,7 @@ START_TEST(test_account_provision_brynknot_idle_preserves_clock_and_existing_pla const char *character_name = "Scenario Brynknot"; char error[HUGE_BUF]; char password_path[HUGE_BUF]; - snprintf(VS(password_path), "%s/scenario-brynknot-password", settings.datapath); + snprintf((password_path), sizeof((password_path)), "%s/scenario-brynknot-password", settings.datapath); char *account_path = account_make_path(account_name); char *player_path = player_make_path(character_name, "player.dat"); char *metrics_path = player_make_path(character_name, "metrics.dat"); @@ -329,7 +329,7 @@ START_TEST(test_account_provision_writing_books_roundtrip_and_save_failure) { const char *character_name = "Scenario Writing"; char error[HUGE_BUF]; char password_path[HUGE_BUF]; - snprintf(VS(password_path), "%s/scenario-writing-password", settings.datapath); + snprintf((password_path), sizeof((password_path)), "%s/scenario-writing-password", settings.datapath); char *account_path = account_make_path(account_name); char *player_path = player_make_path(character_name, "player.dat"); char *metrics_path = player_make_path(character_name, "metrics.dat"); @@ -442,8 +442,8 @@ START_TEST(test_account_provision_lighting_preset_rolls_back) { char error[HUGE_BUF]; char password_path[HUGE_BUF]; char clock_path[HUGE_BUF]; - snprintf(VS(password_path), "%s/scenario-rollback-password", settings.datapath); - snprintf(VS(clock_path), "%s/clockdata", settings.datapath); + snprintf((password_path), sizeof((password_path)), "%s/scenario-rollback-password", settings.datapath); + snprintf((clock_path), sizeof((clock_path)), "%s/clockdata", settings.datapath); char *account_path = account_make_path(account_name_canonical); char *player_path = player_make_path(character_name_canonical, "player.dat"); char *metrics_path = player_make_path(character_name_canonical, "metrics.dat"); @@ -655,7 +655,7 @@ START_TEST(test_exploration_snapshot_batches) { exploration_begin(&ns); for (unsigned i = 0; i < 40; i++) { char name[32]; - snprintf(VS(name), "/world/map%u", i); + snprintf((name), sizeof((name)), "/world/map%u", i); ck_assert(exploration_mark(&ns, name, 1, 1, 0, 0)); } socket_buffer_clear(&ns); @@ -779,7 +779,7 @@ START_TEST(test_exploration_session_flush_is_local_and_backpressure_retains_bits exploration_begin(&first); exploration_begin(&second); for (unsigned i = 0; i < 64; i++) { - snprintf(VS(unrelated_names[i]), "exploreunrelated%02u", i); + snprintf((unrelated_names[i]), sizeof((unrelated_names[i])), "exploreunrelated%02u", i); unrelated[i].state = ST_PLAYING; unrelated[i].account = unrelated_names[i]; exploration_begin(&unrelated[i]); @@ -840,7 +840,7 @@ START_TEST(test_exploration_ten_thousand_maps) { uint64_t started = datetime_monotonic_us(); for (unsigned i = 0; i < 10000; i++) { char name[64]; - snprintf(VS(name), "/world/map%05u", i); + snprintf((name), sizeof((name)), "/world/map%05u", i); ck_assert(exploration_mark(&ns, name, dimension, dimension, dimension - 1, dimension - 1)); } uint64_t mark_us = datetime_monotonic_us() - started; @@ -942,7 +942,7 @@ START_TEST(test_access_preserves_normal_command_permissions) { bool saved_required = settings.access_required; player pl = {0}; settings.access_required = true; - snprintf(VS(settings.default_permission_groups), "[OP]"); + snprintf((settings.default_permission_groups), sizeof((settings.default_permission_groups)), "[OP]"); const char *commands[] = {"console", "create", "patch", "config", "password", "/console", "kick", "ban", "freeze"}; for (size_t i = 0; i < arraysize(commands); i++) @@ -989,7 +989,7 @@ START_TEST(test_access_preserves_ordinary_registration) { memcpy(saved_store, settings.access_store, sizeof(saved_store)); settings.access_required = true; settings.access_initialize = false; - snprintf(VS(settings.access_store), "%s", directory); + snprintf((settings.access_store), sizeof((settings.access_store)), "%s", directory); access_server_route_for_test(registration_access_route); ck_assert(access_server_init(identity_hex)); @@ -1050,7 +1050,7 @@ START_TEST(test_access_preserves_ordinary_registration) { settings.access_initialize = saved_initialize; memcpy(settings.access_store, saved_store, sizeof(saved_store)); char snapshot[256]; - snprintf(VS(snapshot), "%s/%s", directory, ACCESS_STORE_FILENAME); + snprintf((snapshot), sizeof((snapshot)), "%s/%s", directory, ACCESS_STORE_FILENAME); ck_assert_int_eq(unlink(snapshot), 0); ck_assert_int_eq(rmdir(directory), 0); } diff --git a/server/src/tests/unit/server/account_async.c b/server/src/tests/unit/server/account_async.c index fd70d8300..95672c6e7 100644 --- a/server/src/tests/unit/server/account_async.c +++ b/server/src/tests/unit/server/account_async.c @@ -88,8 +88,8 @@ static socket_struct *connection(size_t index) { static void login(socket_struct *ns, const char *password) { char name[MAX_BUF], secret[MAX_BUF]; - snprintf(VS(name), "%s", fixture_name); - snprintf(VS(secret), "%s", password); + snprintf((name), sizeof((name)), "%s", fixture_name); + snprintf((secret), sizeof((secret)), "%s", password); account_login(ns, name, secret); /* Callers' buffers have no lifetime beyond submission. */ memset(secret, 0, sizeof(secret)); @@ -97,9 +97,9 @@ static void login(socket_struct *ns, const char *password) { static void register_account(socket_struct *ns, const char *password) { char name[MAX_BUF], secret[MAX_BUF], confirmation[MAX_BUF]; - snprintf(VS(name), "%s", registration_name); - snprintf(VS(secret), "%s", password); - snprintf(VS(confirmation), "%s", password); + snprintf((name), sizeof((name)), "%s", registration_name); + snprintf((secret), sizeof((secret)), "%s", password); + snprintf((confirmation), sizeof((confirmation)), "%s", password); account_register(ns, name, secret, confirmation); memset(secret, 0, sizeof(secret)); memset(confirmation, 0, sizeof(confirmation)); @@ -189,10 +189,10 @@ static void fixture_setup(void) { suffix[i] = (char)('a' + (digit <= '9' ? digit - '0' : digit - 'a' + 10)); } suffix[sizeof(suffix) - 1] = '\0'; - snprintf(VS(fixture_name), "async%s", suffix); - snprintf(VS(registration_name), "reg%s", suffix); + snprintf((fixture_name), sizeof((fixture_name)), "async%s", suffix); + snprintf((registration_name), sizeof((registration_name)), "reg%s", suffix); suffix[0] = (char)toupper((unsigned char)suffix[0]); - snprintf(VS(fixture_character), "Async %s", suffix); + snprintf((fixture_character), sizeof((fixture_character)), "Async %s", suffix); account_path = account_make_path(fixture_name); fixture_player_path = player_make_path(fixture_character, "player.dat"); registration_path = account_make_path(registration_name); @@ -340,7 +340,7 @@ START_TEST(test_credentials_changed_while_pending_are_not_overwritten) { char record[PASSWORD_RECORD_SIZE]; ck_assert(password_record_create("new-async-9!", record)); char credential[PASSWORD_RECORD_SIZE + 16]; - snprintf(VS(credential), "password %s", record); + snprintf((credential), sizeof((credential)), "password %s", record); replace_fixture_credential(credential); char *changed = read_account(); auth_worker_pause_for_test(false); @@ -389,7 +389,7 @@ START_TEST(test_pbkdf2_upgrade_is_transactional) { char salt_hex[65], hash_hex[65], credential[160]; ck_assert_uint_eq(string_tohex(salt, sizeof(salt), VS(salt_hex), false), 64); ck_assert_uint_eq(string_tohex(hash, sizeof(hash), VS(hash_hex), false), 64); - snprintf(VS(credential), "pswd %s\nsalt %s", hash_hex, salt_hex); + snprintf((credential), sizeof((credential)), "pswd %s\nsalt %s", hash_hex, salt_hex); replace_fixture_credential(credential); char *legacy = read_account(); socket_struct *ns = connection(0); @@ -406,7 +406,7 @@ START_TEST(test_pbkdf2_upgrade_is_transactional) { ck_assert_ptr_null(strstr(after, "pswd ")); ck_assert_ptr_null(strstr(after, "salt ")); char character_line[MAX_BUF + 32]; - snprintf(VS(character_line), "char human_male:%s::1", fixture_character); + snprintf((character_line), sizeof((character_line)), "char human_male:%s::1", fixture_character); ck_assert_ptr_nonnull(strstr(after, character_line)); ck_assert_uint_eq(successful_logins(), 1); } @@ -477,7 +477,7 @@ START_TEST(test_password_change_preserves_session_and_commits_atomically) { char *before = read_account(); char old_password[MAX_BUF], new_password[] = "new-async-9!"; char confirmation[] = "new-async-9!"; - snprintf(VS(old_password), "%s", fixture_password); + snprintf((old_password), sizeof((old_password)), "%s", fixture_password); account_fail_saves_for_test(_i == 1); auth_worker_pause_for_test(true); account_password_change(ns, old_password, new_password, confirmation); @@ -514,8 +514,8 @@ START_TEST(test_concurrent_password_changes_recheck_current_credential) { char old_first[MAX_BUF], old_second[MAX_BUF]; char new_first[] = "first-async-9!", confirm_first[] = "first-async-9!"; char new_second[] = "second-async-9!", confirm_second[] = "second-async-9!"; - snprintf(VS(old_first), "%s", fixture_password); - snprintf(VS(old_second), "%s", fixture_password); + snprintf((old_first), sizeof((old_first)), "%s", fixture_password); + snprintf((old_second), sizeof((old_second)), "%s", fixture_password); auth_worker_pause_for_test(true); account_password_change(first, old_first, new_first, confirm_first); account_password_change(second, old_second, new_second, confirm_second); @@ -562,7 +562,7 @@ START_TEST(test_password_reset_rechecks_actor_permission) { object *actor = pl->ob; auth_worker_pause_for_test(true); char name[MAX_BUF]; - snprintf(VS(name), "%s", fixture_name); + snprintf((name), sizeof((name)), "%s", fixture_name); account_password_force(actor, name, "reset-async-8!"); ck_assert_uint_ne(pl->cs->auth_request, 0); ck_assert(auth_worker_wait_running_for_test(1)); @@ -635,12 +635,12 @@ static socket_struct *submit_admission_operation(int operation) { ns->account = xstrdup(fixture_name); char old_password[MAX_BUF], password[] = "changed-async-9!"; char confirmation[] = "changed-async-9!"; - snprintf(VS(old_password), "%s", fixture_password); + snprintf((old_password), sizeof((old_password)), "%s", fixture_password); account_password_change(ns, old_password, password, confirmation); } else { player *pl = reset_actor(); char name[MAX_BUF]; - snprintf(VS(name), "%s", fixture_name); + snprintf((name), sizeof((name)), "%s", fixture_name); account_password_force(pl->ob, name, "changed-async-9!"); ns = pl->cs; } diff --git a/server/src/tests/unit/server/assets.c b/server/src/tests/unit/server/assets.c index 97e1558f2..70207f322 100644 --- a/server/src/tests/unit/server/assets.c +++ b/server/src/tests/unit/server/assets.c @@ -50,7 +50,7 @@ static void *scheduler_client_main(void *data) { static bool first_regular_file(const char *root, const char *relative, char *output, size_t size) { char directory[HUGE_BUF]; - snprintf(VS(directory), "%s%s%s", root, *relative != '\0' ? "/" : "", relative); + snprintf((directory), sizeof((directory)), "%s%s%s", root, *relative != '\0' ? "/" : "", relative); DIR *dir = opendir(directory); if (dir == NULL) { return false; @@ -62,7 +62,7 @@ static bool first_regular_file(const char *root, const char *relative, char *out continue; } char child[HUGE_BUF]; - snprintf(VS(child), "%s%s%s", relative, *relative != '\0' ? "/" : "", entry->d_name); + snprintf((child), sizeof((child)), "%s%s%s", relative, *relative != '\0' ? "/" : "", entry->d_name); size_t path_size = strlen(root) + strlen(child) + 2; char *path = xmalloc(path_size); snprintf(path, path_size, "%s/%s", root, child); @@ -88,7 +88,7 @@ static bool first_regular_file(const char *root, const char *relative, char *out static bool first_compressed_data(char *output, size_t size) { char root[HUGE_BUF]; - snprintf(VS(root), "%s/data", settings.assetspath); + snprintf((root), sizeof((root)), "%s/data", settings.assetspath); DIR *dir = opendir(root); if (dir == NULL) { return false; @@ -109,7 +109,7 @@ static bool first_compressed_data(char *output, size_t size) { START_TEST(test_transport_neutral_asset_cache) { char map_path[HUGE_BUF]; - snprintf(VS(map_path), "%s/client-maps/cache-test.png", settings.assetspath); + snprintf((map_path), sizeof((map_path)), "%s/client-maps/cache-test.png", settings.assetspath); FILE *map = fopen(map_path, "wb"); ck_assert_ptr_ne(map, NULL); ck_assert_uint_eq(fwrite("map", 1, 3, map), 3); @@ -149,7 +149,7 @@ START_TEST(test_transport_scheduler_services_initialized_quic_listener) { toolkit_import(socket_server); ck_assert(socket_server_quic_info(VS(client.host), &client.port, client.fingerprint)); if (client.host[0] == '\0') { - snprintf(VS(client.host), "%s", "127.0.0.1"); + snprintf((client.host), sizeof((client.host)), "%s", "127.0.0.1"); } pthread_t thread; diff --git a/server/src/tests/unit/server/celestial_structure.c b/server/src/tests/unit/server/celestial_structure.c index da8242244..f6d58c124 100644 --- a/server/src/tests/unit/server/celestial_structure.c +++ b/server/src/tests/unit/server/celestial_structure.c @@ -36,7 +36,7 @@ static ssize_t fail_inventory_write(void *cookie, const char *buffer, size_t siz } static char *run_inventory_capture(const char *map_ids, size_t *size) { - snprintf(VS(settings.celestial_inventory_maps), "%s", map_ids); + snprintf((settings.celestial_inventory_maps), sizeof((settings.celestial_inventory_maps)), "%s", map_ids); settings.celestial_inventory_limit = 32; FILE *capture = tmpfile(); ck_assert_ptr_ne(capture, NULL); @@ -65,7 +65,7 @@ static void write_filename_tile_map(const char *root, const char *sky_above, const char *records) { char path[HUGE_BUF]; - ck_assert_int_lt(snprintf(VS(path), "%s%s", root, logical_path), (int)sizeof(path)); + ck_assert_int_lt(snprintf((path), sizeof((path)), "%s%s", root, logical_path), (int)sizeof(path)); path_ensure_directories(path); FILE *fp = fopen(path, "wb"); ck_assert_ptr_nonnull(fp); @@ -85,7 +85,7 @@ static void write_filename_tile_map(const char *root, static void write_legacy_filename_tile_map(const char *root, const char *logical_path) { char path[HUGE_BUF]; - ck_assert_int_lt(snprintf(VS(path), "%s%s", root, logical_path), (int)sizeof(path)); + ck_assert_int_lt(snprintf((path), sizeof((path)), "%s%s", root, logical_path), (int)sizeof(path)); path_ensure_directories(path); FILE *fp = fopen(path, "wb"); ck_assert_ptr_nonnull(fp); @@ -957,11 +957,11 @@ START_TEST(test_inventory_is_bounded_deterministic_and_read_only) { char prearch_path[HUGE_BUF]; char missing_metadata_path[HUGE_BUF]; char duplicate_metadata_path[HUGE_BUF]; - snprintf(VS(west_path), "%s/west", temporary_maps); - snprintf(VS(east_path), "%s/east", temporary_maps); - snprintf(VS(prearch_path), "%s/prearch", temporary_maps); - snprintf(VS(missing_metadata_path), "%s/missing-metadata", temporary_maps); - snprintf(VS(duplicate_metadata_path), "%s/duplicate-metadata", temporary_maps); + snprintf((west_path), sizeof((west_path)), "%s/west", temporary_maps); + snprintf((east_path), sizeof((east_path)), "%s/east", temporary_maps); + snprintf((prearch_path), sizeof((prearch_path)), "%s/prearch", temporary_maps); + snprintf((missing_metadata_path), sizeof((missing_metadata_path)), "%s/missing-metadata", temporary_maps); + snprintf((duplicate_metadata_path), sizeof((duplicate_metadata_path)), "%s/duplicate-metadata", temporary_maps); FILE *west = fopen(west_path, "w"); FILE *east = fopen(east_path, "w"); FILE *prearch = fopen(prearch_path, "w"); @@ -1003,10 +1003,10 @@ START_TEST(test_inventory_is_bounded_deterministic_and_read_only) { ck_assert_int_eq(fclose(duplicate_metadata), 0); char saved_mapspath[MAX_BUF]; char saved_inventory_maps[HUGE_BUF]; - snprintf(VS(saved_mapspath), "%s", settings.mapspath); - snprintf(VS(saved_inventory_maps), "%s", settings.celestial_inventory_maps); + snprintf((saved_mapspath), sizeof((saved_mapspath)), "%s", settings.mapspath); + snprintf((saved_inventory_maps), sizeof((saved_inventory_maps)), "%s", settings.celestial_inventory_maps); uint16_t saved_inventory_limit = settings.celestial_inventory_limit; - snprintf(VS(settings.mapspath), "%s", temporary_maps); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%s", temporary_maps); ck_assert_ptr_eq(ready_map_name("/prearch", NULL, MAP_FLUSH | MAP_NO_DYNAMIC), NULL); ck_assert_ptr_eq(ready_map_name("/missing-metadata", NULL, MAP_FLUSH | MAP_NO_DYNAMIC), NULL); ck_assert_ptr_eq(ready_map_name("/duplicate-metadata", NULL, MAP_FLUSH | MAP_NO_DYNAMIC), NULL); @@ -1023,8 +1023,8 @@ START_TEST(test_inventory_is_bounded_deterministic_and_read_only) { ck_assert_ptr_ne(strstr(forward, "\tmap\t/west\t"), NULL); free(forward); free(reverse); - snprintf(VS(settings.mapspath), "%s", saved_mapspath); - snprintf(VS(settings.celestial_inventory_maps), "%s", saved_inventory_maps); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%s", saved_mapspath); + snprintf((settings.celestial_inventory_maps), sizeof((settings.celestial_inventory_maps)), "%s", saved_inventory_maps); settings.celestial_inventory_limit = saved_inventory_limit; ck_assert_int_eq(unlink(west_path), 0); ck_assert_int_eq(unlink(east_path), 0); @@ -1107,22 +1107,22 @@ START_TEST(test_private_map_provenance_writes_authored_source) { char saved_datapath[HUGE_BUF]; char saved_mapspath[HUGE_BUF]; - snprintf(VS(saved_datapath), "%s", settings.datapath); - snprintf(VS(saved_mapspath), "%s", settings.mapspath); + snprintf((saved_datapath), sizeof((saved_datapath)), "%s", settings.datapath); + snprintf((saved_mapspath), sizeof((saved_mapspath)), "%s", settings.mapspath); char datapath[HUGE_BUF], mapspath[HUGE_BUF], maps_dir[HUGE_BUF], source_path[HUGE_BUF]; char private_path[HUGE_BUF], provenance_dir[HUGE_BUF], account_path[HUGE_BUF]; - snprintf(VS(datapath), "%s/data", temporary_root); - snprintf(VS(mapspath), "%s", temporary_root); - snprintf(VS(maps_dir), "%s/maps", temporary_root); - snprintf(VS(source_path), "%s/maps/apartment", temporary_root); - snprintf(VS(private_path), "%s/data/players/a/alice/$maps$apartment", temporary_root); - snprintf(VS(provenance_dir), "%s/data/celestial-provenance", temporary_root); - snprintf(VS(account_path), "%s/data/accounts/a/alice.dat", temporary_root); + snprintf((datapath), sizeof((datapath)), "%s/data", temporary_root); + snprintf((mapspath), sizeof((mapspath)), "%s", temporary_root); + snprintf((maps_dir), sizeof((maps_dir)), "%s/maps", temporary_root); + snprintf((source_path), sizeof((source_path)), "%s/maps/apartment", temporary_root); + snprintf((private_path), sizeof((private_path)), "%s/data/players/a/alice/$maps$apartment", temporary_root); + snprintf((provenance_dir), sizeof((provenance_dir)), "%s/data/celestial-provenance", temporary_root); + snprintf((account_path), sizeof((account_path)), "%s/data/accounts/a/alice.dat", temporary_root); ck_assert_uint_lt(strlen(datapath), sizeof(settings.datapath)); ck_assert_uint_lt(strlen(mapspath), sizeof(settings.mapspath)); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, mapspath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, mapspath); path_ensure_directories(source_path); path_ensure_directories(private_path); @@ -1173,11 +1173,11 @@ START_TEST(test_private_map_provenance_writes_authored_source) { ck_assert_int_eq(unlink(private_path), 0); char private_parent[HUGE_BUF], private_grandparent[HUGE_BUF], players_dir[HUGE_BUF]; char account_parent[HUGE_BUF], accounts_dir[HUGE_BUF]; - snprintf(VS(private_parent), "%s/data/players/a/alice", temporary_root); - snprintf(VS(private_grandparent), "%s/data/players/a", temporary_root); - snprintf(VS(players_dir), "%s/data/players", temporary_root); - snprintf(VS(account_parent), "%s/data/accounts/a", temporary_root); - snprintf(VS(accounts_dir), "%s/data/accounts", temporary_root); + snprintf((private_parent), sizeof((private_parent)), "%s/data/players/a/alice", temporary_root); + snprintf((private_grandparent), sizeof((private_grandparent)), "%s/data/players/a", temporary_root); + snprintf((players_dir), sizeof((players_dir)), "%s/data/players", temporary_root); + snprintf((account_parent), sizeof((account_parent)), "%s/data/accounts/a", temporary_root); + snprintf((accounts_dir), sizeof((accounts_dir)), "%s/data/accounts", temporary_root); ck_assert_int_eq(rmdir(private_parent), 0); ck_assert_int_eq(rmdir(private_grandparent), 0); ck_assert_int_eq(rmdir(players_dir), 0); @@ -1188,8 +1188,8 @@ START_TEST(test_private_map_provenance_writes_authored_source) { ck_assert_int_eq(rmdir(datapath), 0); ck_assert_uint_lt(strlen(saved_datapath), sizeof(settings.datapath)); ck_assert_uint_lt(strlen(saved_mapspath), sizeof(settings.mapspath)); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); ck_assert_int_eq(rmdir(temporary_root), 0); } END_TEST @@ -1211,7 +1211,7 @@ START_TEST(test_inactive_runtime_private_player_save_roundtrip) { if (_i >= 2) { map->map_flags |= MAP_FLAG_OUTDOOR; } - snprintf(VS(CONTR(pl)->savebed_map), "%s", private_path); + snprintf((CONTR(pl)->savebed_map), sizeof((CONTR(pl)->savebed_map)), "%s", private_path); CONTR(pl)->bed_x = 3; CONTR(pl)->bed_y = 4; object *sack = arch_get("sack"); @@ -1273,7 +1273,7 @@ START_TEST(test_active_runtime_private_player_identity_remains_owner_bound) { ck_assert_ptr_nonnull(private_path); FREE_AND_COPY_HASH(map->path, private_path); map->map_flags |= MAP_FLAG_UNIQUE; - snprintf(VS(CONTR(pl)->savebed_map), "%s", private_path); + snprintf((CONTR(pl)->savebed_map), sizeof((CONTR(pl)->savebed_map)), "%s", private_path); celestial_structure_set_runtime_active_for_test(true); ck_assert(player_save_checked(pl)); char *player_path = player_make_path(pl->name, "player.dat"); @@ -1294,7 +1294,7 @@ START_TEST(test_active_runtime_private_player_identity_remains_owner_bound) { if (_i == 0) { FREE_AND_COPY_HASH(map->path, foreign_path); } else { - snprintf(VS(CONTR(pl)->savebed_map), "%s", foreign_path); + snprintf((CONTR(pl)->savebed_map), sizeof((CONTR(pl)->savebed_map)), "%s", foreign_path); } ck_assert(!player_save_checked(pl)); fp = fopen(player_path, "rb"); @@ -1322,23 +1322,23 @@ START_TEST(test_private_map_loads_from_source_without_provenance) { char saved_datapath[HUGE_BUF]; char saved_mapspath[HUGE_BUF]; - snprintf(VS(saved_datapath), "%s", settings.datapath); - snprintf(VS(saved_mapspath), "%s", settings.mapspath); + snprintf((saved_datapath), sizeof((saved_datapath)), "%s", settings.datapath); + snprintf((saved_mapspath), sizeof((saved_mapspath)), "%s", settings.mapspath); char datapath[HUGE_BUF], mapspath[HUGE_BUF], maps_dir[HUGE_BUF]; char source_path[HUGE_BUF], private_path[HUGE_BUF], provenance_dir[HUGE_BUF]; char players_dir[HUGE_BUF], players_a_dir[HUGE_BUF], character_dir[HUGE_BUF]; - snprintf(VS(datapath), "%s/data", temporary_root); - snprintf(VS(mapspath), "%s", temporary_root); - snprintf(VS(maps_dir), "%s/maps", temporary_root); - snprintf(VS(source_path), "%s/maps/apartment", temporary_root); - snprintf(VS(private_path), "%s/data/players/a/alice/$maps$apartment", temporary_root); - snprintf(VS(provenance_dir), "%s/data/celestial-provenance", temporary_root); - snprintf(VS(players_dir), "%s/data/players", temporary_root); - snprintf(VS(players_a_dir), "%s/data/players/a", temporary_root); - snprintf(VS(character_dir), "%s/data/players/a/alice", temporary_root); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, mapspath); + snprintf((datapath), sizeof((datapath)), "%s/data", temporary_root); + snprintf((mapspath), sizeof((mapspath)), "%s", temporary_root); + snprintf((maps_dir), sizeof((maps_dir)), "%s/maps", temporary_root); + snprintf((source_path), sizeof((source_path)), "%s/maps/apartment", temporary_root); + snprintf((private_path), sizeof((private_path)), "%s/data/players/a/alice/$maps$apartment", temporary_root); + snprintf((provenance_dir), sizeof((provenance_dir)), "%s/data/celestial-provenance", temporary_root); + snprintf((players_dir), sizeof((players_dir)), "%s/data/players", temporary_root); + snprintf((players_a_dir), sizeof((players_a_dir)), "%s/data/players/a", temporary_root); + snprintf((character_dir), sizeof((character_dir)), "%s/data/players/a/alice", temporary_root); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, mapspath); path_ensure_directories(source_path); FILE *source = fopen(source_path, "wb"); @@ -1396,8 +1396,8 @@ START_TEST(test_private_map_loads_from_source_without_provenance) { ck_assert_int_eq(rmdir(datapath), 0); ck_assert_int_eq(unlink(source_path), 0); ck_assert_int_eq(rmdir(maps_dir), 0); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); map = load_original_map(private_path, NULL, MAP_PLAYER_UNIQUE); ck_assert_ptr_eq(map, NULL); @@ -1410,8 +1410,8 @@ START_TEST(test_filename_tiling_restores_legacy_links) { ck_assert_ptr_ne(mkdtemp(temporary_root), NULL); char saved_mapspath[HUGE_BUF]; - snprintf(VS(saved_mapspath), "%s", settings.mapspath); - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, temporary_root); + snprintf((saved_mapspath), sizeof((saved_mapspath)), "%s", settings.mapspath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, temporary_root); write_filename_tile_map(temporary_root, "/world_5_5", "open", NULL); write_filename_tile_map(temporary_root, "/world_5_6", "open", NULL); @@ -1577,11 +1577,11 @@ START_TEST(test_filename_tiling_restores_legacy_links) { }; for (size_t i = 0; i < arraysize(logical_paths); i++) { char path[HUGE_BUF]; - ck_assert_int_lt(snprintf(VS(path), "%s%s", temporary_root, logical_paths[i]), + ck_assert_int_lt(snprintf((path), sizeof((path)), "%s%s", temporary_root, logical_paths[i]), (int)sizeof(path)); ck_assert_int_eq(unlink(path), 0); } - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); ck_assert_int_eq(rmdir(temporary_root), 0); } END_TEST @@ -1591,17 +1591,17 @@ START_TEST(test_filename_tiling_survives_temporary_map_reload) { ck_assert_ptr_nonnull(mkdtemp(temporary_root)); char saved_mapspath[HUGE_BUF], saved_datapath[HUGE_BUF]; - snprintf(VS(saved_mapspath), "%s", settings.mapspath); - snprintf(VS(saved_datapath), "%s", settings.datapath); - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, temporary_root); + snprintf((saved_mapspath), sizeof((saved_mapspath)), "%s", settings.mapspath); + snprintf((saved_datapath), sizeof((saved_datapath)), "%s", settings.datapath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, temporary_root); char datapath[HUGE_BUF], temporary_dir[HUGE_BUF], unique_dir[HUGE_BUF]; - snprintf(VS(datapath), "%s/data", temporary_root); - snprintf(VS(temporary_dir), "%s/data/tmp", temporary_root); - snprintf(VS(unique_dir), "%s/data/unique-items", temporary_root); + snprintf((datapath), sizeof((datapath)), "%s/data", temporary_root); + snprintf((temporary_dir), sizeof((temporary_dir)), "%s/data/tmp", temporary_root); + snprintf((unique_dir), sizeof((unique_dir)), "%s/data/unique-items", temporary_root); ck_assert_int_eq(mkdir(datapath, 0700), 0); ck_assert_int_eq(mkdir(temporary_dir, 0700), 0); ck_assert_int_eq(mkdir(unique_dir, 0700), 0); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); bool vertical = _i == 2 || _i == 3; bool authored = _i == 4; @@ -1689,9 +1689,9 @@ START_TEST(test_filename_tiling_survives_temporary_map_reload) { const char *logical_paths[] = {source_path, neighbor_path}; for (size_t i = 0; i < arraysize(logical_paths); i++) { char path[HUGE_BUF]; - snprintf(VS(path), "%s%s", temporary_root, logical_paths[i]); + snprintf((path), sizeof((path)), "%s%s", temporary_root, logical_paths[i]); ck_assert_int_eq(unlink(path), 0); - ck_assert_int_lt(snprintf(VS(path), "%s/data/unique-items/%s.v00", + ck_assert_int_lt(snprintf((path), sizeof((path)), "%s/data/unique-items/%s.v00", temporary_root, logical_paths[i] + 1), (int)sizeof(path)); if (i == 0 || swap_neighbor) { @@ -1702,14 +1702,14 @@ START_TEST(test_filename_tiling_survives_temporary_map_reload) { } if (authored) { char path[HUGE_BUF]; - snprintf(VS(path), "%s/world_5_6", temporary_root); + snprintf((path), sizeof((path)), "%s/world_5_6", temporary_root); ck_assert_int_eq(unlink(path), 0); } ck_assert_int_eq(rmdir(temporary_dir), 0); ck_assert_int_eq(rmdir(unique_dir), 0); ck_assert_int_eq(rmdir(datapath), 0); - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); ck_assert_int_eq(rmdir(temporary_root), 0); } END_TEST @@ -1718,8 +1718,8 @@ START_TEST(test_rejected_temporary_map_withdraws_its_tile_backlinks) { char temporary_root[] = "/tmp/atrinik-celestial-filename-reject-XXXXXX"; ck_assert_ptr_nonnull(mkdtemp(temporary_root)); char saved_mapspath[HUGE_BUF]; - snprintf(VS(saved_mapspath), "%s", settings.mapspath); - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, temporary_root); + snprintf((saved_mapspath), sizeof((saved_mapspath)), "%s", settings.mapspath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, temporary_root); const char *source_path = "/failure_5_5"; const char *neighbor_path = "/failure_5_6"; @@ -1740,8 +1740,8 @@ START_TEST(test_rejected_temporary_map_withdraws_its_tile_backlinks) { ck_assert_ptr_eq(neighbor->tile_map[TILED_NORTH], unrelated); char source_filename[HUGE_BUF], temporary_filename[HUGE_BUF]; - snprintf(VS(source_filename), "%s%s", temporary_root, source_path); - snprintf(VS(temporary_filename), "%s/saved-map", temporary_root); + snprintf((source_filename), sizeof((source_filename)), "%s%s", temporary_root, source_path); + snprintf((temporary_filename), sizeof((temporary_filename)), "%s/saved-map", temporary_root); if (_i == 2) { /* Rejecting a second, invalid source header must also undo its links. */ write_filename_tile_map(temporary_root, source_path, "linked", NULL); @@ -1775,17 +1775,17 @@ START_TEST(test_rejected_temporary_map_withdraws_its_tile_backlinks) { if (unrelated != NULL) { delete_map(unrelated); char path[HUGE_BUF]; - snprintf(VS(path), "%s/unrelated", temporary_root); + snprintf((path), sizeof((path)), "%s/unrelated", temporary_root); ck_assert_int_eq(unlink(path), 0); } char neighbor_filename[HUGE_BUF]; - snprintf(VS(neighbor_filename), "%s%s", temporary_root, neighbor_path); + snprintf((neighbor_filename), sizeof((neighbor_filename)), "%s%s", temporary_root, neighbor_path); ck_assert_int_eq(unlink(neighbor_filename), 0); ck_assert_int_eq(unlink(temporary_filename), 0); if (_i == 2) { ck_assert_int_eq(unlink(source_filename), 0); } - snprintf(VS(settings.mapspath), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%.*s", (int)sizeof(settings.mapspath) - 1, saved_mapspath); ck_assert_int_eq(rmdir(temporary_root), 0); } END_TEST @@ -1795,10 +1795,10 @@ START_TEST(test_character_transaction_lifecycle_is_durable) { ck_assert_ptr_ne(mkdtemp(temporary_root), NULL); char saved_datapath[HUGE_BUF]; - snprintf(VS(saved_datapath), "%s", settings.datapath); + snprintf((saved_datapath), sizeof((saved_datapath)), "%s", settings.datapath); char datapath[HUGE_BUF], map_path[HUGE_BUF], player_path[HUGE_BUF], transaction_dir[HUGE_BUF]; - snprintf(VS(datapath), "%s/data", temporary_root); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); + snprintf((datapath), sizeof((datapath)), "%s/data", temporary_root); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); strcpy(map_path, datapath); strcat(map_path, "/players/a/alice/$maps$apartment"); strcpy(player_path, datapath); @@ -1829,7 +1829,7 @@ START_TEST(test_character_transaction_lifecycle_is_durable) { ck_assert_int_eq(rmdir(transaction_dir), 0); ck_assert_int_eq(rmdir(datapath), 0); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); ck_assert_int_eq(rmdir(temporary_root), 0); } END_TEST @@ -1839,11 +1839,11 @@ START_TEST(test_character_transaction_recovery_quarantines_prepared_group) { ck_assert_ptr_ne(mkdtemp(temporary_root), NULL); char saved_datapath[HUGE_BUF]; - snprintf(VS(saved_datapath), "%s", settings.datapath); + snprintf((saved_datapath), sizeof((saved_datapath)), "%s", settings.datapath); char datapath[HUGE_BUF], map_path[HUGE_BUF], player_path[HUGE_BUF]; char ledger_path[HUGE_BUF], quarantine_dir[HUGE_BUF]; - snprintf(VS(datapath), "%s/data", temporary_root); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); + snprintf((datapath), sizeof((datapath)), "%s/data", temporary_root); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); strcpy(map_path, datapath); strcat(map_path, "/players/a/alice/$maps$apartment"); strcpy(player_path, datapath); @@ -1932,7 +1932,7 @@ START_TEST(test_character_transaction_recovery_quarantines_prepared_group) { ck_assert_int_eq(rmdir(players_dir), 0); ck_assert_int_eq(rmdir(provenance_dir), 0); ck_assert_int_eq(rmdir(datapath), 0); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); ck_assert_int_eq(rmdir(temporary_root), 0); } END_TEST @@ -1942,12 +1942,12 @@ START_TEST(test_committed_map_transaction_missing_unique_is_quarantined) { ck_assert_ptr_ne(mkdtemp(temporary_root), NULL); char saved_datapath[HUGE_BUF]; - snprintf(VS(saved_datapath), "%s", settings.datapath); + snprintf((saved_datapath), sizeof((saved_datapath)), "%s", settings.datapath); char datapath[HUGE_BUF], map_path[HUGE_BUF], unique_path[HUGE_BUF]; char ledger_path[HUGE_BUF], transaction_dir[HUGE_BUF], provenance_dir[HUGE_BUF]; char quarantine_dir[HUGE_BUF]; - snprintf(VS(datapath), "%s/data", temporary_root); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); + snprintf((datapath), sizeof((datapath)), "%s/data", temporary_root); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); strcpy(map_path, datapath); strcat(map_path, "/map.dat"); strcpy(unique_path, datapath); @@ -2021,7 +2021,7 @@ START_TEST(test_committed_map_transaction_missing_unique_is_quarantined) { ck_assert_int_eq(rmdir(transaction_dir), 0); delete_map(map); ck_assert_int_eq(rmdir(datapath), 0); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); ck_assert_int_eq(rmdir(temporary_root), 0); } END_TEST @@ -2031,11 +2031,11 @@ START_TEST(test_character_transaction_rejects_path_escape) { ck_assert_ptr_ne(mkdtemp(temporary_root), NULL); char saved_datapath[HUGE_BUF]; - snprintf(VS(saved_datapath), "%s", settings.datapath); + snprintf((saved_datapath), sizeof((saved_datapath)), "%s", settings.datapath); char datapath[HUGE_BUF], map_path[HUGE_BUF], player_path[HUGE_BUF]; char transaction_dir[HUGE_BUF], transaction_path[HUGE_BUF]; - snprintf(VS(datapath), "%s/data", temporary_root); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); + snprintf((datapath), sizeof((datapath)), "%s/data", temporary_root); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, datapath); strcpy(map_path, datapath); strcat(map_path, "/players/a/alice/$maps$apartment"); strcpy(player_path, datapath); @@ -2086,7 +2086,7 @@ START_TEST(test_character_transaction_rejects_path_escape) { ck_assert_int_eq(unlink(transaction_path), 0); ck_assert_int_eq(rmdir(transaction_dir), 0); ck_assert_int_eq(rmdir(datapath), 0); - snprintf(VS(settings.datapath), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%.*s", (int)sizeof(settings.datapath) - 1, saved_datapath); ck_assert_int_eq(rmdir(temporary_root), 0); } END_TEST diff --git a/server/src/tests/unit/server/content_benchmark.c b/server/src/tests/unit/server/content_benchmark.c index a31a8c87b..65997037f 100644 --- a/server/src/tests/unit/server/content_benchmark.c +++ b/server/src/tests/unit/server/content_benchmark.c @@ -76,7 +76,7 @@ static object *route_fixture(mapstruct *maps[WALKING_ROUTE_MAPS]) { ck_assert_ptr_nonnull(maps[i]); /* Normal spawned-monster base info retains its home map identity. */ char path[MAX_BUF]; - snprintf(VS(path), "/tests/walking-route/world_%d_%d", i % 4, i / 4 + 66); + snprintf((path), sizeof((path)), "/tests/walking-route/world_%d_%d", i % 4, i / 4 + 66); maps[i]->path = add_string(path); for (int y = 0; y < 24; y++) { for (int x = 0; x < 24; x++) { diff --git a/server/src/tests/unit/server/gameplay_journal.c b/server/src/tests/unit/server/gameplay_journal.c index 58ae6b383..9c298d31a 100644 --- a/server/src/tests/unit/server/gameplay_journal.c +++ b/server/src/tests/unit/server/gameplay_journal.c @@ -50,7 +50,7 @@ static size_t count_substring(const char *contents, const char *needle) { static void remove_fixture(const char *directory) { char journal_directory[MAX_BUF]; - snprintf(VS(journal_directory), "%s/gameplay-journal", directory); + snprintf((journal_directory), sizeof((journal_directory)), "%s/gameplay-journal", directory); DIR *dir = opendir(journal_directory); ck_assert_ptr_ne(dir, NULL); struct dirent *entry; @@ -58,7 +58,7 @@ static void remove_fixture(const char *directory) { if (strncmp(entry->d_name, "journal-", 8) == 0 || strcmp(entry->d_name, "journal.lock") == 0) { char path[HUGE_BUF]; - snprintf(VS(path), "%s/%s", journal_directory, entry->d_name); + snprintf((path), sizeof((path)), "%s/%s", journal_directory, entry->d_name); ck_assert_int_eq(unlink(path), 0); } } @@ -69,14 +69,14 @@ static void remove_fixture(const char *directory) { static char *read_fixture(const char *directory) { char journal_directory[MAX_BUF]; - snprintf(VS(journal_directory), "%s/gameplay-journal", directory); + snprintf((journal_directory), sizeof((journal_directory)), "%s/gameplay-journal", directory); DIR *dir = opendir(journal_directory); ck_assert_ptr_ne(dir, NULL); char path[HUGE_BUF] = ""; struct dirent *entry; while ((entry = readdir(dir)) != NULL) { if (strncmp(entry->d_name, "journal-", 8) == 0) { - snprintf(VS(path), "%s/%s", journal_directory, entry->d_name); + snprintf((path), sizeof((path)), "%s/%s", journal_directory, entry->d_name); break; } } @@ -208,7 +208,7 @@ START_TEST(test_intent_commit_abort_and_private_storage) { ck_assert(!gameplay_journal_available()); char journal_directory[MAX_BUF]; - snprintf(VS(journal_directory), "%s/gameplay-journal", directory); + snprintf((journal_directory), sizeof((journal_directory)), "%s/gameplay-journal", directory); struct stat metadata; ck_assert_int_eq(stat(journal_directory, &metadata), 0); ck_assert_uint_eq(metadata.st_mode & 0777, 0700); @@ -222,7 +222,7 @@ START_TEST(test_intent_commit_abort_and_private_storage) { } files++; char path[HUGE_BUF]; - snprintf(VS(path), "%s/%s", journal_directory, entry->d_name); + snprintf((path), sizeof((path)), "%s/%s", journal_directory, entry->d_name); ck_assert_int_eq(stat(path, &metadata), 0); ck_assert_uint_eq(metadata.st_mode & 0777, 0600); FILE *fp = fopen(path, "rb"); @@ -510,10 +510,10 @@ START_TEST(test_party_random_currency_retirement_preserves_message_lifetime) { "transaction_id", VS(source_transaction))); char funding[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE + 32]; - snprintf(VS(funding), "\"funding\":\"%s\"", source_transaction); + snprintf((funding), sizeof((funding)), "\"funding\":\"%s\"", source_transaction); ck_assert_uint_eq(count_substring(split_contents, funding), 2); char source_terminal[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE + 64]; - snprintf(VS(source_terminal), "\"transaction_id\":\"%s\",\"sequence\"", source_transaction); + snprintf((source_terminal), sizeof((source_terminal)), "\"transaction_id\":\"%s\",\"sequence\"", source_transaction); ck_assert_uint_ge(count_substring(split_contents, source_terminal), 2); char grant_transaction[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]; ck_assert(crash_intent_field(split_contents, @@ -522,8 +522,8 @@ START_TEST(test_party_random_currency_retirement_preserves_message_lifetime) { VS(grant_transaction))); char source_id[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE + 32]; char grant_id[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE + 32]; - snprintf(VS(source_id), "\"transaction_id\":\"%s\"", source_transaction); - snprintf(VS(grant_id), "\"transaction_id\":\"%s\"", grant_transaction); + snprintf((source_id), sizeof((source_id)), "\"transaction_id\":\"%s\"", source_transaction); + snprintf((grant_id), sizeof((grant_id)), "\"transaction_id\":\"%s\"", grant_transaction); const char *source_commit_pos = strstr(split_contents, source_id); while (source_commit_pos != NULL) { const char *line_end = strchr(source_commit_pos, '\n'); @@ -742,7 +742,7 @@ START_TEST(test_progression_adjustments_and_level_boundaries_are_journaled) { gameplay_journal_deinit(); char *contents = read_fixture(directory); char subject_pattern[GAMEPLAY_JOURNAL_ID_MAX + 32]; - snprintf(VS(subject_pattern), "\"subject_id\":\"skill:%s\"", skill_id_from_index(SK_UNARMED)); + snprintf((subject_pattern), sizeof((subject_pattern)), "\"subject_id\":\"skill:%s\"", skill_id_from_index(SK_UNARMED)); ck_assert_ptr_ne(strstr(contents, subject_pattern), NULL); ck_assert_ptr_ne(strstr(contents, "\"lineage_id\":\"actor:trusted-content\""), NULL); free(contents); @@ -783,7 +783,7 @@ START_TEST(test_spell_learning_commits_stable_progression_milestone) { gameplay_journal_deinit(); char *contents = read_fixture(directory); char subject_pattern[GAMEPLAY_JOURNAL_ID_MAX + 32]; - snprintf(VS(subject_pattern), + snprintf((subject_pattern), sizeof((subject_pattern)), "\"subject_id\":\"spell:%s\"", spell_id_from_index(SP_MAGIC_MISSILE)); ck_assert_ptr_ne(strstr(contents, subject_pattern), NULL); @@ -859,7 +859,7 @@ START_TEST(test_survival_outcomes_preserve_exclusive_attribution) { check_setup_env_pl(&map, &pl); map->map_flags &= ~MAP_FLAG_PVP; - snprintf(VS(CONTR(pl)->savebed_map), "%s", "/emergency"); + snprintf((CONTR(pl)->savebed_map), sizeof((CONTR(pl)->savebed_map)), "%s", "/emergency"); kill_player(pl, true, false); ck_assert_uint_eq(gameplay_journal_committed_count_for_test("survival.death-pvp"), 1); ck_assert_uint_eq(gameplay_journal_committed_count_for_test("survival.respawn"), 1); @@ -867,7 +867,7 @@ START_TEST(test_survival_outcomes_preserve_exclusive_attribution) { check_setup_env_pl(&map, &pl); map->map_flags &= ~MAP_FLAG_PVP; - snprintf(VS(CONTR(pl)->savebed_map), "%s", "/emergency"); + snprintf((CONTR(pl)->savebed_map), sizeof((CONTR(pl)->savebed_map)), "%s", "/emergency"); kill_player(pl, false, false); ck_assert_uint_eq(gameplay_journal_committed_count_for_test("survival.death-pve"), 1); ck_assert_uint_eq(gameplay_journal_committed_count_for_test("survival.respawn"), 2); @@ -875,7 +875,7 @@ START_TEST(test_survival_outcomes_preserve_exclusive_attribution) { check_setup_env_pl(&map, &pl); map->map_flags &= ~MAP_FLAG_PVP; - snprintf(VS(CONTR(pl)->savebed_map), "%s", "/emergency"); + snprintf((CONTR(pl)->savebed_map), sizeof((CONTR(pl)->savebed_map)), "%s", "/emergency"); kill_player(pl, false, true); ck_assert_uint_eq(gameplay_journal_committed_count_for_test("survival.death-environment"), 1); ck_assert_uint_eq(gameplay_journal_committed_count_for_test("survival.respawn"), 3); @@ -883,7 +883,7 @@ START_TEST(test_survival_outcomes_preserve_exclusive_attribution) { check_setup_env_pl(&map, &pl); map->map_flags &= ~MAP_FLAG_PVP; - snprintf(VS(CONTR(pl)->savebed_map), "%s", "/emergency"); + snprintf((CONTR(pl)->savebed_map), sizeof((CONTR(pl)->savebed_map)), "%s", "/emergency"); object *lifesave = object_insert_into(arch_get("sword"), pl, INS_NO_MERGE); SET_FLAG(lifesave, FLAG_APPLIED); SET_FLAG(lifesave, FLAG_LIFESAVE); @@ -1013,8 +1013,8 @@ START_TEST(test_production_player_and_map_checkpoints_persist_component_watermar char directory[] = "/tmp/atrinik-journal-production-checkpoint-XXXXXX"; ck_assert_ptr_ne(mkdtemp(directory), NULL); char old_datapath[MAX_BUF]; - snprintf(VS(old_datapath), "%s", settings.datapath); - snprintf(VS(settings.datapath), "%s", directory); + snprintf((old_datapath), sizeof((old_datapath)), "%s", settings.datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%s", directory); const gameplay_journal_profile_t profile = { .id = "legacy-unknown", @@ -1066,7 +1066,7 @@ START_TEST(test_production_player_and_map_checkpoints_persist_component_watermar ck_assert_ptr_ne(ambiguous_coin->custody_lineage, NULL); ck_assert_int_eq(strncmp(ambiguous_coin->custody_lineage, "currency:", 9), 0); char ambiguous_transaction[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]; - snprintf(VS(ambiguous_transaction), "%s", ambiguous_coin->custody_lineage + 9); + snprintf((ambiguous_transaction), sizeof((ambiguous_transaction)), "%s", ambiguous_coin->custody_lineage + 9); ck_assert(gameplay_journal_player_checkpoint_allowed(pl)); ck_assert(gameplay_journal_map_checkpoint_allowed(map)); gameplay_journal_fail_after_writes_for_test(SIZE_MAX); @@ -1112,7 +1112,7 @@ START_TEST(test_production_player_and_map_checkpoints_persist_component_watermar ck_assert_ptr_ne(loaded_coin, NULL); ck_assert_ptr_ne(loaded_coin->custody_lineage, NULL); char expected_lineage[9 + GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]; - snprintf(VS(expected_lineage), "currency:%s", ambiguous_transaction); + snprintf((expected_lineage), sizeof((expected_lineage)), "currency:%s", ambiguous_transaction); ck_assert_str_eq(loaded_coin->custody_lineage, expected_lineage); int64_t loaded_value; ck_assert(shop_money_object_value(loaded_coin, &loaded_value)); @@ -1120,9 +1120,9 @@ START_TEST(test_production_player_and_map_checkpoints_persist_component_watermar ck_assert_int_eq(shop_get_money(loaded_player), 3); object_destroy(loaded_player); char runtime_path[HUGE_BUF]; - snprintf(VS(runtime_path), "%s/runtime.map", directory); + snprintf((runtime_path), sizeof((runtime_path)), "%s/runtime.map", directory); char unique_directory[HUGE_BUF]; - snprintf(VS(unique_directory), "%s/unique-items", directory); + snprintf((unique_directory), sizeof((unique_directory)), "%s/unique-items", directory); if (mkdir(unique_directory, 0700) != 0) { ck_assert_int_eq(errno, EEXIST); } @@ -1143,7 +1143,7 @@ START_TEST(test_production_player_and_map_checkpoints_persist_component_watermar ck_assert_uint_eq(loaded_map.journal_sequence, runtime_sequence); char unique_path[HUGE_BUF]; - snprintf(VS(unique_path), "%s/unique-items/", directory); + snprintf((unique_path), sizeof((unique_path)), "%s/unique-items/", directory); size_t offset = strlen(unique_path); const char *map_path = map->path + 1; for (; *map_path != '\0' && offset + 5 < sizeof(unique_path); map_path++) { @@ -1186,12 +1186,12 @@ START_TEST(test_production_player_and_map_checkpoints_persist_component_watermar gameplay_journal_deinit(); #ifndef WIN32 char source_root[HUGE_BUF]; - snprintf(VS(source_root), "%s", ATRINIK_TEST_DATA_DIR); + snprintf((source_root), sizeof((source_root)), "%s", ATRINIK_TEST_DATA_DIR); char *source_suffix = strstr(source_root, "/src/tests/data"); ck_assert_ptr_ne(source_suffix, NULL); *source_suffix = '\0'; char reconcile_path[HUGE_BUF]; - snprintf(VS(reconcile_path), "%s/reconcile.json", directory); + snprintf((reconcile_path), sizeof((reconcile_path)), "%s/reconcile.json", directory); StringBuffer *command_buffer = stringbuffer_new(); stringbuffer_append_printf(command_buffer, "python3 '%s/tools/gameplay_journal.py' " @@ -1229,7 +1229,7 @@ START_TEST(test_production_player_and_map_checkpoints_persist_component_watermar #endif free(map->tmpname); map->tmpname = old_tmpname; - snprintf(VS(settings.datapath), "%s", old_datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%s", old_datapath); object_remove(runtime_probe, 0); object_destroy(runtime_probe); object_remove(unique_probe, 0); @@ -1262,7 +1262,7 @@ START_TEST(test_checkpoint_watermarks_persist_player_and_map) { mapstruct *map; object *pl; check_setup_env_pl(&map, &pl); - snprintf(VS(CONTR(pl)->journal_run_id), "%s", run_id); + snprintf((CONTR(pl)->journal_run_id), sizeof((CONTR(pl)->journal_run_id)), "%s", run_id); CONTR(pl)->journal_sequence = 42; FILE *fp = tmpfile(); @@ -1280,7 +1280,7 @@ START_TEST(test_checkpoint_watermarks_persist_player_and_map) { object_destroy(loaded_player); mapstruct saved = {0}; - snprintf(VS(saved.journal_run_id), "%s", run_id); + snprintf((saved.journal_run_id), sizeof((saved.journal_run_id)), "%s", run_id); saved.journal_sequence = 84; fp = tmpfile(); ck_assert_ptr_ne(fp, NULL); @@ -1338,7 +1338,7 @@ START_TEST(test_checkpoint_sequence_remains_ordered_across_runs) { ck_assert_int_eq(shop_insert_coins_reason(pl, 3, "test.run-a"), OBJECT_SEMANTIC_COMMITTED); uint64_t first = CONTR(pl)->journal_sequence; char first_run[33]; - snprintf(VS(first_run), "%s", CONTR(pl)->journal_run_id); + snprintf((first_run), sizeof((first_run)), "%s", CONTR(pl)->journal_run_id); gameplay_journal_deinit(); ck_assert(gameplay_journal_init(directory, "server", &profile)); @@ -1930,7 +1930,7 @@ START_TEST(test_long_lived_intent_fails_before_hard_file_limit) { gameplay_journal_deinit(); char journal_directory[MAX_BUF]; - snprintf(VS(journal_directory), "%s/gameplay-journal", directory); + snprintf((journal_directory), sizeof((journal_directory)), "%s/gameplay-journal", directory); DIR *dir = opendir(journal_directory); ck_assert_ptr_ne(dir, NULL); struct dirent *entry; @@ -1939,7 +1939,7 @@ START_TEST(test_long_lived_intent_fails_before_hard_file_limit) { continue; } char path[HUGE_BUF]; - snprintf(VS(path), "%s/%s", journal_directory, entry->d_name); + snprintf((path), sizeof((path)), "%s/%s", journal_directory, entry->d_name); struct stat metadata; ck_assert_int_eq(stat(path, &metadata), 0); ck_assert_int_le(metadata.st_size, 4096); @@ -1973,7 +1973,7 @@ START_TEST(test_init_fails_closed_for_unsafe_directory_or_profile) { char directory[] = "/tmp/atrinik-gameplay-journal-unsafe-XXXXXX"; ck_assert_ptr_ne(mkdtemp(directory), NULL); char path[MAX_BUF]; - snprintf(VS(path), "%s/gameplay-journal", directory); + snprintf((path), sizeof((path)), "%s/gameplay-journal", directory); FILE *fp = fopen(path, "wb"); ck_assert_ptr_ne(fp, NULL); ck_assert_int_eq(fclose(fp), 0); @@ -1994,7 +1994,7 @@ START_TEST(test_init_rejects_insecure_directory_permissions) { char directory[] = "/tmp/atrinik-gameplay-journal-mode-XXXXXX"; ck_assert_ptr_ne(mkdtemp(directory), NULL); char path[MAX_BUF]; - snprintf(VS(path), "%s/gameplay-journal", directory); + snprintf((path), sizeof((path)), "%s/gameplay-journal", directory); ck_assert_int_eq(mkdir(path, 0755), 0); const gameplay_journal_profile_t profile = { .id = "legacy-unknown", @@ -2015,16 +2015,16 @@ START_TEST(test_retention_stays_bounded_when_opening_a_file) { char directory[] = "/tmp/atrinik-gameplay-journal-retention-XXXXXX"; ck_assert_ptr_ne(mkdtemp(directory), NULL); char journal_directory[MAX_BUF]; - snprintf(VS(journal_directory), "%s/gameplay-journal", directory); + snprintf((journal_directory), sizeof((journal_directory)), "%s/gameplay-journal", directory); ck_assert_int_eq(mkdir(journal_directory, 0700), 0); char sentinel[HUGE_BUF]; - snprintf(VS(sentinel), "%s/journal-investigation.jsonl", journal_directory); + snprintf((sentinel), sizeof((sentinel)), "%s/journal-investigation.jsonl", journal_directory); int sentinel_fd = open(sentinel, O_WRONLY | O_CREAT | O_EXCL, 0600); ck_assert_int_ge(sentinel_fd, 0); ck_assert_int_eq(close(sentinel_fd), 0); for (size_t i = 0; i < 16; i++) { char path[HUGE_BUF]; - snprintf(VS(path), + snprintf((path), sizeof((path)), "%s/journal-00000000000000000000000000000000-%04zu.jsonl", journal_directory, i); @@ -2097,7 +2097,7 @@ START_TEST(test_real_rotation_and_retention_keep_complete_transactions) { gameplay_journal_deinit(); char journal_directory[MAX_BUF]; - snprintf(VS(journal_directory), "%s/gameplay-journal", directory); + snprintf((journal_directory), sizeof((journal_directory)), "%s/gameplay-journal", directory); DIR *dir = opendir(journal_directory); ck_assert_ptr_ne(dir, NULL); size_t files = 0; @@ -2106,7 +2106,7 @@ START_TEST(test_real_rotation_and_retention_keep_complete_transactions) { if (strncmp(entry->d_name, "journal-", 8) == 0) { files++; char path[HUGE_BUF]; - snprintf(VS(path), "%s/%s", journal_directory, entry->d_name); + snprintf((path), sizeof((path)), "%s/%s", journal_directory, entry->d_name); struct stat metadata; ck_assert_int_eq(stat(path, &metadata), 0); ck_assert_int_gt(metadata.st_size, 0); @@ -2170,7 +2170,7 @@ START_TEST(test_abrupt_process_crash_preserves_synced_phases) { ck_assert_int_eq(WEXITSTATUS(status), EXIT_SUCCESS); char journal_directory[MAX_BUF]; - snprintf(VS(journal_directory), "%s/gameplay-journal", directory); + snprintf((journal_directory), sizeof((journal_directory)), "%s/gameplay-journal", directory); DIR *dir = opendir(journal_directory); ck_assert_ptr_ne(dir, NULL); struct dirent *entry; @@ -2180,7 +2180,7 @@ START_TEST(test_abrupt_process_crash_preserves_synced_phases) { continue; } char path[HUGE_BUF]; - snprintf(VS(path), "%s/%s", journal_directory, entry->d_name); + snprintf((path), sizeof((path)), "%s/%s", journal_directory, entry->d_name); FILE *fp = fopen(path, "rb"); ck_assert_ptr_ne(fp, NULL); char contents[HUGE_BUF]; @@ -2200,7 +2200,7 @@ END_TEST static void write_crash_file(const char *directory, const char *name, const char *state) { char path[HUGE_BUF]; - snprintf(VS(path), "%s/%s", directory, name); + snprintf((path), sizeof((path)), "%s/%s", directory, name); FILE *fp = fopen(path, "wb"); ck_assert_ptr_ne(fp, NULL); ck_assert_uint_eq(fwrite(state, 1, strlen(state), fp), strlen(state)); @@ -2231,7 +2231,7 @@ static bool crash_intent_field(const char *contents, char *value, size_t value_size) { char reason_token[GAMEPLAY_JOURNAL_ID_MAX + 32]; - snprintf(VS(reason_token), "\"reason\":\"%s\"", reason); + snprintf((reason_token), sizeof((reason_token)), "\"reason\":\"%s\"", reason); const char *reason_pos = strstr(contents, reason_token); if (reason_pos == NULL) { return false; @@ -2245,7 +2245,7 @@ static bool crash_intent_field(const char *contents, line_end = contents + strlen(contents); } char field_token[128]; - snprintf(VS(field_token), "\"%s\":\"", field); + snprintf((field_token), sizeof((field_token)), "\"%s\":\"", field); const char *start = strstr(line, field_token); if (start == NULL || start >= line_end) { return false; @@ -2468,7 +2468,7 @@ static void check_abrupt_semantic_operation_recovery(crash_operation_t operation ck_assert_ptr_ne(strstr(contents, "\"phase\":\"intent\""), NULL); ck_assert_int_eq(strstr(contents, "\"phase\":\"commit\"") != NULL, !fail_terminal); char reason[GAMEPLAY_JOURNAL_ID_MAX + 32]; - snprintf(VS(reason), + snprintf((reason), sizeof((reason)), "\"reason\":\"%s\"", crash_operation_reason(operation)); ck_assert_ptr_ne(strstr(contents, reason), NULL); @@ -2483,7 +2483,7 @@ static void check_abrupt_semantic_operation_recovery(crash_operation_t operation "lineage_id", VS(intent_lineage))); char arithmetic[160]; - snprintf(VS(arithmetic), + snprintf((arithmetic), sizeof((arithmetic)), "\"before\":%" PRId64 ",\"delta\":%" PRId64 ",\"after\":%" PRId64, expected_arithmetic[operation][0], expected_arithmetic[operation][1], @@ -2492,7 +2492,7 @@ static void check_abrupt_semantic_operation_recovery(crash_operation_t operation free(contents); char state_path[HUGE_BUF]; - snprintf(VS(state_path), "%s/authoritative.state", directory); + snprintf((state_path), sizeof((state_path)), "%s/authoritative.state", directory); struct stat metadata; ck_assert_int_eq(stat(state_path, &metadata), 0); char *state_text = malloc((size_t)metadata.st_size + 1); @@ -2519,7 +2519,7 @@ static void check_abrupt_semantic_operation_recovery(crash_operation_t operation ck_assert_int_eq(shop_get_money(reloaded), checkpoint_after ? 103 : 100); if (checkpoint_after && fail_terminal) { char expected[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(expected), "currency:%s", intent_transaction); + snprintf((expected), sizeof((expected)), "currency:%s", intent_transaction); bool found = false; FOR_INV_PREPARE(reloaded, coin) { if (coin->type == MONEY && coin->custody_lineage != NULL && @@ -2541,7 +2541,7 @@ static void check_abrupt_semantic_operation_recovery(crash_operation_t operation if (coins != NULL && fail_terminal) { ck_assert_ptr_ne(coins->custody_lineage, NULL); char expected[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(expected), "currency:%s", intent_transaction); + snprintf((expected), sizeof((expected)), "currency:%s", intent_transaction); ck_assert_str_eq(coins->custody_lineage, expected); } else if (coins != NULL) { ck_assert_ptr_eq(coins->custody_lineage, NULL); @@ -2558,7 +2558,7 @@ static void check_abrupt_semantic_operation_recovery(crash_operation_t operation if (coins != NULL && fail_terminal) { ck_assert_ptr_ne(coins->custody_lineage, NULL); char expected[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(expected), "currency:%s", intent_transaction); + snprintf((expected), sizeof((expected)), "currency:%s", intent_transaction); ck_assert_str_eq(coins->custody_lineage, expected); } else if (coins != NULL) { ck_assert_ptr_eq(coins->custody_lineage, NULL); @@ -2570,7 +2570,7 @@ static void check_abrupt_semantic_operation_recovery(crash_operation_t operation free(state_text); ck_assert_int_eq(unlink(state_path), 0); if (operation == CRASH_ITEM_ACQUIRE || operation == CRASH_ITEM_DROP) { - snprintf(VS(state_path), "%s/ground.state", directory); + snprintf((state_path), sizeof((state_path)), "%s/ground.state", directory); ck_assert_int_eq(stat(state_path, &metadata), 0); bool ground_expected = operation == CRASH_ITEM_ACQUIRE ? !checkpoint_after : checkpoint_after; diff --git a/server/src/tests/unit/server/light.c b/server/src/tests/unit/server/light.c index b4c17d97d..8352d2c06 100644 --- a/server/src/tests/unit/server/light.c +++ b/server/src/tests/unit/server/light.c @@ -1261,7 +1261,7 @@ START_TEST(test_saved_dense_map_teardown_does_not_rebuild_light_per_object) { mapstruct *map = get_empty_map(WIDTH, HEIGHT); maps[map_index] = map; char label[32]; - snprintf(VS(label), "dense-%d", map_index); + snprintf((label), sizeof((label)), "dense-%d", map_index); assign_temporary_unique_path(map, paths[map_index], sizeof(paths[map_index]), label); map->in_memory = MAP_LOADING; @@ -1394,7 +1394,7 @@ START_TEST(test_failed_map_save_preserves_live_objects_state_and_light) { mapstruct *map = get_empty_map(9, 9); FREE_AND_COPY_HASH(map->path, "/tests/light-save-failure"); char invalid_path[HUGE_BUF]; - int written = snprintf(VS(invalid_path), "%s/missing/runtime.map", directory); + int written = snprintf((invalid_path), sizeof((invalid_path)), "%s/missing/runtime.map", directory); ck_assert_int_ge(written, 0); ck_assert_uint_lt((size_t)written, sizeof(invalid_path)); map->tmpname = xstrdup(invalid_path); diff --git a/server/src/tests/unit/server/metrics.c b/server/src/tests/unit/server/metrics.c index 2dbc63523..f4175e9f0 100644 --- a/server/src/tests/unit/server/metrics.c +++ b/server/src/tests/unit/server/metrics.c @@ -178,7 +178,7 @@ START_TEST(test_unique_collections_validate_deduplicate_and_bound) { for (size_t i = 1; i < METRICS_UNIQUE_DEFAULT_LIMIT; i++) { char id[32]; - snprintf(VS(id), "map/%" PRIuMAX, (uintmax_t)i); + snprintf((id), sizeof((id)), "map/%" PRIuMAX, (uintmax_t)i); ck_assert(metrics_mark_unique(&store, METRIC_COLLECTION_CHARACTER_REGIONS_VISITED, id)); } ck_assert_uint_eq(metrics_unique_count(&store, METRIC_COLLECTION_CHARACTER_REGIONS_VISITED), @@ -221,7 +221,7 @@ START_TEST(test_keyed_metrics_validate_kinds_sort_bound_and_saturate) { for (size_t i = 2; i < METRICS_KEYED_DEFAULT_LIMIT; i++) { char id[32]; - snprintf(VS(id), "quest/%" PRIuMAX, (uintmax_t)i); + snprintf((id), sizeof((id)), "quest/%" PRIuMAX, (uintmax_t)i); ck_assert(metrics_keyed_add(&store, METRIC_KEYED_CHARACTER_QUEST_COMPLETIONS, id, 1)); } ck_assert_uint_eq(metrics_keyed_count(&store, METRIC_KEYED_CHARACTER_QUEST_COMPLETIONS), @@ -239,7 +239,7 @@ START_TEST(test_keyed_metrics_validate_kinds_sort_bound_and_saturate) { METRICS_CONTENT_ID_LIMIT); for (size_t i = 1; i < METRICS_CONTENT_ID_LIMIT; i++) { char name[32]; - snprintf(VS(name), "variant_%" PRIuMAX, (uintmax_t)i); + snprintf((name), sizeof((name)), "variant_%" PRIuMAX, (uintmax_t)i); ck_assert(metrics_format_named_monster_id(VS(named_id), "lost_soul", name)); ck_assert( metrics_keyed_add(&store, METRIC_KEYED_CHARACTER_MONSTER_KILLS_BY_NAME, named_id, 1)); @@ -260,7 +260,7 @@ START_TEST(test_content_by_name_updates_are_best_effort_at_capacity) { for (size_t i = 0; i < METRICS_UNIQUE_DEFAULT_LIMIT; i++) { char id[32]; - snprintf(VS(id), "region:test_%" PRIuMAX, (uintmax_t)i); + snprintf((id), sizeof((id)), "region:test_%" PRIuMAX, (uintmax_t)i); ck_assert( metrics_mark_unique(&pl.metrics, METRIC_COLLECTION_CHARACTER_REGIONS_VISITED, id)); } @@ -273,7 +273,7 @@ START_TEST(test_content_by_name_updates_are_best_effort_at_capacity) { for (size_t i = 0; i < METRICS_KEYED_DEFAULT_LIMIT; i++) { char id[32]; - snprintf(VS(id), "quest:test_%" PRIuMAX, (uintmax_t)i); + snprintf((id), sizeof((id)), "quest:test_%" PRIuMAX, (uintmax_t)i); ck_assert(metrics_keyed_add(&pl.metrics, METRIC_KEYED_CHARACTER_QUEST_COMPLETIONS, id, 1)); } ck_assert(metrics_character_keyed_add_by_name(&pl, @@ -306,7 +306,7 @@ START_TEST(test_serialization_round_trip_unknown_ids_and_malformed_preservation) char directory[] = "/tmp/atrinik-metrics-XXXXXX"; ck_assert_ptr_ne(mkdtemp(directory), NULL); char path[MAX_BUF]; - snprintf(VS(path), "%s/metrics.dat", directory); + snprintf((path), sizeof((path)), "%s/metrics.dat", directory); metric_store_t original, loaded; metrics_store_init(&original, METRIC_SCOPE_CHARACTER, 1234); diff --git a/server/src/tests/unit/server/random_map_style.c b/server/src/tests/unit/server/random_map_style.c index ad18aaed3..2ee383860 100644 --- a/server/src/tests/unit/server/random_map_style.c +++ b/server/src/tests/unit/server/random_map_style.c @@ -19,7 +19,7 @@ static char captured_log[HUGE_BUF]; static void capture_log(const char *message) { - snprintf(VS(captured_log), "%s", message); + snprintf((captured_log), sizeof((captured_log)), "%s", message); } static void write_style_map(const char *path) { @@ -45,9 +45,9 @@ START_TEST(test_directory_only_selection_stays_within_child) { char styles_dir[sizeof(directory) + sizeof("/styles")]; char child[sizeof(styles_dir) + sizeof("/child")]; char style_map_path[sizeof(child) + sizeof("/style_1")]; - snprintf(VS(styles_dir), "%s/styles", directory); - snprintf(VS(child), "%s/child", styles_dir); - snprintf(VS(style_map_path), "%s/style_1", child); + snprintf((styles_dir), sizeof((styles_dir)), "%s/styles", directory); + snprintf((child), sizeof((child)), "%s/child", styles_dir); + snprintf((style_map_path), sizeof((style_map_path)), "%s/style_1", child); ck_assert_int_eq(mkdir(styles_dir, 0700), 0); ck_assert_int_eq(mkdir(child, 0700), 0); write_style_map(style_map_path); @@ -59,7 +59,7 @@ START_TEST(test_directory_only_selection_stays_within_child) { free(namelist[0]); free(namelist); - snprintf(VS(settings.mapspath), "%s", directory); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%s", directory); for (uint64_t seed = 0; seed < 64; seed++) { rng_state_t rng; @@ -80,7 +80,7 @@ END_TEST START_TEST(test_missing_style_fails_closed_with_path_diagnostic) { char directory[] = "/tmp/atrinik-random-map-style-XXXXXX"; ck_assert_ptr_ne(mkdtemp(directory), NULL); - snprintf(VS(settings.mapspath), "%s", directory); + snprintf((settings.mapspath), sizeof((settings.mapspath)), "%s", directory); captured_log[0] = '\0'; logger_set_print_func(capture_log); diff --git a/server/src/tests/unit/server/region.c b/server/src/tests/unit/server/region.c index f93b19524..e8b808f01 100644 --- a/server/src/tests/unit/server/region.c +++ b/server/src/tests/unit/server/region.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2026 The Atrinik Project * + * Copyright 2026 The Atrinik Project * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -47,7 +47,7 @@ static bool load_fixture(const char *contents, char *error, size_t error_size) { char path[HUGE_BUF]; - snprintf(VS(path), "%s/regions-189-fixture.reg", settings.datapath); + snprintf((path), sizeof((path)), "%s/regions-189-fixture.reg", settings.datapath); FILE *fp = fopen(path, "wb"); ck_assert_ptr_nonnull(fp); ck_assert_int_ne(fputs(contents, fp), EOF); @@ -60,7 +60,7 @@ static bool load_fixture(const char *contents, char *error, size_t error_size) { static bool load_with_child(const char *child, char *error, size_t error_size) { char contents[HUGE_BUF * 2]; - int length = snprintf(VS(contents), "%s%s", ROOT_REGION, child); + int length = snprintf((contents), sizeof((contents)), "%s%s", ROOT_REGION, child); ck_assert_int_ge(length, 0); ck_assert_uint_lt((size_t)length, sizeof(contents)); return load_fixture(contents, error, error_size); @@ -519,7 +519,7 @@ START_TEST(test_reload_load_order_and_map_transitions_are_deterministic) { ck_assert_msg(load_fixture(fixture, VS(error)), "%s", error); const region_struct *child = region_find_by_name("child"); char digest[sizeof(child->celestial.digest_hex)]; - snprintf(VS(digest), "%s", child->celestial.digest_hex); + snprintf((digest), sizeof((digest)), "%s", child->celestial.digest_hex); region_celestial_phases_t before; todtick = ULONG_MAX; region_celestial_phases(&child->celestial, todtick, &before); diff --git a/server/src/tests/unit/server/request.c b/server/src/tests/unit/server/request.c index ce704fd8f..a5cff922b 100644 --- a/server/src/tests/unit/server/request.c +++ b/server/src/tests/unit/server/request.c @@ -152,7 +152,7 @@ START_TEST(test_invalid_access_attempts_do_not_lock_out_valid_codes) { memcpy(previous_store, settings.access_store, sizeof(previous_store)); bool previous_required = settings.access_required; bool previous_initialize = settings.access_initialize; - snprintf(VS(settings.access_store), "%s", directory); + snprintf((settings.access_store), sizeof((settings.access_store)), "%s", directory); settings.access_required = true; settings.access_initialize = false; access_server_route_for_test(request_access_route); @@ -172,7 +172,7 @@ START_TEST(test_invalid_access_attempts_do_not_lock_out_valid_codes) { settings.access_required = previous_required; settings.access_initialize = previous_initialize; char snapshot[256]; - snprintf(VS(snapshot), "%s/%s", directory, ACCESS_STORE_FILENAME); + snprintf((snapshot), sizeof((snapshot)), "%s/%s", directory, ACCESS_STORE_FILENAME); ck_assert_int_eq(unlink(snapshot), 0); ck_assert_int_eq(rmdir(directory), 0); } @@ -366,7 +366,7 @@ START_TEST(test_access_completed_auth_reservations_cancel_and_shutdown) { memcpy(previous_store, settings.access_store, sizeof(previous_store)); bool previous_required = settings.access_required; bool previous_initialize = settings.access_initialize; - snprintf(VS(settings.access_store), "%s", directory); + snprintf((settings.access_store), sizeof((settings.access_store)), "%s", directory); settings.access_required = true; settings.access_initialize = false; ck_assert(access_server_init(identity_hex)); @@ -384,7 +384,7 @@ START_TEST(test_access_completed_auth_reservations_cancel_and_shutdown) { settings.access_required = previous_required; settings.access_initialize = previous_initialize; char snapshot[256]; - snprintf(VS(snapshot), "%s/%s", directory, ACCESS_STORE_FILENAME); + snprintf((snapshot), sizeof((snapshot)), "%s/%s", directory, ACCESS_STORE_FILENAME); ck_assert_int_eq(unlink(snapshot), 0); ck_assert_int_eq(rmdir(directory), 0); } @@ -2709,7 +2709,7 @@ START_TEST(test_region_exploration_grants_only_visible_public_base_cells) { cs->account = xstrdup("exploregrant"); char *account_path = account_make_path(cs->account); char path[HUGE_BUF]; - snprintf(VS(path), "%s.exploration", account_path); + snprintf((path), sizeof((path)), "%s.exploration", account_path); free(account_path); unlink(path); exploration_begin(cs); diff --git a/server/src/tests/unit/server/swap.c b/server/src/tests/unit/server/swap.c index c6b3be723..c906f635a 100644 --- a/server/src/tests/unit/server/swap.c +++ b/server/src/tests/unit/server/swap.c @@ -20,7 +20,7 @@ static mapstruct *expiring_map(const char *directory, const char *name) { mapstruct *map = get_empty_map(2, 2); char path[HUGE_BUF]; - int written = snprintf(VS(path), "%s/%s", directory, name); + int written = snprintf((path), sizeof((path)), "%s/%s", directory, name); ck_assert_int_ge(written, 0); ck_assert_uint_lt((size_t)written, sizeof(path)); FREE_AND_COPY_HASH(map->path, path); @@ -32,7 +32,7 @@ static mapstruct *expiring_map(const char *directory, const char *name) { static void discard_map(mapstruct *map) { char path[HUGE_BUF]; - snprintf(VS(path), "%s", map->path); + snprintf((path), sizeof((path)), "%s", map->path); delete_map(map); if (access(path, F_OK) == 0) { ck_assert_int_eq(unlink(path), 0); @@ -45,7 +45,7 @@ START_TEST(test_expiry_is_bounded_and_fifo) { mapstruct *maps[4]; for (size_t i = 0; i < arraysize(maps); i++) { char name[32]; - snprintf(VS(name), "map-%zu", i); + snprintf((name), sizeof((name)), "map-%zu", i); maps[i] = expiring_map(directory, name); } /* A negative explicit timeout is already expired, without underflow. */ @@ -226,9 +226,9 @@ START_TEST(test_expiry_pending_delete_unload_and_shutdown_flush) { ck_assert_int_eq(rmdir(first->path), 0); char previous_datapath[MAX_BUF]; - snprintf(VS(previous_datapath), "%s", settings.datapath); + snprintf((previous_datapath), sizeof((previous_datapath)), "%s", settings.datapath); bool previous_recycle = settings.recycle_tmp_maps; - snprintf(VS(settings.datapath), "%s", directory); + snprintf((settings.datapath), sizeof((settings.datapath)), "%s", directory); settings.recycle_tmp_maps = true; clean_tmp_files(); ck_assert_uint_eq(first->in_memory, MAP_SWAPPED); @@ -237,11 +237,11 @@ START_TEST(test_expiry_pending_delete_unload_and_shutdown_flush) { ck_assert_uint_eq(waiting_second->in_memory, MAP_SWAPPED); ck_assert_int_eq(access(waiting->path, F_OK), 0); ck_assert_int_eq(access(waiting_second->path, F_OK), 0); - snprintf(VS(settings.datapath), "%s", previous_datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%s", previous_datapath); settings.recycle_tmp_maps = previous_recycle; char clock_path[HUGE_BUF], log_path[HUGE_BUF]; - snprintf(VS(clock_path), "%s/clockdata", directory); - snprintf(VS(log_path), "%s/temp.maps", directory); + snprintf((clock_path), sizeof((clock_path)), "%s/clockdata", directory); + snprintf((log_path), sizeof((log_path)), "%s/temp.maps", directory); ck_assert_int_eq(unlink(clock_path), 0); ck_assert_int_eq(unlink(log_path), 0); diff --git a/server/src/tests/unit/server/todclock.c b/server/src/tests/unit/server/todclock.c index a0e5f6460..ec047a251 100644 --- a/server/src/tests/unit/server/todclock.c +++ b/server/src/tests/unit/server/todclock.c @@ -57,9 +57,9 @@ static void assert_time_lunar_output(object *pl, command_time(pl, "time", NULL); char expected[HUGE_BUF]; - snprintf(VS(expected), "The current moon phase is %s.", phase); + snprintf((expected), sizeof((expected)), "The current moon phase is %s.", phase); ck_assert(queued_drawinfo_has(CONTR(pl)->cs, expected)); - snprintf(VS(expected), + snprintf((expected), sizeof((expected)), "Moon visibility: %s; moonlight contribution: %s.", visibility, contribution); @@ -236,7 +236,7 @@ END_TEST START_TEST(test_parse_rejects_overflow) { char overflow[64]; unsigned long value = 17; - snprintf(VS(overflow), "%lu0", ULONG_MAX); + snprintf((overflow), sizeof((overflow)), "%lu0", ULONG_MAX); ck_assert(!todclock_parse(overflow, &value)); ck_assert_uint_eq(value, 17); @@ -245,7 +245,7 @@ END_TEST START_TEST(test_write_replaces_clockdata_with_complete_value) { char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/clockdata", settings.datapath); + snprintf((filename), sizeof((filename)), "%s/clockdata", settings.datapath); const char previous[] = "18446744073709551615 trailing evidence"; ck_assert(path_write_atomic(filename, previous, sizeof(previous) - 1, SAVE_MODE)); @@ -261,7 +261,7 @@ END_TEST START_TEST(test_set_persists_clockdata) { char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/clockdata", settings.datapath); + snprintf((filename), sizeof((filename)), "%s/clockdata", settings.datapath); todtick = 17; ck_assert(todclock_set(23)); @@ -276,7 +276,7 @@ END_TEST START_TEST(test_persisted_tick_reconstructs_without_rewrite) { char filename[HUGE_BUF]; - snprintf(VS(filename), "%s/clockdata", settings.datapath); + snprintf((filename), sizeof((filename)), "%s/clockdata", settings.datapath); const unsigned long original = (12345UL * HOURS_PER_YEAR) + (7 * HOURS_PER_MONTH) + (19 * HOURS_PER_DAY) + 13; @@ -378,11 +378,11 @@ START_TEST(test_checked_shutdown_clock_and_maplog_failures) { char directory[] = "/tmp/atrinik-shutdown-metadata-XXXXXX"; ck_assert_ptr_nonnull(mkdtemp(directory)); char previous_datapath[MAX_BUF]; - snprintf(VS(previous_datapath), "%s", settings.datapath); - snprintf(VS(settings.datapath), "%s", directory); + snprintf((previous_datapath), sizeof((previous_datapath)), "%s", settings.datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%s", directory); char clock_path[HUGE_BUF], maplog_path[HUGE_BUF]; - snprintf(VS(clock_path), "%s/clockdata", directory); - snprintf(VS(maplog_path), "%s/temp.maps", directory); + snprintf((clock_path), sizeof((clock_path)), "%s/clockdata", directory); + snprintf((maplog_path), sizeof((maplog_path)), "%s/temp.maps", directory); ck_assert(write_todclock_checked()); ck_assert(write_map_log_checked()); ck_assert_int_eq(unlink(clock_path), 0); @@ -394,7 +394,7 @@ START_TEST(test_checked_shutdown_clock_and_maplog_failures) { ck_assert(!write_map_log_checked()); ck_assert_int_eq(rmdir(clock_path), 0); ck_assert_int_eq(rmdir(maplog_path), 0); - snprintf(VS(settings.datapath), "%s", previous_datapath); + snprintf((settings.datapath), sizeof((settings.datapath)), "%s", previous_datapath); ck_assert_int_eq(rmdir(directory), 0); } END_TEST diff --git a/server/src/tests/unit/server/transport_benchmark.c b/server/src/tests/unit/server/transport_benchmark.c index e3656db30..4e555b6b0 100644 --- a/server/src/tests/unit/server/transport_benchmark.c +++ b/server/src/tests/unit/server/transport_benchmark.c @@ -364,12 +364,12 @@ START_TEST(test_deadline_driven_quic_service_benchmark) { toolkit_import(socket_server); ck_assert(socket_server_quic_info(VS(host), &port, fingerprint)); if (host[0] == '\0') { - snprintf(VS(host), "%s", "127.0.0.1"); + snprintf((host), sizeof((host)), "%s", "127.0.0.1"); } for (size_t i = 0; i < arraysize(clients); i++) { - snprintf(VS(clients[i].host), "%s", host); + snprintf((clients[i].host), sizeof((clients[i].host)), "%s", host); clients[i].port = port; - snprintf(VS(clients[i].fingerprint), "%s", fingerprint); + snprintf((clients[i].fingerprint), sizeof((clients[i].fingerprint)), "%s", fingerprint); } bool connected = transport_benchmark_connect_clients( @@ -778,7 +778,7 @@ static access_outcome_t transport_access_route(void *context, const access_route /* Replace only this forked runner's optional access service with an isolated * protected store, bound to the real QUIC listener certificate. */ static access_result_t transport_access_setup(const char fingerprint[65]) { - snprintf(VS(transport_access_directory), "/tmp/atrinik-transport-access-XXXXXX"); + snprintf((transport_access_directory), sizeof((transport_access_directory)), "/tmp/atrinik-transport-access-XXXXXX"); ck_assert_ptr_nonnull(mkdtemp(transport_access_directory)); uint8_t identity[32]; for (size_t i = 0; i < sizeof(identity); i++) { @@ -797,13 +797,13 @@ static access_result_t transport_access_setup(const char fingerprint[65]) { memcpy(transport_access_saved_store, settings.access_store, sizeof(settings.access_store)); transport_access_saved_required = settings.access_required; transport_access_saved_initialize = settings.access_initialize; - snprintf(VS(transport_access_identity), "%s", fingerprint); + snprintf((transport_access_identity), sizeof((transport_access_identity)), "%s", fingerprint); ck_assert(access_server_shutdown()); access_server_deinit(); transport_access_owned = true; settings.access_required = true; settings.access_initialize = false; - snprintf(VS(settings.access_store), "%s", transport_access_directory); + snprintf((settings.access_store), sizeof((settings.access_store)), "%s", transport_access_directory); access_server_route_for_test(transport_access_route); ck_assert(access_server_init(fingerprint)); return issue; @@ -820,7 +820,7 @@ static void transport_auth_teardown(void) { settings.access_required = transport_access_saved_required; settings.access_initialize = transport_access_saved_initialize; char snapshot[256]; - snprintf(VS(snapshot), "%s/%s", transport_access_directory, ACCESS_STORE_FILENAME); + snprintf((snapshot), sizeof((snapshot)), "%s/%s", transport_access_directory, ACCESS_STORE_FILENAME); ck_assert_int_eq(unlink(snapshot), 0); ck_assert_int_eq(rmdir(transport_access_directory), 0); transport_access_owned = false; @@ -850,9 +850,9 @@ START_TEST(test_quic_login_and_movement_continue_while_authentication_pending) { ck_assert(socket_server_quic_info(VS(host), &port, fingerprint)); access_result_t admission = transport_access_setup(fingerprint); for (size_t i = 0; i < arraysize(clients); i++) { - snprintf(VS(clients[i].host), "%s", *host != '\0' ? host : "127.0.0.1"); + snprintf((clients[i].host), sizeof((clients[i].host)), "%s", *host != '\0' ? host : "127.0.0.1"); clients[i].port = port; - snprintf(VS(clients[i].fingerprint), "%s", fingerprint); + snprintf((clients[i].fingerprint), sizeof((clients[i].fingerprint)), "%s", fingerprint); } ck_assert(transport_benchmark_connect_clients(clients, arraysize(clients), &simulation_passes)); const uint8_t version[] = {SERVER_CMD_VERSION, (uint8_t)(SOCKET_VERSION >> 24), @@ -1068,7 +1068,7 @@ START_TEST(test_strakewood_idle_simulation) { mapstruct *route_maps[WALKING_ROUTE_MAPS] = {NULL}; for (int m = 0; m < WALKING_ROUTE_MAPS; m++) { char path[MAX_BUF]; - snprintf(VS(path), "/shattered_islands/world_%d_%d", m % 4, m / 4 + 66); + snprintf((path), sizeof((path)), "/shattered_islands/world_%d_%d", m % 4, m / 4 + 66); strakewood_progress(0, path); uint64_t load_started_us = datetime_monotonic_us(); route_maps[m] = ready_map_name(path, NULL, 0); @@ -1192,7 +1192,7 @@ START_TEST(test_strakewood_idle_simulation) { ck_assert_uint_lt(point.direction, arraysize(compass)); ck_assert_uint_gt(compass[point.direction], 0); char expected_path[MAX_BUF]; - snprintf(VS(expected_path), "/shattered_islands/world_%d_%d", + snprintf((expected_path), sizeof((expected_path)), "/shattered_islands/world_%d_%d", point.map % 4, point.map / 4 + 66); strakewood_progress(tick, "route-movement"); started_us = datetime_monotonic_us(); @@ -1216,7 +1216,7 @@ START_TEST(test_strakewood_idle_simulation) { } else if (actions && tick % 128 == 0) { strakewood_progress(tick, "settime"); char hour[16]; - snprintf(VS(hour), "%u", (tick / 128) % HOURS_PER_DAY); + snprintf((hour), sizeof((hour)), "%u", (tick / 128) % HOURS_PER_DAY); command_settime(pl, "settime", hour); strakewood_progress(tick, "movement"); started_us = datetime_monotonic_us(); @@ -1239,7 +1239,7 @@ START_TEST(test_strakewood_idle_simulation) { if (route_mode) { walking_route_point point = route_points[tick + 1U]; char expected_path[MAX_BUF]; - snprintf(VS(expected_path), "/shattered_islands/world_%d_%d", + snprintf((expected_path), sizeof((expected_path)), "/shattered_islands/world_%d_%d", point.map % 4, point.map / 4 + 66); ck_assert_msg(strcmp(pl->map->path, expected_path) == 0 && pl->x == point.x && pl->y == point.y, diff --git a/server/src/tests/unit/socket/item.c b/server/src/tests/unit/socket/item.c index 8f87c706a..449695e42 100644 --- a/server/src/tests/unit/socket/item.c +++ b/server/src/tests/unit/socket/item.c @@ -445,7 +445,7 @@ START_TEST(test_player_status_capacity_resnapshots_membership_changes) { SET_FLAG(effect, FLAG_APPLIED); if (i % 2U == 0U) { char key[ATRINIK_PLAYER_STATUS_KEY_SIZE + 1U]; - snprintf(VS(key), "capacity:%zu", i); + snprintf((key), sizeof((key)), "capacity:%zu", i); ck_assert(player_status_set(effect, key, "capacity", "Capacity test.", effect->face)); } effect = object_insert_into(effect, pl, INS_NO_MERGE); @@ -851,7 +851,7 @@ START_TEST(test_player_status_word_of_recall_cancel) { socket_buffer_clear(CONTR(pl)->cs); ck_assert_ptr_nonnull(spells[SP_WOR].at); - snprintf(VS(CONTR(pl)->savebed_map), "%s", "/emergency"); + snprintf((CONTR(pl)->savebed_map), sizeof((CONTR(pl)->savebed_map)), "%s", "/emergency"); ck_assert_int_eq(cast_wor(pl, pl), 1); object *recall = NULL; FOR_INV_PREPARE(pl, tmp) { diff --git a/server/src/tests/unit/toolkit/socket_asset.c b/server/src/tests/unit/toolkit/socket_asset.c index 4132bafb9..bb133a616 100644 --- a/server/src/tests/unit/toolkit/socket_asset.c +++ b/server/src/tests/unit/toolkit/socket_asset.c @@ -1261,7 +1261,7 @@ END_TEST START_TEST(test_metaserver_rendezvous_token_bounds) { static const char token[] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; char response[128]; - int length = snprintf(VS(response), "{\"status\":\"ok\",\"rendezvousToken\":\"%s\"}", token); + int length = snprintf((response), sizeof((response)), "{\"status\":\"ok\",\"rendezvousToken\":\"%s\"}", token); ck_assert_int_gt(length, 0); ck_assert_int_lt(length, (int)sizeof(response)); @@ -1340,7 +1340,7 @@ START_TEST(test_metaserver_access_route_acknowledgments) { 100), ACCESS_CONFLICT); char changed[1025]; - snprintf(VS(changed), "%s ", reply); + snprintf((changed), sizeof((changed)), "%s ", reply); ck_assert_int_eq(metaserver_access_response_parse(changed, strlen(changed), request, @@ -1688,7 +1688,7 @@ START_TEST(test_access_maintenance_rotates_and_services_queued_jobs) { char previous_store[sizeof(settings.access_store)]; memcpy(previous_store, settings.access_store, sizeof(previous_store)); bool previous_required = settings.access_required, previous_initialize = settings.access_initialize; - snprintf(VS(settings.access_store), "%s", directory); + snprintf((settings.access_store), sizeof((settings.access_store)), "%s", directory); settings.access_required = true; settings.access_initialize = false; atomic_store(&maintenance_calls, 0); @@ -1732,7 +1732,7 @@ START_TEST(test_access_maintenance_rotates_and_services_queued_jobs) { ACCESS_COMMITTED); access_store_close(store); char snapshot[256]; - snprintf(VS(snapshot), "%s/access-tokens.snapshot", directory); + snprintf((snapshot), sizeof((snapshot)), "%s/access-tokens.snapshot", directory); ck_assert_int_eq(unlink(snapshot), 0); ck_assert_int_eq(rmdir(directory), 0); } @@ -1767,7 +1767,7 @@ START_TEST(test_access_worker_shutdown_preserves_pending_route) { memcpy(previous_store, settings.access_store, sizeof(previous_store)); bool previous_required = settings.access_required; bool previous_initialize = settings.access_initialize; - snprintf(VS(settings.access_store), "%s", directory); + snprintf((settings.access_store), sizeof((settings.access_store)), "%s", directory); settings.access_required = true; settings.access_initialize = false; access_server_route_for_test(access_shutdown_route); @@ -1819,7 +1819,7 @@ START_TEST(test_access_worker_shutdown_preserves_pending_route) { access_result_cleanse(&receipt); access_store_close(store); char snapshot[256]; - snprintf(VS(snapshot), "%s/access-tokens.snapshot", directory); + snprintf((snapshot), sizeof((snapshot)), "%s/access-tokens.snapshot", directory); ck_assert_int_eq(unlink(snapshot), 0); ck_assert_int_eq(rmdir(directory), 0); } @@ -1852,10 +1852,10 @@ START_TEST(test_metaserver_private_presence_renews_without_activity) { player joined = {0}; char previous_name[sizeof(settings.server_name)]; memcpy(previous_name, settings.server_name, sizeof(previous_name)); - snprintf(VS(settings.server_name), "%s", "Private presence test"); + snprintf((settings.server_name), sizeof((settings.server_name)), "%s", "Private presence test"); settings.server_public = false; settings.access_required = true; - snprintf(VS(settings.metaserver_hostname), "%s", "private.example.invalid"); + snprintf((settings.metaserver_hostname), sizeof((settings.metaserver_hostname)), "%s", "private.example.invalid"); settings.port_quic = 13327; first_player = NULL; @@ -2207,7 +2207,7 @@ START_TEST(test_metaserver_rendezvous_ticket_isolation) { metaserver_rendezvous_auth_job_t full_jobs[METASERVER_RENDEZVOUS_AUTH_JOBS_MAX] = {0}; char generated_ticket[RENDEZVOUS_TICKET_HEX_SIZE + 1U]; for (size_t i = 0; i < arraysize(full_jobs); i++) { - ck_assert_int_eq(snprintf(VS(generated_ticket), "%064zx", i), RENDEZVOUS_TICKET_HEX_SIZE); + ck_assert_int_eq(snprintf((generated_ticket), sizeof((generated_ticket)), "%064zx", i), RENDEZVOUS_TICKET_HEX_SIZE); ck_assert_int_eq(metaserver_rendezvous_auth_claim(full_jobs, arraysize(full_jobs), generated_ticket, @@ -2215,7 +2215,7 @@ START_TEST(test_metaserver_rendezvous_ticket_isolation) { &claimed), METASERVER_RENDEZVOUS_AUTH_CLAIM_OK); } - ck_assert_int_eq(snprintf(VS(generated_ticket), "%064x", METASERVER_RENDEZVOUS_AUTH_JOBS_MAX), + ck_assert_int_eq(snprintf((generated_ticket), sizeof((generated_ticket)), "%064x", METASERVER_RENDEZVOUS_AUTH_JOBS_MAX), RENDEZVOUS_TICKET_HEX_SIZE); ck_assert_int_eq(metaserver_rendezvous_auth_claim(full_jobs, arraysize(full_jobs), @@ -2313,7 +2313,7 @@ START_TEST(test_path_secret_reader) { #if !defined(WIN32) && defined(O_NOFOLLOW) char link_path[sizeof(path) + 8]; - snprintf(VS(link_path), "%s.link", path); + snprintf((link_path), sizeof((link_path)), "%s.link", path); ck_assert_int_eq(symlink(path, link_path), 0); ck_assert_int_eq(path_read_secret(link_path, VS(secret), NULL), PATH_SECRET_UNSAFE_LINK); ck_assert_int_eq(unlink(link_path), 0); @@ -2413,7 +2413,7 @@ START_TEST(test_socket_rendezvous_messages) { char parsed_ticket[65]; ck_assert(socket_rendezvous_attempt_auth_init(attempt, VS(message))); ck_assert_ptr_nonnull(strstr(message, "access_init")); - snprintf(VS(message), "{\"type\":\"access_ready\",\"version\":1}"); + snprintf((message), sizeof((message)), "{\"type\":\"access_ready\",\"version\":1}"); rendezvous_server_auth_state_t server_auth = RENDEZVOUS_SERVER_AUTH_AUTHORIZED; ck_assert_int_eq(socket_rendezvous_attempt_auth_result(attempt, message, strlen(message)), SOCKET_RENDEZVOUS_FRAME_AUTHORIZED); diff --git a/server/src/tests/unit/toolkit/string.c b/server/src/tests/unit/toolkit/string.c index 014f1f23b..c5836e5ed 100644 --- a/server/src/tests/unit/toolkit/string.c +++ b/server/src/tests/unit/toolkit/string.c @@ -402,7 +402,7 @@ START_TEST(test_string_get_word) { free(cp); pos = 0; - snprintf(VS(word), "not empty"); + snprintf((word), sizeof((word)), "not empty"); ck_assert_ptr_eq(string_get_word(NULL, &pos, ' ', word, sizeof(word), 0), NULL); ck_assert_str_eq(word, ""); ck_assert_uint_eq(pos, 0); diff --git a/server/src/types/disease.c b/server/src/types/disease.c index f04f9dbcc..4a227ec14 100644 --- a/server/src/types/disease.c +++ b/server/src/types/disease.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -439,9 +439,9 @@ bool disease_infect(object *op, object *victim, bool force) { /* if the disease has a title, it has a special infection message */ if (new_disease->title != NULL) { - snprintf(VS(buf), "%s %s!!", op->title, victim->name); + snprintf((buf), sizeof((buf)), "%s %s!!", op->title, victim->name); } else { - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "You infect %s with your disease, %s!", victim->name, new_disease->name); diff --git a/server/src/types/monster/monster_guard.c b/server/src/types/monster/monster_guard.c index d77cf22f7..881a6c53d 100644 --- a/server/src/types/monster/monster_guard.c +++ b/server/src/types/monster/monster_guard.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -74,12 +74,12 @@ void monster_guard_activate_gate(object *op, int state) { char buf[HUGE_BUF]; if (state == 1) { - snprintf(VS(buf), + snprintf((buf), sizeof((buf)), "%s shouts:\nThe gate, get the gate! Let no one " "leave or enter until this matter is resolved!", op->name); } else { - snprintf(VS(buf), "%s shouts:\nAll clear, open the gate!", op->name); + snprintf((buf), sizeof((buf)), "%s shouts:\nAll clear, open the gate!", op->name); } draw_info_map(CHAT_TYPE_GAME, diff --git a/server/src/types/player.c b/server/src/types/player.c index 66a3a6df0..95b3b72b4 100644 --- a/server/src/types/player.c +++ b/server/src/types/player.c @@ -940,7 +940,7 @@ static void player_death_deplete_stats(object *op, const char *correlation) { } char subject[GAMEPLAY_JOURNAL_ID_MAX + 1]; - snprintf(VS(subject), "stat:%d", stat); + snprintf((subject), sizeof((subject)), "stat:%d", stat); char transaction[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]; if (!gameplay_journal_milestone_begin(op, GAMEPLAY_JOURNAL_PROGRESSION, @@ -1036,7 +1036,7 @@ void kill_player(object *op, bool pvp, bool environmental) { if (deaths >= INT64_MAX) { return; } - snprintf(VS(correlation), "survival-death:%" PRIu64, deaths + 1); + snprintf((correlation), sizeof((correlation)), "survival-death:%" PRIu64, deaths + 1); char death_transaction[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]; if (!survival_metric_milestone_begin(op, "survival.defeat-pvp", @@ -1121,7 +1121,7 @@ void kill_player(object *op, bool pvp, bool environmental) { if (lifesaves >= INT64_MAX) { return; } - snprintf(VS(correlation), "survival-lifesave:%" PRIu64, lifesaves + 1); + snprintf((correlation), sizeof((correlation)), "survival-lifesave:%" PRIu64, lifesaves + 1); char lifesave_transaction[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]; if (!survival_metric_milestone_begin(op, "survival.lifesave", @@ -1149,7 +1149,7 @@ void kill_player(object *op, bool pvp, bool environmental) { if (deaths >= INT64_MAX) { return; } - snprintf(VS(correlation), "survival-death:%" PRIu64, deaths + 1); + snprintf((correlation), sizeof((correlation)), "survival-death:%" PRIu64, deaths + 1); char death_transaction[GAMEPLAY_JOURNAL_TRANSACTION_ID_SIZE]; const char *death_reason = pvp ? "survival.death-pvp" : environmental ? "survival.death-environment" @@ -1173,7 +1173,7 @@ void kill_player(object *op, bool pvp, bool environmental) { /* Put a gravestone up where the character 'almost' died. */ tmp = arch_to_object(arch_find("gravestone")); - snprintf(VS(buf), "%s's gravestone", op->name); + snprintf((buf), sizeof((buf)), "%s's gravestone", op->name); FREE_AND_COPY_HASH(tmp->name, buf); StringBuffer *sb = stringbuffer_new(); const char *killer = player_get_killer(CONTR(op)); @@ -3727,7 +3727,7 @@ static void player_create(player *pl, archetype_t *at, const char *name) { trigger_global_event(GEVENT_BORN, pl->ob, NULL); CLEAR_FLAG(pl->ob, FLAG_NO_FIX_PLAYER); - snprintf(VS(pl->maplevel), "%s", first_map_path); + snprintf((pl->maplevel), sizeof((pl->maplevel)), "%s", first_map_path); pl->ob->x = first_map_x; pl->ob->y = first_map_y; } @@ -3796,7 +3796,7 @@ bool player_provision_scenario(const char *name, return false; } - snprintf(VS(pl->savebed_map), "%s", map_path); + snprintf((pl->savebed_map), sizeof((pl->savebed_map)), "%s", map_path); pl->bed_x = x; pl->bed_y = y; if (item != NULL) { @@ -3890,7 +3890,7 @@ object *player_get_dummy(const char *name, const char *host) { FREE_AND_COPY_HASH(pl->ob->name, name); } - snprintf(VS(pl->savebed_map), "%s", EMERGENCY_MAPPATH); + snprintf((pl->savebed_map), sizeof((pl->savebed_map)), "%s", EMERGENCY_MAPPATH); pl->bed_x = EMERGENCY_X; pl->bed_y = EMERGENCY_Y; @@ -4160,7 +4160,7 @@ void player_login(socket_struct *ns, const char *name, struct archetype *at) { "Player %s has no usable map or savebed; rewriting fallback to %s.", name, EMERGENCY_MAPPATH); - snprintf(VS(pl->savebed_map), "%s", EMERGENCY_MAPPATH); + snprintf((pl->savebed_map), sizeof((pl->savebed_map)), "%s", EMERGENCY_MAPPATH); pl->bed_x = EMERGENCY_X; pl->bed_y = EMERGENCY_Y; object_enter_map(pl->ob, NULL, NULL, EMERGENCY_X, EMERGENCY_Y, true); @@ -4168,7 +4168,7 @@ void player_login(socket_struct *ns, const char *name, struct archetype *at) { char bed_token[MAX_BUF]; if (MAP_UNIQUE(bed) && player_unique_token_from_path(pl, bed->path, bed_token)) { - snprintf(VS(pl->savebed_map), "%s", bed_token); + snprintf((pl->savebed_map), sizeof((pl->savebed_map)), "%s", bed_token); } object_enter_map(pl->ob, NULL, bed, pl->bed_x, pl->bed_y, true); } diff --git a/server/src/types/savebed.c b/server/src/types/savebed.c index ee775d221..31bc71c3f 100644 --- a/server/src/types/savebed.c +++ b/server/src/types/savebed.c @@ -58,9 +58,9 @@ static int apply_func(object *op, object *applier, int aflags) { int lineage_length; if (!gameplay_journal_map_identity(applier->map, map_identity) || !gameplay_journal_map_path_identity(CONTR(applier)->savebed_map, previous_identity) || - (subject_length = snprintf(VS(subject), "map:%s", map_identity)) < 0 || + (subject_length = snprintf((subject), sizeof((subject)), "map:%s", map_identity)) < 0 || (size_t)subject_length >= sizeof(subject) || - (lineage_length = snprintf(VS(lineage), + (lineage_length = snprintf((lineage), sizeof((lineage)), "previous-map:%s@%d+%d", previous_identity[0] != '\0' ? previous_identity : "unset", CONTR(applier)->bed_x, @@ -79,7 +79,7 @@ static int apply_func(object *op, object *applier, int aflags) { } /* Update respawn position. */ - snprintf(VS(CONTR(applier)->savebed_map), "%s", applier->map->path); + snprintf((CONTR(applier)->savebed_map), sizeof((CONTR(applier)->savebed_map)), "%s", applier->map->path); CONTR(applier)->bed_x = applier->x; CONTR(applier)->bed_y = applier->y; metrics_add(&CONTR(applier)->metrics, METRIC_CHARACTER_SAVEBEDS_BOUND, 1); From a87b26629336c4870a6d5740aede5e2a406a1bee Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:15:09 -0500 Subject: [PATCH 056/148] fix(build): select matching macOS dependency headers --- .../workflows/llvm-macos-qualification.yml | 5 + libatrinik/CMakeLists.txt | 9 +- .../test_macos_qualification_workflow.py | 4 + .../test_toolkit_dependency_interfaces.py | 91 +++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tools/tests/test_toolkit_dependency_interfaces.py diff --git a/.github/workflows/llvm-macos-qualification.yml b/.github/workflows/llvm-macos-qualification.yml index 6c6f0bfc9..1b88afc2c 100644 --- a/.github/workflows/llvm-macos-qualification.yml +++ b/.github/workflows/llvm-macos-qualification.yml @@ -114,6 +114,7 @@ jobs: set -euo pipefail prefix=$(brew --prefix) python_prefix=$(brew --prefix python@3.12) + readline_prefix=$(brew --prefix readline) export PKG_CONFIG_PATH="${prefix}/lib/pkgconfig" for formula in openssl@3 curl libxml2 zlib readline; do formula_prefix=$(brew --prefix "${formula}") @@ -128,6 +129,8 @@ jobs: "-DOPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" \ "-DCURL_ROOT=$(brew --prefix curl)" \ "-DPython3_EXECUTABLE=${python_prefix}/bin/python3.12" \ + "-DREADLINE_INCLUDE_DIR=${readline_prefix}/include" \ + "-DREADLINE_LIBRARY=${readline_prefix}/lib/libreadline.dylib" \ "-DATRINIK_GPU_SHADER_DIRECTORY=${GITHUB_WORKSPACE}/build/macos-qualification-shaders" \ 2>&1 | tee build/macos-qualification-evidence/configure.log - name: Verify required production features were retained @@ -151,6 +154,8 @@ jobs: raise SystemExit(f'required macOS build setting absent: {option}={value}') if not cache.get('READLINE_LIBRARY') or cache['READLINE_LIBRARY'].endswith('-NOTFOUND'): raise SystemExit('required toolkit dependency absent: READLINE_LIBRARY') + if not cache.get('READLINE_INCLUDE_DIR') or cache['READLINE_INCLUDE_DIR'].endswith('-NOTFOUND'): + raise SystemExit('required toolkit dependency absent: READLINE_INCLUDE_DIR') PY - name: Build the production client, shared libraries and test executables shell: bash diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index fc4197a2c..adfe212dc 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -196,8 +196,13 @@ else () endif () find_library(READLINE_LIBRARY readline) +find_path(READLINE_INCLUDE_DIR readline/readline.h) if (READLINE_LIBRARY) + if (NOT READLINE_INCLUDE_DIR OR + NOT EXISTS "${READLINE_INCLUDE_DIR}/readline/readline.h") + message(FATAL_ERROR "The selected Readline library requires matching Readline headers") + endif () set(HAVE_READLINE true) else () message(STATUS "Could not find readline library, simple interactive console will be compiled instead.") @@ -484,11 +489,13 @@ endif () # Link readline if available. if (HAVE_READLINE) + target_include_directories(${ATRINIK_TOOLKIT} PRIVATE ${READLINE_INCLUDE_DIR}) target_link_libraries(${ATRINIK_TOOLKIT} PRIVATE ${READLINE_LIBRARY}) endif () if (TARGET OpenSSL::SSL AND TARGET OpenSSL::Crypto) - target_link_libraries(${ATRINIK_TOOLKIT} PRIVATE OpenSSL::SSL OpenSSL::Crypto) + # metaserver_publisher.h exposes OpenSSL types to Core consumers. + target_link_libraries(${ATRINIK_TOOLKIT} PUBLIC OpenSSL::Crypto PRIVATE OpenSSL::SSL) else () target_link_libraries(${ATRINIK_TOOLKIT} PRIVATE ${OPENSSL_LIBRARIES}) endif () diff --git a/tools/tests/test_macos_qualification_workflow.py b/tools/tests/test_macos_qualification_workflow.py index 85a991d5a..92f6a8fd9 100644 --- a/tools/tests/test_macos_qualification_workflow.py +++ b/tools/tests/test_macos_qualification_workflow.py @@ -61,6 +61,7 @@ def test_incorrect_platform_scope_or_disabled_required_features_fail(self): "ATRINIK_BUILD_CLIENT": "ON", "ATRINIK_BUILD_SERVER": "OFF", "ENABLE_WARNING_ERRORS": "ON", "BUILD_TESTING": "ON", "CMAKE_BUILD_TYPE": "Release", "READLINE_LIBRARY": "/native/libreadline.dylib", + "READLINE_INCLUDE_DIR": "/native/include", } with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -74,6 +75,7 @@ def test_incorrect_platform_scope_or_disabled_required_features_fail(self): cache[disabled] = { "ATRINIK_BUILD_SERVER": "ON", "CMAKE_BUILD_TYPE": "Debug", "READLINE_LIBRARY": "READLINE_LIBRARY-NOTFOUND", + "READLINE_INCLUDE_DIR": "READLINE_INCLUDE_DIR-NOTFOUND", }.get(disabled, "OFF") (build / "CMakeCache.txt").write_text("".join( f"{key}:STRING={value}\n" for key, value in cache.items() @@ -124,6 +126,8 @@ def test_manual_lane_keeps_production_features_and_gpu_boundary(self): self.assertNotIn("server-plugin-python", text) self.assertNotIn("complete Classic Release graph", text) self.assertNotIn("GD_LIBRARY", text) + self.assertIn('"-DREADLINE_INCLUDE_DIR=${readline_prefix}/include"', text) + self.assertIn('"-DREADLINE_LIBRARY=${readline_prefix}/lib/libreadline.dylib"', text) @unittest.skipUnless(shutil.which("bash"), "Bash unavailable") def test_build_collects_reachable_errors_and_preserves_failure_through_tee(self): diff --git a/tools/tests/test_toolkit_dependency_interfaces.py b/tools/tests/test_toolkit_dependency_interfaces.py new file mode 100644 index 000000000..9208bb1fb --- /dev/null +++ b/tools/tests/test_toolkit_dependency_interfaces.py @@ -0,0 +1,91 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +@unittest.skipUnless(shutil.which("cmake") and shutil.which("ninja") and shutil.which("cc"), + "CMake, Ninja and a fixture C compiler are required") +class ToolkitDependencyInterfaceTests(unittest.TestCase): + """Synthetic target fixtures, not native application or SDK acceptance.""" + + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="toolkit dependencies ") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.prefix = self.root / "readline" + (self.prefix / "include/readline").mkdir(parents=True) + (self.prefix / "lib").mkdir() + (self.prefix / "include/readline/readline.h").write_text( + "#define ATRINIK_READLINE_FIXTURE 1\n") + # Static fixture consumers exercise compilation without linking an + # application against this discovery-only placeholder archive. + (self.prefix / "lib/libreadline.a").touch() + self.openssl = self.root / "openssl" + (self.openssl / "include/openssl").mkdir(parents=True) + (self.openssl / "include/openssl/types.h").write_text( + "#define ATRINIK_OPENSSL_FIXTURE 1\ntypedef struct ssl_st SSL;\n") + self.sdk = self.root / "sdk" + (self.sdk / "include/readline").mkdir(parents=True) + (self.sdk / "include/readline/readline.h").write_text( + '#error "SDK libedit header must not replace selected GNU Readline"\n') + text = (ROOT / "libatrinik/CMakeLists.txt").read_text() + discovery = text.split("find_library(READLINE_LIBRARY readline)\n", 1)[1] + discovery = "find_library(READLINE_LIBRARY readline)\n" + discovery.split( + "find_package(OpenSSL REQUIRED)", 1)[0] + links = text.split("# Link readline if available.\n", 1)[1].split( + "if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)", 1)[0] + (self.root / "core.c").write_text( + "#include \nint core(void) { return ATRINIK_READLINE_FIXTURE; }\n") + (self.root / "consumer.c").write_text( + "#include \n" + '#ifndef ATRINIK_OPENSSL_FIXTURE\n#error "selected OpenSSL headers required"\n#endif\n' + "SSL *consumer(void) { return (SSL *)0; }\n") + (self.root / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.21) +project(ToolkitDependencies LANGUAGES C) +{discovery} +add_library(OpenSSL::SSL INTERFACE IMPORTED) +add_library(OpenSSL::Crypto INTERFACE IMPORTED) +set_target_properties(OpenSSL::Crypto PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "{self.openssl}/include") +set(ATRINIK_TOOLKIT core) +add_library(core STATIC core.c) +target_compile_options(core PRIVATE "-idirafter{self.sdk}/include") +{links} +add_library(consumer STATIC consumer.c) +target_link_libraries(consumer PRIVATE core) +export(TARGETS core FILE "${{CMAKE_BINARY_DIR}}/CoreTargets.cmake") +''') + + def configure(self, *options): + return subprocess.run([ + shutil.which("cmake"), "-S", str(self.root), "-B", str(self.root / "build"), + "-G", "Ninja", f"-DCMAKE_C_COMPILER={shutil.which('cc')}", + f"-DCMAKE_PREFIX_PATH={self.prefix}", *options, + ], capture_output=True, text=True) + + def test_selected_readline_headers_and_public_openssl_compile_for_consumers(self): + result = self.configure() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([shutil.which("cmake"), "--build", str(self.root / "build"), + "--parallel", "2"], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + exported = (self.root / "build/CoreTargets.cmake").read_text() + self.assertIn("OpenSSL::Crypto", exported) + self.assertNotIn(str(self.openssl), exported) + self.assertNotIn(str(self.prefix / "include"), exported) + + def test_selected_library_without_matching_header_path_is_rejected(self): + result = self.configure(f"-DREADLINE_INCLUDE_DIR={self.root}/missing-headers") + self.assertNotEqual(result.returncode, 0) + self.assertIn("requires matching Readline headers", result.stderr) + + +if __name__ == "__main__": + unittest.main() From af52b5f1d76efc1d5d40c65518a9a0ab9b3570aa Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:34:49 -0500 Subject: [PATCH 057/148] test(client): make movement report failure fixture portable --- client/src/tests/live_movement.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/src/tests/live_movement.c b/client/src/tests/live_movement.c index d9cc78484..20d747640 100644 --- a/client/src/tests/live_movement.c +++ b/client/src/tests/live_movement.c @@ -8,6 +8,9 @@ #include #include #include +#ifndef WIN32 +#include +#endif #include @@ -568,13 +571,22 @@ static int test_deadline_and_report_failure(void) { CHECK(fixture_create(&paths, route_xml)); CHECK(fixture_ready(&paths)); CHECK(fclose(report) == 0); - report = fopen("/dev/full", "w"); + /* A pipe without readers rejects writes on every supported POSIX host. */ + int report_pipe[2]; + CHECK(pipe(report_pipe) == 0); + CHECK(close(report_pipe[0]) == 0); + struct sigaction ignore_pipe = {.sa_handler = SIG_IGN}, previous_pipe; + CHECK(sigemptyset(&ignore_pipe.sa_mask) == 0); + CHECK(sigaction(SIGPIPE, &ignore_pipe, &previous_pipe) == 0); + report = fdopen(report_pipe[1], "w"); CHECK(report != NULL && setvbuf(report, NULL, _IONBF, 0) == 0); publication_generation = 1; profile_statistics.frames = 1; live_movement_frame_finished(true, true, &(client_keepalive_statistics_t){0}); CHECK(live_movement_finished() && live_movement_exit_status() == 8); + CHECK(ferror(report)); live_movement_close(); + CHECK(sigaction(SIGPIPE, &previous_pipe, NULL) == 0); fixture_destroy(&paths); #endif return 0; From 6167570267e467ae3af3bab11f9cb30f43caf8e4 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:37:42 -0500 Subject: [PATCH 058/148] fix(build): require coherent Readline headers and library --- libatrinik/CMakeLists.txt | 22 +++++++++- .../test_toolkit_dependency_interfaces.py | 42 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index adfe212dc..49368f11e 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -196,13 +196,33 @@ else () endif () find_library(READLINE_LIBRARY readline) -find_path(READLINE_INCLUDE_DIR readline/readline.h) if (READLINE_LIBRARY) + if (NOT EXISTS "${READLINE_LIBRARY}") + message(FATAL_ERROR "The selected Readline library does not exist") + endif () + # Resolve both sides of opt/Cellar or system-library symlinks before pairing. + file(REAL_PATH "${READLINE_LIBRARY}" atrinik_readline_library) + get_filename_component(atrinik_readline_libdir "${atrinik_readline_library}" DIRECTORY) + if (atrinik_readline_libdir MATCHES "^(.+)/(lib|lib64)(/[^/]+)?$") + set(atrinik_readline_headers "${CMAKE_MATCH_1}/include") + elseif (atrinik_readline_libdir MATCHES "^/(lib|lib64)(/[^/]+)?$") + # Non-usr-merged system layouts still pair /lib with /usr/include. + set(atrinik_readline_headers "/usr/include") + else () + message(FATAL_ERROR "The selected Readline library requires a supported lib/lib64 prefix") + endif () + find_path(READLINE_INCLUDE_DIR readline/readline.h + PATHS "${atrinik_readline_headers}" NO_DEFAULT_PATH) if (NOT READLINE_INCLUDE_DIR OR NOT EXISTS "${READLINE_INCLUDE_DIR}/readline/readline.h") message(FATAL_ERROR "The selected Readline library requires matching Readline headers") endif () + file(REAL_PATH "${READLINE_INCLUDE_DIR}" atrinik_readline_selected_headers) + file(REAL_PATH "${atrinik_readline_headers}" atrinik_readline_expected_headers) + if (NOT atrinik_readline_selected_headers STREQUAL atrinik_readline_expected_headers) + message(FATAL_ERROR "Readline headers and library must come from the same resolved prefix") + endif () set(HAVE_READLINE true) else () message(STATUS "Could not find readline library, simple interactive console will be compiled instead.") diff --git a/tools/tests/test_toolkit_dependency_interfaces.py b/tools/tests/test_toolkit_dependency_interfaces.py index 9208bb1fb..7e47444ec 100644 --- a/tools/tests/test_toolkit_dependency_interfaces.py +++ b/tools/tests/test_toolkit_dependency_interfaces.py @@ -86,6 +86,48 @@ def test_selected_library_without_matching_header_path_is_rejected(self): self.assertNotEqual(result.returncode, 0) self.assertIn("requires matching Readline headers", result.stderr) + def test_selected_library_rejects_explicit_headers_from_another_prefix(self): + other = self.root / "other-readline/include/readline" + other.mkdir(parents=True) + (other / "readline.h").write_text("#define ATRINIK_READLINE_FIXTURE 2\n") + result = self.configure(f"-DREADLINE_INCLUDE_DIR={other.parent}") + self.assertNotEqual(result.returncode, 0) + self.assertIn("same resolved prefix", result.stderr) + + def test_missing_selected_headers_do_not_fall_back_to_another_prefix(self): + (self.prefix / "include/readline/readline.h").unlink() + other = self.root / "other-readline/include/readline" + other.mkdir(parents=True) + (other / "readline.h").write_text("#define ATRINIK_READLINE_FIXTURE 2\n") + result = self.configure(f"-DCMAKE_PREFIX_PATH={self.prefix};{other.parent.parent}") + self.assertNotEqual(result.returncode, 0) + self.assertIn("requires matching Readline headers", result.stderr) + + def test_multiarch_library_pairs_with_the_prefix_include_directory(self): + multiarch = self.prefix / "lib/x86_64-linux-gnu" + multiarch.mkdir() + library = multiarch / "libreadline.a" + (self.prefix / "lib/libreadline.a").rename(library) + result = self.configure(f"-DREADLINE_LIBRARY={library}") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([shutil.which("cmake"), "--build", str(self.root / "build"), + "--parallel", "2"], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_brew_style_symlink_prefix_pairs_with_resolved_headers(self): + alias = self.root / "opt/readline" + alias.parent.mkdir() + alias.symlink_to(self.prefix, target_is_directory=True) + result = self.configure(f"-DREADLINE_LIBRARY={alias}/lib/libreadline.a", + f"-DREADLINE_INCLUDE_DIR={alias}/include") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_no_selected_library_preserves_optional_console_fallback(self): + (self.prefix / "include/readline/readline.h").unlink() + result = self.configure("-DREADLINE_LIBRARY:FILEPATH=") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("simple interactive console", result.stdout) + if __name__ == "__main__": unittest.main() From 878a1453265d7fa21b1cc84ba222b5090bd92517 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:45:58 -0500 Subject: [PATCH 059/148] feat(ci): collect native macOS curl provider and trust evidence --- .../workflows/llvm-macos-qualification.yml | 10 ++ docs/MACOS_TOOLCHAIN.md | 18 ++ tools/ci/collect_macos_curl.py | 148 +++++++++++++++ tools/ci/macos_curl_probe/CMakeLists.txt | 20 +++ tools/ci/macos_curl_probe/main.c | 156 ++++++++++++++++ tools/tests/test_macos_curl_collection.py | 168 ++++++++++++++++++ 6 files changed, 520 insertions(+) create mode 100644 tools/ci/collect_macos_curl.py create mode 100644 tools/ci/macos_curl_probe/CMakeLists.txt create mode 100644 tools/ci/macos_curl_probe/main.c create mode 100644 tools/tests/test_macos_curl_collection.py diff --git a/.github/workflows/llvm-macos-qualification.yml b/.github/workflows/llvm-macos-qualification.yml index 1b88afc2c..57dae29bf 100644 --- a/.github/workflows/llvm-macos-qualification.yml +++ b/.github/workflows/llvm-macos-qualification.yml @@ -101,6 +101,16 @@ jobs: # Free their space before building the native application graph. rm "${RUNNER_TEMP}/classic-llvm-macos/stage/LLVM-23.1.3-macOS-ARM64.tar.xz" \ "${RUNNER_TEMP}/classic-llvm-macos/stage/llvm-project-23.1.3.src.tar.xz" + - name: Record the selected native curl provider and normal public TLS trust + shell: bash + run: | + set -euo pipefail + python3 tools/ci/collect_macos_curl.py \ + --prefix "$(brew --prefix curl)" \ + --llvm-prefix "${RUNNER_TEMP}/classic-llvm-macos/llvm" \ + --output "${GITHUB_WORKSPACE}/build/macos-qualification-evidence/curl-baseline" \ + --ca-bundle "${GITHUB_WORKSPACE}/client/ca-bundle.crt" \ + 2>&1 | tee build/macos-qualification-evidence/curl-baseline.log - name: Acquire and verify the existing locked runtime and source inputs shell: bash run: | diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md index 12dd27a5e..3cd0a3c6c 100644 --- a/docs/MACOS_TOOLCHAIN.md +++ b/docs/MACOS_TOOLCHAIN.md @@ -84,6 +84,24 @@ can change; the exact installed versions and formula metadata are retained with each qualification run. This lane does not create a released dependency producer or qualify different dependency versions by inference. +Before configuring Classic, `tools/ci/collect_macos_curl.py` compiles a small +native probe with the same LLVM toolchain against the explicitly selected +Homebrew curl headers and library. It records the runtime provider via +`dladdr`, header/runtime versions, features, protocols, SSL and resolver identity, +default CA paths, curl configuration, Mach-O dependencies and retained bottle +formula/receipt. Normal HTTPS requests to `https://curl.se/` must pass both +with the provider's default trust and the locked Classic CA bundle. Peer and +hostname verification stay enabled; the probe uses no proxies, credentials or +redirects. A provider/header mismatch or failed verified request fails the lane. +The absent output directory preserves previous runs rather than replacing them. + +This evidence is a prerequisite for selecting a source-built Mac curl/c-ares +cohort. It does not qualify Homebrew's threaded resolver for Classic cancellation, +prove Apple SecTrust solely from the SSL version, or establish source-built +dependency acceptance. The exact feature, protocol and trust baseline must be +preserved when the resolver provider changes. Linux and Windows curl policies +do not establish the native Mac baseline. + The integrated root selects `ATRINIK_BUILD_CLIENT=ON` and `ATRINIK_BUILD_SERVER=OFF`, building the supported macOS protocol, libatrinik, pathfinding, client and test targets through LLVM 23.1.3. The server access diff --git a/tools/ci/collect_macos_curl.py b/tools/ci/collect_macos_curl.py new file mode 100644 index 000000000..81492f36b --- /dev/null +++ b/tools/ci/collect_macos_curl.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Collect an existing native curl provider before choosing a source cohort.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[2] +CA_SHA256 = "a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505" + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def clean_environment(environment): + """Prevent loader, build flag, proxy, and TLS environment overrides.""" + blocked = { + "CFLAGS", "CXXFLAGS", "CPPFLAGS", "LDFLAGS", "CC", "CXX", "SDKROOT", + "CURL_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR", "CMAKE_PREFIX_PATH", + "CMAKE_TOOLCHAIN_FILE", "CMAKE_GENERATOR", "CMAKE_ARGS", + "PKG_CONFIG_PATH", "PKG_CONFIG_LIBDIR", "PKG_CONFIG_SYSROOT_DIR", + "CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "LIBRARY_PATH", + "COMPILER_PATH", "GCC_EXEC_PREFIX", "MACOSX_DEPLOYMENT_TARGET", + "CURL_SSL_BACKEND", "OPENSSL_CONF", "OPENSSL_CONF_INCLUDE", "OPENSSL_MODULES", + "OPENSSL_ENGINES", "SSLKEYLOGFILE", + } + return {key: value for key, value in environment.items() + if key not in blocked and not key.startswith(("DYLD_", "LD_")) + and not key.lower().endswith("_proxy")} + + +def validate_provider(data, selected, prefix): + """A captured provider must agree with the selected library and headers.""" + selected = Path(selected).resolve(strict=True) + prefix = Path(prefix).resolve(strict=True) + if not selected.is_relative_to(prefix): + raise ValueError("CMake selected a curl library outside the requested prefix") + if Path(data["loaded_library"]).resolve(strict=True) != selected: + raise ValueError("runtime curl provider differs from the CMake selected library") + if data["header_version_num"] != data["version_num"]: + raise ValueError("curl headers and loaded library versions differ") + for key in ("features", "protocols"): + if not data[key] or len(set(data[key])) != len(data[key]): + raise ValueError(f"invalid runtime curl {key}") + for key in ("public_tls_default", "public_tls_bundle"): + proof = data[key] + if proof["code"] != 0 or not 200 <= proof["status"] < 400: + raise ValueError(f"normal verified public TLS request failed: {key}") + + +def validate_headers(include_directories, prefix): + expected = (Path(prefix) / "include").resolve(strict=True) + actual = [Path(value).resolve(strict=True) for value in include_directories.split(";")] + if actual != [expected]: + raise ValueError("CMake curl header directories differ from the requested prefix") + + +def collect(prefix, llvm_prefix, output, ca_bundle): + if platform.system() != "Darwin": + raise ValueError("curl baseline collection requires a native macOS host") + prefix = prefix.resolve(strict=True) + llvm_prefix = llvm_prefix.resolve(strict=True) + ca_bundle = ca_bundle.resolve(strict=True) + if digest(ca_bundle) != CA_SHA256: + raise ValueError("Classic public CA bundle digest differs from the locked input") + for relative in (".brew/curl.rb", "INSTALL_RECEIPT.json"): + if not (prefix / relative).is_file(): + raise ValueError(f"required native bottle provenance absent: {relative}") + output = output.absolute() + output.mkdir() # Preserve an earlier run; the output must be absent. + build = output / "probe-build" + env = clean_environment(os.environ) + + def run(name, arguments, check=True): + result = subprocess.run(arguments, env=env, text=True, capture_output=True) + (output / f"{name}.stdout").write_text(result.stdout) + (output / f"{name}.stderr").write_text(result.stderr) + if check and result.returncode: + raise ValueError(f"native curl collection command failed: {name} ({result.returncode})") + return result + + library = prefix / "lib/libcurl.dylib" + run("configure", [ + "cmake", "-S", str(ROOT / "tools/ci/macos_curl_probe"), "-B", str(build), + "-G", "Ninja", f"-DCMAKE_TOOLCHAIN_FILE={ROOT}/cmake/toolchains/llvm-macos.cmake", + f"-DCMAKE_PROGRAM_PATH={llvm_prefix}/bin", "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + f"-DCURL_ROOT={prefix}", f"-DCURL_INCLUDE_DIR={prefix}/include", + f"-DCURL_LIBRARY_RELEASE={library}", f"-DCURL_LIBRARY_DEBUG={library}", + ]) + headers = (build / "selected-headers.txt").read_text().strip() + validate_headers(headers, prefix) + run("build", ["cmake", "--build", str(build), "--parallel", "2"]) + result = run("runtime", [str(build / "macos-curl-probe"), str(ca_bundle)], check=False) + data = json.loads(result.stdout) + selected = (build / "selected-library.txt").read_text().strip() + record = { + "schema_version": 1, "source_commit": run("source", ["git", "-C", str(ROOT), "rev-parse", "HEAD"]).stdout.strip(), + "purpose": "baseline collection; not source-built curl acceptance", + "prefix": str(prefix), "selected_library": selected, + "selected_library_sha256": digest(selected), + "header_sha256": digest(prefix / "include/curl/curlver.h"), + "curl_api_header_sha256": digest(prefix / "include/curl/curl.h"), + "selected_header_directories": headers.split(";"), + "ca_bundle_sha256": digest(ca_bundle), "public_url": "https://curl.se/", + "runtime": data, + } + # A bottle retains its exact formula and receipt, including native TLS policy. + # Keep raw inputs; configure output is evidence and is never executed. + for name in ("--version", "--features", "--protocols", "--configure", "--ca"): + run("curl-config-" + name[2:], [str(prefix / "bin/curl-config"), name]) + run("curl-version", [str(prefix / "bin/curl"), "-q", "--version"]) + run("library-dependencies", ["xcrun", "otool", "-L", selected]) + run("library-architecture", ["xcrun", "lipo", "-info", selected]) + for relative in (".brew/curl.rb", "INSTALL_RECEIPT.json"): + path = prefix / relative + shutil.copyfile(path, output / path.name) + record[relative + "_sha256"] = digest(path) + (output / "baseline.json").write_text(json.dumps(record, indent=2, sort_keys=True) + "\n") + validate_provider(data, selected, prefix) + if result.returncode: + raise ValueError(f"native curl runtime probe failed ({result.returncode})") + print(json.dumps(record, indent=2, sort_keys=True)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prefix", required=True, type=Path) + parser.add_argument("--llvm-prefix", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--ca-bundle", required=True, type=Path) + args = parser.parse_args() + try: + collect(args.prefix, args.llvm_prefix, args.output, args.ca_bundle) + except (ValueError, OSError, KeyError, json.JSONDecodeError) as error: + parser.exit(1, f"macOS curl collection failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/tools/ci/macos_curl_probe/CMakeLists.txt b/tools/ci/macos_curl_probe/CMakeLists.txt new file mode 100644 index 000000000..0615f3c7e --- /dev/null +++ b/tools/ci/macos_curl_probe/CMakeLists.txt @@ -0,0 +1,20 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +cmake_minimum_required(VERSION 3.21) +include(../../../cmake/AtrinikLLVM.cmake) +project(AtrinikMacOSCurlProbe LANGUAGES C) +atrinik_require_llvm() +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Darwin") + message(FATAL_ERROR "curl baseline collection requires native macOS") +endif() +set(CURL_NO_CURL_CMAKE TRUE) +find_package(CURL MODULE REQUIRED) +get_target_property(curl_header_directories CURL::libcurl INTERFACE_INCLUDE_DIRECTORIES) +file(WRITE "${CMAKE_BINARY_DIR}/selected-headers.txt" "${curl_header_directories}\n") +add_executable(macos-curl-probe main.c) +set_target_properties(macos-curl-probe PROPERTIES + C_STANDARD 17 C_STANDARD_REQUIRED YES C_EXTENSIONS NO) +target_link_libraries(macos-curl-probe PRIVATE CURL::libcurl) +target_compile_options(macos-curl-probe PRIVATE -Wall -Wextra -Werror) +file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/selected-library.txt" + CONTENT "$\n") diff --git a/tools/ci/macos_curl_probe/main.c b/tools/ci/macos_curl_probe/main.c new file mode 100644 index 000000000..a6298b3f3 --- /dev/null +++ b/tools/ci/macos_curl_probe/main.c @@ -0,0 +1,156 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + */ +#include +#include +#include + +static void json_string(const char *text) +{ + if (text == NULL) { + printf("null"); + return; + } + putchar('"'); + if (text != NULL) { + for (const unsigned char *p = (const unsigned char *) text; *p; p++) { + if (*p == '"' || *p == '\\') { + putchar('\\'); + putchar(*p); + } else if (*p < 32) { + printf("\\u%04x", *p); + } else { + putchar(*p); + } + } + } + putchar('"'); +} + +static void string_array(const char *const *values) +{ + putchar('['); + if (values != NULL) { + for (size_t i = 0; values[i] != NULL; i++) { + if (i != 0) { + putchar(','); + } + json_string(values[i]); + } + } + putchar(']'); +} + +static size_t discard(char *data, size_t size, size_t count, void *context) +{ + (void) data; + (void) context; + return size * count; +} + +/* No curlrc, proxy, netrc, credentials, redirects, or weakened TLS checks. */ +static int public_tls(const char *bundle) +{ + CURL *handle = curl_easy_init(); + CURLcode result = CURLE_FAILED_INIT; + long status = 0; + long verify_result = -1; + char error[CURL_ERROR_SIZE] = {0}; + const char *default_bundle = NULL; + const char *default_path = NULL; + if (handle != NULL) { +#define SET(option, value) do { \ + result = curl_easy_setopt(handle, option, value); \ + if (result != CURLE_OK) goto finished; \ +} while (0) + result = curl_easy_getinfo(handle, CURLINFO_CAINFO, &default_bundle); + if (result != CURLE_OK) goto finished; + result = curl_easy_getinfo(handle, CURLINFO_CAPATH, &default_path); + if (result != CURLE_OK) goto finished; + SET(CURLOPT_URL, "https://curl.se/"); + SET(CURLOPT_PROXY, ""); + SET(CURLOPT_NETRC, CURL_NETRC_IGNORED); + SET(CURLOPT_FOLLOWLOCATION, 0L); + SET(CURLOPT_SSL_VERIFYPEER, 1L); + SET(CURLOPT_SSL_VERIFYHOST, 2L); + SET(CURLOPT_CONNECTTIMEOUT, 15L); + SET(CURLOPT_TIMEOUT, 45L); + SET(CURLOPT_WRITEFUNCTION, discard); + SET(CURLOPT_ERRORBUFFER, error); + if (bundle != NULL) { + SET(CURLOPT_CAINFO, bundle); + } + result = curl_easy_perform(handle); + if (result == CURLE_OK) { + result = curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status); + if (result == CURLE_OK) { + result = curl_easy_getinfo(handle, CURLINFO_SSL_VERIFYRESULT, &verify_result); + } + } +finished: +#undef SET + ; + } + printf("{\"code\":%d,\"status\":%ld,\"ssl_verify_result\":%ld,\"error\":", + (int) result, status, verify_result); + json_string(error); + printf(",\"default_ca_bundle\":"); + json_string(default_bundle); + printf(",\"default_ca_path\":"); + json_string(default_path); + printf("}"); + curl_easy_cleanup(handle); + return result == CURLE_OK && status >= 200 && status < 400; +} + +int main(int argc, char **argv) +{ + if (argc != 2 || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + return 2; + } + curl_version_info_data *info = curl_version_info(CURLVERSION_NOW); + Dl_info provider = {0}; + void *symbol = dlsym(RTLD_DEFAULT, "curl_version_info"); + if (info == NULL || symbol == NULL || dladdr(symbol, &provider) == 0 || + info->age < CURLVERSION_ELEVENTH) { + curl_global_cleanup(); + return 2; + } + printf("{\"header_version\":"); + json_string(LIBCURL_VERSION); + printf(",\"header_version_num\":%u,\"version\":", (unsigned) LIBCURL_VERSION_NUM); + json_string(info->version); + printf(",\"version_num\":%u,\"loaded_library\":", info->version_num); + json_string(provider.dli_fname); + printf(",\"host\":"); + json_string(info->host); + printf(",\"ssl_version\":"); + json_string(info->ssl_version); + printf(",\"ares\":"); + json_string(info->ares); + printf(",\"ares_num\":%d,\"libz_version\":", info->ares_num); + json_string(info->libz_version); + printf(",\"libidn\":"); + json_string(info->libidn); + printf(",\"libssh_version\":"); + json_string(info->libssh_version); + printf(",\"brotli_version\":"); + json_string(info->brotli_version); + printf(",\"nghttp2_version\":"); + json_string(info->nghttp2_version); + printf(",\"quic_version\":"); + json_string(info->quic_version); + printf(",\"zstd_version\":"); + json_string(info->zstd_version); + printf(",\"features\":"); + string_array(info->feature_names); + printf(",\"protocols\":"); + string_array(info->protocols); + printf(",\"public_tls_default\":"); + int default_ok = public_tls(NULL); + printf(",\"public_tls_bundle\":"); + int bundle_ok = public_tls(argv[1]); + printf("}\n"); + curl_global_cleanup(); + return default_ok && bundle_ok ? 0 : 1; +} diff --git a/tools/tests/test_macos_curl_collection.py b/tools/tests/test_macos_curl_collection.py new file mode 100644 index 000000000..cc5f88f6c --- /dev/null +++ b/tools/tests/test_macos_curl_collection.py @@ -0,0 +1,168 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import copy +import contextlib +import importlib.util +import json +import io +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("macos_curl", ROOT / "tools/ci/collect_macos_curl.py") +COLLECTOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(COLLECTOR) + + +class MacOSCurlCollectionTests(unittest.TestCase): + def fixture(self, root): + prefix = root / "curl" + (prefix / "lib").mkdir(parents=True) + library = prefix / "lib/libcurl.dylib" + library.write_bytes(b"fixture curl library, not a native binary") + (prefix / "include/curl").mkdir(parents=True) + (prefix / "include/curl/curlver.h").write_text("fixture header") + (prefix / "include/curl/curl.h").write_text("fixture API header") + (prefix / ".brew").mkdir() + (prefix / ".brew/curl.rb").write_text("fixture bottle formula") + (prefix / "INSTALL_RECEIPT.json").write_text("{}") + llvm = root / "llvm" + llvm.mkdir() + bundle = root / "ca.pem" + bundle.write_text("fixture CA, never used for a network request") + data = { + "header_version_num": 0x081600, "version_num": 0x081600, + "loaded_library": str(library), "features": ["SSL", "AsynchDNS", "HTTP3"], + "protocols": ["http", "https", "ldap"], + "public_tls_default": {"code": 0, "status": 200}, + "public_tls_bundle": {"code": 0, "status": 200}, + } + return prefix, library, llvm, bundle, data + + def test_environment_removes_tls_proxy_loader_and_compile_overrides(self): + environment = { + "PATH": "/native/tools", "HOME": "/runner/home", "NORMAL": "retained", + "HTTPS_PROXY": "https://proxy.invalid", "all_proxy": "socks://proxy.invalid", + "DYLD_INSERT_LIBRARIES": "/foreign.dylib", "LD_PRELOAD": "/foreign.so", + "SSL_CERT_FILE": "/foreign-ca", "CURL_CA_BUNDLE": "/foreign-ca", + "CFLAGS": "-O0", "LDFLAGS": "-L/foreign", "CPATH": "/foreign/include", + "PKG_CONFIG_PATH": "/foreign/pkgconfig", "CMAKE_TOOLCHAIN_FILE": "/foreign/toolchain", + "CURL_SSL_BACKEND": "other", "OPENSSL_CONF": "/foreign.cnf", + "OPENSSL_CONF_INCLUDE": "/foreign", "OPENSSL_MODULES": "/foreign", + "OPENSSL_ENGINES": "/foreign", "SSLKEYLOGFILE": "/secret-output", + } + self.assertEqual(COLLECTOR.clean_environment(environment), { + "PATH": "/native/tools", "HOME": "/runner/home", "NORMAL": "retained", + }) + + def test_binding_and_verified_tls_fail_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix, library, _, _, original = self.fixture(root) + foreign = root / "foreign.dylib" + foreign.write_bytes(b"foreign fixture") + COLLECTOR.validate_provider(original, library, prefix) + mutations = [ + ("loaded_library", str(foreign)), ("header_version_num", 0x080700), + ("features", []), ("protocols", ["http", "http"]), + ("public_tls_default", {"code": 60, "status": 0}), + ("public_tls_bundle", {"code": 0, "status": 401}), + ] + for key, value in mutations: + data = copy.deepcopy(original) + data[key] = value + with self.subTest(mutation=key), self.assertRaises(ValueError): + COLLECTOR.validate_provider(data, library, prefix) + with self.assertRaisesRegex(ValueError, "outside"): + COLLECTOR.validate_provider(original, foreign, prefix) + COLLECTOR.validate_headers(str(prefix / "include"), prefix) + other = root / "foreign-include" + other.mkdir() + for header_dirs in (str(other), f"{prefix}/include;{other}"): + with self.assertRaisesRegex(ValueError, "header directories"): + COLLECTOR.validate_headers(header_dirs, prefix) + + def test_native_orchestration_records_failure_and_never_executes_configure_text(self): + for runtime_status in (0, 1): + with self.subTest(runtime_status=runtime_status), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix, library, llvm, bundle, data = self.fixture(root) + output = root / "absent-output" + calls = [] + if runtime_status: + data["public_tls_default"] = {"code": 60, "status": 0} + + def run(arguments, **kwargs): + calls.append(arguments) + self.assertNotIn("HTTPS_PROXY", kwargs["env"]) + stdout = "captured text; never execute $(touch injected)\n" + code = 0 + if arguments[0] == "cmake" and "-S" in arguments: + build = Path(arguments[arguments.index("-B") + 1]) + build.mkdir() + (build / "selected-library.txt").write_text(str(library) + "\n") + (build / "selected-headers.txt").write_text(str(prefix / "include") + "\n") + elif arguments[0].endswith("macos-curl-probe"): + stdout = json.dumps(data) + code = runtime_status + elif arguments[0] == "git": + stdout = "a" * 40 + "\n" + return subprocess.CompletedProcess(arguments, code, stdout, "") + + with patch.object(COLLECTOR.platform, "system", return_value="Darwin"), \ + patch.object(COLLECTOR, "CA_SHA256", COLLECTOR.digest(bundle)), \ + patch.object(COLLECTOR.subprocess, "run", side_effect=run), \ + patch.dict(COLLECTOR.os.environ, {"HTTPS_PROXY": "https://foreign.invalid"}), \ + contextlib.redirect_stdout(io.StringIO()): + if runtime_status: + with self.assertRaisesRegex(ValueError, "verified public TLS"): + COLLECTOR.collect(prefix, llvm, output, bundle) + else: + COLLECTOR.collect(prefix, llvm, output, bundle) + record = json.loads((output / "baseline.json").read_text()) + self.assertEqual(record["runtime"], data) + self.assertIn("not source-built", record["purpose"]) + self.assertIn("-DCMAKE_BUILD_TYPE=Release", calls[0]) + self.assertIn(f"-DCURL_LIBRARY_RELEASE={library}", calls[0]) + self.assertTrue(any(call[-1] == "--configure" for call in calls)) + self.assertFalse(any(call[0] in ("bash", "sh") for call in calls)) + self.assertTrue((output / "runtime.stdout").is_file()) + with self.assertRaises(FileExistsError), \ + patch.object(COLLECTOR.platform, "system", return_value="Darwin"), \ + patch.object(COLLECTOR, "CA_SHA256", COLLECTOR.digest(bundle)): + COLLECTOR.collect(prefix, llvm, output, bundle) + + def test_rejects_foreign_host_and_changed_locked_ca_before_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix, _, llvm, bundle, _ = self.fixture(root) + output = root / "not-created" + with patch.object(COLLECTOR.platform, "system", return_value="Linux"), \ + self.assertRaisesRegex(ValueError, "native macOS"): + COLLECTOR.collect(prefix, llvm, output, bundle) + with patch.object(COLLECTOR.platform, "system", return_value="Darwin"), \ + self.assertRaisesRegex(ValueError, "CA bundle digest"): + COLLECTOR.collect(prefix, llvm, output, bundle) + self.assertFalse(output.exists()) + (prefix / ".brew/curl.rb").unlink() + with patch.object(COLLECTOR.platform, "system", return_value="Darwin"), \ + patch.object(COLLECTOR, "CA_SHA256", COLLECTOR.digest(bundle)), \ + self.assertRaisesRegex(ValueError, "bottle provenance absent"): + COLLECTOR.collect(prefix, llvm, output, bundle) + self.assertFalse(output.exists()) + + def test_workflow_collects_before_application_selection_and_retains_evidence(self): + text = (ROOT / ".github/workflows/llvm-macos-qualification.yml").read_text() + self.assertLess(text.index("collect_macos_curl.py"), text.index("Configure the supported macOS")) + self.assertIn('--prefix "$(brew --prefix curl)"', text) + self.assertIn('--ca-bundle "${GITHUB_WORKSPACE}/client/ca-bundle.crt"', text) + self.assertIn("curl-baseline.log", text) + self.assertIn(" build/macos-qualification-evidence\n", text) + + +if __name__ == "__main__": + unittest.main() From b5a80c55753077300414d58260195c0459f27098 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:43:54 -0500 Subject: [PATCH 060/148] fix(client): qualify the macOS mixer decoder contract --- .../workflows/llvm-macos-qualification.yml | 15 +- client/src/tests/sound_playback.c | 10 +- docs/MACOS_TOOLCHAIN.md | 15 +- tools/ci/macos_mixer_probe/CMakeLists.txt | 10 ++ tools/ci/macos_mixer_probe/main.c | 93 +++++++++++ tools/ci/prepare_macos_mixer.sh | 57 +++++++ .../test_macos_qualification_workflow.py | 11 ++ tools/tests/test_prepare_macos_mixer.py | 149 ++++++++++++++++++ 8 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 tools/ci/macos_mixer_probe/CMakeLists.txt create mode 100644 tools/ci/macos_mixer_probe/main.c create mode 100755 tools/ci/prepare_macos_mixer.sh create mode 100644 tools/tests/test_prepare_macos_mixer.py diff --git a/.github/workflows/llvm-macos-qualification.yml b/.github/workflows/llvm-macos-qualification.yml index 57dae29bf..5dda89bb1 100644 --- a/.github/workflows/llvm-macos-qualification.yml +++ b/.github/workflows/llvm-macos-qualification.yml @@ -83,7 +83,7 @@ jobs: set -euo pipefail brew install --force-bottle \ pkgconf libidn2 curl openssl@3 zlib \ - libxml2 readline python@3.12 sdl3 sdl3_image sdl3_ttf sdl3_mixer \ + libxml2 readline python@3.12 sdl3 sdl3_image sdl3_ttf opusfile \ 2>&1 | tee build/macos-qualification-evidence/dependency-install.log brew list --versions > build/macos-qualification-evidence/dependency-versions.txt brew info --json=v2 --installed > build/macos-qualification-evidence/dependencies.json @@ -111,6 +111,16 @@ jobs: --output "${GITHUB_WORKSPACE}/build/macos-qualification-evidence/curl-baseline" \ --ca-bundle "${GITHUB_WORKSPACE}/client/ca-bundle.crt" \ 2>&1 | tee build/macos-qualification-evidence/curl-baseline.log + - name: Build and qualify the supported mixer decoder contract + shell: bash + run: | + set -euo pipefail + tools/ci/prepare_macos_mixer.sh "${RUNNER_TEMP}/classic-mixer-macos" \ + 2>&1 | tee build/macos-qualification-evidence/mixer-probe.log + cp "${RUNNER_TEMP}/classic-mixer-macos/stage/build/CMakeCache.txt" \ + build/macos-qualification-evidence/mixer-CMakeCache.txt + cp "${RUNNER_TEMP}/classic-mixer-macos/stage/source.txt" \ + build/macos-qualification-evidence/mixer-source.txt - name: Acquire and verify the existing locked runtime and source inputs shell: bash run: | @@ -135,7 +145,8 @@ jobs: -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DPACKAGE_TYPE=none \ -DATRINIK_BUILD_CLIENT=ON -DATRINIK_BUILD_SERVER=OFF \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - "-DCMAKE_PREFIX_PATH=${prefix};$(brew --prefix openssl@3);$(brew --prefix curl);$(brew --prefix libxml2);$(brew --prefix zlib);$(brew --prefix readline);${python_prefix}" \ + "-DCMAKE_PREFIX_PATH=${RUNNER_TEMP}/classic-mixer-macos;${prefix};$(brew --prefix openssl@3);$(brew --prefix curl);$(brew --prefix libxml2);$(brew --prefix zlib);$(brew --prefix readline);${python_prefix}" \ + "-DSDL3_mixer_DIR=${RUNNER_TEMP}/classic-mixer-macos/lib/cmake/SDL3_mixer" \ "-DOPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" \ "-DCURL_ROOT=$(brew --prefix curl)" \ "-DPython3_EXECUTABLE=${python_prefix}/bin/python3.12" \ diff --git a/client/src/tests/sound_playback.c b/client/src/tests/sound_playback.c index b8f94aed7..27c008346 100644 --- a/client/src/tests/sound_playback.c +++ b/client/src/tests/sound_playback.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -52,7 +53,11 @@ int64_t setting_get_int(int category, int setting) { void draw_info_format(const char *color, const char *format, ...) { (void)color; - (void)format; + va_list arguments; + va_start(arguments, format); + vfprintf(stderr, format, arguments); + va_end(arguments); + fputc('\n', stderr); } static void capture_print(const char *text) { @@ -171,7 +176,8 @@ static void test_server_and_ambient_sources(void) { socket_command_sound(packet->data, packet->len, 0); TEST_CHECK(strstr(captured, "source=server-positional") != NULL); char expected[MAX_BUF]; - snprintf((expected), sizeof((expected)), + snprintf((expected), + sizeof((expected)), "requested=\"%s\" effective=\"%s\"", hurt_effects[i], hurt_effects[i]); diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md index 3cd0a3c6c..e311e0546 100644 --- a/docs/MACOS_TOOLCHAIN.md +++ b/docs/MACOS_TOOLCHAIN.md @@ -70,13 +70,12 @@ provides CMake, Ninja, Homebrew and Xcode; the lane records the actual image, OS, selected SDK and disk space instead of inferring them from the label. The native job installs ARM64 Homebrew bottles for the current application -prerequisites: SDL3, SDL3_image, SDL3_ttf, SDL3_mixer, OpenSSL 3, libxml2, +prerequisites: SDL3, SDL3_image, SDL3_ttf, Opusfile, OpenSSL 3, libxml2, libidn2, curl, zlib, Readline and Python 3.12. Python supplies the protocol, client and validation tooling interpreter. Readline retains the toolkit's interactive console feature. Flex, MiniUPnPc, libgd, Check and libpcpnatpmp belong to the server build and are not acquired by this Mac lane. -The official [SDL3](https://formulae.brew.sh/formula/sdl3), -[SDL3_mixer](https://formulae.brew.sh/formula/sdl3_mixer) and +The official [SDL3](https://formulae.brew.sh/formula/sdl3) and [OpenSSL 3](https://formulae.brew.sh/formula/openssl@3) formulae currently supply bottles meeting Classic's required versions. CMake remains the authority for required versions at execution. Homebrew dependencies and hosted SDK images @@ -102,6 +101,16 @@ dependency acceptance. The exact feature, protocol and trust baseline must be preserved when the resolver provider changes. Linux and Windows curl policies do not establish the native Mac baseline. +`tools/ci/prepare_macos_mixer.sh` builds the SHA-verified SDL3_mixer source +from the existing packaging lock with native LLVM and the same decoder options +as the Linux and Windows producers. Homebrew's mixer enables additional +decoders and does not satisfy Classic's exact supported set: WAV, STBVORBIS, +OPUS, VOC, AIFF, AU, DRMP3, SINEWAVE and RAW. The preparation probe checks that +set, decodes the verified Opus fixture at its legacy `.mid` path into nonzero +PCM and starts playback on SDL's dummy audio device. The application selects +that qualified prefix explicitly. These checks retain sound features and +headless test coverage; audible output still requires separate native proof. + The integrated root selects `ATRINIK_BUILD_CLIENT=ON` and `ATRINIK_BUILD_SERVER=OFF`, building the supported macOS protocol, libatrinik, pathfinding, client and test targets through LLVM 23.1.3. The server access diff --git a/tools/ci/macos_mixer_probe/CMakeLists.txt b/tools/ci/macos_mixer_probe/CMakeLists.txt new file mode 100644 index 000000000..2905ae598 --- /dev/null +++ b/tools/ci/macos_mixer_probe/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +cmake_minimum_required(VERSION 3.22) +project(AtrinikMixerProbe C) +find_package(SDL3 3.4 CONFIG REQUIRED) +find_package(SDL3_mixer 3.2.4 CONFIG REQUIRED) +add_executable(mixer-probe main.c) +set_target_properties(mixer-probe PROPERTIES C_STANDARD 17 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) +target_compile_options(mixer-probe PRIVATE -Wall -Wextra -Werror) +target_link_libraries(mixer-probe PRIVATE SDL3::SDL3 SDL3_mixer::SDL3_mixer) diff --git a/tools/ci/macos_mixer_probe/main.c b/tools/ci/macos_mixer_probe/main.c new file mode 100644 index 000000000..bcb640b3a --- /dev/null +++ b/tools/ci/macos_mixer_probe/main.c @@ -0,0 +1,93 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + */ +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: mixer-probe VERIFIED_OPUS_FIXTURE\n"); + return 2; + } + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + if (!SDL_Init(SDL_INIT_AUDIO | SDL_INIT_EVENTS) || !MIX_Init()) { + fprintf(stderr, "mixer initialization failed: %s\n", SDL_GetError()); + return 1; + } + int result = 1; + MIX_AudioDecoder *decoder = NULL; + MIX_Mixer *mixer = NULL; + MIX_Audio *audio = NULL; + static const char *const expected[] = + {"WAV", "STBVORBIS", "OPUS", "VOC", "AIFF", "AU", "DRMP3", "SINEWAVE", "RAW"}; + int count = MIX_GetNumAudioDecoders(); + printf("SDL3_mixer decoders:"); + for (int i = 0; i < count; i++) { + const char *name = MIX_GetAudioDecoder(i); + printf(" %s", name != NULL ? name : "(null)"); + } + puts(""); + if (count != (int)(sizeof(expected) / sizeof(expected[0]))) { + fprintf(stderr, "unsupported decoder count: %d\n", count); + goto cleanup; + } + for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); i++) { + unsigned int matches = 0; + for (int j = 0; j < count; j++) { + const char *actual = MIX_GetAudioDecoder(j); + matches += actual != NULL && strcmp(actual, expected[i]) == 0; + } + if (matches != 1) { + fprintf(stderr, "missing or duplicate decoder: %s\n", expected[i]); + goto cleanup; + } + } + decoder = MIX_CreateAudioDecoder(argv[1], 0); + if (decoder == NULL) { + fprintf(stderr, "Opus fixture decoder failed: %s\n", SDL_GetError()); + goto cleanup; + } + SDL_AudioSpec spec = {SDL_AUDIO_S16, 2, 48000}; + unsigned char pcm[4096]; + size_t total = 0; + bool nonzero = false; + for (;;) { + int decoded = MIX_DecodeAudio(decoder, pcm, sizeof(pcm), &spec); + if (decoded < 0 || (size_t)decoded > 23040 - total) { + fprintf(stderr, "invalid Opus fixture PCM: %s\n", SDL_GetError()); + goto cleanup; + } + if (decoded == 0) { + break; + } + total += (size_t)decoded; + for (int i = 0; i < decoded; i++) { + nonzero = nonzero || pcm[i] != 0; + } + } + if (total != 23040 || !nonzero) { + fprintf(stderr, "expected 23040 nonzero Opus PCM bytes, got %zu\n", total); + goto cleanup; + } + mixer = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, NULL); + if (mixer == NULL || (audio = MIX_LoadAudio(mixer, argv[1], true)) == NULL) { + fprintf(stderr, "dummy device/fixture load failed: %s\n", SDL_GetError()); + goto cleanup; + } + MIX_Track *track = MIX_CreateTrack(mixer); + if (track == NULL || !MIX_SetTrackAudio(track, audio) || !MIX_PlayTrack(track, 0)) { + fprintf(stderr, "dummy playback failed: %s\n", SDL_GetError()); + goto cleanup; + } + result = 0; + puts("exact decoder contract, Opus PCM and dummy playback passed"); +cleanup: + MIX_DestroyMixer(mixer); + MIX_DestroyAudio(audio); + MIX_DestroyAudioDecoder(decoder); + MIX_Quit(); + SDL_Quit(); + return result; +} diff --git a/tools/ci/prepare_macos_mixer.sh b/tools/ci/prepare_macos_mixer.sh new file mode 100755 index 000000000..abeceb731 --- /dev/null +++ b/tools/ci/prepare_macos_mixer.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +set -euo pipefail + +# Homebrew's mixer enables a different decoder set. Build the same locked +# source and decoder options used by the supported Linux/Windows producers. +if test "$(uname -s)" != Darwin; then + echo 'Mixer preparation requires a native macOS host' >&2 + exit 1 +fi +if test "$#" -ne 1; then + echo 'usage: prepare_macos_mixer.sh ABSENT_INSTALL_DIRECTORY' >&2 + exit 1 +fi +root=$1 +case "${root}" in /*) ;; *) echo 'install directory must be absolute' >&2; exit 1 ;; esac +repository=$(cd "$(dirname "$0")/../.." && pwd) +mkdir "${root}" +stage="${root}/stage" +mkdir "${stage}" +python3 - "${repository}/tools/ci/appimage/packaging.lock.json" > "${stage}/source.txt" <<'PY' +import json +import sys +lock = json.load(open(sys.argv[1], encoding="utf-8")) +source, = (entry for entry in lock["sources"] if entry["name"] == "SDL3_mixer") +print(source["url"]) +print(source["sha256"]) +print(source["source_directory"]) +PY +{ read -r url; read -r digest; read -r source; } < "${stage}/source.txt" +curl --fail --location --retry 3 "${url}" --output "${stage}/source.tar.gz" +printf '%s %s\n' "${digest}" "${stage}/source.tar.gz" | shasum -a 256 --check +tar -xf "${stage}/source.tar.gz" -C "${stage}" +prefix=$(brew --prefix) +export PKG_CONFIG_PATH="$(brew --prefix opusfile)/lib/pkgconfig:$(brew --prefix opus)/lib/pkgconfig:$(brew --prefix libogg)/lib/pkgconfig:${prefix}/lib/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" +toolchain="${repository}/cmake/toolchains/llvm-macos.cmake" +cmake -S "${stage}/${source}" -B "${stage}/build" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="${toolchain}" -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${root}" -DCMAKE_PREFIX_PATH="${prefix}" \ + -DBUILD_SHARED_LIBS=ON -DSDLMIXER_STRICT=ON \ + -DSDLMIXER_VENDORED=OFF -DSDLMIXER_DEPS_SHARED=OFF -DSDLMIXER_EXAMPLES=OFF \ + -DSDLMIXER_AIFF=ON -DSDLMIXER_WAVE=ON -DSDLMIXER_VOC=ON -DSDLMIXER_AU=ON \ + -DSDLMIXER_FLAC=OFF -DSDLMIXER_GME=OFF -DSDLMIXER_INSTALL=ON -DSDLMIXER_MIDI=OFF \ + -DSDLMIXER_MOD=OFF -DSDLMIXER_MP3=ON -DSDLMIXER_MP3_DRMP3=ON -DSDLMIXER_MP3_MPG123=OFF \ + -DSDLMIXER_OPUS=ON -DSDLMIXER_TESTS=OFF -DSDLMIXER_VORBIS_STB=ON \ + -DSDLMIXER_VORBIS_TREMOR=OFF -DSDLMIXER_VORBIS_VORBISFILE=OFF -DSDLMIXER_WAVPACK=OFF +cmake --build "${stage}/build" --parallel 2 +cmake --install "${stage}/build" +cmake -S "${repository}/tools/ci/macos_mixer_probe" -B "${stage}/probe" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="${toolchain}" -DCMAKE_BUILD_TYPE=Release \ + "-DCMAKE_PREFIX_PATH=${root};${prefix}" "-DSDL3_mixer_DIR=${root}/lib/cmake/SDL3_mixer" +cmake --build "${stage}/probe" --parallel 2 +cmake -DFIXTURE="${repository}/client/src/tests/fixtures/audio/opus-tone.mid" \ + -DCHECKSUM_FILE="${repository}/client/src/tests/fixtures/audio/opus-tone.sha256" \ + -P "${repository}/client/src/tests/fixtures/audio/verify.cmake" +"${stage}/probe/mixer-probe" "${repository}/client/src/tests/fixtures/audio/opus-tone.mid" diff --git a/tools/tests/test_macos_qualification_workflow.py b/tools/tests/test_macos_qualification_workflow.py index 92f6a8fd9..fa25ceb0e 100644 --- a/tools/tests/test_macos_qualification_workflow.py +++ b/tools/tests/test_macos_qualification_workflow.py @@ -23,6 +23,17 @@ def embedded_python_script(index): class MacOSQualificationWorkflowTests(unittest.TestCase): + def test_audio_uses_the_qualified_decoder_prefix(self): + text = WORKFLOW.read_text() + self.assertIn('prepare_macos_mixer.sh "${RUNNER_TEMP}/classic-mixer-macos"', text) + self.assertIn('"-DSDL3_mixer_DIR=${RUNNER_TEMP}/classic-mixer-macos/lib/cmake/SDL3_mixer"', text) + self.assertIn('"-DCMAKE_PREFIX_PATH=${RUNNER_TEMP}/classic-mixer-macos;', text) + self.assertIn('sdl3 sdl3_image sdl3_ttf opusfile', text) + self.assertNotIn('sdl3 sdl3_image sdl3_ttf sdl3_mixer', text) + self.assertIn('mixer-CMakeCache.txt', text) + self.assertIn('mixer-source.txt', text) + self.assertIn('mixer-probe.log', text) + def test_discovery_requires_each_supported_native_component(self): names = [ "protocol-generate-check", "libatrinik-smoke", "atrinik-pathfinding", diff --git a/tools/tests/test_prepare_macos_mixer.py b/tools/tests/test_prepare_macos_mixer.py new file mode 100644 index 000000000..be36dc2d5 --- /dev/null +++ b/tools/tests/test_prepare_macos_mixer.py @@ -0,0 +1,149 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from contextlib import contextmanager +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +class PrepareMacOSMixerTests(unittest.TestCase): + @contextmanager + def fixture(self, **extra_environment): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = root / "checkout with spaces" + helper = repository / "tools/ci/prepare_macos_mixer.sh" + helper.parent.mkdir(parents=True) + shutil.copyfile(ROOT / "tools/ci/prepare_macos_mixer.sh", helper) + lock = repository / "tools/ci/appimage/packaging.lock.json" + lock.parent.mkdir() + archive = root / "source.tar.gz" + with tarfile.open(archive, "w:gz") as output: + content = b"verified source fixture\n" + item = tarfile.TarInfo("SDL3_mixer-fixture/LICENSE.txt") + item.size = len(content) + output.addfile(item, io.BytesIO(content)) + lock.write_text(json.dumps({"sources": [{ + "name": "SDL3_mixer", "url": "https://example.invalid/source.tar.gz", + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "source_directory": "SDL3_mixer-fixture", + }]})) + binary = root / "bin" + binary.mkdir() + fake = binary / "fake" + fake.write_text('''#!/usr/bin/env python3 +import json, os, pathlib, shutil, sys +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +with open(os.environ['COMMAND_LOG'], 'a') as log: + log.write(json.dumps({'name': name, 'args': args}) + '\\n') +if name == 'uname': + print(os.environ.get('FIXTURE_SYSTEM', 'Darwin')) +elif name == 'brew': + print(os.environ['DEPENDENCY_PREFIX']) +elif name == 'curl': + shutil.copyfile(os.environ['SOURCE_ARCHIVE'], args[args.index('--output') + 1]) +elif name == 'cmake': + if '--build' in args and os.environ.get('FAIL_BUILD') == '1': + sys.exit(17) + if '-S' in args and args[args.index('-S') + 1].endswith('macos_mixer_probe'): + build = pathlib.Path(args[args.index('-B') + 1]) + build.mkdir(parents=True) + probe = build / 'mixer-probe' + probe.write_text('#!/bin/sh\\nprintf "decoder/Opus/playback probe\\\\n"\\nexit ' + os.environ.get('PROBE_EXIT', '0') + '\\n') + probe.chmod(0o755) +''') + fake.chmod(0o755) + for name in ("uname", "brew", "curl", "cmake"): + (binary / name).symlink_to(fake) + environment = dict(os.environ, PATH=f"{binary}:{os.environ['PATH']}", + COMMAND_LOG=str(root / "commands.jsonl"), + SOURCE_ARCHIVE=str(archive), DEPENDENCY_PREFIX=str(root / "deps"), + **extra_environment) + yield root, helper, lock, environment + + def invoke(self, root, helper, environment, destination=None): + result = subprocess.run(["bash", str(helper), str(destination or root / "mixer prefix")], + env=environment, capture_output=True, text=True) + log = root / "commands.jsonl" + calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + return result, calls + + def test_exact_supported_decoders_and_explicit_native_toolchain(self): + with self.fixture() as (root, helper, lock, environment): + result, calls = self.invoke(root, helper, environment) + self.assertEqual(result.returncode, 0, result.stderr) + configurations = [call["args"] for call in calls + if call["name"] == "cmake" and "-S" in call["args"]] + self.assertEqual(len(configurations), 2) + options = dict(argument[2:].split("=", 1) for argument in configurations[0] + if argument.startswith("-D")) + expected = { + "AIFF": "ON", "WAVE": "ON", "VOC": "ON", "AU": "ON", + "FLAC": "OFF", "GME": "OFF", "MIDI": "OFF", "MOD": "OFF", + "MP3": "ON", "MP3_DRMP3": "ON", "MP3_MPG123": "OFF", + "OPUS": "ON", "VORBIS_STB": "ON", "VORBIS_TREMOR": "OFF", + "VORBIS_VORBISFILE": "OFF", "WAVPACK": "OFF", "STRICT": "ON", + } + for name, value in expected.items(): + self.assertEqual(options["SDLMIXER_" + name], value) + self.assertEqual(options["CMAKE_BUILD_TYPE"], "Release") + self.assertEqual(options["BUILD_SHARED_LIBS"], "ON") + toolchain = str(helper.parents[2] / "cmake/toolchains/llvm-macos.cmake") + for configure in configurations: + self.assertIn("-DCMAKE_TOOLCHAIN_FILE=" + toolchain, configure) + self.assertIn("-DSDL3_mixer_DIR=" + str(root / "mixer prefix/lib/cmake/SDL3_mixer"), + configurations[1]) + self.assertIn("decoder/Opus/playback probe", result.stdout) + + def test_changed_archive_fails_before_configuration(self): + with self.fixture() as (root, helper, lock, environment): + value = json.loads(lock.read_text()) + value["sources"][0]["sha256"] = "0" * 64 + lock.write_text(json.dumps(value)) + result, calls = self.invoke(root, helper, environment) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(any(call["name"] == "cmake" for call in calls)) + + def test_build_failure_is_preserved(self): + with self.fixture(FAIL_BUILD="1") as (root, helper, lock, environment): + result, calls = self.invoke(root, helper, environment) + self.assertEqual(result.returncode, 17, result.stderr) + self.assertFalse(any("--install" in call["args"] for call in calls)) + + def test_runtime_contract_failure_is_preserved(self): + with self.fixture(PROBE_EXIT="9") as (root, helper, lock, environment): + result, calls = self.invoke(root, helper, environment) + self.assertEqual(result.returncode, 9, result.stderr) + + def test_other_hosts_fail_before_download(self): + with self.fixture(FIXTURE_SYSTEM="Linux") as (root, helper, lock, environment): + result, calls = self.invoke(root, helper, environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("native macOS", result.stderr) + self.assertEqual([call["name"] for call in calls], ["uname"]) + + def test_existing_destination_is_preserved(self): + with self.fixture() as (root, helper, lock, environment): + destination = root / "existing" + destination.mkdir() + sentinel = destination / "owned-by-another-task" + sentinel.write_text("preserve") + result, calls = self.invoke(root, helper, environment, destination) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(sentinel.read_text(), "preserve") + self.assertFalse(any(call["name"] == "curl" for call in calls)) + + +if __name__ == "__main__": + unittest.main() From 0d838951a23bc3b54cb7a701634130ebe29ce9ec Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:45:54 -0500 Subject: [PATCH 061/148] fix(ci): preserve macOS mixer dependency lookup errors --- tools/ci/prepare_macos_mixer.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/ci/prepare_macos_mixer.sh b/tools/ci/prepare_macos_mixer.sh index abeceb731..1e91fb437 100755 --- a/tools/ci/prepare_macos_mixer.sh +++ b/tools/ci/prepare_macos_mixer.sh @@ -33,7 +33,11 @@ curl --fail --location --retry 3 "${url}" --output "${stage}/source.tar.gz" printf '%s %s\n' "${digest}" "${stage}/source.tar.gz" | shasum -a 256 --check tar -xf "${stage}/source.tar.gz" -C "${stage}" prefix=$(brew --prefix) -export PKG_CONFIG_PATH="$(brew --prefix opusfile)/lib/pkgconfig:$(brew --prefix opus)/lib/pkgconfig:$(brew --prefix libogg)/lib/pkgconfig:${prefix}/lib/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" +opusfile_prefix=$(brew --prefix opusfile) +opus_prefix=$(brew --prefix opus) +ogg_prefix=$(brew --prefix libogg) +PKG_CONFIG_PATH="${opusfile_prefix}/lib/pkgconfig:${opus_prefix}/lib/pkgconfig:${ogg_prefix}/lib/pkgconfig:${prefix}/lib/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" +export PKG_CONFIG_PATH toolchain="${repository}/cmake/toolchains/llvm-macos.cmake" cmake -S "${stage}/${source}" -B "${stage}/build" -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="${toolchain}" -DCMAKE_BUILD_TYPE=Release \ From 8048191fc62554be68647463a18fc5b956d06f8d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:46:08 -0500 Subject: [PATCH 062/148] test(ci): reject unavailable macOS mixer dependencies --- tools/tests/test_prepare_macos_mixer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/tests/test_prepare_macos_mixer.py b/tools/tests/test_prepare_macos_mixer.py index be36dc2d5..4f3460df1 100644 --- a/tools/tests/test_prepare_macos_mixer.py +++ b/tools/tests/test_prepare_macos_mixer.py @@ -50,6 +50,8 @@ def fixture(self, **extra_environment): if name == 'uname': print(os.environ.get('FIXTURE_SYSTEM', 'Darwin')) elif name == 'brew': + if args == ['--prefix', 'opusfile'] and os.environ.get('FAIL_DEPENDENCY') == '1': + sys.exit(23) print(os.environ['DEPENDENCY_PREFIX']) elif name == 'curl': shutil.copyfile(os.environ['SOURCE_ARCHIVE'], args[args.index('--output') + 1]) @@ -121,6 +123,12 @@ def test_build_failure_is_preserved(self): self.assertEqual(result.returncode, 17, result.stderr) self.assertFalse(any("--install" in call["args"] for call in calls)) + def test_dependency_lookup_failure_is_preserved(self): + with self.fixture(FAIL_DEPENDENCY="1") as (root, helper, lock, environment): + result, calls = self.invoke(root, helper, environment) + self.assertEqual(result.returncode, 23, result.stderr) + self.assertFalse(any(call["name"] == "cmake" for call in calls)) + def test_runtime_contract_failure_is_preserved(self): with self.fixture(PROBE_EXIT="9") as (root, helper, lock, environment): result, calls = self.invoke(root, helper, environment) From 91713b4a5e8818419c075243377c4e0f594879c6 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 01:55:10 -0500 Subject: [PATCH 063/148] test(ci): cover LLVM profile identities and zero baselines --- tools/tests/test_llvm_coverage_identity.py | 297 +++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 tools/tests/test_llvm_coverage_identity.py diff --git a/tools/tests/test_llvm_coverage_identity.py b/tools/tests/test_llvm_coverage_identity.py new file mode 100644 index 000000000..c06d6ccfe --- /dev/null +++ b/tools/tests/test_llvm_coverage_identity.py @@ -0,0 +1,297 @@ +"""Regression evidence for complete LLVM 23 binary/profile associations.""" +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import copy +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import struct +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +HELPER = Path(os.environ.get("ATRINIK_LLVM_COVERAGE_HELPER", Path(__file__).resolve().parents[1] / "ci" / "llvm_coverage.py")) +SPEC = importlib.util.spec_from_file_location("llvm_coverage_identity_helper", HELPER) +cov = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(cov) + + +def text_record(name="f", function_hash=91, values=(1, 0)): + return (f"{name}\n# Func Hash:\n{function_hash}\n# Num Counters:\n{len(values)}\n" + "# Counter Values:\n" + "\n".join(map(str, values))) + + +def name_hash(name): + return int.from_bytes(hashlib.md5(name.encode()).digest()[:8], "little") + + +def mapping(filename): + return {"filename": filename, "branches": [[1, 1, 1, 8, 0, 0, 0, 0, 4]], + "expansions": [], "mcdc_records": [], + "segments": [[1, 1, 0, True, True, False], [1, 8, 0, False, False, False]], + "summary": {key: {"count": 2 if key == "branches" else 1, "covered": 0, "percent": 0} for key in + ("branches", "functions", "instantiations", "lines", "regions")}} + + +def function(filename, name="f"): + return {"branches": [[1, 1, 1, 8, 0, 0, 0, 0, 4]], "count": 0, "filenames": [filename], + "mcdc_records": [], "name": name, "regions": [[1, 1, 1, 8, 0, 0, 0, 0]]} + + +def document(files, functions): + return json.dumps({"type": "llvm.coverage.json.export", "version": "3.1.0", + "data": [{"files": files, "functions": functions, "totals": {}}]}) + + +def lcov(filename, count=0): + return (f"SF:{filename}\nFN:1,f\nFNDA:{count},f\nFNF:1\nFNH:{int(count > 0)}\n" + f"DA:1,{count}\nLF:1\nLH:{int(count > 0)}\nBRDA:1,0,0,{count}\n" + f"BRDA:1,0,1,0\nBRF:2\nBRH:{int(count > 0)}\nend_of_record\n") + + +class IdentityTests(unittest.TestCase): + def test_complete_profile_includes_zero_functions_and_zero_counters(self): + report = text_record() + "\n\n" + text_record("unused", 123, (0, 0, 0)) + "\n" + self.assertEqual(cov.profile_identity(report), tuple(sorted([ + (name_hash("f"), 91, 2), (name_hash("unused"), 123, 3)]))) + + def test_malformed_text_profiles_fail_closed(self): + good = text_record() + bad = ["", good.rsplit("\n", 1)[0], good + "\n2", good.replace("# Num Counters:\n2", "# Num Counters:\n0"), + good + "\n\n" + good, good.replace("# Func Hash:", "# Function hash:"), + good.replace("91", "0x5b"), good.replace("\n1\n0", "\n-1\n0")] + for report in bad: + with self.subTest(report=report), self.assertRaises(ValueError): + cov.profile_identity(report) + + def test_complete_unique_association_and_repeated_process_profiles(self): + a, b = Path("a"), Path("b") + ia, ib = cov.profile_identity(text_record()), cov.profile_identity(text_record("main", 3, (0,))) + with patch.object(cov, "binary_identity", side_effect=lambda p, _: {a: ia, b: ib}[p]), patch.object(cov, "run", return_value=text_record()): + self.assertEqual(cov.executed_objects([a, b], [Path("p1"), Path("p2")], "profdata", "readobj"), [a]) + + def test_foreign_partial_and_ambiguous_associations_rejected(self): + a, b = Path("a"), Path("b") + full = cov.profile_identity(text_record() + "\n\n" + text_record("unused", 2, (0,))) + for report in (text_record(), text_record("foreign", 8, (1,))): + with self.subTest(report=report), patch.object(cov, "binary_identity", return_value=full), patch.object(cov, "run", return_value=report), self.assertRaises(ValueError): + cov.executed_objects([a], [Path("raw")], "profdata", "readobj") + with patch.object(cov, "binary_identity", return_value=full), self.assertRaises(ValueError): + cov.executed_objects([a, b], [], "profdata", "readobj") + + def test_duplicate_byte_artifacts_are_deduplicated(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for name, data in (("a", b"\x7fELF__llvm_covmap one"), ("b", b"\x7fELF__llvm_covmap one"), ("c", b"\x7fELF__llvm_covmap two")): + (root / name).write_bytes(data) + self.assertEqual(cov.coverage_objects(root), [root / "a", root / "c"]) + + +class BinaryIdentityTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / "fixture" + self.bytes = bytearray(512) + self.bytes[:7] = b"\x7fELF\x02\x01\x01" + struct.pack_into(" 1)\nstatic inline int inc(int x) { return x + 1; }\nint common(int x) { if (x > 0) return OUTER(inc(x)); return x ? 8 : 9; }\n") + self.test_main = self.tests / "test.c" + self.test_main.write_text("int common(int); int main(void) { return common(2) == 3 ? 0 : 1; }\n") + self.bench_main = self.tests / "benchmark.c" + self.bench_main.write_text("int common(int); int main(void) { int n = 0; for (int i = 0; i < 4; ++i) n += common(i); return n == 0; }\n") + self.unrun_main = self.src / "application.c" + self.unrun_main.write_text("int main(void) { volatile int x = 0; return x ? 1 : 0; }\n") + self.plugin_src = self.src / "plugin.c" + self.plugin_src.write_text("int plugin(int x) { return x > 0 ? 1 : 0; }\n") + self.test = self.compile("test", [self.test_main, self.common]) + self.compile("benchmark", [self.bench_main, self.common]) + self.compile("application", [self.unrun_main]) + self.compile("plugin.so", [self.plugin_src], ["-shared", "-fPIC"]) + pattern = cov.prepare(self.build) + subprocess.run([str(self.test)], check=True, env={**os.environ, "LLVM_PROFILE_FILE": pattern}) + self.profiles = sorted((self.build / cov.PROFILE_DIRECTORY).glob("*.profraw")) + + def compile(self, name, sources, extra=()): + output = self.build / name + command = [self.clang, "-std=c17", "-Werror", "-O0", "-fprofile-instr-generate", "-fcoverage-mapping", "-fprofile-update=atomic", + "--rtlib=compiler-rt", "--unwindlib=libunwind", "-fuse-ld=lld", *extra, *map(str, sources), "-o", str(output)] + subprocess.run(command, check=True, capture_output=True, text=True) + return output + + def test_native_complete_graph_preserves_measured_macro_and_branch_counts(self): + objects = cov.coverage_objects(self.build) + executed = cov.executed_objects(objects, self.profiles, self.profdata, self.readobj) + self.assertEqual(executed, [self.test]) + merged = self.build / "merged.profdata" + cov.run([self.profdata, "merge", "-o", str(merged), *map(str, self.profiles)]) + measured = cov.lcov_records(cov.run(cov.object_arguments(self.llvmcov, executed, "-format=lcov", f"-instr-profile={merged}")), self.root, "client") + result = cov.complete_report(self.llvmcov, objects, executed, merged, self.root, "client") + records = cov.lcov_records(result, self.root, "client") + self.assertEqual(records[str(self.common)], measured[str(self.common)]) + self.assertIn("BRDA:", measured[str(self.common)]) + for source in (self.unrun_main, self.plugin_src): + self.assertIn(str(source), records) + self.assertIn("FNH:0", records[str(source)]) + self.assertIn("LH:0", records[str(source)]) + + def test_native_unexecuted_additional_same_source_mapping_rejected(self): + self.common.write_text(self.common.read_text() + + "#ifdef EXTRA\nint extra(int x) { return x ? 7 : 8; }\n#endif\n") + self.compile("benchmark", [self.bench_main, self.common], ["-DEXTRA"]) + objects = cov.coverage_objects(self.build) + executed = cov.executed_objects(objects, self.profiles, self.profdata, self.readobj) + merged = self.build / "merged.profdata" + cov.run([self.profdata, "merge", "-o", str(merged), *map(str, self.profiles)]) + with self.assertRaises(ValueError): + cov.complete_report(self.llvmcov, objects, executed, merged, self.root, "client") + + def test_native_rebuilt_incompatible_binary_rejects_old_profile(self): + self.common.write_text("int common(int x) { if (x > 0) { if (x > 1) return 3; return 4; } return 9; }\n") + self.compile("test", [self.test_main, self.common]) + with self.assertRaises(ValueError): + cov.executed_objects(cov.coverage_objects(self.build), self.profiles, self.profdata, self.readobj) + + +if __name__ == "__main__": + unittest.main() From a5c2ee132038a1cfb6b03ed5bdc783869660d464 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:09:08 -0500 Subject: [PATCH 064/148] test(ci): adapt coverage exports to complete LLVM identities --- tools/tests/test_llvm_coverage.py | 102 +++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 15 deletions(-) diff --git a/tools/tests/test_llvm_coverage.py b/tools/tests/test_llvm_coverage.py index 17aabd3fc..62ea4caa6 100644 --- a/tools/tests/test_llvm_coverage.py +++ b/tools/tests/test_llvm_coverage.py @@ -2,10 +2,13 @@ # SPDX-License-Identifier: GPL-2.0-or-later import contextlib +import hashlib import importlib.util import io +import json import os from pathlib import Path +import struct import subprocess import tempfile import tarfile @@ -16,13 +19,50 @@ from tools.release import package_sources spec = importlib.util.spec_from_file_location( - "llvm_coverage", Path(__file__).resolve().parents[1] / "ci" / "llvm_coverage.py") + "llvm_coverage", Path(os.environ.get("ATRINIK_LLVM_COVERAGE_HELPER", + Path(__file__).resolve().parents[1] / "ci" / "llvm_coverage.py"))) coverage = importlib.util.module_from_spec(spec) spec.loader.exec_module(coverage) -def record(path): - return f"SF:{path}\nFN:1,example\nFNDA:1,example\nDA:1,1\nBRDA:1,0,0,1\nend_of_record\n" +def record(path, count=1): + return (f"SF:{path}\nFN:1,example\nFNDA:{count},example\nFNF:1\nFNH:{int(count > 0)}\n" + f"DA:1,{count}\nLF:1\nLH:{int(count > 0)}\nBRDA:1,0,0,{count}\nBRDA:1,0,1,0\n" + f"BRF:2\nBRH:{int(count > 0)}\nend_of_record\n") + + +def zero_json(paths): + summary = {key: {"count": 2 if key == "branches" else 1, "covered": 0, "percent": 0} + for key in ("branches", "functions", "instantiations", "lines", "regions")} + files = [{"filename": str(path), "branches": [[1, 1, 1, 8, 0, 0, 0, 0, 4]], + "expansions": [], "mcdc_records": [], + "segments": [[1, 1, 0, True, True, False], [1, 8, 0, False, False, False]], + "summary": summary} for path in paths] + functions = [{"branches": [[1, 1, 1, 8, 0, 0, 0, 0, 4]], "count": 0, + "filenames": [str(path)], "mcdc_records": [], "name": "example", + "regions": [[1, 1, 1, 8, 0, 0, 0, 0]]} for path in paths] + return json.dumps({"type": "llvm.coverage.json.export", "version": "3.1.0", + "data": [{"files": files, "functions": functions, "totals": summary}]}) + + +def elf_fixture(path): + """An actual supported record in disposable ELF bytes, with readobj metadata.""" + content = bytearray(512) + content[:7] = b"\x7fELF\x02\x01\x01" + struct.pack_into(" Date: Sun, 11 Oct 2026 02:13:28 -0500 Subject: [PATCH 065/148] fix(ci): preserve native LLVM coverage identities and zero maps --- CMakeLists.txt | 3 +- client/CMakeLists.txt | 3 +- libatrinik/CMakeLists.txt | 3 +- libatrinik/pathfinding/CMakeLists.txt | 3 +- server/CMakeLists.txt | 3 +- tools/ci/llvm_coverage.py | 294 +++++++++++++++++++-- tools/tests/test_llvm_coverage_identity.py | 91 ++++++- 7 files changed, 368 insertions(+), 32 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index cbd7a3671..9bbef2442 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,7 +56,8 @@ function(atrinik_configure_target target) get_target_property(target_type ${target} TYPE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) target_compile_options(${target} PRIVATE - -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic + -funique-internal-linkage-names -O0 -g) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index badfcbb23..2b0b6d522 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -241,7 +241,8 @@ function(atrinik_configure_target target) get_target_property(target_type ${target} TYPE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) target_compile_options(${target} PRIVATE - -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic + -funique-internal-linkage-names -O0 -g) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index 49368f11e..1c8ce2cc8 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -52,7 +52,8 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) get_target_property(target_type ${target} TYPE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) target_compile_options(${target} PRIVATE - -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic + -funique-internal-linkage-names -O0 -g) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () diff --git a/libatrinik/pathfinding/CMakeLists.txt b/libatrinik/pathfinding/CMakeLists.txt index 4cb8e5f45..bf5cd753b 100644 --- a/libatrinik/pathfinding/CMakeLists.txt +++ b/libatrinik/pathfinding/CMakeLists.txt @@ -27,7 +27,8 @@ endif () if (ENABLE_COVERAGE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) - add_compile_options(-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) + add_compile_options(-fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic + -funique-internal-linkage-names -O0 -g) add_link_options(-fprofile-instr-generate) else () message(FATAL_ERROR "ENABLE_COVERAGE requires native Clang") diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 39fb9b018..caceeefd6 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -96,7 +96,8 @@ function(atrinik_configure_target target) get_target_property(target_type ${target} TYPE) if (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW) target_compile_options(${target} PRIVATE - -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic -O0 -g) + -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic + -funique-internal-linkage-names -O0 -g) if (target_type MATCHES "^(EXECUTABLE|MODULE_LIBRARY|SHARED_LIBRARY)$") target_link_options(${target} PRIVATE -fprofile-instr-generate) endif () diff --git a/tools/ci/llvm_coverage.py b/tools/ci/llvm_coverage.py index f9f4ad742..a7a93cc0f 100644 --- a/tools/ci/llvm_coverage.py +++ b/tools/ci/llvm_coverage.py @@ -4,10 +4,13 @@ # SPDX-License-Identifier: GPL-2.0-or-later import argparse +import hashlib +import json import os from pathlib import Path import re import shutil +import struct import subprocess import sys @@ -49,6 +52,7 @@ def prepare(build_dir): def coverage_objects(build_dir): objects = [] + seen = set() for path in sorted(build_dir.rglob("*")): if path.is_symlink() or not path.is_file(): continue @@ -62,12 +66,116 @@ def coverage_objects(build_dir): if stream.read(4) != b"\x7fELF": continue if b"__llvm_covmap" in stream.read(): - objects.append(path) + # CTest runtime trees contain byte-for-byte plugin copies. + # They represent the same maps and instrumentation identity. + with path.open("rb") as binary: + digest = hashlib.file_digest(binary, "sha256").digest() + if digest not in seen: + objects.append(path) + seen.add(digest) if not objects: raise ValueError("no instrumented executables or shared plugins found") return objects +def binary_identity(path, readobj): + """Read the pinned LLVM 23 ELF64 frontend instrumentation identity.""" + metadata = json.loads(run([readobj, "--elf-output-style=JSON", "--sections", "--symbols", str(path)])) + if len(metadata) != 1 or metadata[0]["FileSummary"]["Format"] != "elf64-x86-64": + raise ValueError(f"unsupported coverage binary format: {path}") + sections = [entry["Section"] for entry in metadata[0]["Sections"]] + + def section(name): + matches = [entry for entry in sections if entry["Name"]["Name"] == name] + if len(matches) != 1: + raise ValueError(f"missing or duplicate {name} section: {path}") + return matches[0] + + with path.open("rb") as stream: + header = stream.read(20) + if header[:7] != b"\x7fELF\x02\x01\x01" or struct.unpack_from(" size: + raise ValueError(f"invalid instrumentation section bounds: {path}") + stream.seek(offset) + return stream.read(length) + + versions = [entry["Symbol"] for entry in metadata[0]["Symbols"] + if entry["Symbol"]["Name"]["Name"] == "__llvm_profile_raw_version"] + if len(versions) != 1 or versions[0]["Size"] != 8: + raise ValueError(f"missing instrumentation version: {path}") + version = versions[0] + storage = section(version["Section"]["Name"]) + displacement = version["Value"] - storage["Address"] + if not 0 <= displacement <= storage["Size"] - 8 or struct.unpack( + " counter_section["Address"] + counter_section["Size"]): + raise ValueError(f"invalid instrumentation counter bounds: {path}") + counter_ranges.append((counter_start, counter_end)) + identities.append((name, function_hash, counters)) + counter_ranges.sort() + if any(left[1] > right[0] for left, right in zip(counter_ranges, counter_ranges[1:])): + raise ValueError(f"overlapping instrumentation counter records: {path}") + # LLVM can retain multiple records with the same name/hash identity. + # Their distinct counter ranges and multiplicity belong to the identity. + return tuple(sorted(identities)) + + +def profile_identity(text): + identities = [] + for record in text.strip().split("\n\n"): + match = re.fullmatch(r"([^\n]+)\n# Func Hash:\n([0-9]+)\n# Num Counters:\n([0-9]+)\n# Counter Values:\n([0-9\n]+)", record) + if not match: + raise ValueError("unsupported or malformed LLVM text profile") + name, function_hash, count, values = match.groups() + count = int(count) + if not count or len(values.splitlines()) != count: + raise ValueError("incomplete LLVM profile counters") + name_hash = int.from_bytes(hashlib.md5(name.encode()).digest()[:8], "little") + identities.append((name_hash, int(function_hash), count)) + return tuple(sorted(identities)) + + +def executed_objects(objects, profiles, profdata, readobj): + identities = {} + for path in objects: + identity = binary_identity(path, readobj) + if identity in identities: + raise ValueError("ambiguous instrumentation identity in different binaries") + identities[identity] = path + executed = set() + for profile in profiles: + # Including zero-count records distinguishes an unexecuted binary from + # a stale/partial profile with a coincidentally matching entry point. + identity = profile_identity(run([profdata, "show", "--all-functions", "--counts", "--text", str(profile)])) + if identity not in identities: + raise ValueError(f"profile does not match a complete current binary: {profile}") + executed.add(identities[identity]) + return sorted(executed) + + def source_prefixes(root, component): modules = ("client", "server", "libatrinik") if component == "all" else (component,) projects = {"client": "atrinik-client", "server": "atrinik-server", "libatrinik": "libatrinik"} @@ -96,6 +204,22 @@ def source_prefixes(root, component): return [(module, root / module) for module in modules] +def production_path(filename, root, prefixes): + path = Path(filename) + if not path.is_absolute(): + path = root / path + path = path.resolve() + for module, prefix in prefixes: + if path.is_relative_to(prefix): + relative = path.relative_to(prefix) + excluded = {"tests", "build", "dependencies", "_deps", "third_party", "third-party"} + if path.suffix == ".c" and not excluded.intersection(relative.parts): + if module == "libatrinik" or relative.parts[0] == "src": + return str(path) + break + return None + + def filter_lcov(report, source_root, component): root = source_root.resolve() prefixes = source_prefixes(root, component) @@ -104,27 +228,148 @@ def filter_lcov(report, source_root, component): match = re.search(r"^SF:(.+)$", record, re.MULTILINE) if not match: continue - path = Path(match.group(1)) - if not path.is_absolute(): - path = root / path - path = path.resolve() - for module, prefix in prefixes: - if not path.is_relative_to(prefix): - continue - relative = path.relative_to(prefix) - # Production C only. Tests, generated build/dependency trees and - # bundled upstream sources never contribute to Classic coverage. - excluded = {"tests", "build", "dependencies", "_deps", "third_party", "third-party"} - if path.suffix == ".c" and not excluded.intersection(relative.parts): - if module == "libatrinik" or relative.parts[0] == "src": - records.append(record.strip() + "\nend_of_record\n") - break + if production_path(match.group(1), root, prefixes): + records.append(record.strip() + "\nend_of_record\n") if not records: raise ValueError("coverage export contains no intended production C sources") return "".join(records) +def object_arguments(cov, objects, *options): + command = [cov, "export", *options, str(objects[0])] + for path in objects[1:]: + command += ["-object", str(path)] + return command + + +def zero_maps(report, source_root, component): + root = source_root.resolve() + prefixes = source_prefixes(root, component) + document = json.loads(report) + if (set(document) != {"data", "type", "version"} or + document["type"] != "llvm.coverage.json.export" or document["version"] != "3.1.0" or + len(document["data"]) != 1): + raise ValueError("unsupported LLVM coverage JSON schema") + data = document["data"][0] + if set(data) != {"files", "functions", "totals"}: + raise ValueError("unsupported LLVM coverage JSON data") + + def counters(records, width, indices): + for record in records: + if len(record) != width or any(type(record[index]) is not int or record[index] != 0 for index in indices): + raise ValueError("unsupported or nonzero LLVM baseline mapping") + + def summary(metrics): + if not set(metrics) <= {"branches", "functions", "instantiations", "lines", "regions", "mcdc"}: + raise ValueError("unsupported LLVM coverage summary") + for metric in metrics.values(): + if (not {"count", "covered", "percent"} <= set(metric) <= {"count", "covered", "percent", "notcovered"} or + type(metric["count"]) is not int or metric["count"] < 0 or + metric["covered"] != 0 or metric["percent"] != 0 or + metric.get("notcovered", metric["count"]) != metric["count"]): + raise ValueError("unsupported or nonzero LLVM baseline summary") + + summary(data["totals"]) + + def normalized(value): + if isinstance(value, list): + return [normalized(item) for item in value] + if isinstance(value, dict): + return {key: (str((root / item).resolve()) if key == "filename" else + [str((root / path).resolve()) for path in item] if key == "filenames" else + normalized(item)) for key, item in value.items()} + return value + + maps = {} + for entry in data["files"]: + if set(entry) != {"filename", "branches", "expansions", "mcdc_records", "segments", "summary"} or entry["mcdc_records"]: + raise ValueError("unsupported LLVM coverage file mapping") + counters(entry["segments"], 6, (2,)) + counters(entry["branches"], 9, (4, 5)) + summary(entry["summary"]) + for expansion in entry["expansions"]: + if set(expansion) != {"branches", "filenames", "source_region", "target_regions"}: + raise ValueError("unsupported LLVM coverage expansion mapping") + counters(expansion["branches"], 9, (4, 5)) + counters([expansion["source_region"], *expansion["target_regions"]], 8, (4,)) + filename = production_path(entry["filename"], root, prefixes) + if filename: + if filename in maps: + raise ValueError("duplicate production source in LLVM JSON") + maps[filename] = [normalized(entry), []] + for function in data["functions"]: + if (set(function) != {"branches", "count", "filenames", "mcdc_records", "name", "regions"} or + function["count"] != 0 or function["mcdc_records"]): + raise ValueError("unsupported or nonzero LLVM baseline function") + counters(function["branches"], 9, (4, 5)) + counters(function["regions"], 8, (4,)) + descriptor = json.dumps(normalized(function), sort_keys=True) + for filename in set(function["filenames"]): + filename = production_path(filename, root, prefixes) + if filename in maps: + maps[filename][1].append(descriptor) + for entry in maps.values(): + entry[1].sort() # Preserve multiplicity, filename table ordering and IDs. + return maps + + +def lcov_records(report, source_root, component): + root = source_root.resolve() + prefixes = source_prefixes(root, component) + records = {} + pending = [] + patterns = (r"TN:.*", r"SF:.+", r"FN:[1-9][0-9]*,.+", r"FNDA:[0-9]+,.+", + r"(?:FNF|FNH|LF|LH|BRF|BRH):[0-9]+", r"DA:[1-9][0-9]*,[0-9]+", + r"BRDA:[1-9][0-9]*,[0-9]+,[0-9]+,(?:[0-9]+|-)") + for line in report.splitlines(): + if not line: + continue + if line != "end_of_record": + if not any(re.fullmatch(pattern, line) for pattern in patterns): + raise ValueError("unsupported or malformed LLVM LCOV record") + pending.append(line) + continue + sources = [line[3:] for line in pending if line.startswith("SF:")] + if len(sources) != 1: + raise ValueError("missing or duplicate source in LLVM LCOV record") + filename = production_path(sources[0], root, prefixes) + if filename: + if filename in records: + raise ValueError("duplicate production source in LLVM LCOV") + records[filename] = "\n".join(pending) + "\nend_of_record\n" + pending = [] + if pending: + raise ValueError("unterminated LLVM LCOV record") + return records + + +def complete_report(cov, objects, executed, merged, source_root, component): + measured = lcov_records(run(object_arguments(cov, executed, "-format=lcov", f"-instr-profile={merged}")), source_root, component) + baseline = zero_maps(run(object_arguments(cov, objects, "-empty-profile")), source_root, component) + associated = zero_maps(run(object_arguments(cov, executed, "-empty-profile")), source_root, component) + zero_records = lcov_records(run(object_arguments(cov, objects, "-empty-profile", "-format=lcov")), source_root, component) + if not measured or set(measured) != set(associated) or set(zero_records) != set(baseline): + raise ValueError("coverage export contains no intended production C sources or inconsistent file sets") + # LCOV branch IDs are ordinal within a line, including macro expansions. + # Never merge ordinal IDs from different map sets. Keep LLVM's measured + # records intact, and append only entire source files from its zero baseline. + for filename, mapping in associated.items(): + if baseline.get(filename) != mapping: + raise ValueError(f"unexecuted binary adds mappings to a measured source; execute that binary: {filename}") + records = {**zero_records, **measured} + if set(records) != set(baseline): + raise ValueError("incomplete production coverage file set") + return "".join(records[name] for name in sorted(records)) + + def export(build_dir, source_root, component, output, compiler=None): + """Collect measured counts plus authentic LLVM zero-hit file baselines. + + The caller owns freshness: prepare, run its tests successfully, then export. + Structural identity detects incompatible/partial supplied profiles, but + cannot date unchanged instrumentation or prove a wholly absent execution. + Objects without a supplied profile contribute only explicitly zero counts. + """ if output != "-": # A failed collection must never leave an older report to be uploaded. Path(output).unlink(missing_ok=True) @@ -140,20 +385,21 @@ def export(build_dir, source_root, component, output, compiler=None): compiler = match.group(1) profdata = os.environ.get("LLVM_PROFDATA", "llvm-profdata") cov = os.environ.get("LLVM_COV", "llvm-cov") - for tool in (compiler, profdata, cov): + readobj = os.environ.get("LLVM_READOBJ", "llvm-readobj") + for tool in (compiler, profdata, cov, readobj): verify_tool(tool) objects = coverage_objects(build_dir) + executed = executed_objects(objects, profiles, profdata, readobj) merged = directory / "coverage.profdata" - run([profdata, "merge", "-sparse", "-o", str(merged), *map(str, profiles)]) - arguments = [cov, "export", "-format=lcov", f"-instr-profile={merged}", str(objects[0])] - for object_path in objects[1:]: - arguments += ["-object", str(object_path)] - report = filter_lcov(run(arguments), source_root, component) + # Zero-count records retain legitimate hash variants from different linked + # consumers; sparse merging would mistake those variants for stale data. + run([profdata, "merge", "-o", str(merged), *map(str, profiles)]) + report = complete_report(cov, objects, executed, merged, source_root, component) if output == "-": sys.stdout.write(report) else: Path(output).write_text(report) - print(f"LLVM coverage: {len(profiles)} profiles, {len(objects)} objects, {component} -> {output}", file=sys.stderr) + print(f"LLVM coverage: {len(profiles)} profiles, {len(executed)} measured/{len(objects)} objects, {component} -> {output}", file=sys.stderr) def main(): @@ -173,7 +419,7 @@ def main(): print(prepare(args.build_dir)) else: export(args.build_dir, args.source_root, args.component, args.output, args.compiler) - except (OSError, ValueError, subprocess.CalledProcessError) as error: + except (OSError, ValueError, KeyError, TypeError, struct.error, subprocess.CalledProcessError) as error: print(f"LLVM coverage failed: {error}", file=sys.stderr) if isinstance(error, subprocess.CalledProcessError) and error.stderr: print(error.stderr, file=sys.stderr) diff --git a/tools/tests/test_llvm_coverage_identity.py b/tools/tests/test_llvm_coverage_identity.py index c06d6ccfe..f81dadafa 100644 --- a/tools/tests/test_llvm_coverage_identity.py +++ b/tools/tests/test_llvm_coverage_identity.py @@ -54,6 +54,15 @@ def lcov(filename, count=0): class IdentityTests(unittest.TestCase): + def test_every_native_coverage_entrypoint_separates_internal_names(self): + root = Path(__file__).resolve().parents[2] + for name in ("CMakeLists.txt", "client/CMakeLists.txt", "server/CMakeLists.txt", + "libatrinik/CMakeLists.txt", "libatrinik/pathfinding/CMakeLists.txt"): + with self.subTest(entrypoint=name): + cmake = (root / name).read_text() + self.assertEqual(cmake.count("-funique-internal-linkage-names"), 1) + self.assertRegex(cmake, r"-fprofile-update=atomic\s+-funique-internal-linkage-names") + def test_complete_profile_includes_zero_functions_and_zero_counters(self): report = text_record() + "\n\n" + text_record("unused", 123, (0, 0, 0)) + "\n" self.assertEqual(cov.profile_identity(report), tuple(sorted([ @@ -62,12 +71,19 @@ def test_complete_profile_includes_zero_functions_and_zero_counters(self): def test_malformed_text_profiles_fail_closed(self): good = text_record() bad = ["", good.rsplit("\n", 1)[0], good + "\n2", good.replace("# Num Counters:\n2", "# Num Counters:\n0"), - good + "\n\n" + good, good.replace("# Func Hash:", "# Function hash:"), + good.replace("# Func Hash:", "# Function hash:"), good.replace("91", "0x5b"), good.replace("\n1\n0", "\n-1\n0")] for report in bad: with self.subTest(report=report), self.assertRaises(ValueError): cov.profile_identity(report) + def test_profile_record_multiplicity_is_part_of_identity(self): + record = text_record() + identity = (name_hash("f"), 91, 2) + self.assertEqual(cov.profile_identity(record + "\n\n" + record), (identity, identity)) + with patch.object(cov, "binary_identity", return_value=(identity, identity)), patch.object(cov, "run", return_value=record), self.assertRaises(ValueError): + cov.executed_objects([Path("a")], [Path("raw")], "profdata", "readobj") + def test_complete_unique_association_and_repeated_process_profiles(self): a, b = Path("a"), Path("b") ia, ib = cov.profile_identity(text_record()), cov.profile_identity(text_record("main", 3, (0,))) @@ -117,6 +133,13 @@ def identity(self): def test_real_record_bytes_with_relative_counters(self): self.assertEqual(self.identity(), tuple(sorted([(name_hash("f"), 91, 2), (name_hash("unused"), 123, 1)]))) + def test_duplicate_identity_preserves_distinct_ranges_and_section_padding(self): + struct.pack_into(" 0 ? 1 : 0; }\n") + self.zero_src = self.src / "zero.c" + self.zero_src.write_text("int never_called(int x) { return x ? 1 : 0; }\n") + self.zero_main = self.tests / "zero.c" + self.zero_main.write_text("int main(void) { return 0; }\n") self.test = self.compile("test", [self.test_main, self.common]) + self.zero_test = self.compile("zero-test", [self.zero_main, self.zero_src]) self.compile("benchmark", [self.bench_main, self.common]) self.compile("application", [self.unrun_main]) self.compile("plugin.so", [self.plugin_src], ["-shared", "-fPIC"]) pattern = cov.prepare(self.build) subprocess.run([str(self.test)], check=True, env={**os.environ, "LLVM_PROFILE_FILE": pattern}) + subprocess.run([str(self.zero_test)], check=True, env={**os.environ, "LLVM_PROFILE_FILE": pattern}) self.profiles = sorted((self.build / cov.PROFILE_DIRECTORY).glob("*.profraw")) def compile(self, name, sources, extra=()): output = self.build / name - command = [self.clang, "-std=c17", "-Werror", "-O0", "-fprofile-instr-generate", "-fcoverage-mapping", "-fprofile-update=atomic", + command = [self.clang, "-std=c17", "-Werror", "-O0", "-fprofile-instr-generate", "-fcoverage-mapping", "-fprofile-update=atomic", "-funique-internal-linkage-names", "--rtlib=compiler-rt", "--unwindlib=libunwind", "-fuse-ld=lld", *extra, *map(str, sources), "-o", str(output)] subprocess.run(command, check=True, capture_output=True, text=True) return output @@ -262,7 +309,7 @@ def compile(self, name, sources, extra=()): def test_native_complete_graph_preserves_measured_macro_and_branch_counts(self): objects = cov.coverage_objects(self.build) executed = cov.executed_objects(objects, self.profiles, self.profdata, self.readobj) - self.assertEqual(executed, [self.test]) + self.assertEqual(executed, [self.test, self.zero_test]) merged = self.build / "merged.profdata" cov.run([self.profdata, "merge", "-o", str(merged), *map(str, self.profiles)]) measured = cov.lcov_records(cov.run(cov.object_arguments(self.llvmcov, executed, "-format=lcov", f"-instr-profile={merged}")), self.root, "client") @@ -270,6 +317,8 @@ def test_native_complete_graph_preserves_measured_macro_and_branch_counts(self): records = cov.lcov_records(result, self.root, "client") self.assertEqual(records[str(self.common)], measured[str(self.common)]) self.assertIn("BRDA:", measured[str(self.common)]) + self.assertEqual(records[str(self.zero_src)], measured[str(self.zero_src)]) + self.assertIn("FNH:0", records[str(self.zero_src)]) for source in (self.unrun_main, self.plugin_src): self.assertIn(str(source), records) self.assertIn("FNH:0", records[str(source)]) @@ -292,6 +341,42 @@ def test_native_rebuilt_incompatible_binary_rejects_old_profile(self): with self.assertRaises(ValueError): cov.executed_objects(cov.coverage_objects(self.build), self.profiles, self.profdata, self.readobj) + def test_native_unique_internal_names_preserve_unused_inline_mapping(self): + header = self.tests / "shared.h" + header.write_text("static inline int shared(int x) { return x ? 1 : 0; }\n") + sources = [] + for unit, body in (("a", "return 0;"), ("b", "return shared(0);")): + folder = self.tests / unit + folder.mkdir() + source = folder / "same.c" + source.write_text('#include "../shared.h"\nint main(void) { ' + body + ' }\n') + sources.append(source) + for unique in (False, True): + directory = self.root / ("unique" if unique else "original") + directory.mkdir() + binaries = [self.compile(f"{directory.name}-{index}", [source], + [] if unique else ["-fno-unique-internal-linkage-names"]) + for index, source in enumerate(sources)] + for binary in binaries: + subprocess.run([str(binary)], check=True, env={**os.environ, + "LLVM_PROFILE_FILE": str(directory / "%m-%p.profraw")}) + merged = directory / "coverage.profdata" + cov.run([self.profdata, "merge", "-o", str(merged), *map(str, directory.glob("*.profraw"))]) + command = cov.object_arguments(self.llvmcov, binaries, "-format=lcov", f"-instr-profile={merged}") + if not unique: + with self.assertRaisesRegex(ValueError, "mismatched data"): + cov.run(command) + continue + report = cov.run(command) + self.assertRegex(report, r"FNDA:0,.*shared.*__uniq") + self.assertRegex(report, r"FNDA:1,.*shared.*__uniq") + profiles = cov.run([self.profdata, "show", "--all-functions", "--counts", "--text", str(merged)]) + self.assertIn("# Counter Values:\n1\n0", profiles) + baseline = json.loads(cov.run(cov.object_arguments(self.llvmcov, binaries, "-empty-profile"))) + shared = [f for f in baseline["data"][0]["functions"] if "shared" in f["name"]] + self.assertEqual(len(shared), 2) + self.assertEqual(len({f["name"] for f in shared}), 2) + if __name__ == "__main__": unittest.main() From 43097506c49128eb5031270b2a622512437aea06 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:21:30 -0500 Subject: [PATCH 066/148] fix(ci): validate LLVM report and raw profile completeness --- tools/ci/classify_changes.py | 1 + tools/ci/llvm_coverage.py | 51 ++++++++++++++++++++-- tools/tests/test_classify_changes.py | 1 + tools/tests/test_llvm_coverage.py | 8 ++-- tools/tests/test_llvm_coverage_identity.py | 39 ++++++++++++++--- 5 files changed, 87 insertions(+), 13 deletions(-) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index ef7836a40..8ae6c7c07 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -24,6 +24,7 @@ "CMakePresets.json", "codecov.yml", "tools/tests/test_llvm_coverage.py", + "tools/tests/test_llvm_coverage_identity.py", "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", "tools/tests/test_macos_llvm_toolchain.py", diff --git a/tools/ci/llvm_coverage.py b/tools/ci/llvm_coverage.py index a7a93cc0f..750f6662f 100644 --- a/tools/ci/llvm_coverage.py +++ b/tools/ci/llvm_coverage.py @@ -16,6 +16,7 @@ LLVM_VERSION = "23.1.3" PROFILE_DIRECTORY = "llvm-profiles" +RAW_MAGIC = 0xFF6C70726F667281 # LLVM little-endian, 64-bit frontend profile. def run(command): @@ -260,12 +261,14 @@ def counters(records, width, indices): raise ValueError("unsupported or nonzero LLVM baseline mapping") def summary(metrics): - if not set(metrics) <= {"branches", "functions", "instantiations", "lines", "regions", "mcdc"}: + if set(metrics) != {"branches", "functions", "instantiations", "lines", "regions", "mcdc"}: raise ValueError("unsupported LLVM coverage summary") for metric in metrics.values(): if (not {"count", "covered", "percent"} <= set(metric) <= {"count", "covered", "percent", "notcovered"} or type(metric["count"]) is not int or metric["count"] < 0 or - metric["covered"] != 0 or metric["percent"] != 0 or + type(metric["covered"]) is not int or metric["covered"] != 0 or + type(metric["percent"]) not in (int, float) or metric["percent"] != 0 or + type(metric.get("notcovered", metric["count"])) is not int or metric.get("notcovered", metric["count"]) != metric["count"]): raise ValueError("unsupported or nonzero LLVM baseline summary") @@ -313,7 +316,7 @@ def normalized(value): return maps -def lcov_records(report, source_root, component): +def lcov_records(report, source_root, component, zero=False): root = source_root.resolve() prefixes = source_prefixes(root, component) records = {} @@ -332,6 +335,41 @@ def lcov_records(report, source_root, component): sources = [line[3:] for line in pending if line.startswith("SF:")] if len(sources) != 1: raise ValueError("missing or duplicate source in LLVM LCOV record") + summaries = {} + declarations, functions, lines, branches = [], [], {}, {} + for entry in pending: + key, value = entry.split(":", 1) + if key in {"FNF", "FNH", "LF", "LH", "BRF", "BRH"}: + if key in summaries: + raise ValueError("duplicate LLVM LCOV summary") + summaries[key] = int(value) + elif key == "FN": + declarations.append(value.split(",", 1)[1]) + elif key == "FNDA": + count, name = value.split(",", 1) + functions.append(name) + if zero and int(count): + raise ValueError("nonzero LLVM LCOV baseline function") + elif key in {"DA", "BRDA"}: + fields = value.split(",") + identity = tuple(fields[:-1]) + target = lines if key == "DA" else branches + if identity in target: + raise ValueError("duplicate LLVM LCOV counter") + count = 0 if fields[-1] == "-" else int(fields[-1]) + target[identity] = count + if zero and count: + raise ValueError("nonzero LLVM LCOV baseline counter") + if (set(summaries) != {"FNF", "FNH", "LF", "LH", "BRF", "BRH"} or + sorted(declarations) != sorted(functions) or + (summaries["FNF"] and not declarations) or + summaries["FNH"] > summaries["FNF"] or + summaries["LH"] > summaries["LF"] or summaries["BRH"] > summaries["BRF"] or + (summaries["LF"] and not lines) or (summaries["BRF"] and not branches) or + (zero and any(summaries[key] for key in ("FNH", "LH", "BRH")))): + raise ValueError("incomplete or inconsistent LLVM LCOV summaries") + # LLVM summaries group source functions/lines/branches differently from + # instantiated macro records. Preserve them; do not count flat rows. filename = production_path(sources[0], root, prefixes) if filename: if filename in records: @@ -347,7 +385,7 @@ def complete_report(cov, objects, executed, merged, source_root, component): measured = lcov_records(run(object_arguments(cov, executed, "-format=lcov", f"-instr-profile={merged}")), source_root, component) baseline = zero_maps(run(object_arguments(cov, objects, "-empty-profile")), source_root, component) associated = zero_maps(run(object_arguments(cov, executed, "-empty-profile")), source_root, component) - zero_records = lcov_records(run(object_arguments(cov, objects, "-empty-profile", "-format=lcov")), source_root, component) + zero_records = lcov_records(run(object_arguments(cov, objects, "-empty-profile", "-format=lcov")), source_root, component, zero=True) if not measured or set(measured) != set(associated) or set(zero_records) != set(baseline): raise ValueError("coverage export contains no intended production C sources or inconsistent file sets") # LCOV branch IDs are ordinal within a line, including macro expansions. @@ -377,6 +415,11 @@ def export(build_dir, source_root, component, output, compiler=None): profiles = sorted(directory.glob("*.profraw")) if not profiles or any(path.is_symlink() or path.stat().st_size == 0 for path in profiles): raise ValueError("missing, empty or symlinked LLVM raw profiles") + for profile in profiles: + with profile.open("rb") as stream: + header = stream.read(16) + if len(header) != 16 or struct.unpack(" None: for path in ("cmake/toolchains/llvm.cmake", "cmake/toolchains/llvm-macos.cmake", "cmake/AtrinikLLVM.cmake", "tools/ci/llvm_coverage.py", "tools/tests/test_llvm_coverage.py", + "tools/tests/test_llvm_coverage_identity.py", "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", "tools/tests/test_macos_llvm_toolchain.py", diff --git a/tools/tests/test_llvm_coverage.py b/tools/tests/test_llvm_coverage.py index 62ea4caa6..119204ba7 100644 --- a/tools/tests/test_llvm_coverage.py +++ b/tools/tests/test_llvm_coverage.py @@ -32,8 +32,8 @@ def record(path, count=1): def zero_json(paths): - summary = {key: {"count": 2 if key == "branches" else 1, "covered": 0, "percent": 0} - for key in ("branches", "functions", "instantiations", "lines", "regions")} + summary = {key: {"count": 2 if key == "branches" else 0 if key == "mcdc" else 1, "covered": 0, "percent": 0} + for key in ("branches", "functions", "instantiations", "lines", "regions", "mcdc")} files = [{"filename": str(path), "branches": [[1, 1, 1, 8, 0, 0, 0, 0, 4]], "expansions": [], "mcdc_records": [], "segments": [[1, 1, 0, True, True, False], [1, 8, 0, False, False, False]], @@ -77,7 +77,7 @@ def setUp(self): def fixture(self): coverage.prepare(self.build) - (self.build / "llvm-profiles" / "binary-123.profraw").write_bytes(b"profile") + (self.build / "llvm-profiles" / "binary-123.profraw").write_bytes(struct.pack(" Date: Sun, 11 Oct 2026 02:40:16 -0500 Subject: [PATCH 067/148] fix(coverage): bound mapping exports to production sources --- tools/ci/llvm_coverage.py | 11 +++++++-- tools/tests/test_llvm_coverage_identity.py | 27 ++++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/tools/ci/llvm_coverage.py b/tools/ci/llvm_coverage.py index 750f6662f..7172564a2 100644 --- a/tools/ci/llvm_coverage.py +++ b/tools/ci/llvm_coverage.py @@ -383,9 +383,16 @@ def lcov_records(report, source_root, component, zero=False): def complete_report(cov, objects, executed, merged, source_root, component): measured = lcov_records(run(object_arguments(cov, executed, "-format=lcov", f"-instr-profile={merged}")), source_root, component) - baseline = zero_maps(run(object_arguments(cov, objects, "-empty-profile")), source_root, component) - associated = zero_maps(run(object_arguments(cov, executed, "-empty-profile")), source_root, component) zero_records = lcov_records(run(object_arguments(cov, objects, "-empty-profile", "-format=lcov")), source_root, component, zero=True) + if not measured or not zero_records: + raise ValueError("coverage export contains no intended production C sources") + # Discover the complete production inventory from the unfiltered native + # export first. LLVM's source selectors avoid rendering excluded test/header + # expansions while retaining its global function descriptors. Both mapping + # checks use this same inventory; their complete production maps still match. + sources = sorted(zero_records) + baseline = zero_maps(run(object_arguments(cov, objects, "-empty-profile") + sources), source_root, component) + associated = zero_maps(run(object_arguments(cov, executed, "-empty-profile") + sources), source_root, component) if not measured or set(measured) != set(associated) or set(zero_records) != set(baseline): raise ValueError("coverage export contains no intended production C sources or inconsistent file sets") # LCOV branch IDs are ordinal within a line, including macro expansions. diff --git a/tools/tests/test_llvm_coverage_identity.py b/tools/tests/test_llvm_coverage_identity.py index 49910936f..b9240e86a 100644 --- a/tools/tests/test_llvm_coverage_identity.py +++ b/tools/tests/test_llvm_coverage_identity.py @@ -180,9 +180,9 @@ def setUp(self): def complete(self, baseline=None, associated=None, measured=None, zero=None): responses = [measured if measured is not None else self.measured, + zero if zero is not None else lcov(self.shared) + lcov(self.unique), baseline if baseline is not None else self.baseline, - associated if associated is not None else self.associated, - zero if zero is not None else lcov(self.shared) + lcov(self.unique)] + associated if associated is not None else self.associated] with patch.object(cov, "run", side_effect=responses): return cov.complete_report("cov", [Path("test"), Path("unrun")], [Path("test")], Path("merged"), self.root, "client") @@ -192,6 +192,16 @@ def test_measured_records_preserved_and_unique_files_zero(self): self.assertIn(lcov(self.unique), result) self.assertEqual(result.count("SF:"), 2) + def test_json_selectors_follow_complete_unfiltered_production_inventory(self): + with patch.object(cov, "run", side_effect=[self.measured, lcov(self.shared) + lcov(self.unique), + self.baseline, self.associated]) as run: + cov.complete_report("cov", [Path("test"), Path("unrun")], [Path("test")], Path("merged"), self.root, "client") + commands = [call.args[0] for call in run.call_args_list] + self.assertNotIn(self.shared, commands[0]) + self.assertNotIn(self.shared, commands[1]) + self.assertEqual(commands[2][-2:], sorted([self.shared, self.unique])) + self.assertEqual(commands[3][-2:], sorted([self.shared, self.unique])) + def test_normalized_filename_tables_match(self): relative = document([mapping("client/src/shared.c")], [function("client/src/shared.c")]) self.assertIn(self.measured, self.complete(associated=relative)) @@ -339,6 +349,19 @@ def test_native_complete_graph_preserves_measured_macro_and_branch_counts(self): self.assertIn("FNH:0", records[str(source)]) self.assertIn("LH:0", records[str(source)]) + def test_native_source_selectors_preserve_complete_production_zero_maps(self): + objects = cov.coverage_objects(self.build) + unfiltered = cov.run(cov.object_arguments(self.llvmcov, objects, "-empty-profile")) + sources = sorted(cov.lcov_records(cov.run(cov.object_arguments(self.llvmcov, objects, + "-empty-profile", "-format=lcov")), self.root, "client", zero=True)) + filtered = cov.run(cov.object_arguments(self.llvmcov, objects, "-empty-profile") + sources) + self.assertEqual(cov.zero_maps(unfiltered, self.root, "client"), + cov.zero_maps(filtered, self.root, "client")) + # Source selection reduces file rendering, never the global functions + # used to compare shared inline, branch and expansion mappings. + self.assertEqual(json.loads(unfiltered)["data"][0]["functions"], + json.loads(filtered)["data"][0]["functions"]) + def test_native_unexecuted_additional_same_source_mapping_rejected(self): self.common.write_text(self.common.read_text() + "#ifdef EXTRA\nint extra(int x) { return x ? 7 : 8; }\n#endif\n") From d9f37f4fd4dd5b88920b33eb846bcc6c9e2fdad1 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 02:48:58 -0500 Subject: [PATCH 068/148] fix(coverage): compare complete expansion descriptor multisets --- tools/ci/llvm_coverage.py | 7 +++++- tools/tests/test_llvm_coverage_identity.py | 28 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/tools/ci/llvm_coverage.py b/tools/ci/llvm_coverage.py index 7172564a2..b9b51df54 100644 --- a/tools/ci/llvm_coverage.py +++ b/tools/ci/llvm_coverage.py @@ -299,7 +299,12 @@ def normalized(value): if filename: if filename in maps: raise ValueError("duplicate production source in LLVM JSON") - maps[filename] = [normalized(entry), []] + mapping = normalized(entry) + # LLVM's outer expansion collection follows object/function + # discovery order. Each descriptor owns its filename table and + # regions; preserve its internal ordering and duplicate multiplicity. + mapping["expansions"].sort(key=lambda value: json.dumps(value, sort_keys=True)) + maps[filename] = [mapping, []] for function in data["functions"]: if (set(function) != {"branches", "count", "filenames", "mcdc_records", "name", "regions"} or function["count"] != 0 or function["mcdc_records"]): diff --git a/tools/tests/test_llvm_coverage_identity.py b/tools/tests/test_llvm_coverage_identity.py index b9240e86a..7cfaf2a3c 100644 --- a/tools/tests/test_llvm_coverage_identity.py +++ b/tools/tests/test_llvm_coverage_identity.py @@ -206,6 +206,34 @@ def test_normalized_filename_tables_match(self): relative = document([mapping("client/src/shared.c")], [function("client/src/shared.c")]) self.assertIn(self.measured, self.complete(associated=relative)) + def test_outer_expansion_order_is_independent_of_object_discovery(self): + baseline = json.loads(self.baseline) + associated = json.loads(self.associated) + expansions = [{"filenames": [self.shared, "client/src/header.h"], + "branches": [[line, 1, line, 8, 0, 0, 1, 0, 4], + [line, 2, line, 7, 0, 0, 1, 0, 4]], + "source_region": [line, 1, line, 8, 0, 0, 1, 1], + "target_regions": [[line, 1, line, 8, 0, 1, 0, 0], + [line, 2, line, 7, 0, 1, 0, 0]]} + for line in (1, 2)] + baseline["data"][0]["files"][0]["expansions"] = expansions + associated["data"][0]["files"][0]["expansions"] = list(reversed(expansions)) + self.assertIn(self.measured, self.complete(baseline=json.dumps(baseline), associated=json.dumps(associated))) + for change in ("coordinate", "duplicate", "remove", "filenames", "branches", "regions"): + altered = copy.deepcopy(baseline) + entries = altered["data"][0]["files"][0]["expansions"] + if change == "coordinate": + entries[0]["source_region"][1] += 1 + elif change == "duplicate": + entries.append(copy.deepcopy(entries[0])) + elif change == "remove": + entries.pop() + else: + key = {"filenames": "filenames", "branches": "branches", "regions": "target_regions"}[change] + entries[0][key].reverse() + with self.subTest(change=change), self.assertRaises(ValueError): + self.complete(baseline=json.dumps(altered), associated=json.dumps(associated)) + def test_changed_shared_segments_branches_expansions_and_functions_rejected(self): for field in ("segments", "branches", "expansions", "functions"): baseline = json.loads(self.baseline) From b26271a0bcc4a155014b7001aa75f18af3552e71 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:04:35 -0500 Subject: [PATCH 069/148] docs(coverage): list LLVM object inspector prerequisite --- libatrinik/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libatrinik/README.md b/libatrinik/README.md index e8033c6d5..c37f71a29 100644 --- a/libatrinik/README.md +++ b/libatrinik/README.md @@ -35,7 +35,8 @@ The `llvm-coverage` preset selects the pinned toolchain and its own `build/llvm-coverage` cache and profiles. Existing `linux-coverage` build trees remain available for the previously released producer. -The helper requires matching Clang, llvm-profdata and llvm-cov 23.1.3. +The helper requires matching Clang, llvm-profdata, llvm-cov and llvm-readobj +23.1.3. CTest writes separate `%m-%p.profraw` files for processes and shared libraries; LCOV includes production C sources and excludes tests, build output and upstream dependency trees. Missing or empty profiles fail the export. Run `prepare` only From 3d5beca667ee222c0c5db0d273b51798cc540efa Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:08:13 -0500 Subject: [PATCH 070/148] ci(coverage): exercise LLVM identity fixtures in pinned SDK --- .github/workflows/check.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 8f7f17bb4..bb558ff8e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -310,6 +310,8 @@ jobs: "${CLASSIC_LINUX_IMAGE}" \ bash -c ' set -euo pipefail + ATRINIK_LLVM_COVERAGE_NATIVE_TEST=1 python3 -m unittest -v \ + tools.tests.test_llvm_coverage_identity.NativeLLVM23Tests CI=1 python3 -m unittest -v \ tools.tests.test_version_metadata.VersionMetadataTests.test_pathfinding_physical_tag_untagged_and_installed_package tools/ci/run_linux_check.sh core /workspace From ebef0b616739bf32428d90c89cd3aaeb3195b59f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:08:18 -0500 Subject: [PATCH 071/148] fix(libatrinik): preserve Windows socket conversion in fd sets --- libatrinik/CMakeLists.txt | 6 +++- libatrinik/socket.c | 4 +-- libatrinik/socket_direct.c | 2 +- libatrinik/socket_fd_set.h | 25 ++++++++++++++ libatrinik/socket_private.h | 3 +- libatrinik/socket_quic.c | 4 +-- libatrinik/tests/socket_fd_set.c | 50 +++++++++++++++++++++++++++ tools/tests/test_cmake_incremental.py | 2 +- 8 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 libatrinik/socket_fd_set.h create mode 100644 libatrinik/tests/socket_fd_set.c diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index 1c8ce2cc8..8b9cf583f 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -379,7 +379,7 @@ set(LIBATRINIK_PUBLIC_HEADER_NAMES utarray.h uthash.h utlist.h) -set(LIBATRINIK_PRIVATE_HEADER_NAMES signals_private.h socket_private.h) +set(LIBATRINIK_PRIVATE_HEADER_NAMES signals_private.h socket_private.h socket_fd_set.h) set(LIBATRINIK_BUILD_HEADERS ${LIBATRINIK_PUBLIC_HEADER_NAMES}) list(APPEND LIBATRINIK_BUILD_HEADERS ${LIBATRINIK_PRIVATE_HEADER_NAMES}) list(TRANSFORM LIBATRINIK_BUILD_HEADERS @@ -672,6 +672,10 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR add_test(NAME libatrinik-socket-websocket COMMAND libatrinik-socket-websocket) endif () + add_executable(libatrinik-socket-fd-set tests/socket_fd_set.c) + atrinik_configure_target(libatrinik-socket-fd-set) + add_test(NAME libatrinik-socket-fd-set COMMAND libatrinik-socket-fd-set) + if (HAVE_GETADDRINFO) add_executable(libatrinik-socket-address tests/socket_address.c) atrinik_configure_target(libatrinik-socket-address) diff --git a/libatrinik/socket.c b/libatrinik/socket.c index ff9d7b9f2..f2245f0fb 100644 --- a/libatrinik/socket.c +++ b/libatrinik/socket.c @@ -1136,10 +1136,10 @@ bool socket_wait(socket_t *sc, bool readable, bool writable, unsigned int timeou FD_ZERO(&read_fds); FD_ZERO(&write_fds); if (readable) { - FD_SET(sc->handle, &read_fds); + socket_fd_set(sc->handle, &read_fds); } if (writable) { - FD_SET(sc->handle, &write_fds); + socket_fd_set(sc->handle, &write_fds); } struct timeval timeout = { .tv_sec = (long)(timeout_ms / 1000), diff --git a/libatrinik/socket_direct.c b/libatrinik/socket_direct.c index bbe62d9bd..a4732041e 100644 --- a/libatrinik/socket_direct.c +++ b/libatrinik/socket_direct.c @@ -1068,7 +1068,7 @@ bool socket_stun_discover_until(socket_t *sc, uint64_t timeout_ms = MIN(deadline_ms - now_ms, 3000U); fd_set readfds; FD_ZERO(&readfds); - FD_SET(sc->handle, &readfds); + socket_fd_set(sc->handle, &readfds); struct timeval timeout = {.tv_sec = (long)(timeout_ms / 1000U), .tv_usec = (long)((timeout_ms % 1000U) * 1000U)}; if (select(sc->handle + 1, &readfds, NULL, NULL, &timeout) != 1) { diff --git a/libatrinik/socket_fd_set.h b/libatrinik/socket_fd_set.h new file mode 100644 index 000000000..a1af1dba1 --- /dev/null +++ b/libatrinik/socket_fd_set.h @@ -0,0 +1,25 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + */ +#ifndef TOOLKIT_SOCKET_FD_SET_H +#define TOOLKIT_SOCKET_FD_SET_H + +#ifdef WIN32 +#include +#else +#include +#endif + +/* Private select() adapter. Callers retain their existing descriptor-validity + * checks. Winsock's FD_SET compares against SOCKET before assigning it, so use + * the same signed-int-to-SOCKET conversion as its assignment and the socket + * APIs. An unsigned-int intermediate would change negative values on Win64. */ +static inline void socket_fd_set(int handle, fd_set *set) { +#ifdef WIN32 + FD_SET((SOCKET)handle, set); +#else + FD_SET(handle, set); +#endif +} + +#endif diff --git a/libatrinik/socket_private.h b/libatrinik/socket_private.h index 9361ee402..d94d07bb8 100644 --- a/libatrinik/socket_private.h +++ b/libatrinik/socket_private.h @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -25,6 +25,7 @@ #define TOOLKIT_SOCKET_PRIVATE_H #include "socket.h" +#include "socket_fd_set.h" #include #if OPENSSL_VERSION_NUMBER >= 0x30500000L diff --git a/libatrinik/socket_quic.c b/libatrinik/socket_quic.c index 4d42e442f..402372664 100644 --- a/libatrinik/socket_quic.c +++ b/libatrinik/socket_quic.c @@ -220,10 +220,10 @@ static bool socket_quic_wait(SSL *ssl) { FD_ZERO(&read_fds); FD_ZERO(&write_fds); if (SSL_net_read_desired(ssl)) { - FD_SET(fd, &read_fds); + socket_fd_set(fd, &read_fds); } if (SSL_net_write_desired(ssl)) { - FD_SET(fd, &write_fds); + socket_fd_set(fd, &write_fds); } /* Keep candidate cancellation and first-success handoff responsive. */ diff --git a/libatrinik/tests/socket_fd_set.c b/libatrinik/tests/socket_fd_set.c new file mode 100644 index 000000000..62dd382ce --- /dev/null +++ b/libatrinik/tests/socket_fd_set.c @@ -0,0 +1,50 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + */ +#include "../socket_fd_set.h" + +#include +#include +#include +#include + +#define REQUIRE(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "check failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + abort(); \ + } \ + } while (0) + +int main(void) { + fd_set actual; + FD_ZERO(&actual); +#ifdef WIN32 + /* Exercise the conversion only: no invalid socket reaches select(). */ + static const int handles[] = {0, 1, INT_MAX, -1, INT_MIN, -2}; + fd_set expected; + FD_ZERO(&expected); + for (size_t i = 0; i < sizeof(handles) / sizeof(handles[0]); i++) { + SOCKET implicit = handles[i]; + FD_SET(implicit, &expected); + socket_fd_set(handles[i], &actual); + socket_fd_set(handles[i], &actual); + REQUIRE(actual.fd_count == expected.fd_count); + REQUIRE(actual.fd_array[i] == implicit); + } + REQUIRE(actual.fd_array[3] == INVALID_SOCKET); + REQUIRE(actual.fd_array[4] == (SOCKET)(UINT64_MAX - INT_MAX)); + REQUIRE(actual.fd_array[5] == (SOCKET)(UINT64_MAX - 1)); + REQUIRE(actual.fd_count == sizeof(handles) / sizeof(handles[0])); +#else + static const int handles[] = {0, 1, FD_SETSIZE - 1}; + for (size_t i = 0; i < sizeof(handles) / sizeof(handles[0]); i++) { + socket_fd_set(handles[i], &actual); + socket_fd_set(handles[i], &actual); + } + for (int fd = 0; fd < FD_SETSIZE; fd++) { + REQUIRE(!!FD_ISSET(fd, &actual) == (fd == 0 || fd == 1 || fd == FD_SETSIZE - 1)); + } +#endif + return 0; +} diff --git a/tools/tests/test_cmake_incremental.py b/tools/tests/test_cmake_incremental.py index 8a64f1172..c84053418 100644 --- a/tools/tests/test_cmake_incremental.py +++ b/tools/tests/test_cmake_incremental.py @@ -30,7 +30,7 @@ def test_libatrinik_header_manifest_is_complete(self) -> None: self.assertEqual(declared, actual) self.assertEqual( set(match.group("private").split()), - {"signals_private.h", "socket_private.h"}, + {"signals_private.h", "socket_private.h", "socket_fd_set.h"}, ) def test_immutable_source_cache_reuses_verified_extraction(self) -> None: From bc02dd9aeafc2067b270805951a8a51a98ef5ae5 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:11:56 -0500 Subject: [PATCH 072/148] test(ci): reject macOS curl cohort policy drift --- tools/tests/test_macos_curl_cohort.py | 285 ++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 tools/tests/test_macos_curl_cohort.py diff --git a/tools/tests/test_macos_curl_cohort.py b/tools/tests/test_macos_curl_cohort.py new file mode 100644 index 000000000..7a8377db3 --- /dev/null +++ b/tools/tests/test_macos_curl_cohort.py @@ -0,0 +1,285 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import copy +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +CI = ROOT / "tools/ci" +SPEC = importlib.util.spec_from_file_location("prepare_macos_curl", CI / "prepare_macos_curl.py") +COHORT = importlib.util.module_from_spec(SPEC) +sys.path.insert(0, str(CI)) +try: + SPEC.loader.exec_module(COHORT) +finally: + sys.path.pop(0) + + +class MacOSCurlCohortTests(unittest.TestCase): + def setUp(self): + self.manifest = COHORT.load_manifest() + + def runtime(self): + baseline = self.manifest["baseline"] + result = dict(baseline["versions"], features=list(baseline["features"]), + protocols=list(baseline["protocols"]), header_version="8.22.0", + header_version_num=0x081600, version_num=0x081600, + ares="1.34.6", cares_version="1.34.6", cares_header_version="1.34.6") + for name in ("public_tls_default", "public_tls_bundle", "private_tls_trusted"): + result[name] = {"code": 0, "status": 200, "ssl_verify_result": 0, + "default_ca_bundle": None, "default_ca_path": None} + for name in ("public_tls_unrelated", "private_tls_untrusted", "private_tls_wrong_hostname"): + result[name] = {"code": 60, "status": 0, "ssl_verify_result": 20} + return result + + def baseline(self): + runtime = self.runtime() + runtime["ares"] = None + return {"runtime": runtime, "ca_bundle_sha256": self.manifest["ca_bundle_sha256"]} + + def test_manifest_rejects_unsupported_schema_toolchain_and_malformed_digest(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + for key, value in (("schema_version", 2), ("llvm_version", "23.1.2"), + ("sha256", "a" * 63), ("sha256", "A" * 64)): + data = copy.deepcopy(self.manifest) + if key == "sha256": + data["sources"]["curl"][key] = value + else: + data[key] = value + path.write_text(json.dumps(data)) + with self.subTest(key=key, value=value), patch.object(COHORT, "MANIFEST", path), \ + self.assertRaises(ValueError): + COHORT.load_manifest() + + def test_baseline_requires_exact_measured_capabilities_and_versions(self): + original = self.baseline() + COHORT.validate_baseline(original, self.manifest) + self.assertEqual(len(original["runtime"]["features"]), 20) + self.assertEqual(len(original["runtime"]["protocols"]), 25) + for key in ("features", "protocols"): + for lost in original["runtime"][key]: + data = copy.deepcopy(original) + data["runtime"][key].remove(lost) + with self.subTest(key=key, lost=lost), self.assertRaises(ValueError): + COHORT.validate_baseline(data, self.manifest) + for added in ("unmeasured", original["runtime"][key][0]): + data = copy.deepcopy(original) + data["runtime"][key].append(added) + with self.subTest(key=key, added=added), self.assertRaises(ValueError): + COHORT.validate_baseline(data, self.manifest) + for key in self.manifest["baseline"]["versions"]: + data = copy.deepcopy(original) + data["runtime"][key] = "changed version" + with self.subTest(version=key), self.assertRaises(ValueError): + COHORT.validate_baseline(data, self.manifest) + for key, value in (("ares", "1.34.6"),): + data = copy.deepcopy(original) + data["runtime"][key] = value + with self.assertRaises(ValueError): + COHORT.validate_baseline(data, self.manifest) + data = copy.deepcopy(original) + data["ca_bundle_sha256"] = "0" * 64 + with self.assertRaises(ValueError): + COHORT.validate_baseline(data, self.manifest) + + def test_baseline_trust_policy_and_positive_proofs_are_fixed(self): + for proof in ("public_tls_default", "public_tls_bundle"): + for key, value in (("code", 60), ("status", 301), ("ssl_verify_result", 20), + ("default_ca_bundle", "/foreign.pem"), ("default_ca_path", "/foreign")): + data = self.baseline() + data["runtime"][proof][key] = value + with self.subTest(proof=proof, key=key), self.assertRaises(ValueError): + COHORT.validate_baseline(data, self.manifest) + + def test_runtime_preserves_every_capability_and_provider_identity(self): + original = self.runtime() + COHORT.validate_runtime(original, self.manifest) + extra = copy.deepcopy(original) + extra["features"].append("additional-supported-feature") + extra["protocols"].append("additional-supported-protocol") + COHORT.validate_runtime(extra, self.manifest) + for key in ("features", "protocols"): + for lost in original[key]: + data = copy.deepcopy(original) + data[key].remove(lost) + with self.subTest(key=key, lost=lost), self.assertRaises(ValueError): + COHORT.validate_runtime(data, self.manifest) + data = copy.deepcopy(original) + data[key].append(data[key][0]) + with self.subTest(duplicate=key), self.assertRaises(ValueError): + COHORT.validate_runtime(data, self.manifest) + changes = [(key, "changed version") for key in self.manifest["baseline"]["versions"]] + changes += [("header_version", "8.21.0"), ("header_version_num", 0x081500), + ("ares", None), ("ares", "1.34.5"), ("cares_version", "1.34.5"), ("cares_header_version", "1.34.5"), + ("features", original["features"] + ["asyn-rr"])] + for key, value in changes: + data = copy.deepcopy(original) + data[key] = value + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + COHORT.validate_runtime(data, self.manifest) + + def test_runtime_requires_verified_positive_and_specific_negative_tls_results(self): + for proof in ("public_tls_default", "public_tls_bundle", "private_tls_trusted"): + for key, value in (("code", 60), ("status", 0), ("status", 302), ("ssl_verify_result", 1)): + data = self.runtime() + data[proof][key] = value + with self.subTest(proof=proof, key=key, value=value), self.assertRaises(ValueError): + COHORT.validate_runtime(data, self.manifest) + for proof in ("public_tls_unrelated", "private_tls_untrusted", "private_tls_wrong_hostname"): + for key, value in (("code", 0), ("code", 7), ("code", 35), ("status", 200)): + data = self.runtime() + data[proof][key] = value + with self.subTest(proof=proof, key=key, value=value), self.assertRaises(ValueError): + COHORT.validate_runtime(data, self.manifest) + for proof in ("public_tls_default", "public_tls_bundle"): + for key in ("default_ca_bundle", "default_ca_path"): + data = self.runtime() + data[proof][key] = "/unexpected-default" + with self.subTest(proof=proof, key=key), self.assertRaises(ValueError): + COHORT.validate_runtime(data, self.manifest) + + def test_config_requires_cares_and_preserves_ldap_native_idn_and_trust_policy(self): + original = "\n".join(f"#define {name} 1" for name in self.manifest["required_config"]) + COHORT.validate_config(original, self.manifest) + for name in self.manifest["required_config"]: + for replacement in ("", f"#define {name} 0", f"/* #undef {name} */"): + changed = original.replace(f"#define {name} 1", replacement) + with self.subTest(required=name, replacement=replacement), self.assertRaises(ValueError): + COHORT.validate_config(changed, self.manifest) + for name in self.manifest["forbidden_config"]: + with self.subTest(forbidden=name), self.assertRaises(ValueError): + COHORT.validate_config(original + f'\n#define {name} "unexpected"', self.manifest) + + def test_configure_policy_uses_sdk_zlib_and_owned_resolver(self): + prefix, sdk = Path("/owned curl"), Path("/native SDK") + deps = {name: Path("/kegs") / name for name in self.manifest["dependency_formulae"]} + arguments = COHORT.configure_arguments(prefix, sdk, deps) + options = dict(argument.split("=", 1) if "=" in argument else (argument, None) + for argument in arguments) + self.assertEqual(options["--enable-ares"], str(prefix)) + self.assertEqual(options["--with-zlib"], str(sdk / "usr")) + self.assertEqual(options["--with-default-ssl-backend"], "openssl") + for option, dep in (("ssl", "openssl@3"), ("brotli", "brotli"), ("zstd", "zstd"), + ("libpsl", "libpsl"), ("libssh2", "libssh2"), + ("nghttp2", "libnghttp2"), ("nghttp3", "libnghttp3"), ("ngtcp2", "libngtcp2")): + self.assertEqual(options["--with-" + option], str(deps[dep])) + for option in ("--disable-threaded-resolver", "--without-ca-bundle", "--without-ca-path", + "--with-ca-fallback", "--with-apple-sectrust", "--with-gssapi", + "--with-apple-idn", "--without-libidn2", "--enable-ldap", "--enable-ldaps"): + self.assertIn(option, options) + self.assertFalse(any("unwind" in value or "libstdc++" in value for value in arguments)) + + def test_source_archive_digest_root_and_link_containment(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index, (name, target) in enumerate((("curl-fixture/COPYING", None), + ("other/COPYING", None), + ("curl-fixture/../../escaped", None), + ("/curl-fixture/absolute", None), + ("curl-fixture/link", "../../escaped"))): + archive = root / f"source-{index}.tar" + with tarfile.open(archive, "w") as output: + member = tarfile.TarInfo(name) + if target: + member.type = tarfile.SYMTYPE + member.linkname = target + output.addfile(member) + else: + content = b"source fixture" + member.size = len(content) + output.addfile(member, io.BytesIO(content)) + source = {"sha256": COHORT.digest(archive), "directory": "curl-fixture"} + destination = root / f"destination-{index}" + with self.subTest(name=name, target=target): + if index == 0: + extracted = COHORT.extract_source(archive, destination, source) + self.assertEqual((extracted / "COPYING").read_bytes(), b"source fixture") + source["sha256"] = "0" * 64 + with self.assertRaises(ValueError): + COHORT.extract_source(archive, root / "bad-digest", source) + self.assertFalse((root / "bad-digest").exists()) + else: + with self.assertRaises((ValueError, tarfile.TarError)): + COHORT.extract_source(archive, destination, source) + self.assertFalse((root / "escaped").exists()) + + def test_loaded_inventory_rejects_foreign_providers_missing_images_and_dependency_drift(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix = root / "cohort" + (prefix / "lib").mkdir(parents=True) + curl, cares = prefix / "lib/libcurl.dylib", prefix / "lib/libcares.dylib" + probe, dep, foreign = root / "probe", root / "keg/ssl.dylib", root / "foreign/libcurl.dylib" + for path in (curl, cares, probe, dep, foreign): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"same-version fixture binary") + files = {str(dep): COHORT.digest(dep)} + data = {"loaded_library": str(curl), "loaded_cares_library": str(cares), + "loaded_images": [str(curl), str(cares), str(probe), str(dep), + "/usr/lib/libSystem.B.dylib", "/System/Library/Frameworks/Security.framework/Security", + "/Library/Apple/System/Library/fixture.dylib"]} + COHORT.validate_loaded(data, prefix, files, probe) + mutations = [("loaded_library", str(foreign)), ("loaded_cares_library", str(foreign)), + ("loaded_images", []), ("loaded_images", data["loaded_images"] + [str(curl)]), + ("loaded_images", data["loaded_images"] + [str(foreign)]), + ("loaded_images", data["loaded_images"] + ["relative.dylib"]), + ("loaded_images", data["loaded_images"] + ["/usr/lib/../foreign.dylib"])] + mutations += [("loaded_images", [image for image in data["loaded_images"] if image != str(own)]) + for own in (curl, cares)] + for key, value in mutations: + changed = copy.deepcopy(data) + changed[key] = value + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + COHORT.validate_loaded(changed, prefix, files, probe) + with self.assertRaises(ValueError): + COHORT.validate_loaded(data, prefix, {}, probe) + dep.write_bytes(b"changed dependency bytes") + with self.assertRaises(ValueError): + COHORT.validate_loaded(data, prefix, files, probe) + curl.unlink() + curl.symlink_to(foreign) + with self.assertRaises(ValueError): + COHORT.validate_loaded(data, prefix, files, probe) + + def test_header_directories_are_owned_even_for_same_version_headers(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix, foreign = root / "cohort", root / "foreign" + for include in (prefix / "include", foreign / "include"): + include.mkdir(parents=True) + (include / "curl").mkdir() + (include / "curl/curl.h").write_text("same version API") + COHORT.validate_headers(str(prefix / "include"), prefix) + for selected in (str(foreign / "include"), f"{prefix}/include;{foreign}/include"): + with self.subTest(selected=selected), self.assertRaises(ValueError): + COHORT.validate_headers(selected, prefix) + + def test_failed_command_retains_output_status_and_arguments(self): + with tempfile.TemporaryDirectory() as directory: + evidence = Path(directory) + commands = COHORT.Commands(evidence, {"PATH": "/fixture"}) + result = subprocess.CompletedProcess(["fixture", "literal $(not executed)"], 17, "partial output", "failure detail") + with patch.object(COHORT.subprocess, "run", return_value=result) as run, \ + self.assertRaisesRegex(ValueError, "17"): + commands.run("failure", result.args) + self.assertEqual((evidence / "failure.stdout").read_text(), result.stdout) + self.assertEqual((evidence / "failure.stderr").read_text(), result.stderr) + record = json.loads((evidence / "commands.json").read_text())[0] + self.assertEqual(record["returncode"], 17) + self.assertEqual(record["arguments"], result.args) + self.assertEqual(run.call_args.args[0], result.args) + self.assertNotIn("shell", run.call_args.kwargs) + + +if __name__ == "__main__": + unittest.main() From 2419c0111e8a4fa599ab33515906dee90fd26cf1 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:16:54 -0500 Subject: [PATCH 073/148] test(ci): bind native curl consumers and SDK providers --- tools/tests/test_macos_curl_cohort.py | 76 ++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tools/tests/test_macos_curl_cohort.py b/tools/tests/test_macos_curl_cohort.py index 7a8377db3..7453dce29 100644 --- a/tools/tests/test_macos_curl_cohort.py +++ b/tools/tests/test_macos_curl_cohort.py @@ -1,6 +1,7 @@ # Copyright 2026 The Atrinik Project # SPDX-License-Identifier: GPL-2.0-or-later +import contextlib import copy import importlib.util import io @@ -228,7 +229,16 @@ def test_loaded_inventory_rejects_foreign_providers_missing_images_and_dependenc "loaded_images": [str(curl), str(cares), str(probe), str(dep), "/usr/lib/libSystem.B.dylib", "/System/Library/Frameworks/Security.framework/Security", "/Library/Apple/System/Library/fixture.dylib"]} - COHORT.validate_loaded(data, prefix, files, probe) + platform_images = ["/usr/lib/libkrb5.dylib", "/usr/lib/libldap.dylib", + "/usr/lib/libz.1.dylib", "/usr/lib/libicucore.A.dylib"] + data["loaded_images"].extend(platform_images) + # macOS shared-cache install names need no ordinary filesystem file. + COHORT.validate_loaded(data, prefix, files, probe, platform_images) + for missing in platform_images: + changed = copy.deepcopy(data) + changed["loaded_images"].remove(missing) + with self.subTest(missing_platform=missing), self.assertRaisesRegex(ValueError, "baseline SDK"): + COHORT.validate_loaded(changed, prefix, files, probe, platform_images) mutations = [("loaded_library", str(foreign)), ("loaded_cares_library", str(foreign)), ("loaded_images", []), ("loaded_images", data["loaded_images"] + [str(curl)]), ("loaded_images", data["loaded_images"] + [str(foreign)]), @@ -264,6 +274,70 @@ def test_header_directories_are_owned_even_for_same_version_headers(self): with self.subTest(selected=selected), self.assertRaises(ValueError): COHORT.validate_headers(selected, prefix) + def test_consumer_cache_binds_prefix_providers_headers_compiler_and_release_policy(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix, foreign = root / "cohort", root / "same-version-foreign" + headers = ("curl/curl.h", "curl/curlver.h", "ares.h", "ares_version.h") + owned_files = [prefix / "lib/libcurl.dylib", prefix / "lib/libcares.dylib", + *[prefix / "include" / name for name in headers]] + compiler = root / "llvm/bin/clang" + for path in [*owned_files, compiler]: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"verified same-version fixture") + for path in (foreign / "lib/libcurl.dylib", foreign / "include/curl/curl.h", foreign / "bin/clang"): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"verified same-version fixture") + receipt_path = prefix / "evidence/receipt.json" + receipt_path.parent.mkdir() + receipt = {"prefix": str(prefix), "curl_sha256": COHORT.digest(owned_files[0]), + "cares_sha256": COHORT.digest(owned_files[1]), + "header_hashes": {name: COHORT.digest(prefix / "include" / name) for name in headers}, + "tools": {"clang": {"path": str(compiler), "sha256": COHORT.digest(compiler)}}, + "release_flags": ["-O3", "-DNDEBUG"]} + receipt_path.write_text(json.dumps(receipt)) + cache_path = root / "CMakeCache.txt" + cache = {"CURL_INCLUDE_DIR": str(prefix / "include"), + "CURL_LIBRARY_RELEASE": str(prefix / "lib/libcurl.dylib"), + "CMAKE_C_COMPILER": str(compiler), "CMAKE_BUILD_TYPE": "Release", + "CMAKE_C_FLAGS_RELEASE": "-O3 -DNDEBUG"} + + def write_cache(values): + cache_path.write_text("# fixture CMake cache\n" + "\n".join( + f"{key}:STRING={value}" for key, value in values.items()) + "\n") + + write_cache(cache) + with patch.object(COHORT.platform, "system", return_value="Linux"), \ + self.assertRaisesRegex(ValueError, "actual native Mac"): + COHORT.verify_consumer_cache(prefix, cache_path) + with patch.object(COHORT.platform, "system", return_value="Darwin"), \ + contextlib.redirect_stdout(io.StringIO()): + COHORT.verify_consumer_cache(prefix, cache_path) + mutations = [("CURL_INCLUDE_DIR", str(foreign / "include")), + ("CURL_LIBRARY_RELEASE", str(foreign / "lib/libcurl.dylib")), + ("CMAKE_C_COMPILER", str(foreign / "bin/clang")), + ("CURL_INCLUDE_DIR", ""), ("CURL_LIBRARY_RELEASE", ""), + ("CMAKE_BUILD_TYPE", "Debug"), ("CMAKE_C_FLAGS_RELEASE", "-O0 -DNDEBUG"), + ("CMAKE_C_FLAGS_RELEASE", "-O3"), ("CMAKE_C_FLAGS_RELEASE", "-O3 -DNDEBUG -g")] + for key, value in mutations: + changed = dict(cache, **{key: value}) + write_cache(changed) + with self.subTest(cache_key=key, value=value), self.assertRaises(ValueError): + COHORT.verify_consumer_cache(prefix, cache_path) + write_cache(cache) + changed = dict(receipt, prefix=str(foreign)) + receipt_path.write_text(json.dumps(changed)) + with self.assertRaisesRegex(ValueError, "another install prefix"): + COHORT.verify_consumer_cache(prefix, cache_path) + receipt_path.write_text(json.dumps(receipt)) + for path in [*owned_files, compiler]: + original = path.read_bytes() + path.write_bytes(b"changed same-version fixture") + with self.subTest(changed_bytes=path), self.assertRaises(ValueError): + COHORT.verify_consumer_cache(prefix, cache_path) + path.write_bytes(original) + COHORT.verify_consumer_cache(prefix, cache_path) + def test_failed_command_retains_output_status_and_arguments(self): with tempfile.TemporaryDirectory() as directory: evidence = Path(directory) From 16bf74d3f5797f2bd23d80e87682f3a9edebcb36 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:18:57 -0500 Subject: [PATCH 074/148] test(ci): remove ambient native curl build overrides --- tools/tests/test_macos_curl_cohort.py | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tools/tests/test_macos_curl_cohort.py b/tools/tests/test_macos_curl_cohort.py index 7453dce29..4b5c79c85 100644 --- a/tools/tests/test_macos_curl_cohort.py +++ b/tools/tests/test_macos_curl_cohort.py @@ -47,6 +47,39 @@ def baseline(self): runtime["ares"] = None return {"runtime": runtime, "ca_bundle_sha256": self.manifest["ca_bundle_sha256"]} + def test_native_environment_removes_ambient_build_and_trust_overrides(self): + retained = {"PATH": "/fixture/bin", "HOME": "/fixture/home", "NORMAL": "retained", + "SOURCE_DATE_EPOCH": "1791676800"} + overrides = { + "CONFIG_SITE": "/foreign/site", "CONFIG_SHELL": "/foreign/shell", + "BASH_ENV": "/foreign/bash-env", "ENV": "/foreign/env", + "MAKEFLAGS": "-e CC=foreign", "MFLAGS": "-e", "GNUMAKEFLAGS": "-e", + "MAKELEVEL": "3", "MAKEOVERRIDES": "CC=foreign", + "LIBS": "-lforeign", "CPP": "/foreign/cpp", "AR": "/foreign/ar", "ARFLAGS": "foreign", + "RANLIB": "/foreign/ranlib", "RANLIBFLAGS": "foreign", "NM": "/foreign/nm", + "LD": "/foreign/ld", "AS": "/foreign/as", "OBJCOPY": "/foreign/objcopy", + "OBJDUMP": "/foreign/objdump", "STRIP": "/foreign/strip", + "CC": "/foreign/cc", "CXX": "/foreign/cxx", "CFLAGS": "-O0", + "CXXFLAGS": "-O0", "CPPFLAGS": "-I/foreign", "LDFLAGS": "-L/foreign", + "PKG_CONFIG": "/foreign/pkg-config", "PKG_CONFIG_PATH": "/foreign/pkgconfig", + "PKG_CONFIG_LIBDIR": "/foreign/pkgconfig", "PKG_CONFIG_SYSTEM_INCLUDE_PATH": "/foreign/include", + "PKG_CONFIG_SYSTEM_LIBRARY_PATH": "/foreign/lib", "LIBRARY_PATH": "/foreign/lib", + "CCC_OVERRIDE_OPTIONS": "+-O0", "CCC_ADD_ARGS": "-O0", + "ac_cv_func_getaddrinfo": "no", "curl_cv_ssl": "foreign", "custom_cv_value": "foreign", + "CMAKE_C_COMPILER_LAUNCHER": "/foreign/launcher", "CMAKE_TOOLCHAIN_FILE": "/foreign/toolchain", + "CMAKE_CUSTOM_OVERRIDE": "foreign", "CCACHE_PREFIX": "/foreign/launcher", + "CCACHE_CUSTOM_OVERRIDE": "foreign", "DISTCC_HOSTS": "foreign", "DISTCC_CUSTOM_OVERRIDE": "foreign", + "DYLD_INSERT_LIBRARIES": "/foreign.dylib", "LD_PRELOAD": "/foreign.so", + "HTTPS_PROXY": "https://foreign.invalid", "all_proxy": "socks://foreign.invalid", + "SSL_CERT_FILE": "/foreign/ca", "CURL_CA_BUNDLE": "/foreign/ca", + "OPENSSL_CONF": "/foreign/openssl.cnf", "OPENSSL_MODULES": "/foreign/modules", + } + original = dict(retained, **overrides) + cleaned = COHORT.native_environment(original) + self.assertEqual(cleaned, dict(retained, CONFIG_SITE="/dev/null", CONFIG_SHELL="/bin/sh")) + self.assertEqual(original, dict(retained, **overrides)) + self.assertNotIn("SOURCE_DATE_EPOCH", COHORT.native_environment({"PATH": "/fixture/bin"})) + def test_manifest_rejects_unsupported_schema_toolchain_and_malformed_digest(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manifest.json" From 0ba7e9507a564363c65e6e94a37a9c4959cafc0f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:20:54 -0500 Subject: [PATCH 075/148] test(ci): retain failed curl diagnostics and reject extra providers --- tools/tests/test_macos_curl_cohort.py | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tools/tests/test_macos_curl_cohort.py b/tools/tests/test_macos_curl_cohort.py index 4b5c79c85..369b9e132 100644 --- a/tools/tests/test_macos_curl_cohort.py +++ b/tools/tests/test_macos_curl_cohort.py @@ -284,6 +284,12 @@ def test_loaded_inventory_rejects_foreign_providers_missing_images_and_dependenc changed[key] = value with self.subTest(key=key, value=value), self.assertRaises(ValueError): COHORT.validate_loaded(changed, prefix, files, probe) + captured_foreign_files = dict(files, **{str(foreign): COHORT.digest(foreign)}) + for additional in ("/usr/lib/libcurl.4.dylib", "/usr/lib/libcares.2.dylib", str(foreign)): + changed = copy.deepcopy(data) + changed["loaded_images"].append(additional) + with self.subTest(extra_provider=additional), self.assertRaises(ValueError): + COHORT.validate_loaded(changed, prefix, captured_foreign_files, probe) with self.assertRaises(ValueError): COHORT.validate_loaded(data, prefix, {}, probe) dep.write_bytes(b"changed dependency bytes") @@ -371,6 +377,73 @@ def write_cache(values): path.write_bytes(original) COHORT.verify_consumer_cache(prefix, cache_path) + def test_diagnostics_retains_failed_configuration_without_private_keys_or_unknown_files(self): + with tempfile.TemporaryDirectory() as directory: + prefix = Path(directory) / "cohort" + diagnostics = ("CMakeCache.txt", "compile_commands.json", "config.log", "config.status", + "lib/curl_config.h", "CMakeFiles/CMakeConfigureLog.yaml") + for build in ("llvm-probe", "cares-build", "curl-build", "curl-probe"): + for relative in diagnostics: + path = prefix / "stage" / build / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"retained failure diagnostic: {build}/{relative}\n") + (prefix / "stage" / build / "unknown-source-file").write_text("exclude unknown source") + private = prefix / "stage/private-tls/private.key" + private.parent.mkdir() + private.write_text("private fixture key must remain excluded") + (prefix / "stage/curl-build/unknown.key").write_text("exclude key") + COHORT.retain_diagnostics(prefix) + evidence = prefix / "evidence/configuration" + expected = {f"{build}/{relative}" for build in ("llvm-probe", "cares-build", "curl-build", "curl-probe") + for relative in diagnostics} + actual = {str(path.relative_to(evidence)) for path in evidence.rglob("*") if path.is_file()} + self.assertEqual(actual, expected) + self.assertEqual((evidence / "curl-build/config.log").read_text(), + "retained failure diagnostic: curl-build/config.log\n") + self.assertFalse(any(path.suffix == ".key" for path in (prefix / "evidence").rglob("*"))) + # A failed early configure may produce only config.log; missing optional + # diagnostics must not conceal the original command failure. + sparse = Path(directory) / "sparse" + log = sparse / "stage/curl-build/config.log" + log.parent.mkdir(parents=True) + log.write_text("configure exited before creating its cache\n") + COHORT.retain_diagnostics(sparse) + self.assertEqual((sparse / "evidence/configuration/curl-build/config.log").read_text(), log.read_text()) + + def test_prepare_preserves_command_failure_and_retains_early_configure_diagnostics(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix, llvm = root / "absent-cohort", root / "llvm" + llvm.mkdir() + bundle = root / "ca.pem" + bundle.write_text("fixture CA input") + manifest = copy.deepcopy(self.manifest) + manifest["ca_bundle_sha256"] = COHORT.digest(bundle) + baseline = self.baseline() + baseline["ca_bundle_sha256"] = manifest["ca_bundle_sha256"] + baseline_path = root / "baseline.json" + baseline_path.write_text(json.dumps(baseline)) + + def failed_build(owned_prefix, *arguments): + build = owned_prefix / "stage/curl-build" + build.mkdir(parents=True) + (build / "config.log").write_text("early configure failure\n") + evidence = owned_prefix / "evidence" + evidence.mkdir() + COHORT.Commands(evidence, {}).run("curl-configure", ["fixture-configure"]) + + result = subprocess.CompletedProcess(["fixture-configure"], 17, "partial", "configure failed") + with patch.object(COHORT, "load_manifest", return_value=manifest), \ + patch.object(COHORT.platform, "system", return_value="Darwin"), \ + patch.object(COHORT.platform, "machine", return_value="arm64"), \ + patch.object(COHORT, "_prepare_owned", side_effect=failed_build), \ + patch.object(COHORT.subprocess, "run", return_value=result), \ + self.assertRaisesRegex(ValueError, r"curl-configure \(17\)"): + COHORT.prepare(prefix, llvm, baseline_path, bundle) + self.assertEqual((prefix / "evidence/configuration/curl-build/config.log").read_text(), + "early configure failure\n") + self.assertEqual(json.loads((prefix / "evidence/commands.json").read_text())[0]["returncode"], 17) + def test_failed_command_retains_output_status_and_arguments(self): with tempfile.TemporaryDirectory() as directory: evidence = Path(directory) From 6dea3d0c2a87669853be81a7757a8bd2bbc358b8 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:25:33 -0500 Subject: [PATCH 076/148] test(ci): reject Autoconf dependency helper overrides --- tools/tests/test_macos_curl_cohort.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/tests/test_macos_curl_cohort.py b/tools/tests/test_macos_curl_cohort.py index 369b9e132..b7053fee7 100644 --- a/tools/tests/test_macos_curl_cohort.py +++ b/tools/tests/test_macos_curl_cohort.py @@ -61,6 +61,7 @@ def test_native_environment_removes_ambient_build_and_trust_overrides(self): "OBJDUMP": "/foreign/objdump", "STRIP": "/foreign/strip", "CC": "/foreign/cc", "CXX": "/foreign/cxx", "CFLAGS": "-O0", "CXXFLAGS": "-O0", "CPPFLAGS": "-I/foreign", "LDFLAGS": "-L/foreign", + "PKGCONFIG": "/foreign/pkg-config", "KRB5CONFIG": "/foreign/krb5-config", "PKG_CONFIG": "/foreign/pkg-config", "PKG_CONFIG_PATH": "/foreign/pkgconfig", "PKG_CONFIG_LIBDIR": "/foreign/pkgconfig", "PKG_CONFIG_SYSTEM_INCLUDE_PATH": "/foreign/include", "PKG_CONFIG_SYSTEM_LIBRARY_PATH": "/foreign/lib", "LIBRARY_PATH": "/foreign/lib", From 9af550a4fdac5ee202d8a8ef488ebf25bd0857ad Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:27:24 -0500 Subject: [PATCH 077/148] build(ci): prepare native c-ares curl without losing macOS features --- docs/MACOS_TOOLCHAIN.md | 57 +++ tools/ci/macos-curl-toolchain.json | 133 ++++++ tools/ci/macos_curl_probe/CMakeLists.txt | 9 + tools/ci/macos_curl_probe/main.c | 77 +++- tools/ci/prepare_macos_curl.py | 511 +++++++++++++++++++++++ 5 files changed, 781 insertions(+), 6 deletions(-) create mode 100644 tools/ci/macos-curl-toolchain.json create mode 100644 tools/ci/prepare_macos_curl.py diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md index e311e0546..a70a69866 100644 --- a/docs/MACOS_TOOLCHAIN.md +++ b/docs/MACOS_TOOLCHAIN.md @@ -101,6 +101,63 @@ dependency acceptance. The exact feature, protocol and trust baseline must be preserved when the resolver provider changes. Linux and Windows curl policies do not establish the native Mac baseline. +`tools/ci/macos-curl-toolchain.json` records the authenticated ARM64 baseline +from native run `38122697026` at Classic +`8048191fc62554be68647463a18fc5b956d06f8d`: curl 8.22.0, 20 features and +25 protocols, OpenSSL 3.6.4 and Apple SecTrust, system zlib and Apple IDN, +native Kerberos/LDAP, HTTP/2 and HTTP/3, SSH2, compression and suffix policy. +The measured Homebrew resolver is threaded and does not meet Classic's +cancellation contract. The native helper replaces it with pinned c-ares 1.34.6 +while retaining every measured feature/protocol and TLS policy: + +```sh +python3 tools/ci/prepare_macos_curl.py \ + --prefix /absolute/absent/task-curl-directory \ + --llvm-prefix /absolute/task-llvm-directory/llvm \ + --baseline /absolute/curl-baseline/baseline.json \ + --ca-bundle "$PWD/client/ca-bundle.crt" --jobs 2 +``` + +The helper is native ARM64 only; its absent prefix preserves interrupted +stages. Verified release archives and full notices accompany both shared +libraries. c-ares uses the existing native CMake toolchain; curl uses explicit +arguments translated from the measured formula's configure policy, preserving +Kerberos/LDAP and SDK zlib discovery. Both use LLVM 23.1.3, compiler-rt and +Mach-O LLD with native Release `-O3 -DNDEBUG`, without LTO/PGO or Linux unwind +flags. Generated resolver configuration must select c-ares and exclude the +threaded/hybrid backend. Unknown baseline changes or missing runtime +capabilities fail with named evidence rather than silently reducing features. + +Select this prefix first in CMake and pkg-config, with +`CURL_NO_CURL_CMAKE=TRUE`, `CURL_ROOT`, explicit `CURL_INCLUDE_DIR` and +`CURL_LIBRARY_RELEASE` pointing to its headers and dylib. After configuring +Classic, verify selection before building: + +```sh +python3 tools/ci/prepare_macos_curl.py \ + --prefix /absolute/task-curl-directory \ + --verify-consumer-cache build/macos-qualification/CMakeCache.txt +``` + +The helper binds selected headers and loaded curl/c-ares images to the owned +prefix, captures native dependency keg paths/hashes, and records system image +names plus SDK/OS identity. Many system dylibs reside only in the dyld shared +cache and cannot be hashed as filesystem files. It requires ordinary public +HTTPS with default trust and the locked Classic bundle, rejects that endpoint +with a fresh unrelated CA, and checks a private loopback TLS chain with trusted, +untrusted and wrong-hostname cases. Peer and hostname checks stay enabled; +private CA tests do not opt back into native CA trust. SecTrust and OpenSSL +fallback configuration are retained, while public success alone cannot prove +which trust engine executed. + +Upload only the helper's `evidence/` directory and the existing application +reports. Private fixture keys remain in `stage/private-tls/`, outside uploaded +evidence. The owned absolute dylib install names describe a CI-local dependency +cohort; they do not qualify a relocatable Mac package. Helper probes and cache +selection do not qualify Classic cancellation or the application: the unchanged +`libatrinik-curl` stalled-DNS tests and the supported Release/CPU suites must +also pass with this cohort. Intel, packaged Mac and GPU acceptance stay separate. + `tools/ci/prepare_macos_mixer.sh` builds the SHA-verified SDL3_mixer source from the existing packaging lock with native LLVM and the same decoder options as the Linux and Windows producers. Homebrew's mixer enables additional diff --git a/tools/ci/macos-curl-toolchain.json b/tools/ci/macos-curl-toolchain.json new file mode 100644 index 000000000..311dd49c8 --- /dev/null +++ b/tools/ci/macos-curl-toolchain.json @@ -0,0 +1,133 @@ +{ + "schema_version": 1, + "llvm_version": "23.1.3", + "architecture": "arm64", + "baseline": { + "run_id": 38122697026, + "artifact_id": 11696680160, + "artifact_sha256": "521996348a6ae724044f3af51271d2b690c9a77ad3d59674ee1744fd0e161fd7", + "source_commit": "8048191fc62554be68647463a18fc5b956d06f8d", + "json_sha256": "ec4d94deb2c25871f406b82f0ec1f93b1130456bf7fdd78b2dc55999a309e186", + "formula_sha256": "518c363f0092cc18c09a385768edea00ccdb8ffc76796eb403a76b146f624932", + "features": [ + "alt-svc", + "AsynchDNS", + "brotli", + "GSS-API", + "HSTS", + "HTTP2", + "HTTP3", + "HTTPS-proxy", + "IDN", + "IPv6", + "Kerberos", + "Largefile", + "libz", + "PSL", + "AppleSecTrust", + "SPNEGO", + "SSL", + "threadsafe", + "UnixSockets", + "zstd" + ], + "protocols": [ + "dict", + "file", + "ftp", + "ftps", + "gopher", + "gophers", + "http", + "https", + "imap", + "imaps", + "ldap", + "ldaps", + "mqtt", + "mqtts", + "pop3", + "pop3s", + "rtsp", + "scp", + "sftp", + "smtp", + "smtps", + "telnet", + "tftp", + "ws", + "wss" + ], + "versions": { + "version": "8.22.0", + "ssl_version": "OpenSSL/3.6.4", + "libz_version": "1.2.12", + "libssh_version": "libssh2/1.11.1", + "brotli_version": "brotli/1.2.0", + "nghttp2_version": "1.70.0", + "quic_version": "ngtcp2/1.25.0 nghttp3/1.18.0", + "zstd_version": "zstd/1.5.7" + } + }, + "sources": { + "curl": { + "version": "8.22.0", + "archive": "curl-8.22.0.tar.bz2", + "url": "https://curl.se/download/curl-8.22.0.tar.bz2", + "sha256": "5d956a6a22b3c279f50c421ee5d3c9e9d660cb6f115dcf881b579e952130549c", + "directory": "curl-8.22.0", + "notice": "COPYING" + }, + "cares": { + "version": "1.34.6", + "archive": "c-ares-1.34.6.tar.gz", + "url": "https://github.com/c-ares/c-ares/releases/download/v1.34.6/c-ares-1.34.6.tar.gz", + "sha256": "912dd7cc3b3e8a79c52fd7fb9c0f4ecf0aaa73e45efda880266a2d6e26b84ef5", + "directory": "c-ares-1.34.6", + "notice": "LICENSE.md" + } + }, + "dependency_formulae": [ + "openssl@3", + "brotli", + "libnghttp2", + "libnghttp3", + "libngtcp2", + "libpsl", + "libssh2", + "zstd" + ], + "ca_bundle_sha256": "a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505", + "public_url": "https://curl.se/", + "required_config": [ + "USE_ARES", + "USE_RESOLV_ARES", + "USE_OPENSSL", + "USE_APPLE_SECTRUST", + "CURL_CA_FALLBACK", + "USE_APPLE_IDN", + "HAVE_GSSAPI", + "USE_LIBSSH2", + "USE_NGHTTP2", + "USE_NGHTTP3", + "USE_NGTCP2", + "USE_LIBPSL", + "HAVE_BROTLI", + "HAVE_ZSTD" + ], + "forbidden_config": [ + "USE_RESOLV_THREADED", + "CURL_DISABLE_LDAP", + "CURL_DISABLE_LDAPS", + "CURL_CA_BUNDLE", + "CURL_CA_PATH", + "HAVE_GSSAPPLE" + ], + "scope": "Native ARM64 CI dependency cohort; no packaging or Intel Mac acceptance", + "required_platform_images": [ + "/System/Library/Frameworks/Kerberos.framework/Versions/A/Kerberos", + "/System/Library/Frameworks/LDAP.framework/Versions/A/LDAP", + "/usr/lib/libz.1.dylib", + "/usr/lib/libicucore.A.dylib" + ] +} diff --git a/tools/ci/macos_curl_probe/CMakeLists.txt b/tools/ci/macos_curl_probe/CMakeLists.txt index 0615f3c7e..3246c068c 100644 --- a/tools/ci/macos_curl_probe/CMakeLists.txt +++ b/tools/ci/macos_curl_probe/CMakeLists.txt @@ -15,6 +15,15 @@ add_executable(macos-curl-probe main.c) set_target_properties(macos-curl-probe PROPERTIES C_STANDARD 17 C_STANDARD_REQUIRED YES C_EXTENSIONS NO) target_link_libraries(macos-curl-probe PRIVATE CURL::libcurl) +if(ATRINIK_CURL_COHORT) + find_package(c-ares CONFIG REQUIRED) + target_link_libraries(macos-curl-probe PRIVATE c-ares::cares) + target_compile_definitions(macos-curl-probe PRIVATE ATRINIK_CURL_COHORT=1) + get_target_property(cares_header_directories c-ares::cares INTERFACE_INCLUDE_DIRECTORIES) + file(WRITE "${CMAKE_BINARY_DIR}/selected-cares-headers.txt" "${cares_header_directories}\n") + file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/selected-cares-library.txt" + CONTENT "$\n") +endif() target_compile_options(macos-curl-probe PRIVATE -Wall -Wextra -Werror) file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/selected-library.txt" CONTENT "$\n") diff --git a/tools/ci/macos_curl_probe/main.c b/tools/ci/macos_curl_probe/main.c index a6298b3f3..1ecaa631d 100644 --- a/tools/ci/macos_curl_probe/main.c +++ b/tools/ci/macos_curl_probe/main.c @@ -4,6 +4,10 @@ #include #include #include +#ifdef ATRINIK_CURL_COHORT +#include +#include +#endif static void json_string(const char *text) { @@ -49,7 +53,7 @@ static size_t discard(char *data, size_t size, size_t count, void *context) } /* No curlrc, proxy, netrc, credentials, redirects, or weakened TLS checks. */ -static int public_tls(const char *bundle) +static int tls_request(const char *url, const char *bundle, const char *resolve) { CURL *handle = curl_easy_init(); CURLcode result = CURLE_FAILED_INIT; @@ -58,6 +62,7 @@ static int public_tls(const char *bundle) char error[CURL_ERROR_SIZE] = {0}; const char *default_bundle = NULL; const char *default_path = NULL; + struct curl_slist *addresses = NULL; if (handle != NULL) { #define SET(option, value) do { \ result = curl_easy_setopt(handle, option, value); \ @@ -67,7 +72,7 @@ static int public_tls(const char *bundle) if (result != CURLE_OK) goto finished; result = curl_easy_getinfo(handle, CURLINFO_CAPATH, &default_path); if (result != CURLE_OK) goto finished; - SET(CURLOPT_URL, "https://curl.se/"); + SET(CURLOPT_URL, url); SET(CURLOPT_PROXY, ""); SET(CURLOPT_NETRC, CURL_NETRC_IGNORED); SET(CURLOPT_FOLLOWLOCATION, 0L); @@ -77,6 +82,14 @@ static int public_tls(const char *bundle) SET(CURLOPT_TIMEOUT, 45L); SET(CURLOPT_WRITEFUNCTION, discard); SET(CURLOPT_ERRORBUFFER, error); + if (resolve != NULL) { + addresses = curl_slist_append(NULL, resolve); + if (addresses == NULL) { + result = CURLE_OUT_OF_MEMORY; + goto finished; + } + SET(CURLOPT_RESOLVE, addresses); + } if (bundle != NULL) { SET(CURLOPT_CAINFO, bundle); } @@ -100,12 +113,22 @@ static int public_tls(const char *bundle) json_string(default_path); printf("}"); curl_easy_cleanup(handle); + curl_slist_free_all(addresses); return result == CURLE_OK && status >= 200 && status < 400; } int main(int argc, char **argv) { - if (argc != 2 || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { +#ifdef ATRINIK_CURL_COHORT + if (argc != 5) { + return 2; + } +#else + if (argc != 2) { + return 2; + } +#endif + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { return 2; } curl_version_info_data *info = curl_version_info(CURLVERSION_NOW); @@ -147,10 +170,52 @@ int main(int argc, char **argv) printf(",\"protocols\":"); string_array(info->protocols); printf(",\"public_tls_default\":"); - int default_ok = public_tls(NULL); + int default_ok = tls_request("https://curl.se/", NULL, NULL); printf(",\"public_tls_bundle\":"); - int bundle_ok = public_tls(argv[1]); + int bundle_ok = tls_request("https://curl.se/", argv[1], NULL); +#ifdef ATRINIK_CURL_COHORT + Dl_info cares_provider = {0}; + void *cares_symbol = dlsym(RTLD_DEFAULT, "ares_version"); + if (cares_symbol == NULL || dladdr(cares_symbol, &cares_provider) == 0) { + curl_global_cleanup(); + return 2; + } + printf(",\"cares_version\":"); + json_string(ares_version(NULL)); + printf(",\"cares_header_version\":"); + json_string(ARES_VERSION_STR); + printf(",\"loaded_cares_library\":"); + json_string(cares_provider.dli_fname); + printf(",\"public_tls_unrelated\":"); + tls_request("https://curl.se/", argv[2], NULL); + char trusted_url[128], wrong_url[128], wrong_resolve[128]; + int trusted_size = snprintf(trusted_url, sizeof(trusted_url), "https://localhost:%s/", argv[4]); + int wrong_size = snprintf(wrong_url, sizeof(wrong_url), "https://wrong.invalid:%s/", argv[4]); + int resolve_size = snprintf(wrong_resolve, sizeof(wrong_resolve), "wrong.invalid:%s:127.0.0.1", argv[4]); + if (trusted_size < 0 || (size_t) trusted_size >= sizeof(trusted_url) || + wrong_size < 0 || (size_t) wrong_size >= sizeof(wrong_url) || + resolve_size < 0 || (size_t) resolve_size >= sizeof(wrong_resolve)) { + curl_global_cleanup(); + return 2; + } + printf(",\"private_tls_trusted\":"); + int private_ok = tls_request(trusted_url, argv[3], NULL); + printf(",\"private_tls_untrusted\":"); + tls_request(trusted_url, NULL, NULL); + printf(",\"private_tls_wrong_hostname\":"); + tls_request(wrong_url, argv[3], wrong_resolve); + printf(",\"loaded_images\":["); + for (uint32_t i = 0; i < _dyld_image_count(); i++) { + if (i != 0) { + putchar(','); + } + json_string(_dyld_get_image_name(i)); + } + printf("]"); +#else + int private_ok = 1; +#endif printf("}\n"); curl_global_cleanup(); - return default_ok && bundle_ok ? 0 : 1; + return default_ok && bundle_ok && private_ok ? 0 : 1; } diff --git a/tools/ci/prepare_macos_curl.py b/tools/ci/prepare_macos_curl.py new file mode 100644 index 000000000..bb24583b4 --- /dev/null +++ b/tools/ci/prepare_macos_curl.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Build the measured native Mac curl features with a qualified c-ares resolver.""" + +import argparse +import contextlib +import http.server +import json +import os +from pathlib import Path +import platform +import re +import shlex +import shutil +import ssl +import subprocess +import tarfile +import threading + +from collect_macos_curl import clean_environment, digest, validate_headers + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = Path(__file__).with_name("macos-curl-toolchain.json") + + +def native_environment(environment): + blocked = { + "CONFIG_SITE", "CONFIG_SHELL", "BASH_ENV", "ENV", "MAKEFLAGS", "MFLAGS", + "GNUMAKEFLAGS", "MAKELEVEL", "MAKEOVERRIDES", "LIBS", "CPP", "AR", "ARFLAGS", + "RANLIB", "RANLIBFLAGS", "NM", "LD", "AS", "OBJCOPY", "OBJDUMP", "STRIP", + "PKG_CONFIG", "PKGCONFIG", "KRB5CONFIG", "PKG_CONFIG_SYSTEM_INCLUDE_PATH", "PKG_CONFIG_SYSTEM_LIBRARY_PATH", + "CCC_OVERRIDE_OPTIONS", "CCC_ADD_ARGS", + } + result = {key: value for key, value in clean_environment(environment).items() + if key not in blocked and "_cv_" not in key + and not key.startswith(("CMAKE_", "CCACHE_", "DISTCC_"))} + # Disable both environment-selected and automatically discovered site scripts. + result["CONFIG_SITE"] = "/dev/null" + result["CONFIG_SHELL"] = "/bin/sh" + return result + + +def load_manifest(): + data = json.loads(MANIFEST.read_text()) + if data["schema_version"] != 1 or data["llvm_version"] != "23.1.3": + raise ValueError("unsupported native curl toolchain manifest") + for source in data["sources"].values(): + if not re.fullmatch(r"[0-9a-f]{64}", source["sha256"]): + raise ValueError("source archive digest is not SHA256") + return data + + +def validate_baseline(data, manifest): + runtime = data["runtime"] + expected = manifest["baseline"] + for key in ("features", "protocols"): + if set(runtime[key]) != set(expected[key]) or len(runtime[key]) != len(set(runtime[key])): + raise ValueError(f"native baseline {key} changed; policy review required") + for key, version in expected["versions"].items(): + if runtime[key] != version: + raise ValueError(f"native baseline {key} changed; policy review required") + if runtime["ares"] is not None: + raise ValueError("unexpected baseline resolver provider") + for key in ("public_tls_default", "public_tls_bundle"): + proof = runtime[key] + if (proof["code"] != 0 or proof["status"] != 200 or proof["ssl_verify_result"] != 0 + or proof["default_ca_bundle"] is not None or proof["default_ca_path"] is not None): + raise ValueError("native baseline trust policy changed") + if data["ca_bundle_sha256"] != manifest["ca_bundle_sha256"]: + raise ValueError("baseline CA bundle differs from the locked Classic input") + + +def extract_source(archive, destination, source): + if digest(archive) != source["sha256"]: + raise ValueError("source archive checksum mismatch") + with tarfile.open(archive) as bundle: + for member in bundle.getmembers(): + parts = Path(member.name).parts + if not parts or parts[0] != source["directory"] or ".." in parts: + raise ValueError("source archive contains an unexpected or escaping path") + bundle.extractall(destination, filter="data") + return Path(destination) / source["directory"] + + +def validate_config(text, manifest): + macros = dict(re.findall(r"^#define\s+(\w+)\s+([^\n]+)$", text, re.M)) + for name in manifest["required_config"]: + if macros.get(name) != "1": + raise ValueError(f"required native curl configuration absent: {name}") + for name in manifest["forbidden_config"]: + if name in macros: + raise ValueError(f"forbidden native curl configuration: {name}") + + +def configure_arguments(prefix, sdk, dependencies): + def dep(name): + return str(dependencies[name]) + return [ + f"--prefix={prefix}", f"--libdir={prefix}/lib", "--disable-silent-rules", + "--disable-debug", "--disable-dependency-tracking", "--enable-shared", "--disable-static", + f"--enable-ares={prefix}", "--disable-threaded-resolver", + f"--with-ssl={dep('openssl@3')}", "--with-default-ssl-backend=openssl", + "--without-ca-bundle", "--without-ca-path", "--with-ca-fallback", + "--with-apple-sectrust", "--with-gssapi", "--with-apple-idn", "--without-libidn2", + f"--with-zlib={sdk}/usr", f"--with-brotli={dep('brotli')}", + f"--with-zstd={dep('zstd')}", f"--with-libpsl={dep('libpsl')}", + f"--with-libssh2={dep('libssh2')}", f"--with-nghttp2={dep('libnghttp2')}", + f"--with-nghttp3={dep('libnghttp3')}", f"--with-ngtcp2={dep('libngtcp2')}", + "--enable-ldap", "--enable-ldaps", "--enable-ipv6", + ] + + +def validate_runtime(data, manifest): + for key in ("features", "protocols"): + missing = set(manifest["baseline"][key]) - set(data[key]) + if missing: + raise ValueError(f"native curl lost {key}: {', '.join(sorted(missing))}") + if len(set(data[key])) != len(data[key]): + raise ValueError(f"duplicate native curl {key}") + if "asyn-rr" in data["features"]: + raise ValueError("hybrid threaded resolver is not qualified for cancellation") + for key, value in manifest["baseline"]["versions"].items(): + if data[key] != value: + raise ValueError(f"native curl dependency identity changed: {key}") + if data["header_version"] != manifest["sources"]["curl"]["version"]: + raise ValueError("native curl header version differs from its source") + if data["header_version_num"] != data["version_num"]: + raise ValueError("native curl header/runtime version mismatch") + cares = manifest["sources"]["cares"]["version"] + if data["ares"] != cares or data["cares_version"] != cares or data["cares_header_version"] != cares: + raise ValueError("native curl did not load the qualified c-ares version") + for key in ("public_tls_default", "public_tls_bundle", "private_tls_trusted"): + proof = data[key] + if proof["code"] != 0 or proof["status"] != 200 or proof["ssl_verify_result"] != 0: + raise ValueError(f"verified native TLS request failed: {key}") + for key in ("public_tls_unrelated", "private_tls_untrusted", "private_tls_wrong_hostname"): + if data[key]["code"] != 60 or data[key]["status"] != 0: + raise ValueError(f"native TLS rejection did not establish certificate verification: {key}") + for key in ("public_tls_default", "public_tls_bundle"): + if data[key]["default_ca_bundle"] is not None or data[key]["default_ca_path"] is not None: + raise ValueError("native curl default trust paths changed") + + +def validate_loaded(data, prefix, dependency_files, probe, required_platform_images=()): + prefix = Path(prefix).resolve(strict=True) + own = {name: (prefix / "lib" / f"lib{name}.dylib").resolve(strict=True) + for name in ("curl", "cares")} + for name, key in (("curl", "loaded_library"), ("cares", "loaded_cares_library")): + if not own[name].is_relative_to(prefix) or Path(data[key]).resolve(strict=True) != own[name]: + raise ValueError(f"loaded {name} provider is outside the owned cohort") + images = data["loaded_images"] + if not images or len(set(images)) != len(images): + raise ValueError("invalid native loaded-image inventory") + missing_platform = set(required_platform_images) - set(images) + if missing_platform: + raise ValueError("baseline SDK providers absent: " + ", ".join(sorted(missing_platform))) + observed = set() + for name in images: + path = Path(name) + if not path.is_absolute() or ".." in path.parts: + raise ValueError("invalid native loaded-image path") + if path.name.startswith(("libcurl", "libcares")): + if (name.startswith(("/usr/lib/", "/System/Library/", "/Library/Apple/System/Library/")) + or path.resolve(strict=True) not in own.values()): + raise ValueError("additional curl/c-ares provider loaded outside the owned cohort") + # These are OS-owned install names, often resident only in dyld's cache. + if name.startswith(("/usr/lib/", "/System/Library/", "/Library/Apple/System/Library/")): + continue + resolved = path.resolve(strict=True) + observed.add(resolved) + if resolved in {*own.values(), Path(probe).resolve(strict=True)}: + continue + if str(resolved) not in dependency_files or digest(resolved) != dependency_files[str(resolved)]: + raise ValueError(f"unrecorded native dependency loaded: {name}") + if not set(own.values()) <= observed: + raise ValueError("owned curl/c-ares missing from loaded-image inventory") + + +def cmake_cache(path): + values = {} + for line in Path(path).read_text().splitlines(): + if line and not line.startswith(("#", "//")) and ":" in line and "=" in line: + key, value = line.split("=", 1) + values[key.split(":", 1)[0]] = value + return values + + +def retain_diagnostics(prefix): + """Copy only public build diagnostics, including unsuccessful configuration.""" + prefix = Path(prefix) + for directory in ("llvm-probe", "cares-build", "curl-build", "curl-probe"): + build = prefix / "stage" / directory + for name in ("CMakeCache.txt", "compile_commands.json", "config.log", "config.status", + "lib/curl_config.h", "CMakeFiles/CMakeConfigureLog.yaml"): + source = build / name + if source.is_file(): + destination = prefix / "evidence/configuration" / directory / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + + +class Commands: + def __init__(self, evidence, environment): + self.evidence = evidence + self.environment = environment + self.records = [] + + def run(self, name, arguments, *, cwd=None, environment=None, check=True): + result = subprocess.run([str(arg) for arg in arguments], cwd=cwd, + env=environment or self.environment, text=True, capture_output=True) + (self.evidence / f"{name}.stdout").write_text(result.stdout) + (self.evidence / f"{name}.stderr").write_text(result.stderr) + self.records.append({"name": name, "arguments": [str(arg) for arg in arguments], + "cwd": str(cwd) if cwd else None, "returncode": result.returncode}) + (self.evidence / "commands.json").write_text(json.dumps(self.records, indent=2) + "\n") + if check and result.returncode: + raise ValueError(f"native curl command failed: {name} ({result.returncode})") + return result + + +def capture_dependencies(commands, manifest): + inventory = json.loads(commands.run("brew-inventory", ["brew", "info", "--json=v2", "--installed"]).stdout) + formulae = {entry["name"]: entry for entry in inventory["formulae"]} + pending = list(manifest["dependency_formulae"]) + dependencies, files = {}, {} + while pending: + name = pending.pop() + if name in dependencies: + continue + entry = formulae[name] + if len(entry["installed"]) != 1: + raise ValueError(f"ambiguous installed native dependency: {name}") + prefix = Path(commands.run("brew-prefix-" + name.replace("@", "-"), + ["brew", "--prefix", name]).stdout.strip()).resolve(strict=True) + dependencies[name] = prefix + pending.extend(dep["full_name"].split("/")[-1] + for dep in entry["installed"][0].get("runtime_dependencies", [])) + for file in (prefix / "lib").rglob("*.dylib"): + resolved = file.resolve(strict=True) + if not resolved.is_relative_to(prefix): + raise ValueError(f"native dependency escapes its recorded keg: {file}") + files[str(resolved)] = digest(resolved) + return dependencies, files, inventory + + +@contextlib.contextmanager +def private_tls(commands, directory, openssl): + """Own loopback fixture; private keys never enter the public evidence directory.""" + directory.mkdir(mode=0o700) + config = directory / "openssl.cnf" + config.write_text("[req]\nprompt=no\ndistinguished_name=dn\nx509_extensions=ext\n" + "[dn]\nCN=Classic native curl fixture CA\n[ext]\n" + "basicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n") + cert, key = directory / "trusted.pem", directory / "private.key" + commands.run("fixture-trusted-ca", [openssl, "req", "-x509", "-newkey", "rsa:2048", "-sha256", + "-nodes", "-days", "1", "-config", config, "-keyout", key, "-out", cert]) + key.chmod(0o600) + unrelated = directory / "unrelated.pem" + unrelated_key = directory / "unrelated.key" + commands.run("fixture-unrelated-ca", [openssl, "req", "-x509", "-newkey", "rsa:2048", "-sha256", + "-nodes", "-days", "1", "-config", config, "-subj", "/CN=Unrelated native curl fixture CA", + "-keyout", unrelated_key, "-out", unrelated]) + unrelated_key.chmod(0o600) + server_key = directory / "server.key" + server_request = directory / "server.csr" + server_cert = directory / "server.pem" + server_extensions = directory / "server.cnf" + server_extensions.write_text("[server]\nbasicConstraints=critical,CA:FALSE\n" + "keyUsage=critical,digitalSignature,keyEncipherment\n" + "extendedKeyUsage=serverAuth\nsubjectAltName=DNS:localhost\n") + commands.run("fixture-server-request", [openssl, "req", "-new", "-newkey", "rsa:2048", "-sha256", + "-nodes", "-subj", "/CN=localhost", "-keyout", server_key, "-out", server_request]) + server_key.chmod(0o600) + commands.run("fixture-server-sign", [openssl, "x509", "-req", "-in", server_request, + "-CA", cert, "-CAkey", key, "-CAcreateserial", "-days", "1", "-sha256", + "-extfile", server_extensions, "-extensions", "server", "-out", server_cert]) + # Store public certificates only; commands record paths, never key bytes. + shutil.copyfile(cert, commands.evidence / "private-ca-public.pem") + shutil.copyfile(unrelated, commands.evidence / "unrelated-ca-public.pem") + shutil.copyfile(server_cert, commands.evidence / "server-public.pem") + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = b"native TLS fixture\n" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(server_cert, server_key) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield cert, unrelated, server.server_address[1] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def prepare(prefix, llvm_prefix, baseline_path, ca_bundle, jobs=2): + manifest = load_manifest() + if platform.system() != "Darwin" or platform.machine() != manifest["architecture"]: + raise ValueError("native curl cohort requires the qualified ARM64 macOS host") + if jobs not in (1, 2): + raise ValueError("native dependency jobs must be one or two") + if not prefix.is_absolute() or prefix.exists() or prefix.is_symlink(): + raise ValueError("native curl install prefix must be absolute and absent") + llvm_prefix = llvm_prefix.resolve(strict=True) + ca_bundle = ca_bundle.resolve(strict=True) + if digest(ca_bundle) != manifest["ca_bundle_sha256"]: + raise ValueError("Classic CA bundle does not match its locked digest") + baseline = json.loads(baseline_path.read_text()) + validate_baseline(baseline, manifest) + prefix.mkdir(mode=0o755) + try: + _prepare_owned(prefix, llvm_prefix, baseline_path, ca_bundle, jobs, manifest) + finally: + retain_diagnostics(prefix) + + +def _prepare_owned(prefix, llvm_prefix, baseline_path, ca_bundle, jobs, manifest): + stage, evidence = prefix / "stage", prefix / "evidence" + stage.mkdir() + evidence.mkdir() + environment = native_environment(os.environ) + commands = Commands(evidence, environment) + sdk = Path(commands.run("sdk-path", ["xcrun", "--sdk", "macosx", "--show-sdk-path"]).stdout.strip()).resolve(strict=True) + commands.run("sdk-version", ["xcrun", "--sdk", "macosx", "--show-sdk-version"]) + commands.run("host-version", ["sw_vers"]) + tools = {} + for name in ("clang", "clang++", "llvm-ar", "llvm-ranlib", "llvm-nm", "llvm-strip", "ld64.lld"): + path = llvm_prefix / "bin" / name + if not path.resolve(strict=True).is_relative_to(llvm_prefix): + raise ValueError("LLVM tool escapes the selected cohort") + result = commands.run("tool-" + name.replace("+", "x"), [path, "--version"]) + if "Apple clang" in result.stdout or not re.search(r"(?:clang|LLVM|LLD) version 23\.1\.3\b|LLD 23\.1\.3\b", result.stdout): + raise ValueError(f"native dependency tool is not LLVM 23.1.3: {name}") + tools[name] = {"path": str(path), "sha256": digest(path), "version": result.stdout.strip()} + dependencies, dependency_files, inventory = capture_dependencies(commands, manifest) + sources = {} + retained_sources = prefix / "share/atrinik/macos-curl/sources" + retained_sources.mkdir(parents=True) + for name, source in manifest["sources"].items(): + archive = retained_sources / source["archive"] + commands.run("download-" + name, ["/usr/bin/curl", "-q", "--fail", "--location", "--retry", "3", + "--connect-timeout", "15", "--max-time", "120", "--proxy", "", source["url"], "--output", archive]) + sources[name] = extract_source(archive, stage, source) + notices = prefix / "share/licenses" / name + notices.mkdir(parents=True) + shutil.copyfile(sources[name] / source["notice"], notices / source["notice"]) + shutil.copyfile(MANIFEST, prefix / "share/atrinik/macos-curl/toolchain.json") + shutil.copyfile(baseline_path, evidence / "baseline.json") + # Native SDK/runtime/tool validation and optimization come from the same + # CMake toolchain as Classic. No application target is built by this helper. + cmake_arguments = [f"-DCMAKE_TOOLCHAIN_FILE={ROOT}/cmake/toolchains/llvm-macos.cmake", + f"-DCMAKE_PROGRAM_PATH={llvm_prefix}/bin", f"-DCMAKE_OSX_SYSROOT={sdk}", + "-DCMAKE_OSX_ARCHITECTURES=arm64", "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"] + native_probe = stage / "llvm-probe" + commands.run("llvm-configure", ["cmake", "-S", ROOT / "tools/ci/macos_llvm_probe", "-B", native_probe, + "-G", "Ninja", *cmake_arguments]) + commands.run("llvm-build", ["cmake", "--build", native_probe, "--parallel", str(jobs)]) + commands.run("llvm-c-run", [native_probe / "llvm-c-probe"]) + commands.run("llvm-cxx-run", [native_probe / "llvm-cxx-probe"]) + cache = cmake_cache(native_probe / "CMakeCache.txt") + release_flags = shlex.split(cache["CMAKE_C_FLAGS_RELEASE"]) + if release_flags != ["-O3", "-DNDEBUG"]: + raise ValueError("native Release optimization changed; policy review required") + deployment = cache.get("CMAKE_OSX_DEPLOYMENT_TARGET", "") + if deployment: + cmake_arguments.append(f"-DCMAKE_OSX_DEPLOYMENT_TARGET={deployment}") + cares_build = stage / "cares-build" + commands.run("cares-configure", ["cmake", "-S", sources["cares"], "-B", cares_build, "-G", "Ninja", + *cmake_arguments, f"-DCMAKE_INSTALL_PREFIX={prefix}", f"-DCMAKE_INSTALL_NAME_DIR={prefix}/lib", + "-DCARES_SHARED=ON", "-DCARES_STATIC=OFF", "-DCARES_BUILD_TESTS=OFF"]) + commands.run("cares-build", ["cmake", "--build", cares_build, "--parallel", str(jobs)]) + commands.run("cares-install", ["cmake", "--install", cares_build]) + build_environment = dict(environment) + sdk_flags = ["-isysroot", str(sdk), "-arch", "arm64"] + if deployment: + sdk_flags.append(f"-mmacosx-version-min={deployment}") + for key, name in {"CC": "clang", "CXX": "clang++", "AR": "llvm-ar", "RANLIB": "llvm-ranlib", + "NM": "llvm-nm", "STRIP": "llvm-strip", "LD": "ld64.lld"}.items(): + build_environment[key] = shlex.quote(tools[name]["path"]) + build_environment["CFLAGS"] = shlex.join([*release_flags, *sdk_flags]) + build_environment["CXXFLAGS"] = shlex.join([*release_flags, *sdk_flags]) + build_environment["LDFLAGS"] = shlex.join([ + *sdk_flags, "--rtlib=compiler-rt", f"-fuse-ld={tools['ld64.lld']['path']}", + f"-L{prefix}/lib", f"-Wl,-rpath,{prefix}/lib", + ]) + # Exclude installed Brew curl, c-ares, zlib and libidn2 from discovery. + pc_dirs = [prefix / "lib/pkgconfig", *[dependencies[name] / "lib/pkgconfig" + for name in manifest["dependency_formulae"]]] + build_environment["PKG_CONFIG_LIBDIR"] = os.pathsep.join(str(path) for path in pc_dirs) + build_environment["PKG_CONFIG_PATH"] = build_environment["PKG_CONFIG_LIBDIR"] + (evidence / "build-environment.json").write_text(json.dumps({key: build_environment[key] + for key in ("CC", "CXX", "AR", "RANLIB", "NM", "STRIP", "LD", "CFLAGS", "CXXFLAGS", "LDFLAGS", + "PKG_CONFIG_LIBDIR", "PKG_CONFIG_PATH")}, indent=2) + "\n") + curl_build = stage / "curl-build" + curl_build.mkdir() + commands.run("curl-configure", [sources["curl"] / "configure", + *configure_arguments(prefix, sdk, dependencies)], cwd=curl_build, environment=build_environment) + validate_config((curl_build / "lib/curl_config.h").read_text(), manifest) + commands.run("curl-build", ["make", f"-j{jobs}", "V=1"], cwd=curl_build, environment=build_environment) + commands.run("curl-install", ["make", "install"], cwd=curl_build, environment=build_environment) + shutil.copyfile(curl_build / "lib/curl_config.h", evidence / "curl_config.h") + probe_build = stage / "curl-probe" + commands.run("probe-configure", ["cmake", "-S", ROOT / "tools/ci/macos_curl_probe", "-B", probe_build, + "-G", "Ninja", *cmake_arguments, "-DATRINIK_CURL_COHORT=ON", f"-DCURL_ROOT={prefix}", + f"-DCURL_INCLUDE_DIR={prefix}/include", f"-DCURL_LIBRARY_RELEASE={prefix}/lib/libcurl.dylib", + f"-DCURL_LIBRARY_DEBUG={prefix}/lib/libcurl.dylib", f"-Dc-ares_DIR={prefix}/lib/cmake/c-ares"]) + validate_headers((probe_build / "selected-headers.txt").read_text().strip(), prefix) + validate_headers((probe_build / "selected-cares-headers.txt").read_text().strip(), prefix) + for name, file in (("curl", "selected-library.txt"), ("cares", "selected-cares-library.txt")): + selected = Path((probe_build / file).read_text().strip()).resolve(strict=True) + if selected != (prefix / f"lib/lib{name}.dylib").resolve(strict=True): + raise ValueError(f"probe selected a foreign {name} provider") + commands.run("probe-build", ["cmake", "--build", probe_build, "--parallel", str(jobs)]) + probe = probe_build / "macos-curl-probe" + with private_tls(commands, stage / "private-tls", dependencies["openssl@3"] / "bin/openssl") as (trusted, unrelated, port): + result = commands.run("runtime", [probe, ca_bundle, unrelated, trusted, str(port)], check=False) + data = json.loads(result.stdout) + (evidence / "runtime.json").write_text(json.dumps(data, indent=2) + "\n") + validate_runtime(data, manifest) + validate_loaded(data, prefix, dependency_files, probe, manifest["required_platform_images"]) + if result.returncode: + raise ValueError(f"native curl runtime probe failed ({result.returncode})") + for name in ("curl", "cares"): + library = (prefix / f"lib/lib{name}.dylib").resolve(strict=True) + identity = commands.run(name + "-install-name", ["xcrun", "otool", "-D", library]).stdout.splitlines() + if len(identity) != 2 or Path(identity[1]).resolve(strict=True) != library: + raise ValueError(f"{name} dylib install name is not the owned absolute provider") + commands.run(name + "-load-commands", ["xcrun", "otool", "-l", library]) + commands.run(name + "-dependencies", ["xcrun", "otool", "-L", library]) + architecture = commands.run(name + "-architecture", ["xcrun", "lipo", "-archs", library]).stdout.strip() + if architecture != "arm64": + raise ValueError(f"{name} dylib architecture differs from the native cohort") + receipt = {"schema_version": 1, "scope": manifest["scope"], "prefix": str(prefix), + "sources": manifest["sources"], "baseline_provenance": manifest["baseline"], + "source_commit": commands.run("source", ["git", "-C", ROOT, "rev-parse", "HEAD"]).stdout.strip(), + "tools": tools, "sdk": str(sdk), "architecture": "arm64", "deployment_target": deployment, + "release_flags": release_flags, "dependency_prefixes": {key: str(value) for key, value in dependencies.items()}, + "dependency_files": dependency_files, "runtime": data, + "curl_sha256": digest(prefix / "lib/libcurl.dylib"), "cares_sha256": digest(prefix / "lib/libcares.dylib"), + "header_hashes": {name: digest(prefix / "include" / name) + for name in ("curl/curl.h", "curl/curlver.h", "ares.h", "ares_version.h")}, + "manifest_sha256": digest(MANIFEST), + "consumer_cancellation_verified": False, "consumer_cpu_acceptance_verified": False, + "sectrust_execution_verified": False} + (evidence / "receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") + print(json.dumps(receipt, indent=2)) + + +def verify_consumer_cache(prefix, cache_path): + """Prove consumer selection; unchanged CTest remains the cancellation gate.""" + if platform.system() != "Darwin": + raise ValueError("consumer selection requires the actual native Mac filesystem") + prefix = prefix.resolve(strict=True) + receipt = json.loads((prefix / "evidence/receipt.json").read_text()) + cache = cmake_cache(cache_path) + if Path(receipt["prefix"]).resolve(strict=True) != prefix: + raise ValueError("cohort receipt belongs to another install prefix") + for key, expected in (("CURL_INCLUDE_DIR", prefix / "include"), + ("CURL_LIBRARY_RELEASE", prefix / "lib/libcurl.dylib")): + if not cache.get(key) or Path(cache[key]).resolve(strict=True) != expected.resolve(strict=True): + raise ValueError(f"consumer selected a foreign curl provider: {key}") + for name in ("curl", "cares"): + if digest(prefix / f"lib/lib{name}.dylib") != receipt[name + "_sha256"]: + raise ValueError(f"installed {name} bytes changed since native verification") + for name, expected in receipt["header_hashes"].items(): + if digest(prefix / "include" / name) != expected: + raise ValueError("installed curl/c-ares headers changed since verification") + tool = receipt["tools"]["clang"] + if (Path(cache["CMAKE_C_COMPILER"]).resolve(strict=True) != Path(tool["path"]).resolve(strict=True) + or digest(tool["path"]) != tool["sha256"]): + raise ValueError("consumer compiler differs from the verified LLVM cohort") + if cache["CMAKE_BUILD_TYPE"] != "Release" or shlex.split(cache["CMAKE_C_FLAGS_RELEASE"]) != receipt["release_flags"]: + raise ValueError("consumer Release optimization differs from the qualified cohort") + print("consumer curl headers/library/compiler match the verified native cohort; CTest still required") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prefix", required=True, type=Path) + parser.add_argument("--llvm-prefix", type=Path) + parser.add_argument("--baseline", type=Path) + parser.add_argument("--ca-bundle", type=Path) + parser.add_argument("--verify-consumer-cache", type=Path) + parser.add_argument("--jobs", type=int, default=2) + args = parser.parse_args() + try: + if args.verify_consumer_cache: + verify_consumer_cache(args.prefix, args.verify_consumer_cache) + else: + if any(value is None for value in (args.llvm_prefix, args.baseline, args.ca_bundle)): + parser.error("preparation requires --llvm-prefix, --baseline, and --ca-bundle") + prepare(args.prefix, args.llvm_prefix, args.baseline, args.ca_bundle, args.jobs) + except (ValueError, OSError, KeyError, json.JSONDecodeError, tarfile.TarError) as error: + parser.exit(1, f"native Mac curl preparation failed: {error}\n") + + +if __name__ == "__main__": + main() From 70f6d67c5ad2e3bdf12bada218e7ba749f57b01e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:33:36 -0500 Subject: [PATCH 078/148] ci(macos): qualify the feature-preserving c-ares curl cohort --- .../workflows/llvm-macos-qualification.yml | 24 ++++++++++++++++--- .../test_macos_qualification_workflow.py | 4 +++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/llvm-macos-qualification.yml b/.github/workflows/llvm-macos-qualification.yml index 5dda89bb1..d4d979252 100644 --- a/.github/workflows/llvm-macos-qualification.yml +++ b/.github/workflows/llvm-macos-qualification.yml @@ -121,6 +121,16 @@ jobs: build/macos-qualification-evidence/mixer-CMakeCache.txt cp "${RUNNER_TEMP}/classic-mixer-macos/stage/source.txt" \ build/macos-qualification-evidence/mixer-source.txt + - name: Build and qualify the matching c-ares curl provider + shell: bash + run: | + set -euo pipefail + python3 tools/ci/prepare_macos_curl.py \ + --prefix "${RUNNER_TEMP}/classic-curl-macos" \ + --llvm-prefix "${RUNNER_TEMP}/classic-llvm-macos/llvm" \ + --baseline "${GITHUB_WORKSPACE}/build/macos-qualification-evidence/curl-baseline/baseline.json" \ + --ca-bundle "${GITHUB_WORKSPACE}/client/ca-bundle.crt" --jobs 2 \ + 2>&1 | tee build/macos-qualification-evidence/curl-cohort.log - name: Acquire and verify the existing locked runtime and source inputs shell: bash run: | @@ -136,24 +146,31 @@ jobs: python_prefix=$(brew --prefix python@3.12) readline_prefix=$(brew --prefix readline) export PKG_CONFIG_PATH="${prefix}/lib/pkgconfig" - for formula in openssl@3 curl libxml2 zlib readline; do + for formula in openssl@3 libxml2 zlib readline; do formula_prefix=$(brew --prefix "${formula}") PKG_CONFIG_PATH="${formula_prefix}/lib/pkgconfig:${PKG_CONFIG_PATH}" done + export PKG_CONFIG_PATH="${RUNNER_TEMP}/classic-curl-macos/lib/pkgconfig:${PKG_CONFIG_PATH}" cmake -S . -B build/macos-qualification -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="${GITHUB_WORKSPACE}/cmake/toolchains/llvm-macos.cmake" \ -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DPACKAGE_TYPE=none \ -DATRINIK_BUILD_CLIENT=ON -DATRINIK_BUILD_SERVER=OFF \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - "-DCMAKE_PREFIX_PATH=${RUNNER_TEMP}/classic-mixer-macos;${prefix};$(brew --prefix openssl@3);$(brew --prefix curl);$(brew --prefix libxml2);$(brew --prefix zlib);$(brew --prefix readline);${python_prefix}" \ + "-DCMAKE_PREFIX_PATH=${RUNNER_TEMP}/classic-curl-macos;${RUNNER_TEMP}/classic-mixer-macos;${prefix};$(brew --prefix openssl@3);$(brew --prefix libxml2);$(brew --prefix zlib);$(brew --prefix readline);${python_prefix}" \ "-DSDL3_mixer_DIR=${RUNNER_TEMP}/classic-mixer-macos/lib/cmake/SDL3_mixer" \ "-DOPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" \ - "-DCURL_ROOT=$(brew --prefix curl)" \ + -DCURL_NO_CURL_CMAKE=TRUE \ + "-DCURL_ROOT=${RUNNER_TEMP}/classic-curl-macos" \ + "-DCURL_INCLUDE_DIR=${RUNNER_TEMP}/classic-curl-macos/include" \ + "-DCURL_LIBRARY_RELEASE=${RUNNER_TEMP}/classic-curl-macos/lib/libcurl.dylib" \ "-DPython3_EXECUTABLE=${python_prefix}/bin/python3.12" \ "-DREADLINE_INCLUDE_DIR=${readline_prefix}/include" \ "-DREADLINE_LIBRARY=${readline_prefix}/lib/libreadline.dylib" \ "-DATRINIK_GPU_SHADER_DIRECTORY=${GITHUB_WORKSPACE}/build/macos-qualification-shaders" \ 2>&1 | tee build/macos-qualification-evidence/configure.log + python3 tools/ci/prepare_macos_curl.py \ + --prefix "${RUNNER_TEMP}/classic-curl-macos" \ + --verify-consumer-cache build/macos-qualification/CMakeCache.txt - name: Verify required production features were retained shell: bash run: | @@ -215,6 +232,7 @@ jobs: name: macos-llvm-cpu-qualification-${{ github.sha }} path: | build/macos-qualification-evidence + ${{ runner.temp }}/classic-curl-macos/evidence build/macos-qualification/CMakeCache.txt build/macos-qualification/compile_commands.json build/macos-qualification/CMakeFiles/CMakeConfigureLog.yaml diff --git a/tools/tests/test_macos_qualification_workflow.py b/tools/tests/test_macos_qualification_workflow.py index fa25ceb0e..cc4cb4ee6 100644 --- a/tools/tests/test_macos_qualification_workflow.py +++ b/tools/tests/test_macos_qualification_workflow.py @@ -27,7 +27,9 @@ def test_audio_uses_the_qualified_decoder_prefix(self): text = WORKFLOW.read_text() self.assertIn('prepare_macos_mixer.sh "${RUNNER_TEMP}/classic-mixer-macos"', text) self.assertIn('"-DSDL3_mixer_DIR=${RUNNER_TEMP}/classic-mixer-macos/lib/cmake/SDL3_mixer"', text) - self.assertIn('"-DCMAKE_PREFIX_PATH=${RUNNER_TEMP}/classic-mixer-macos;', text) + prefixes = re.search(r'"-DCMAKE_PREFIX_PATH=([^"\n]+)"', text) + self.assertIsNotNone(prefixes) + self.assertIn('${RUNNER_TEMP}/classic-mixer-macos', prefixes.group(1).split(';')) self.assertIn('sdl3 sdl3_image sdl3_ttf opusfile', text) self.assertNotIn('sdl3 sdl3_image sdl3_ttf sdl3_mixer', text) self.assertIn('mixer-CMakeCache.txt', text) From 65dce4a048ccce840af51fcf4ac26413e329c14e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:40:45 -0500 Subject: [PATCH 079/148] fix(ci): require TLS 1.2 for native curl fixture --- tools/ci/prepare_macos_curl.py | 10 ++++++++-- tools/tests/test_macos_curl_cohort.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tools/ci/prepare_macos_curl.py b/tools/ci/prepare_macos_curl.py index bb24583b4..376a4f0b3 100644 --- a/tools/ci/prepare_macos_curl.py +++ b/tools/ci/prepare_macos_curl.py @@ -244,6 +244,13 @@ def capture_dependencies(commands, manifest): return dependencies, files, inventory +def private_tls_context(cert, key): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(cert, key) + return context + + @contextlib.contextmanager def private_tls(commands, directory, openssl): """Own loopback fixture; private keys never enter the public evidence directory.""" @@ -292,8 +299,7 @@ def log_message(self, *args): pass server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.load_cert_chain(server_cert, server_key) + context = private_tls_context(server_cert, server_key) server.socket = context.wrap_socket(server.socket, server_side=True) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/tools/tests/test_macos_curl_cohort.py b/tools/tests/test_macos_curl_cohort.py index b7053fee7..9dea9ed92 100644 --- a/tools/tests/test_macos_curl_cohort.py +++ b/tools/tests/test_macos_curl_cohort.py @@ -6,6 +6,7 @@ import importlib.util import io import json +import ssl from pathlib import Path import subprocess import sys @@ -29,6 +30,15 @@ class MacOSCurlCohortTests(unittest.TestCase): def setUp(self): self.manifest = COHORT.load_manifest() + def test_private_fixture_requires_tls12_and_keeps_tls13_available(self): + with patch.object(ssl.SSLContext, "load_cert_chain") as load: + context = COHORT.private_tls_context("server.pem", "server.key") + self.assertEqual(context.protocol, ssl.PROTOCOL_TLS_SERVER) + self.assertEqual(context.minimum_version, ssl.TLSVersion.TLSv1_2) + self.assertEqual(context.maximum_version, ssl.TLSVersion.MAXIMUM_SUPPORTED) + self.assertFalse(context.options & ssl.OP_NO_TLSv1_3) + load.assert_called_once_with("server.pem", "server.key") + def runtime(self): baseline = self.manifest["baseline"] result = dict(baseline["versions"], features=list(baseline["features"]), From f4858336ccc54fa048282d113720d691a1501def Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:52:32 -0500 Subject: [PATCH 080/148] test(ci): bind native curl symbol providers and platform dependency chains --- tools/tests/test_macos_curl_cohort.py | 126 ++++++++++++++++++++++ tools/tests/test_macos_curl_collection.py | 101 ++++++++++++++++- 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/tools/tests/test_macos_curl_cohort.py b/tools/tests/test_macos_curl_cohort.py index 9dea9ed92..97731278c 100644 --- a/tools/tests/test_macos_curl_cohort.py +++ b/tools/tests/test_macos_curl_cohort.py @@ -22,6 +22,7 @@ sys.path.insert(0, str(CI)) try: SPEC.loader.exec_module(COHORT) + import collect_macos_curl as COLLECTOR finally: sys.path.pop(0) @@ -50,6 +51,8 @@ def runtime(self): "default_ca_bundle": None, "default_ca_path": None} for name in ("public_tls_unrelated", "private_tls_untrusted", "private_tls_wrong_hostname"): result[name] = {"code": 60, "status": 0, "ssl_verify_result": 20} + result["loaded_images"] = ["/usr/lib/libSystem.B.dylib"] + result["dependency_install_names"] = {"/usr/lib/libSystem.B.dylib": []} return result def baseline(self): @@ -277,10 +280,15 @@ def test_loaded_inventory_rejects_foreign_providers_missing_images_and_dependenc "/usr/lib/libz.1.dylib", "/usr/lib/libicucore.A.dylib"] data["loaded_images"].extend(platform_images) # macOS shared-cache install names need no ordinary filesystem file. + data["dependency_install_names"] = {image: [] for image in data["loaded_images"]} + data["dependency_install_names"][str(curl)] = [str(cares)] + data["symbol_providers"] = {name: str(curl) for name in COLLECTOR.CURL_APIS} + data["symbol_providers"]["ares_version"] = str(cares) COHORT.validate_loaded(data, prefix, files, probe, platform_images) for missing in platform_images: changed = copy.deepcopy(data) changed["loaded_images"].remove(missing) + del changed["dependency_install_names"][missing] with self.subTest(missing_platform=missing), self.assertRaisesRegex(ValueError, "baseline SDK"): COHORT.validate_loaded(changed, prefix, files, probe, platform_images) mutations = [("loaded_library", str(foreign)), ("loaded_cares_library", str(foreign)), @@ -299,6 +307,7 @@ def test_loaded_inventory_rejects_foreign_providers_missing_images_and_dependenc for additional in ("/usr/lib/libcurl.4.dylib", "/usr/lib/libcares.2.dylib", str(foreign)): changed = copy.deepcopy(data) changed["loaded_images"].append(additional) + changed["dependency_install_names"][additional] = [] with self.subTest(extra_provider=additional), self.assertRaises(ValueError): COHORT.validate_loaded(changed, prefix, captured_foreign_files, probe) with self.assertRaises(ValueError): @@ -311,6 +320,123 @@ def test_loaded_inventory_rejects_foreign_providers_missing_images_and_dependenc with self.assertRaises(ValueError): COHORT.validate_loaded(data, prefix, files, probe) + def test_system_curl_requires_preserved_baseline_platform_chain_and_owned_api_bindings(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix = root / "cohort" + curl, cares = prefix / "lib/libcurl.dylib", prefix / "lib/libcares.dylib" + probe, foreign = root / "probe", root / "brew/libcurl.4.dylib" + bottle = root / "baseline/libcurl.dylib" + for path in (curl, cares, probe, foreign, bottle): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"same-version fixture") + platform_root = "/usr/lib/libldap.dylib" + intermediary = "/System/Library/Frameworks/fixture.framework/fixture" + system = COLLECTOR.SYSTEM_CURL + platform = [platform_root, intermediary, system] + baseline = {"loaded_library": str(bottle), "loaded_images": [str(bottle), *platform], + "dependency_install_names": {str(bottle): [platform_root], platform_root: [intermediary], + intermediary: [system], system: []}} + data = {"loaded_library": str(curl), "loaded_cares_library": str(cares), + "loaded_images": [str(curl), str(cares), str(probe), *platform], + "dependency_install_names": {str(curl): [str(cares), platform_root], str(cares): [], str(probe): [str(curl)], + **{key: copy.deepcopy(value) for key, value in baseline["dependency_install_names"].items() + if key != str(bottle)}}, + "symbol_providers": {**{name: str(curl) for name in COLLECTOR.CURL_APIS}, + "ares_version": str(cares)}} + files = {str(foreign): COHORT.digest(foreign)} + + def validate(candidate=data, prior=baseline, roots=(platform_root,)): + COHORT.validate_loaded(candidate, prefix, files, probe, baseline=prior, platform_roots=roots) + + validate() + with self.assertRaises(ValueError): + validate(prior=None) + with self.assertRaises(ValueError): + validate(roots=()) + for side in ("baseline", "candidate"): + selected = str(bottle) if side == "baseline" else str(curl) + for parent, child in ((selected, platform_root), (platform_root, intermediary), (intermediary, system)): + changed_data, changed_baseline = copy.deepcopy(data), copy.deepcopy(baseline) + changed = changed_baseline if side == "baseline" else changed_data + changed["dependency_install_names"][parent].remove(child) + with self.subTest(side=side, missing_edge=(parent, child)), self.assertRaises(ValueError): + validate(changed_data, changed_baseline) + changed = copy.deepcopy(data) + changed["dependency_install_names"][platform_root] = [system] + with self.assertRaisesRegex(ValueError, "preserve"): + validate(changed) + changed = copy.deepcopy(data) + other_root = "/usr/lib/libkrb5.dylib" + changed["loaded_images"].append(other_root) + changed["dependency_install_names"][other_root] = [system] + changed["dependency_install_names"][str(curl)] = [str(cares), other_root] + with self.assertRaisesRegex(ValueError, "preserve"): + validate(changed, roots=(platform_root, other_root)) + for edge in (str(foreign), "@rpath/libcurl.4.dylib", "/usr/lib/../lib/libcurl.4.dylib"): + changed = copy.deepcopy(data) + changed["dependency_install_names"][intermediary] = [edge] + if edge == str(foreign): + changed["loaded_images"].append(edge) + changed["dependency_install_names"][edge] = [system] + with self.subTest(foreign_edge=edge), self.assertRaises(ValueError): + validate(changed) + for missing in (intermediary, system): + changed = copy.deepcopy(baseline) + changed["loaded_images"].remove(missing) + del changed["dependency_install_names"][missing] + with self.subTest(baseline_missing_image=missing), self.assertRaises(ValueError): + validate(prior=changed) + for extra in (str(foreign), "/usr/lib/libcurl.3.dylib", "/usr/lib/libcares.2.dylib"): + changed = copy.deepcopy(data) + changed["loaded_images"].append(extra) + changed["dependency_install_names"][extra] = [] + with self.subTest(extra_provider=extra), self.assertRaises(ValueError): + validate(changed) + for api in data["symbol_providers"]: + changed = copy.deepcopy(data) + changed["symbol_providers"][api] = str(foreign) + with self.subTest(api=api), self.assertRaises(ValueError): + validate(changed) + changed = copy.deepcopy(data) + changed["dependency_install_names"][str(curl)] = [] + with self.assertRaisesRegex(ValueError, "c-ares dependency edge"): + validate(changed) + + def test_baseline_binding_requires_same_workflow_source_provider_and_bytes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix = root / "bottle" + library = prefix / "lib/libcurl.dylib" + library.parent.mkdir(parents=True) + library.write_bytes(b"baseline curl") + environment = {"GITHUB_RUN_ID": "17", "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_JOB": "native-curl", "GITHUB_REPOSITORY": "atrinik/classic"} + runtime = self.runtime() + runtime.update(loaded_library=str(library), loaded_images=[str(library)], + dependency_install_names={str(library): []}, + symbol_providers={name: str(library) for name in COLLECTOR.CURL_APIS}) + record = {"schema_version": 2, "source_commit": "a" * 40, + "qualification_identity": COLLECTOR.qualification_identity(environment), + "selected_library": str(library), "selected_library_sha256": COHORT.digest(library), + "prefix": str(prefix), "runtime": runtime} + COHORT.bind_baseline(record, "a" * 40, environment) + for key, value in (("schema_version", 1), ("source_commit", "b" * 40)): + changed = dict(record, **{key: value}) + with self.subTest(key=key), self.assertRaises(ValueError): + COHORT.bind_baseline(changed, "a" * 40, environment) + for key, value in (("GITHUB_RUN_ID", "18"), ("GITHUB_RUN_ATTEMPT", "2"), ("GITHUB_JOB", "other-job")): + with self.subTest(identity=key), self.assertRaises(ValueError): + COHORT.bind_baseline(record, "a" * 40, dict(environment, **{key: value})) + changed = copy.deepcopy(record) + changed["runtime"]["loaded_images"] = [] + changed["runtime"]["dependency_install_names"] = {} + with self.assertRaises(ValueError): + COHORT.bind_baseline(changed, "a" * 40, environment) + library.write_bytes(b"baseline bytes replaced after collection") + with self.assertRaisesRegex(ValueError, "bytes changed"): + COHORT.bind_baseline(record, "a" * 40, environment) + def test_header_directories_are_owned_even_for_same_version_headers(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tools/tests/test_macos_curl_collection.py b/tools/tests/test_macos_curl_collection.py index cc5f88f6c..404dc001f 100644 --- a/tools/tests/test_macos_curl_collection.py +++ b/tools/tests/test_macos_curl_collection.py @@ -36,6 +36,8 @@ def fixture(self, root): bundle.write_text("fixture CA, never used for a network request") data = { "header_version_num": 0x081600, "version_num": 0x081600, + "loaded_images": [str(library)], "dependency_install_names": {str(library): []}, + "symbol_providers": {name: str(library) for name in COLLECTOR.CURL_APIS}, "loaded_library": str(library), "features": ["SSL", "AsynchDNS", "HTTP3"], "protocols": ["http", "https", "ldap"], "public_tls_default": {"code": 0, "status": 200}, @@ -43,6 +45,101 @@ def fixture(self, root): } return prefix, library, llvm, bundle, data + def test_inventory_requires_complete_canonical_unique_loaded_images_and_edges(self): + images = ["/fixture/probe", "/usr/lib/libSystem.B.dylib"] + original = {"loaded_images": images, + "dependency_install_names": {images[0]: [images[1], "@rpath/optional.dylib"], images[1]: []}} + COLLECTOR.validate_inventory(original) + changes = [("loaded_images", []), ("loaded_images", images + [images[0]]), + ("loaded_images", ["relative.dylib"]), ("loaded_images", ["/usr/lib/../foreign.dylib"]), + ("loaded_images", ["/usr//lib/foreign.dylib"]), ("loaded_images", ["/usr/lib/./foreign.dylib"]), + ("loaded_images", [f"/fixture/image-{number}" for number in range(4097)]), + ("dependency_install_names", {images[0]: []}), + ("dependency_install_names", {**original["dependency_install_names"], "/unloaded": []})] + for invalid in (None, "not-a-list", [""], [None], ["bad\0install-name"], ["/too-many"] * 16385): + changes.append(("dependency_install_names", {images[0]: invalid, images[1]: []})) + for key, value in changes: + data = dict(original, **{key: value}) + with self.subTest(key=key, value=str(value)[:100]), self.assertRaises(ValueError): + COLLECTOR.validate_inventory(data) + + def test_every_directly_used_curl_api_must_bind_the_selected_provider(self): + with tempfile.TemporaryDirectory() as directory: + prefix, library, _, _, original = self.fixture(Path(directory)) + foreign = Path(directory) / "same-version-foreign.dylib" + foreign.write_bytes(library.read_bytes()) + COLLECTOR.validate_provider(original, library, prefix) + for api in COLLECTOR.CURL_APIS: + for replacement in (str(foreign), "relative.dylib"): + data = copy.deepcopy(original) + data["symbol_providers"][api] = replacement + with self.subTest(api=api, replacement=replacement), self.assertRaises(ValueError): + COLLECTOR.validate_provider(data, library, prefix) + data = copy.deepcopy(original) + del data["symbol_providers"][api] + with self.subTest(missing_api=api), self.assertRaises(ValueError): + COLLECTOR.validate_provider(data, library, prefix) + data = copy.deepcopy(original) + data["symbol_providers"]["unknown_api"] = str(library) + with self.assertRaises(ValueError): + COLLECTOR.validate_provider(data, library, prefix) + data = copy.deepcopy(original) + data["loaded_images"] = [str(foreign)] + data["dependency_install_names"] = {str(foreign): []} + with self.assertRaisesRegex(ValueError, "absent"): + COLLECTOR.validate_provider(data, library, prefix) + + def test_baseline_rejects_additional_resolver_and_nonexact_curl_images(self): + with tempfile.TemporaryDirectory() as directory: + prefix, library, _, _, original = self.fixture(Path(directory)) + foreign = Path(directory) / "foreign/libcurl.dylib" + foreign.parent.mkdir() + foreign.write_bytes(library.read_bytes()) + for extra in (str(foreign), "/usr/lib/libcurl.3.dylib", "/usr/lib/libcares.2.dylib"): + data = copy.deepcopy(original) + data["loaded_images"].append(extra) + data["dependency_install_names"][extra] = [] + with self.subTest(extra=extra), self.assertRaises(ValueError): + COLLECTOR.validate_provider(data, library, prefix) + + def test_qualification_identity_is_scoped_to_one_native_workflow_execution(self): + original = {"GITHUB_RUN_ID": "17", "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_JOB": "native-curl_qualification", "GITHUB_REPOSITORY": "atrinik/classic"} + self.assertEqual(COLLECTOR.qualification_identity(original), + {"run_id": "17", "run_attempt": "1", "job": "native-curl_qualification", + "repository": "atrinik/classic"}) + for key in original: + missing = dict(original) + del missing[key] + with self.subTest(missing=key), self.assertRaises(KeyError): + COLLECTOR.qualification_identity(missing) + for key, values in (("GITHUB_RUN_ID", ("0", "-1", "17x")), ("GITHUB_RUN_ATTEMPT", ("0", "", "1.0")), + ("GITHUB_JOB", ("", "../../job", "job name")), + ("GITHUB_REPOSITORY", ("other/classic", "atrinik/other"))): + for value in values: + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + COLLECTOR.qualification_identity(dict(original, **{key: value})) + + def test_probe_emits_inventory_for_baseline_and_cohort_modes(self): + source = (ROOT / "tools/ci/macos_curl_probe/main.c").read_text() + conditions = [] + inventory_calls = 0 + providers = set() + for line in source.splitlines(): + stripped = line.strip() + if stripped.startswith(("#if ", "#ifdef ", "#ifndef ")): + conditions.append(stripped) + elif stripped.startswith("#endif"): + conditions.pop() + if "if (!image_inventory())" in line: + inventory_calls += 1 + self.assertFalse(any("ATRINIK_CURL_COHORT" in condition for condition in conditions)) + if stripped.startswith("PROVIDER(curl_"): + providers.add(stripped.split("(", 1)[1].split(")", 1)[0]) + self.assertFalse(any("ATRINIK_CURL_COHORT" in condition for condition in conditions)) + self.assertEqual(inventory_calls, 1) + self.assertEqual(providers, set(COLLECTOR.CURL_APIS)) + def test_environment_removes_tls_proxy_loader_and_compile_overrides(self): environment = { "PATH": "/native/tools", "HOME": "/runner/home", "NORMAL": "retained", @@ -116,7 +213,9 @@ def run(arguments, **kwargs): with patch.object(COLLECTOR.platform, "system", return_value="Darwin"), \ patch.object(COLLECTOR, "CA_SHA256", COLLECTOR.digest(bundle)), \ patch.object(COLLECTOR.subprocess, "run", side_effect=run), \ - patch.dict(COLLECTOR.os.environ, {"HTTPS_PROXY": "https://foreign.invalid"}), \ + patch.dict(COLLECTOR.os.environ, {"HTTPS_PROXY": "https://foreign.invalid", + "GITHUB_RUN_ID": "17", "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_JOB": "native-curl", "GITHUB_REPOSITORY": "atrinik/classic"}), \ contextlib.redirect_stdout(io.StringIO()): if runtime_status: with self.assertRaisesRegex(ValueError, "verified public TLS"): From 2aa54edc61b05cde3756b92c28cc2a68a0fe312f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:54:14 -0500 Subject: [PATCH 081/148] fix(ci): bind macOS system curl coexistence to native dependency proof --- docs/MACOS_TOOLCHAIN.md | 12 +++- tools/ci/collect_macos_curl.py | 87 ++++++++++++++++++++++++- tools/ci/macos-curl-toolchain.json | 13 ++++ tools/ci/macos_curl_probe/main.c | 100 +++++++++++++++++++++++++---- tools/ci/prepare_macos_curl.py | 48 ++++++++++++-- 5 files changed, 243 insertions(+), 17 deletions(-) diff --git a/docs/MACOS_TOOLCHAIN.md b/docs/MACOS_TOOLCHAIN.md index a70a69866..fafd240b1 100644 --- a/docs/MACOS_TOOLCHAIN.md +++ b/docs/MACOS_TOOLCHAIN.md @@ -142,7 +142,17 @@ python3 tools/ci/prepare_macos_curl.py \ The helper binds selected headers and loaded curl/c-ares images to the owned prefix, captures native dependency keg paths/hashes, and records system image names plus SDK/OS identity. Many system dylibs reside only in the dyld shared -cache and cannot be hashed as filesystem files. It requires ordinary public +cache and cannot be hashed as filesystem files. Both baseline and candidate +probes record loaded images and bounded Mach-O dependency commands from memory. +The schema-2 baseline is bound to the same source, repository, workflow run, +attempt and job as the candidate. Each directly linked curl API and +`ares_version` must belong to the selected provider. The only permitted extra +curl image is the exact OS install name `/usr/lib/libcurl.4.dylib`, when both +snapshots prove the same loaded OS dependency chain from the recorded platform +roots. Other extra curl or c-ares providers fail qualification. An unresolved +weak dependency or `@rpath` name cannot establish that chain. + +The helper requires ordinary public HTTPS with default trust and the locked Classic bundle, rejects that endpoint with a fresh unrelated CA, and checks a private loopback TLS chain with trusted, untrusted and wrong-hostname cases. Peer and hostname checks stay enabled; diff --git a/tools/ci/collect_macos_curl.py b/tools/ci/collect_macos_curl.py index 81492f36b..ebc5b5a24 100644 --- a/tools/ci/collect_macos_curl.py +++ b/tools/ci/collect_macos_curl.py @@ -9,11 +9,84 @@ import os from pathlib import Path import platform +import re import shutil import subprocess ROOT = Path(__file__).resolve().parents[2] CA_SHA256 = "a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505" +CURL_APIS = ("curl_global_init", "curl_global_cleanup", "curl_version_info", "curl_easy_init", + "curl_easy_setopt", "curl_easy_getinfo", "curl_easy_perform", "curl_easy_cleanup", + "curl_slist_append", "curl_slist_free_all") +SYSTEM_CURL = "/usr/lib/libcurl.4.dylib" + + +def qualification_identity(environment): + keys = {"run_id": "GITHUB_RUN_ID", "run_attempt": "GITHUB_RUN_ATTEMPT", + "job": "GITHUB_JOB", "repository": "GITHUB_REPOSITORY"} + identity = {key: environment[value] for key, value in keys.items()} + if (not re.fullmatch(r"[1-9][0-9]*", identity["run_id"]) + or not re.fullmatch(r"[1-9][0-9]*", identity["run_attempt"]) + or not re.fullmatch(r"[A-Za-z0-9_-]+", identity["job"]) + or identity["repository"] != "atrinik/classic"): + raise ValueError("invalid native qualification identity") + return identity + + +def canonical_image(name): + return (isinstance(name, str) and Path(name).is_absolute() + and str(Path(name)) == name and ".." not in Path(name).parts) + + +def platform_image(name): + return canonical_image(name) and name.startswith( + ("/usr/lib/", "/System/Library/", "/Library/Apple/System/Library/")) + + +def validate_inventory(data): + images, edges = data["loaded_images"], data["dependency_install_names"] + if (not isinstance(images, list) or not 0 < len(images) <= 4096 + or any(not canonical_image(name) for name in images) or len(set(images)) != len(images) + or not isinstance(edges, dict) or set(edges) != set(images)): + raise ValueError("invalid native loaded-image dependency inventory") + for dependencies in edges.values(): + if (not isinstance(dependencies, list) or len(dependencies) > 16384 + or any(not isinstance(name, str) or not name or "\0" in name for name in dependencies)): + raise ValueError("invalid Mach-O dependency install names") + + +def validate_symbol_providers(data, curl, cares=None): + providers = data["symbol_providers"] + expected = {name: Path(curl).resolve(strict=True) for name in CURL_APIS} + if cares is not None: + expected["ares_version"] = Path(cares).resolve(strict=True) + if not isinstance(providers, dict) or set(providers) != set(expected): + raise ValueError("incomplete directly linked curl/c-ares API provider inventory") + for name, library in expected.items(): + if not canonical_image(providers[name]) or Path(providers[name]).resolve(strict=True) != library: + raise ValueError(f"directly linked API belongs to a foreign provider: {name}") + + +def platform_dependency_chain(data, roots, target=SYSTEM_CURL): + """A loaded OS edge is evidence; an unresolved weak/@rpath name is not.""" + validate_inventory(data) + loaded, edges = set(data["loaded_images"]), data["dependency_install_names"] + curl = data["loaded_library"] + if curl not in loaded: + raise ValueError("selected curl absent from its platform dependency inventory") + direct_roots = set(roots) & loaded & set(edges[curl]) + pending = [[name] for name in sorted(direct_roots) if platform_image(name) and name != target] + visited = set() + for chain in pending: + name = chain[-1] + if name in visited: + continue + visited.add(name) + if name == target: + return chain + pending.extend([*chain, dependency] for dependency in sorted(set(edges[name])) + if dependency in loaded and platform_image(dependency) and dependency not in visited) + raise ValueError("loaded system curl lacks a recorded platform dependency chain") def digest(path): @@ -45,6 +118,17 @@ def validate_provider(data, selected, prefix): raise ValueError("CMake selected a curl library outside the requested prefix") if Path(data["loaded_library"]).resolve(strict=True) != selected: raise ValueError("runtime curl provider differs from the CMake selected library") + validate_inventory(data) + validate_symbol_providers(data, selected) + for name in data["loaded_images"]: + if Path(name).name.startswith("libcares"): + raise ValueError("unexpected c-ares provider in the threaded-resolver baseline") + if Path(name).name.startswith("libcurl") and name != SYSTEM_CURL: + if platform_image(name) or Path(name).resolve(strict=True) != selected: + raise ValueError("additional curl provider in the native baseline") + if str(selected) not in {str(Path(name).resolve(strict=True)) for name in data["loaded_images"] + if not platform_image(name)}: + raise ValueError("selected curl absent from the loaded-image inventory") if data["header_version_num"] != data["version_num"]: raise ValueError("curl headers and loaded library versions differ") for key in ("features", "protocols"): @@ -103,7 +187,8 @@ def run(name, arguments, check=True): data = json.loads(result.stdout) selected = (build / "selected-library.txt").read_text().strip() record = { - "schema_version": 1, "source_commit": run("source", ["git", "-C", str(ROOT), "rev-parse", "HEAD"]).stdout.strip(), + "schema_version": 2, "source_commit": run("source", ["git", "-C", str(ROOT), "rev-parse", "HEAD"]).stdout.strip(), + "qualification_identity": qualification_identity(env), "purpose": "baseline collection; not source-built curl acceptance", "prefix": str(prefix), "selected_library": selected, "selected_library_sha256": digest(selected), diff --git a/tools/ci/macos-curl-toolchain.json b/tools/ci/macos-curl-toolchain.json index 311dd49c8..1148c4be8 100644 --- a/tools/ci/macos-curl-toolchain.json +++ b/tools/ci/macos-curl-toolchain.json @@ -124,6 +124,19 @@ "HAVE_GSSAPPLE" ], "scope": "Native ARM64 CI dependency cohort; no packaging or Intel Mac acceptance", + "baseline_platform_roots": [ + "/System/Library/Frameworks/Kerberos.framework/Versions/A/Kerberos", + "/System/Library/Frameworks/LDAP.framework/Versions/A/LDAP", + "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", + "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices", + "/System/Library/Frameworks/Security.framework/Versions/A/Security", + "/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration", + "/usr/lib/libresolv.9.dylib", + "/usr/lib/libSystem.B.dylib", + "/usr/lib/libz.1.dylib", + "/usr/lib/libicucore.A.dylib", + "/usr/lib/libiconv.2.dylib" + ], "required_platform_images": [ "/System/Library/Frameworks/Kerberos.framework/Versions/A/Kerberos", "/System/Library/Frameworks/LDAP.framework/Versions/A/LDAP", diff --git a/tools/ci/macos_curl_probe/main.c b/tools/ci/macos_curl_probe/main.c index 1ecaa631d..f60fdae5e 100644 --- a/tools/ci/macos_curl_probe/main.c +++ b/tools/ci/macos_curl_probe/main.c @@ -3,10 +3,12 @@ */ #include #include +#include +#include #include +#include #ifdef ATRINIK_CURL_COHORT #include -#include #endif static void json_string(const char *text) @@ -52,6 +54,69 @@ static size_t discard(char *data, size_t size, size_t count, void *context) return size * count; } +static int symbol_provider(const char *name, const void *symbol, int *first) +{ + Dl_info provider = {0}; + if (symbol == NULL || dladdr(symbol, &provider) == 0 || provider.dli_fname == NULL) { + return 0; + } + if (!*first) putchar(','); + *first = 0; + json_string(name); + putchar(':'); + json_string(provider.dli_fname); + return 1; +} + +/* Read loaded headers, including OS images resident only in dyld's cache. */ +static int image_inventory(void) +{ + uint32_t count = _dyld_image_count(); + if (count == 0 || count > 4096) return 0; + printf(",\"loaded_images\":["); + for (uint32_t i = 0; i < count; i++) { + const char *name = _dyld_get_image_name(i); + if (name == NULL) return 0; + if (i != 0) putchar(','); + json_string(name); + } + printf("],\"dependency_install_names\":{"); + for (uint32_t i = 0; i < count; i++) { + const struct mach_header *header = _dyld_get_image_header(i); + if (header == NULL || header->magic != MH_MAGIC_64) return 0; + const struct mach_header_64 *native = (const struct mach_header_64 *) header; + if (native->ncmds > 16384 || native->sizeofcmds > 16U * 1024U * 1024U) return 0; + const unsigned char *cursor = (const unsigned char *) (native + 1); + size_t remaining = native->sizeofcmds; + if (i != 0) putchar(','); + json_string(_dyld_get_image_name(i)); + printf(":["); + int first = 1; + for (uint32_t command = 0; command < native->ncmds; command++) { + if (remaining < sizeof(struct load_command)) return 0; + const struct load_command *load = (const struct load_command *) cursor; + if (load->cmdsize < sizeof(*load) || load->cmdsize > remaining || load->cmdsize % 8 != 0) return 0; + if (load->cmd == LC_LOAD_DYLIB || load->cmd == LC_LOAD_WEAK_DYLIB || + load->cmd == LC_REEXPORT_DYLIB || load->cmd == LC_LOAD_UPWARD_DYLIB) { + if (load->cmdsize < sizeof(struct dylib_command)) return 0; + const struct dylib_command *dylib = (const struct dylib_command *) load; + uint32_t offset = dylib->dylib.name.offset; + if (offset < sizeof(*dylib) || offset >= load->cmdsize || + memchr(cursor + offset, '\0', load->cmdsize - offset) == NULL) return 0; + if (!first) putchar(','); + first = 0; + json_string((const char *) cursor + offset); + } + remaining -= load->cmdsize; + cursor += load->cmdsize; + } + if (remaining != 0) return 0; + putchar(']'); + } + putchar('}'); + return 1; +} + /* No curlrc, proxy, netrc, credentials, redirects, or weakened TLS checks. */ static int tls_request(const char *url, const char *bundle, const char *resolve) { @@ -133,7 +198,7 @@ int main(int argc, char **argv) } curl_version_info_data *info = curl_version_info(CURLVERSION_NOW); Dl_info provider = {0}; - void *symbol = dlsym(RTLD_DEFAULT, "curl_version_info"); + const void *symbol = (const void *) curl_version_info; if (info == NULL || symbol == NULL || dladdr(symbol, &provider) == 0 || info->age < CURLVERSION_ELEVENTH) { curl_global_cleanup(); @@ -175,7 +240,7 @@ int main(int argc, char **argv) int bundle_ok = tls_request("https://curl.se/", argv[1], NULL); #ifdef ATRINIK_CURL_COHORT Dl_info cares_provider = {0}; - void *cares_symbol = dlsym(RTLD_DEFAULT, "ares_version"); + const void *cares_symbol = (const void *) ares_version; if (cares_symbol == NULL || dladdr(cares_symbol, &cares_provider) == 0) { curl_global_cleanup(); return 2; @@ -204,17 +269,30 @@ int main(int argc, char **argv) tls_request(trusted_url, NULL, NULL); printf(",\"private_tls_wrong_hostname\":"); tls_request(wrong_url, argv[3], wrong_resolve); - printf(",\"loaded_images\":["); - for (uint32_t i = 0; i < _dyld_image_count(); i++) { - if (i != 0) { - putchar(','); - } - json_string(_dyld_get_image_name(i)); - } - printf("]"); #else int private_ok = 1; #endif + printf(",\"symbol_providers\":{"); + int first = 1; +#define PROVIDER(name) do { \ + if (!symbol_provider(#name, (const void *) name, &first)) return 2; \ +} while (0) + PROVIDER(curl_global_init); + PROVIDER(curl_global_cleanup); + PROVIDER(curl_version_info); + PROVIDER(curl_easy_init); + PROVIDER(curl_easy_setopt); + PROVIDER(curl_easy_getinfo); + PROVIDER(curl_easy_perform); + PROVIDER(curl_easy_cleanup); + PROVIDER(curl_slist_append); + PROVIDER(curl_slist_free_all); +#ifdef ATRINIK_CURL_COHORT + PROVIDER(ares_version); +#endif +#undef PROVIDER + putchar('}'); + if (!image_inventory()) return 2; printf("}\n"); curl_global_cleanup(); return default_ok && bundle_ok && private_ok ? 0 : 1; diff --git a/tools/ci/prepare_macos_curl.py b/tools/ci/prepare_macos_curl.py index 376a4f0b3..b39b812ac 100644 --- a/tools/ci/prepare_macos_curl.py +++ b/tools/ci/prepare_macos_curl.py @@ -18,7 +18,10 @@ import tarfile import threading -from collect_macos_curl import clean_environment, digest, validate_headers +from collect_macos_curl import (SYSTEM_CURL, canonical_image, clean_environment, digest, + platform_dependency_chain, platform_image, qualification_identity, + validate_headers, validate_inventory, validate_provider, + validate_symbol_providers) ROOT = Path(__file__).resolve().parents[2] MANIFEST = Path(__file__).with_name("macos-curl-toolchain.json") @@ -53,6 +56,7 @@ def load_manifest(): def validate_baseline(data, manifest): runtime = data["runtime"] + validate_inventory(runtime) expected = manifest["baseline"] for key in ("features", "protocols"): if set(runtime[key]) != set(expected[key]) or len(runtime[key]) != len(set(runtime[key])): @@ -142,13 +146,20 @@ def validate_runtime(data, manifest): raise ValueError("native curl default trust paths changed") -def validate_loaded(data, prefix, dependency_files, probe, required_platform_images=()): +def validate_loaded(data, prefix, dependency_files, probe, required_platform_images=(), + baseline=None, platform_roots=()): prefix = Path(prefix).resolve(strict=True) own = {name: (prefix / "lib" / f"lib{name}.dylib").resolve(strict=True) for name in ("curl", "cares")} for name, key in (("curl", "loaded_library"), ("cares", "loaded_cares_library")): if not own[name].is_relative_to(prefix) or Path(data[key]).resolve(strict=True) != own[name]: raise ValueError(f"loaded {name} provider is outside the owned cohort") + validate_inventory(data) + validate_symbol_providers(data, own["curl"], own["cares"]) + resolver_edges = data["dependency_install_names"][str(own["curl"])] + if not any(canonical_image(name) and not platform_image(name) + and Path(name).resolve(strict=True) == own["cares"] for name in resolver_edges): + raise ValueError("owned curl lacks its owned c-ares dependency edge") images = data["loaded_images"] if not images or len(set(images)) != len(images): raise ValueError("invalid native loaded-image inventory") @@ -156,11 +167,24 @@ def validate_loaded(data, prefix, dependency_files, probe, required_platform_ima if missing_platform: raise ValueError("baseline SDK providers absent: " + ", ".join(sorted(missing_platform))) observed = set() + allowed_system_curl = False + if SYSTEM_CURL in images: + if baseline is None: + raise ValueError("system curl coexistence requires the same-run native baseline") + chain = platform_dependency_chain(baseline, platform_roots) + platform_dependency_chain(data, platform_roots) + edges = data["dependency_install_names"] + if (chain[0] not in edges[data["loaded_library"]] + or any(parent not in edges or child not in edges[parent] for parent, child in zip(chain, chain[1:]))): + raise ValueError("candidate did not preserve the baseline platform curl dependency chain") + allowed_system_curl = True for name in images: path = Path(name) if not path.is_absolute() or ".." in path.parts: raise ValueError("invalid native loaded-image path") if path.name.startswith(("libcurl", "libcares")): + if name == SYSTEM_CURL and allowed_system_curl: + continue if (name.startswith(("/usr/lib/", "/System/Library/", "/Library/Apple/System/Library/")) or path.resolve(strict=True) not in own.values()): raise ValueError("additional curl/c-ares provider loaded outside the owned cohort") @@ -177,6 +201,15 @@ def validate_loaded(data, prefix, dependency_files, probe, required_platform_ima raise ValueError("owned curl/c-ares missing from loaded-image inventory") +def bind_baseline(record, source_commit, environment): + if (record["schema_version"] != 2 or record["source_commit"] != source_commit + or record["qualification_identity"] != qualification_identity(environment)): + raise ValueError("native baseline belongs to a different source or workflow execution") + if digest(record["selected_library"]) != record["selected_library_sha256"]: + raise ValueError("baseline selected curl bytes changed since collection") + validate_provider(record["runtime"], record["selected_library"], record["prefix"]) + + def cmake_cache(path): values = {} for line in Path(path).read_text().splitlines(): @@ -338,6 +371,9 @@ def _prepare_owned(prefix, llvm_prefix, baseline_path, ca_bundle, jobs, manifest evidence.mkdir() environment = native_environment(os.environ) commands = Commands(evidence, environment) + source_commit = commands.run("source", ["git", "-C", ROOT, "rev-parse", "HEAD"]).stdout.strip() + baseline = json.loads(baseline_path.read_text()) + bind_baseline(baseline, source_commit, environment) sdk = Path(commands.run("sdk-path", ["xcrun", "--sdk", "macosx", "--show-sdk-path"]).stdout.strip()).resolve(strict=True) commands.run("sdk-version", ["xcrun", "--sdk", "macosx", "--show-sdk-version"]) commands.run("host-version", ["sw_vers"]) @@ -436,7 +472,8 @@ def _prepare_owned(prefix, llvm_prefix, baseline_path, ca_bundle, jobs, manifest data = json.loads(result.stdout) (evidence / "runtime.json").write_text(json.dumps(data, indent=2) + "\n") validate_runtime(data, manifest) - validate_loaded(data, prefix, dependency_files, probe, manifest["required_platform_images"]) + validate_loaded(data, prefix, dependency_files, probe, manifest["required_platform_images"], + baseline["runtime"], manifest["baseline_platform_roots"]) if result.returncode: raise ValueError(f"native curl runtime probe failed ({result.returncode})") for name in ("curl", "cares"): @@ -451,7 +488,10 @@ def _prepare_owned(prefix, llvm_prefix, baseline_path, ca_bundle, jobs, manifest raise ValueError(f"{name} dylib architecture differs from the native cohort") receipt = {"schema_version": 1, "scope": manifest["scope"], "prefix": str(prefix), "sources": manifest["sources"], "baseline_provenance": manifest["baseline"], - "source_commit": commands.run("source", ["git", "-C", ROOT, "rev-parse", "HEAD"]).stdout.strip(), + "source_commit": source_commit, "qualification_identity": qualification_identity(environment), + "baseline_sha256": digest(baseline_path), + "platform_curl_chain": (platform_dependency_chain(baseline["runtime"], manifest["baseline_platform_roots"]) + if SYSTEM_CURL in data["loaded_images"] else None), "tools": tools, "sdk": str(sdk), "architecture": "arm64", "deployment_target": deployment, "release_flags": release_flags, "dependency_prefixes": {key: str(value) for key, value in dependencies.items()}, "dependency_files": dependency_files, "runtime": data, From bbaf7785b6f7c5905b3f5c52f079d6198da0d540 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 03:56:52 -0500 Subject: [PATCH 082/148] fix(server): preserve Windows socket conversion in fd sets --- server/src/socket/server.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/src/socket/server.c b/server/src/socket/server.c index f482c65ec..b2819c55b 100644 --- a/server/src/socket/server.c +++ b/server/src/socket/server.c @@ -1539,7 +1539,12 @@ bool socket_server_process(void) { if (nfds < fd) { nfds = fd; } +#ifdef WIN32 + /* Match Winsock's assignment conversion before its SOCKET comparison. */ + FD_SET((SOCKET)fd, &fds_read); +#else FD_SET(fd, &fds_read); +#endif } csocket_entry_t *entry, *entry_tmp; From 2e76f60d8f5f3e59de1cf98b9729355a9a42a27d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 04:25:50 -0500 Subject: [PATCH 083/148] fix(ci): isolate historical Windows font compiler cache --- .github/workflows/check.yml | 4 + .../tests/test_windows_font_baseline_cache.py | 130 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tools/tests/test_windows_font_baseline_cache.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bb558ff8e..23c7dc322 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -410,11 +410,15 @@ jobs: docker run --rm --network none --cpus 2 --memory 4g \ --user "$(id -u):$(id -g)" \ --env HOME=/tmp/classic-home \ + --env CCACHE_DIR=/tmp/atrinik-font-baseline-ccache \ + --env CCACHE_TEMPDIR=/tmp/atrinik-font-baseline-ccache-tmp \ + --env CCACHE_MAXSIZE=250M \ --env BASELINE_IMAGE="${CLASSIC_WINDOWS_FONT_BASELINE_IMAGE}" \ --env PATH=/opt/mxe/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" --workdir /workspace \ "${CLASSIC_WINDOWS_FONT_BASELINE_IMAGE}" bash -c ' set -euo pipefail + mkdir -p "${CCACHE_DIR}" "${CCACHE_TEMPDIR}" x86_64-w64-mingw32.shared-cmake -S tools/ci/font-render-probe \ -B build/font-baseline -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build/font-baseline --target client-font-render-probe --parallel 2 diff --git a/tools/tests/test_windows_font_baseline_cache.py b/tools/tests/test_windows_font_baseline_cache.py new file mode 100644 index 000000000..e052404ac --- /dev/null +++ b/tools/tests/test_windows_font_baseline_cache.py @@ -0,0 +1,130 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +IMAGE = "ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69" + + +def baseline_recipe(): + text = (ROOT / ".github/workflows/check.yml").read_text() + step = text.split(" - name: Build the original pinned Windows font baseline\n", 1)[1] + step = step.split(" - name: Cross-build and stage standalone tests\n", 1)[0] + return textwrap.dedent(step.split(" run: |\n", 1)[1]) + + +class WindowsFontBaselineCacheTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("bash"), "Bash unavailable") + def test_workflow_build_uses_writable_isolated_cache_without_changing_baseline(self): + for fixed in (False, True): + with self.subTest(fixed=fixed), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + binary = root / "bin" + binary.mkdir() + readonly = root / "image-cache" + (readonly / "tmp").mkdir(parents=True) + (readonly / "tmp").chmod(0o555) + readonly.chmod(0o555) + source = root / "build/font-baseline-source/tools/ci" + source.mkdir(parents=True) + (source / "stage_windows_runtime.py").write_text('''import pathlib, sys +assert '--objdump' in sys.argv and 'x86_64-w64-mingw32.shared-objdump' in sys.argv +output = pathlib.Path(sys.argv[sys.argv.index('--output-dir') + 1]) +output.mkdir(parents=True) +(output / 'staged.txt').write_text('historical helper staged baseline') +''') + fake = binary / "fake" + fake.write_text('''#!/usr/bin/env python3 +import json, os, pathlib, subprocess, sys, tempfile +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +root = pathlib.Path(os.environ['FIXTURE_ROOT']) +if name == 'docker': + if args[0] == 'pull': + assert args[1] == os.environ['CLASSIC_WINDOWS_FONT_BASELINE_IMAGE'] + sys.exit(0) + assert args[0] == 'run' + environment = dict(os.environ, CCACHE_DIR=str(root / 'image-cache')) + environment.pop('CCACHE_TEMPDIR', None) + declared = {} + index = 1 + while index < len(args): + value = args[index] + if value == '--env': + key, value = args[index + 1].split('=', 1) + declared[key] = value + if key in ('HOME', 'CCACHE_DIR', 'CCACHE_TEMPDIR'): + value = str(root / 'container-tmp' / value.removeprefix('/tmp/')) + environment[key] = value + index += 2 + elif value in ('--network', '--cpus', '--memory', '--user', '--volume', '--workdir'): + index += 2 + elif value == '--rm': + index += 1 + else: + break + assert args[index] == os.environ['CLASSIC_WINDOWS_FONT_BASELINE_IMAGE'] + assert args[index + 1:index + 3] == ['bash', '-c'] + (root / 'declared-env.json').write_text(json.dumps(declared)) + environment['PATH'] = str(root / 'bin') + ':' + os.environ['PATH'] + sys.exit(subprocess.run(['bash', '-c', args[index + 3]], cwd=root, env=environment).returncode) +elif name == 'cmake' and '--build' in args: + cache = pathlib.Path(os.environ['CCACHE_DIR']) + temporary = pathlib.Path(os.environ.get('CCACHE_TEMPDIR', str(cache / 'tmp'))) + # Model ccache's actual write, including read-only image ownership. Tests + # also remain meaningful when invoked by a privileged local test runner. + if cache.stat().st_mode & 0o222 == 0 or temporary.stat().st_mode & 0o222 == 0: + print('ccache: Failed to create temporary file: Permission denied', file=sys.stderr) + sys.exit(1) + with tempfile.NamedTemporaryFile(dir=temporary, prefix='font_render.stdout'): + (cache / 'font-render-cache-entry').write_text('cached historical object') + build = root / 'build/font-baseline' + build.mkdir(parents=True) + (build / 'client-font-render-probe.exe').write_text('historical baseline executable') +elif name == 'git': + print('fd4943252ac872fce2995c286b10a0c6d249aba3') +elif name == 'x86_64-w64-mingw32.shared-gcc': + print('historical GCC version retained') +''') + fake.chmod(0o755) + for name in ("docker", "cmake", "git", "x86_64-w64-mingw32.shared-cmake", + "x86_64-w64-mingw32.shared-gcc"): + (binary / name).symlink_to(fake) + recipe = baseline_recipe() + if not fixed: + recipe = "\n".join(line for line in recipe.splitlines() + if "--env CCACHE_" not in line and + 'mkdir -p "${CCACHE_DIR}"' not in line) + result = subprocess.run(["bash", "-c", recipe], cwd=root, + env=dict(os.environ, PATH=f"{binary}:{os.environ['PATH']}", + FIXTURE_ROOT=str(root), GITHUB_WORKSPACE=str(root), + CLASSIC_WINDOWS_FONT_BASELINE_IMAGE=IMAGE, + MXE_RUNTIME_DIR="/immutable/historical/runtime"), + capture_output=True, text=True) + self.assertEqual(result.returncode == 0, fixed, result.stderr) + bundle = root / "build/font-baseline-bundle" + self.assertEqual(bundle.exists(), fixed) + self.assertEqual(list(readonly.iterdir()), [readonly / "tmp"]) + if fixed: + declared = json.loads((root / "declared-env.json").read_text()) + self.assertEqual(declared["CCACHE_DIR"], "/tmp/atrinik-font-baseline-ccache") + self.assertEqual(declared["CCACHE_TEMPDIR"], "/tmp/atrinik-font-baseline-ccache-tmp") + self.assertEqual(declared["CCACHE_MAXSIZE"], "250M") + self.assertEqual((bundle / "producer-image.txt").read_text().strip(), IMAGE) + self.assertIn("historical GCC", (bundle / "compiler.txt").read_text()) + self.assertTrue((bundle / "staging-helper.sha256").is_file()) + self.assertTrue((bundle / "staged.txt").is_file()) + else: + self.assertIn("Permission denied", result.stderr) + + +if __name__ == "__main__": + unittest.main() From 3aef3944e949123bd139f80aee7a614ee8d34fbd Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 04:35:53 -0500 Subject: [PATCH 084/148] fix(ci): verify historical Windows font runtime closure --- .github/workflows/check.yml | 5 +- tools/ci/stage_historical_windows_font.py | 203 ++++++++++++++++++ .../fixtures/windows-font-baseline/README.md | 8 + .../stage_windows_runtime.py.txt | 160 ++++++++++++++ tools/tests/test_historical_windows_font.py | 184 ++++++++++++++++ .../tests/test_windows_font_baseline_cache.py | 9 +- tools/tests/test_workflow_contracts.py | 4 +- 7 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 tools/ci/stage_historical_windows_font.py create mode 100644 tools/tests/fixtures/windows-font-baseline/README.md create mode 100644 tools/tests/fixtures/windows-font-baseline/stage_windows_runtime.py.txt create mode 100644 tools/tests/test_historical_windows_font.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 23c7dc322..1ef84939d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -423,8 +423,11 @@ jobs: -B build/font-baseline -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build/font-baseline --target client-font-render-probe --parallel 2 test "$(git -C build/font-baseline-source rev-parse HEAD)" = fd4943252ac872fce2995c286b10a0c6d249aba3 - python3 build/font-baseline-source/tools/ci/stage_windows_runtime.py \ + python3 tools/ci/stage_historical_windows_font.py \ + --historical-helper build/font-baseline-source/tools/ci/stage_windows_runtime.py \ + --producer-image "${BASELINE_IMAGE}" \ --objdump x86_64-w64-mingw32.shared-objdump --runtime-dir "${MXE_RUNTIME_DIR}" \ + --notice-dir /opt/mxe/usr/x86_64-w64-mingw32.shared/share/licenses \ --output-dir build/font-baseline-bundle \ build/font-baseline/client-font-render-probe.exe sha256sum build/font-baseline-source/tools/ci/stage_windows_runtime.py \ diff --git a/tools/ci/stage_historical_windows_font.py b/tools/ci/stage_historical_windows_font.py new file mode 100644 index 000000000..bb683aac5 --- /dev/null +++ b/tools/ci/stage_historical_windows_font.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Adapt one immutable MXE font baseline; never stage LLVM candidate bundles.""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import types + +HELPER_REVISION = "fd4943252ac872fce2995c286b10a0c6d249aba3" +HELPER_SHA256 = "12d315f854726e46ec768c5c4db6a34b0aabb93e5cab959233f5c5ec1650aeca" +IMAGE = "ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69" +PREFIX = Path("/opt/mxe/usr/x86_64-w64-mingw32.shared") +OBJDUMP = "x86_64-w64-mingw32.shared-objdump" +GCC = "x86_64-w64-mingw32.shared-gcc" +EXTRA_SYSTEM_DLLS = frozenset({"imm32.dll", "oleaut32.dll", "setupapi.dll"}) +IMPORT_NAME = re.compile(r"[A-Za-z0-9_+.-]+\.dll", re.IGNORECASE) +DELAY_DIRECTORY = re.compile( + r"^Entry d ([0-9a-f]+) ([0-9a-f]+) Delay Import Directory\s*$", + re.IGNORECASE | re.MULTILINE, +) + + +class BaselineError(RuntimeError): + """The historical font fixture cannot be bound to its verified inputs.""" + + +def digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def regular_bytes(path: Path) -> bytes: + if path.is_symlink() or not path.is_file(): + raise BaselineError(f"not a regular fixture file: {path}") + return path.read_bytes() + + +def load_helper(path: Path): + # Execute the exact buffer we verified, without rereading it or using pyc. + data = regular_bytes(path) + if digest(data) != HELPER_SHA256: + raise BaselineError("historical staging helper checksum mismatch") + module = types.ModuleType("verified_historical_windows_font_staging") + module.__file__ = str(path) + exec(compile(data, str(path), "exec"), module.__dict__) + return module + + +def current_policy(path: Path) -> tuple[set[str], str]: + data = regular_bytes(path) + for node in ast.parse(data).body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "SYSTEM_DLLS" + for target in node.targets + ): + policy = ast.literal_eval(node.value) + if isinstance(policy, set) and all(isinstance(name, str) for name in policy): + if not EXTRA_SYSTEM_DLLS <= policy: + raise BaselineError("historical additions are absent from current system policy") + return policy, digest(data) + raise BaselineError("cannot validate current Windows system policy") + + +def parse_inspection(helper, output: str) -> set[str]: + if len(re.findall(r"file format pei-x86-64\s*$", output, re.MULTILINE)) != 1: + raise BaselineError("historical fixture is not an AMD64 PE image") + delay = DELAY_DIRECTORY.findall(output) + if len(delay) != 1 or any(int(value, 16) for value in delay[0]): + raise BaselineError("historical fixture has unsupported or unknown delayed imports") + names = [match.group(1) for match in helper.DLL_NAME.finditer(output)] + if len(names) != len(re.findall(r"DLL Name:", output, re.IGNORECASE)): + raise BaselineError("malformed historical import row") + if any(IMPORT_NAME.fullmatch(name) is None for name in names): + raise BaselineError("invalid historical DLL import name") + return {name.lower() for name in names} + + +def inspect(helper, objdump: str, path: Path) -> dict: + before = digest(regular_bytes(path)) + result = subprocess.run( + [objdump, "-p", str(path)], capture_output=True, text=True, timeout=30, + ) + if result.returncode: + raise BaselineError(f"cannot inspect {path}: {result.stderr.strip()}") + imports = parse_inspection(helper, result.stdout) + if digest(regular_bytes(path)) != before: + raise BaselineError(f"fixture changed during inspection: {path}") + return {"sha256": before, "imports": sorted(imports), "architecture": "AMD64"} + + +def copy_notices(source: Path, output: Path) -> dict[str, str]: + if source.is_symlink() or not source.is_dir(): + raise BaselineError("historical notice directory is not a normal directory") + hashes = {} + seen = set() + for path in sorted(source.rglob("*")): + relative = path.relative_to(source) + normalized = relative.as_posix().lower() + if normalized in seen or path.is_symlink(): + raise BaselineError(f"ambiguous historical notice path: {relative}") + seen.add(normalized) + if path.is_dir(): + continue + data = regular_bytes(path) + destination = output / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + hashes[relative.as_posix()] = digest(data) + if not hashes: + raise BaselineError("historical notice tree is empty") + return hashes + + +def stage_fixture(helper_path: Path, binary: Path, runtime: Path, notices: Path, + output: Path, objdump: str, compiler: str, *, inspector=inspect) -> dict: + if binary.name != "client-font-render-probe.exe": + raise BaselineError("adapter accepts only the standalone historical font probe") + helper = load_helper(helper_path) + _, policy_sha256 = current_policy(Path(__file__).with_name("stage_windows_runtime.py")) + helper.SYSTEM_DLLS |= EXTRA_SYSTEM_DLLS + # Every name has passed IMPORT_NAME before this legacy API-set prefix test. + records = {} + + def record(path): + data = inspector(helper, objdump, path) + records[path.name.lower()] = data + return set(data["imports"]) + + helper.stage_runtime([binary], runtime, output, record) + actual = {} + for path in output.iterdir(): + if path.is_symlink() or not path.is_file() or path.name.lower() in actual: + raise BaselineError("invalid staged historical fixture inventory") + actual[path.name.lower()] = path + if actual.keys() != records.keys(): + raise BaselineError("staged historical inventory differs from inspected source inventory") + for name, path in actual.items(): + if inspector(helper, objdump, path) != records[name]: + raise BaselineError(f"copied historical fixture bytes changed: {name}") + for dependency in records[name]["imports"]: + if not helper.is_system_dll(dependency) and dependency not in actual: + raise BaselineError(f"incomplete copied historical closure: {dependency}") + notice_hashes = copy_notices(notices, output / "licenses") + manifest = { + "schema_version": 1, + "purpose": "historical-windows-font-comparison-fixture", + "producer_image": IMAGE, + "producer_identity_evidence": "workflow exact digest-pinned docker run", + "historical_helper_revision": HELPER_REVISION, + "historical_helper_sha256": HELPER_SHA256, + "adapter_sha256": digest(regular_bytes(Path(__file__))), + "current_system_policy_sha256": policy_sha256, + "additional_system_dlls": sorted(EXTRA_SYSTEM_DLLS), + "compiler": compiler, + "objdump": objdump, + "runtime_directory": str(runtime), + "pe_files": records, + "notices": notice_hashes, + "notice_coverage": {"complete": False, "status": "historical producer completeness unverified"}, + "native_windows_rendering_verified": False, + } + (output / "historical-font-inventory.json").write_text(json.dumps(manifest, indent=2) + "\n") + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--historical-helper", type=Path, required=True) + parser.add_argument("--producer-image", required=True) + parser.add_argument("--objdump", required=True) + parser.add_argument("--runtime-dir", type=Path, required=True) + parser.add_argument("--notice-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("binary", type=Path) + args = parser.parse_args() + try: + if args.producer_image != IMAGE or args.objdump != OBJDUMP: + raise BaselineError("adapter requires the exact historical producer and inspector") + if args.runtime_dir.resolve() != PREFIX / "bin" or args.notice_dir.resolve() != PREFIX / "share/licenses": + raise BaselineError("adapter requires the historical SDK runtime and notice directories") + for tool in (OBJDUMP, GCC): + found = shutil.which(tool) + if not found or not Path(found).resolve().is_relative_to(Path("/opt/mxe/usr")): + raise BaselineError(f"historical SDK tool cannot be resolved: {tool}") + compiler = subprocess.check_output([GCC, "--version"], text=True, timeout=30) + manifest = stage_fixture(args.historical_helper, args.binary, args.runtime_dir, + args.notice_dir, args.output_dir, args.objdump, compiler) + except (BaselineError, OSError, RuntimeError, subprocess.SubprocessError) as error: + parser.exit(1, f"Historical font staging failed: {error}\n") + print(json.dumps(manifest, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/fixtures/windows-font-baseline/README.md b/tools/tests/fixtures/windows-font-baseline/README.md new file mode 100644 index 000000000..c3d0138c8 --- /dev/null +++ b/tools/tests/fixtures/windows-font-baseline/README.md @@ -0,0 +1,8 @@ +`stage_windows_runtime.py.txt` is the byte-identical historical helper from +`fd4943252ac872fce2995c286b10a0c6d249aba3:tools/ci/stage_windows_runtime.py`. +Its SHA-256 is `12d315f854726e46ec768c5c4db6a34b0aabb93e5cab959233f5c5ec1650aeca`. + +This frozen fixture tests the bounded historical font adapter without requiring +Git history in a shallow CI checkout. Production still acquires the helper from +that immutable revision and verifies its bytes before executing them. Candidate +LLVM runtime staging uses the current helper and its producer inventory. diff --git a/tools/tests/fixtures/windows-font-baseline/stage_windows_runtime.py.txt b/tools/tests/fixtures/windows-font-baseline/stage_windows_runtime.py.txt new file mode 100644 index 000000000..dab93b32d --- /dev/null +++ b/tools/tests/fixtures/windows-font-baseline/stage_windows_runtime.py.txt @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Stage the bounded MinGW DLL closure for native Windows test executables.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import shutil +import subprocess +from typing import Callable, Iterable + + +DLL_NAME = re.compile(r"^\s*DLL Name:\s*(\S+)\s*$", re.IGNORECASE | re.MULTILINE) +SYSTEM_DLLS = { + "advapi32.dll", + "bcrypt.dll", + "crypt32.dll", + "dnsapi.dll", + "gdi32.dll", + "imagehlp.dll", + "iphlpapi.dll", + "kernel32.dll", + "msvcrt.dll", + "ncrypt.dll", + "normaliz.dll", + "ole32.dll", + "rpcrt4.dll", + "secur32.dll", + "shell32.dll", + "user32.dll", + "version.dll", + "winmm.dll", + "wldap32.dll", + "ws2_32.dll", + "wsock32.dll", +} +MAX_RUNTIME_DLLS = 256 + + +class StageError(RuntimeError): + """Raised when a Windows runtime closure cannot be proven complete.""" + + +def imported_dlls(objdump: str, binary: Path) -> set[str]: + result = subprocess.run( + [objdump, "-p", str(binary)], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=30, + ) + if result.returncode: + raise StageError( + f"cannot inspect {binary}: {result.stderr.strip() or result.returncode}" + ) + return {match.group(1) for match in DLL_NAME.finditer(result.stdout)} + + +def is_system_dll(name: str) -> bool: + normalized = name.lower() + return ( + normalized in SYSTEM_DLLS + or normalized.startswith("api-ms-win-") + or normalized.startswith("ext-ms-win-") + ) + + +def runtime_index(runtime_dir: Path) -> dict[str, Path]: + if runtime_dir.is_symlink() or not runtime_dir.is_dir(): + raise StageError(f"runtime directory is not a normal directory: {runtime_dir}") + index: dict[str, Path] = {} + for candidate in runtime_dir.iterdir(): + if candidate.is_symlink() or not candidate.is_file() or candidate.suffix.lower() != ".dll": + continue + normalized = candidate.name.lower() + if normalized in index: + raise StageError(f"duplicate case-insensitive runtime DLL: {candidate.name}") + index[normalized] = candidate + return index + + +def stage_runtime( + binaries: Iterable[Path], + runtime_dir: Path, + output_dir: Path, + inspector: Callable[[Path], set[str]], +) -> list[str]: + sources = runtime_index(runtime_dir) + if output_dir.exists(): + if output_dir.is_symlink() or not output_dir.is_dir(): + raise StageError(f"output directory is not a normal directory: {output_dir}") + if next(output_dir.iterdir(), None) is not None: + raise StageError(f"output directory is not empty: {output_dir}") + else: + output_dir.mkdir(parents=True) + queue: list[Path] = [] + staged: dict[str, Path] = {} + + def stage(source: Path) -> None: + if source.is_symlink() or not source.is_file(): + raise StageError(f"runtime input is not a normal file: {source}") + normalized = source.name.lower() + previous = staged.get(normalized) + if previous is not None: + if previous != source: + raise StageError(f"duplicate staged filename: {source.name}") + return + destination = output_dir / source.name + shutil.copyfile(source, destination) + staged[normalized] = source + queue.append(source) + + for binary in binaries: + stage(binary) + + position = 0 + runtime_count = 0 + while position < len(queue): + binary = queue[position] + position += 1 + for dependency in sorted(inspector(binary), key=str.lower): + normalized = dependency.lower() + if is_system_dll(dependency) or normalized in staged: + continue + source = sources.get(normalized) + if source is None: + raise StageError(f"unresolved runtime DLL for {binary.name}: {dependency}") + runtime_count += 1 + if runtime_count > MAX_RUNTIME_DLLS: + raise StageError("Windows runtime dependency closure is unexpectedly large") + stage(source) + + return sorted((path.name for path in output_dir.iterdir()), key=str.lower) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--objdump", required=True) + parser.add_argument("--runtime-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("binaries", nargs="+", type=Path) + arguments = parser.parse_args() + try: + staged = stage_runtime( + arguments.binaries, + arguments.runtime_dir, + arguments.output_dir, + lambda binary: imported_dlls(arguments.objdump, binary), + ) + except (OSError, StageError, subprocess.SubprocessError) as error: + parser.exit(1, f"Windows runtime staging failed: {error}\n") + print(json.dumps({"staged": staged}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_historical_windows_font.py b/tools/tests/test_historical_windows_font.py new file mode 100644 index 000000000..9c73b3972 --- /dev/null +++ b/tools/tests/test_historical_windows_font.py @@ -0,0 +1,184 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +from tools.ci import stage_historical_windows_font as baseline + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("fixtures") / "windows-font-baseline/stage_windows_runtime.py.txt" + + +def objdump_output(imports=(), *, architecture="pei-x86-64", delay="00000000 00000000"): + return (f"fixture: file format {architecture}\n" + f"Entry d {delay} Delay Import Directory\n" + + "".join(f" DLL Name: {name}\n" for name in imports)) + + +class HistoricalWindowsFontTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.runtime = self.root / "runtime" + self.runtime.mkdir() + self.notices = self.root / "notices" + (self.notices / "SDL3").mkdir(parents=True) + (self.notices / "SDL3/LICENSE.txt").write_text("original historical SDL notice") + self.binary = self.root / "client-font-render-probe.exe" + self.binary.write_text(objdump_output(["SDL3.dll", "libgcc_s_seh-1.dll"])) + (self.runtime / "SDL3.dll").write_text(objdump_output([ + "IMM32.dll", "OLEAUT32.dll", "SETUPAPI.dll", "MSVCRT.dll", "KERNEL32.dll", + ])) + (self.runtime / "libgcc_s_seh-1.dll").write_text(objdump_output(["KERNEL32.dll"])) + self.output = self.root / "bundle" + self.objdump = self.root / "objdump" + self.objdump.write_text("#!/usr/bin/env python3\nimport pathlib,sys\n" + "assert sys.argv[1] == '-p'\n" + "print(pathlib.Path(sys.argv[2]).read_text())\n") + self.objdump.chmod(0o755) + + def stage(self, **kwargs): + return baseline.stage_fixture(HELPER, self.binary, self.runtime, self.notices, + self.output, str(self.objdump), "exact historical GCC", **kwargs) + + def assert_no_success(self): + self.assertFalse((self.output / "historical-font-inventory.json").exists()) + + def test_exact_helper_recursive_closure_copied_bytes_and_notices(self): + self.assertEqual(hashlib.sha256(HELPER.read_bytes()).hexdigest(), baseline.HELPER_SHA256) + manifest = self.stage() + self.assertEqual(set(manifest["pe_files"]), { + "client-font-render-probe.exe", "sdl3.dll", "libgcc_s_seh-1.dll", + }) + self.assertEqual(manifest["additional_system_dlls"], ["imm32.dll", "oleaut32.dll", "setupapi.dll"]) + self.assertEqual(manifest["historical_helper_revision"], baseline.HELPER_REVISION) + self.assertEqual(manifest["producer_image"], baseline.IMAGE) + self.assertFalse(manifest["notice_coverage"]["complete"]) + self.assertFalse(manifest["native_windows_rendering_verified"]) + for name, record in manifest["pe_files"].items(): + path = next(path for path in self.output.iterdir() if path.name.lower() == name) + self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), record["sha256"]) + notice = self.output / "licenses/SDL3/LICENSE.txt" + self.assertEqual(notice.read_bytes(), (self.notices / "SDL3/LICENSE.txt").read_bytes()) + self.assertEqual(manifest["notices"]["SDL3/LICENSE.txt"], hashlib.sha256(notice.read_bytes()).hexdigest()) + + def test_altered_helper_rejected_before_execution(self): + changed = self.root / "changed.py" + marker = self.root / "executed" + changed.write_bytes(HELPER.read_bytes() + f"\nopen({str(marker)!r},'w').write('unsafe')\n".encode()) + with self.assertRaisesRegex(baseline.BaselineError, "checksum mismatch"): + baseline.load_helper(changed) + self.assertFalse(marker.exists()) + + def test_load_executes_verified_buffer_without_rereading(self): + with mock.patch.object(baseline, "regular_bytes", side_effect=[HELPER.read_bytes(), b"raise RuntimeError('changed')"]) as read: + helper = baseline.load_helper(HELPER) + self.assertTrue(callable(helper.stage_runtime)) + read.assert_called_once_with(HELPER) + + def test_unknown_private_or_unapproved_current_system_dll_rejected(self): + for dependency in ("private-runtime.dll", "dwrite.dll"): + with self.subTest(dependency=dependency): + self.binary.write_text(objdump_output([dependency])) + with self.assertRaisesRegex(RuntimeError, "unresolved runtime DLL"): + self.stage() + self.assert_no_success() + for path in self.output.iterdir(): + path.unlink() + self.output.rmdir() + + def test_architecture_delay_and_import_validation_fail_closed(self): + helper = baseline.load_helper(HELPER) + invalid = [ + objdump_output(architecture="pei-i386"), + objdump_output(delay="00001000 00000008"), + objdump_output().replace("Entry d", "Entry c"), + objdump_output(["api-ms-win-private.pyd"]), + objdump_output(["api-ms-win-../../private.dll"]), + objdump_output().replace("Entry d", "Entry d invalid"), + objdump_output() + " DLL Name:\n", + ] + for output in invalid: + with self.subTest(output=output), self.assertRaises(baseline.BaselineError): + baseline.parse_inspection(helper, output) + self.assertEqual(baseline.parse_inspection(helper, objdump_output(["api-ms-win-core-file-l1-1-0.dll"])), + {"api-ms-win-core-file-l1-1-0.dll"}) + + def test_policy_cannot_expand_or_lose_required_additions(self): + policy = self.root / "policy.py" + policy.write_text("SYSTEM_DLLS={'imm32.dll','oleaut32.dll'}") + with self.assertRaisesRegex(baseline.BaselineError, "absent from current system policy"): + baseline.current_policy(policy) + + def test_copy_mutation_or_unexpected_inventory_rejected(self): + for tamper in ("bytes", "extra"): + with self.subTest(tamper=tamper): + helper = baseline.load_helper(HELPER) + original = helper.stage_runtime + + def stage(*args): + result = original(*args) + path = self.output / ("SDL3.dll" if tamper == "bytes" else "unexpected.dll") + path.write_text(objdump_output(["KERNEL32.dll"])) + return result + + helper.stage_runtime = stage + with mock.patch.object(baseline, "load_helper", return_value=helper): + with self.assertRaisesRegex(baseline.BaselineError, "copied historical fixture bytes changed|inventory differs"): + self.stage() + self.assert_no_success() + for path in self.output.iterdir(): + path.unlink() + self.output.rmdir() + + def test_inspection_source_mutation_rejected(self): + helper = baseline.load_helper(HELPER) + result = subprocess.CompletedProcess([], 0, objdump_output(), "") + + def mutate(*args, **kwargs): + self.binary.write_text("changed after inspection") + return result + + with mock.patch.object(baseline.subprocess, "run", side_effect=mutate): + with self.assertRaisesRegex(baseline.BaselineError, "changed during inspection"): + baseline.inspect(helper, str(self.objdump), self.binary) + + def test_notice_symlink_case_collision_and_empty_tree_rejected(self): + for failure in ("symlink", "case", "empty"): + with self.subTest(failure=failure): + source = self.root / failure + source.mkdir() + if failure == "symlink": + (source / "COPYING").symlink_to(self.notices / "SDL3/LICENSE.txt") + elif failure == "case": + (source / "COPYING").write_text("one") + (source / "copying").write_text("two") + with self.assertRaises(baseline.BaselineError): + baseline.copy_notices(source, self.root / f"copied-{failure}") + + def test_cli_rejects_wrong_producer_inspector_or_sdk(self): + for image, objdump, runtime in (("other-image", baseline.OBJDUMP, str(baseline.PREFIX / "bin")), + (baseline.IMAGE, "other-objdump", str(baseline.PREFIX / "bin")), + (baseline.IMAGE, baseline.OBJDUMP, str(self.runtime))): + with self.subTest(image=image, objdump=objdump, runtime=runtime): + result = subprocess.run([sys.executable, str(ROOT / "tools/ci/stage_historical_windows_font.py"), + "--historical-helper", str(HELPER), "--producer-image", image, + "--objdump", objdump, "--runtime-dir", runtime, + "--notice-dir", str(baseline.PREFIX / "share/licenses"), + "--output-dir", str(self.output), str(self.binary)], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("requires", result.stderr) + self.assert_no_success() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_windows_font_baseline_cache.py b/tools/tests/test_windows_font_baseline_cache.py index e052404ac..2912d7fee 100644 --- a/tools/tests/test_windows_font_baseline_cache.py +++ b/tools/tests/test_windows_font_baseline_cache.py @@ -33,14 +33,19 @@ def test_workflow_build_uses_writable_isolated_cache_without_changing_baseline(s (readonly / "tmp").mkdir(parents=True) (readonly / "tmp").chmod(0o555) readonly.chmod(0o555) - source = root / "build/font-baseline-source/tools/ci" + source = root / "tools/ci" source.mkdir(parents=True) - (source / "stage_windows_runtime.py").write_text('''import pathlib, sys + (source / "stage_historical_windows_font.py").write_text('''import pathlib, sys assert '--objdump' in sys.argv and 'x86_64-w64-mingw32.shared-objdump' in sys.argv +assert '--historical-helper' in sys.argv and 'build/font-baseline-source/tools/ci/stage_windows_runtime.py' in sys.argv +assert '--producer-image' in sys.argv and '--notice-dir' in sys.argv output = pathlib.Path(sys.argv[sys.argv.index('--output-dir') + 1]) output.mkdir(parents=True) (output / 'staged.txt').write_text('historical helper staged baseline') ''') + historical = root / "build/font-baseline-source/tools/ci" + historical.mkdir(parents=True) + (historical / "stage_windows_runtime.py").write_text("immutable helper bytes") fake = binary / "fake" fake.write_text('''#!/usr/bin/env python3 import json, os, pathlib, subprocess, sys, tempfile diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index d29699d45..5d05f02d5 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -89,7 +89,9 @@ def test_windows_font_baseline_is_separate_and_native_comparison_is_required(sel self.assertIn("client-rich-presence-tests client-font-render-probe", build) self.assertIn('cmake -E copy_directory build/font-baseline-bundle "${stage}/font-baseline"', build) self.assertIn("ref: fd4943252ac872fce2995c286b10a0c6d249aba3", build) - self.assertIn("python3 build/font-baseline-source/tools/ci/stage_windows_runtime.py", baseline) + self.assertIn("python3 tools/ci/stage_historical_windows_font.py", baseline) + self.assertIn("--historical-helper build/font-baseline-source/tools/ci/stage_windows_runtime.py", baseline) + self.assertIn("--notice-dir /opt/mxe/usr/x86_64-w64-mingw32.shared/share/licenses", baseline) self.assertIn("--objdump x86_64-w64-mingw32.shared-objdump", baseline) self.assertIn("staging-helper.sha256", baseline) self.assertIn('--output-dir "${stage}/font-candidate"', build) From 6065bf71737a1d7ae16e5146820e25b1656e99f3 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 04:40:35 -0500 Subject: [PATCH 085/148] fix(server): align LLVM coverage preset without precompiled headers --- server/CMakePresets.json | 5 ++++- server/doc/TESTING.md | 11 ++++++++++- tools/tests/test_coverage_presets.py | 9 +++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/server/CMakePresets.json b/server/CMakePresets.json index 067cebad3..1304cfd0d 100644 --- a/server/CMakePresets.json +++ b/server/CMakePresets.json @@ -36,7 +36,10 @@ { "name": "llvm-coverage", "inherits": "linux-coverage", - "binaryDir": "${sourceDir}/build/llvm-coverage" + "binaryDir": "${sourceDir}/build/llvm-coverage", + "cacheVariables": { + "ENABLE_PRECOMPILED_HEADERS": false + } }, { "name": "linux-sanitizers", diff --git a/server/doc/TESTING.md b/server/doc/TESTING.md index cd66e0ced..fcf99ea35 100644 --- a/server/doc/TESTING.md +++ b/server/doc/TESTING.md @@ -47,7 +47,16 @@ python3 ../tools/ci/llvm_coverage.py export --build-dir build/llvm-coverage \ The `llvm-coverage` preset selects a separate `build/llvm-coverage` cache so existing compiler selections in `linux-coverage` trees remain intact. The -coverage test preset sets `LLVM_PROFILE_FILE` to the build's isolated +server coverage preset disables precompiled headers, matching native CI. +LLVM 23.1.3 emits different coverage mapping descriptors when shared sources +are compiled through the server precompiled header and directly into standalone +tools. Compiling coverage targets without precompiled headers keeps those maps +consistent for strict export, including zero-hit sources in unexecuted tools. +Normal builds, including the Release preset, retain their precompiled-header +default. Coverage instrumentation, test selection and export validation remain +the same. + +The coverage test preset sets `LLVM_PROFILE_FILE` to the build's isolated `llvm-profiles/%m-%p.profraw` path. The binary signature and process ID separate parallel processes and instrumented plugins. `prepare` clears only that build's previous profiles before a fresh pass; additional runtime probes retain the diff --git a/tools/tests/test_coverage_presets.py b/tools/tests/test_coverage_presets.py index 9d34b8f40..5de8a4adc 100644 --- a/tools/tests/test_coverage_presets.py +++ b/tools/tests/test_coverage_presets.py @@ -36,6 +36,15 @@ def preset(section, name): class CoveragePresetTests(unittest.TestCase): + def test_server_coverage_disables_pch_without_changing_normal_presets(self): + data = json.loads((ROOT / "server/CMakePresets.json").read_text()) + coverage = preset(data["configurePresets"], "llvm-coverage") + self.assertIs(coverage["cacheVariables"]["ENABLE_PRECOMPILED_HEADERS"], False) + for name in ("linux-debug", "linux-release", "linux-sanitizers"): + with self.subTest(preset=name): + self.assertNotIn("ENABLE_PRECOMPILED_HEADERS", + preset(data["configurePresets"], name)["cacheVariables"]) + def test_opt_in_has_matching_configure_build_and_test_presets(self): for module in MODULES: with self.subTest(module=module): From d90876bed7c266046db67c90cd63dd2ce201648c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 05:02:32 -0500 Subject: [PATCH 086/148] build(ci): pin published LLVM Windows candidate SDKs --- .github/workflows/build-release-candidate.yml | 10 ++-- .github/workflows/check.yml | 10 ++-- .github/workflows/gpu-qualification.yml | 2 +- tools/tests/test_workflow_contracts.py | 46 ++++++++++++++++--- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 38842bed4..e047c035d 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -51,7 +51,7 @@ permissions: env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 - WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-1.16.0-4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 + WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-1d82af6036b831d97cc470e2230357047e4c78f8-914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 concurrency: group: >- @@ -616,7 +616,7 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - run: docker pull ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 + - run: docker pull ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 - name: Build portable package from monorepo siblings env: RELEASE_VERSION: ${{ needs.metadata.outputs.version }} @@ -638,7 +638,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/client \ - ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ + ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ bash -c \ 'set -euo pipefail ccache --zero-stats @@ -684,7 +684,7 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - run: docker pull ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 + - run: docker pull ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 - name: Build portable package from monorepo siblings env: RELEASE_VERSION: ${{ needs.metadata.outputs.version }} @@ -704,7 +704,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/server \ - ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ + ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ bash -c \ 'set -euo pipefail ccache --zero-stats diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1ef84939d..9eb8a7691 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -396,7 +396,9 @@ jobs: printf '%s\n' '123456789012345678' > \ build/discord-test-application-id - name: Pull pinned Windows build environment - run: docker pull ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 + run: | + docker pull ghcr.io/atrinik/windows-build:classic-check-candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:eb1fbbfbfbe0b784cdc705f3a1a01b8d69b84338adcb6eb9d8e418a743c8ec09 + docker pull ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 - name: Check out the immutable historical baseline staging helper uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -454,7 +456,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace \ - ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ + ghcr.io/atrinik/windows-build:classic-check-candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:eb1fbbfbfbe0b784cdc705f3a1a01b8d69b84338adcb6eb9d8e418a743c8ec09 \ bash -c ' set -euo pipefail pcpnatpmp_source="$(cat build/libpcpnatpmp-source-path)" @@ -574,7 +576,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace \ - ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ + ghcr.io/atrinik/windows-build:classic-check-candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:eb1fbbfbfbe0b784cdc705f3a1a01b8d69b84338adcb6eb9d8e418a743c8ec09 \ bash -c ' set -euo pipefail cmake \ @@ -614,7 +616,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/server \ - ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 \ + ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ bash tools/build-windows-package.sh build/windows-pr-package cp server/build/windows-pr-package/*.zip \ libatrinik/build/windows-test-bundle/ diff --git a/.github/workflows/gpu-qualification.yml b/.github/workflows/gpu-qualification.yml index fbca88a22..dec3cdecf 100644 --- a/.github/workflows/gpu-qualification.yml +++ b/.github/workflows/gpu-qualification.yml @@ -14,7 +14,7 @@ concurrency: env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 CLASSIC_LINUX_RUNTIME_IMAGE: ghcr.io/atrinik/linux-build:1.16.0@sha256:89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e - CLASSIC_WINDOWS_IMAGE: ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 + CLASSIC_WINDOWS_IMAGE: ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 jobs: shaders: diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 5d05f02d5..04b0993f6 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -12,6 +12,11 @@ ROOT = Path(__file__).resolve().parents[2] +WINDOWS_PRODUCER_REVISION = "1d82af6036b831d97cc470e2230357047e4c78f8" +WINDOWS_GENERAL_DIGEST = "914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325" +WINDOWS_FOCUSED_DIGEST = "eb1fbbfbfbe0b784cdc705f3a1a01b8d69b84338adcb6eb9d8e418a743c8ec09" +WINDOWS_GENERAL_IMAGE = f"ghcr.io/atrinik/windows-build:candidate-sha-{WINDOWS_PRODUCER_REVISION}@sha256:{WINDOWS_GENERAL_DIGEST}" +WINDOWS_FOCUSED_IMAGE = f"ghcr.io/atrinik/windows-build:classic-check-candidate-sha-{WINDOWS_PRODUCER_REVISION}@sha256:{WINDOWS_FOCUSED_DIGEST}" class WorkflowContractTests(unittest.TestCase): @@ -39,6 +44,34 @@ def test_final_published_provider_pins_and_installed_library_lane(self) -> None: self.assertNotIn(forbidden, lane) self.assertNotIn("apt-get", sources) + def test_windows_sdk_roles_preserve_python_worldmaker_and_historical_baseline(self) -> None: + check = self.text("check.yml") + baseline = check[check.index(" - name: Build the original pinned Windows font baseline"): + check.index(" - name: Cross-build and stage standalone tests")] + target = check[check.index(" - name: Cross-build and stage standalone tests"): + check.index(" - name: Build portable Windows server package")] + package = check[check.index(" - name: Build portable Windows server package"): + check.index(" windows-test:")] + self.assertEqual(target.count(WINDOWS_FOCUSED_IMAGE), 2) + self.assertEqual(target.count("-DENABLE_PYTHON_PLUGIN=OFF"), 2) + self.assertNotIn("worldmaker", target) + self.assertNotIn("WINDOWS_PYTHON", target) + self.assertIn(WINDOWS_GENERAL_IMAGE, package) + self.assertNotIn(WINDOWS_FOCUSED_IMAGE, package) + self.assertIn("bash tools/build-windows-package.sh", package) + server_helper = (ROOT / "server/tools/build-windows-package.sh").read_text() + self.assertIn("--worldmaker", server_helper) + self.assertIn("ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR", server_helper) + self.assertIn("/opt/llvm-23.1.3/bin", server_helper) + self.assertIn("CLASSIC_WINDOWS_FONT_BASELINE_IMAGE: ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69", check) + self.assertNotIn("candidate-sha-", baseline) + self.assertIn("fd4943252ac872fce2995c286b10a0c6d249aba3", baseline) + gpu = self.text("gpu-qualification.yml") + self.assertIn(f"CLASSIC_WINDOWS_IMAGE: {WINDOWS_GENERAL_IMAGE}", gpu) + self.assertNotIn(WINDOWS_FOCUSED_IMAGE, gpu) + for digest in (WINDOWS_GENERAL_DIGEST, WINDOWS_FOCUSED_DIGEST): + self.assertRegex(digest, r"^[0-9a-f]{64}$") + def test_native_consumers_require_llvm_and_native_lcov(self) -> None: for name in ("check.yml", "pr-benchmarks.yml", "daily-client-performance.yml"): workflow = self.text(name) @@ -1396,10 +1429,10 @@ def test_windows_packages_persist_toolchain_bound_compiler_caches(self) -> None: server_job = candidate[ candidate.index(" server-windows:") : candidate.index(" server-image:") ] - digest = "4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69" - image = f"ghcr.io/atrinik/windows-build:1.16.0@sha256:{digest}" + digest = WINDOWS_GENERAL_DIGEST + image = WINDOWS_GENERAL_IMAGE self.assertEqual( - candidate.count(f"WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-1.16.0-{digest}"), 1 + candidate.count(f"WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-{WINDOWS_PRODUCER_REVISION}-{digest}"), 1 ) self.assertEqual(candidate.count(image), 4) self.assertNotIn("ghcr.io/atrinik/windows-build:1.0.5", candidate) @@ -1529,12 +1562,11 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) run_start = workflow.index(" windows-test:") run = workflow[run_start : workflow.index(" server:", run_start)] aggregate = workflow[workflow.index(" classic-validation:") :] - digest = "4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69" - image = f"ghcr.io/atrinik/windows-build:1.16.0@sha256:{digest}" self.assertIn("if: needs.changes.outputs.windows == 'true'", build) - self.assertEqual(build.count(image), 4) - self.assertEqual(build.count(digest), 4) + self.assertEqual(build.count(WINDOWS_FOCUSED_IMAGE), 3) + self.assertEqual(build.count(WINDOWS_GENERAL_IMAGE), 2) + self.assertNotIn("windows-build:1.16.0", build) self.assertNotIn("ghcr.io/atrinik/windows-build:1.0.5", build) self.assertEqual(build.count("--network none"), 4) self.assertIn("persist-credentials: false", build) From 0ca0e720447985ffd42500344a814380299d4470 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 05:18:44 -0500 Subject: [PATCH 087/148] fix(ci): use complete Windows SDK for server graphs --- .github/workflows/check.yml | 5 +- tools/tests/test_workflow_contracts.py | 68 ++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9eb8a7691..e08ec6dcf 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -397,7 +397,6 @@ jobs: build/discord-test-application-id - name: Pull pinned Windows build environment run: | - docker pull ghcr.io/atrinik/windows-build:classic-check-candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:eb1fbbfbfbe0b784cdc705f3a1a01b8d69b84338adcb6eb9d8e418a743c8ec09 docker pull ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 - name: Check out the immutable historical baseline staging helper uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -456,7 +455,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace \ - ghcr.io/atrinik/windows-build:classic-check-candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:eb1fbbfbfbe0b784cdc705f3a1a01b8d69b84338adcb6eb9d8e418a743c8ec09 \ + ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ bash -c ' set -euo pipefail pcpnatpmp_source="$(cat build/libpcpnatpmp-source-path)" @@ -576,7 +575,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace \ - ghcr.io/atrinik/windows-build:classic-check-candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:eb1fbbfbfbe0b784cdc705f3a1a01b8d69b84338adcb6eb9d8e418a743c8ec09 \ + ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ bash -c ' set -euo pipefail cmake \ diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 04b0993f6..721368505 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -52,7 +52,8 @@ def test_windows_sdk_roles_preserve_python_worldmaker_and_historical_baseline(se check.index(" - name: Build portable Windows server package")] package = check[check.index(" - name: Build portable Windows server package"): check.index(" windows-test:")] - self.assertEqual(target.count(WINDOWS_FOCUSED_IMAGE), 2) + self.assertEqual(target.count(WINDOWS_GENERAL_IMAGE), 2) + self.assertNotIn(WINDOWS_FOCUSED_IMAGE, target) self.assertEqual(target.count("-DENABLE_PYTHON_PLUGIN=OFF"), 2) self.assertNotIn("worldmaker", target) self.assertNotIn("WINDOWS_PYTHON", target) @@ -72,6 +73,67 @@ def test_windows_sdk_roles_preserve_python_worldmaker_and_historical_baseline(se for digest in (WINDOWS_GENERAL_DIGEST, WINDOWS_FOCUSED_DIGEST): self.assertRegex(digest, r"^[0-9a-f]{64}$") + @unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") + def test_windows_server_sdk_roles_include_host_lexer_generators_with_python_off(self) -> None: + # Use the actual unconditional server lookup, without compiling the app. + # PythonOFF removes its plugin, not the Flex-generated loader sources. + server = (ROOT / "server/CMakeLists.txt").read_text() + required_flex = re.findall(r"^find_package\(FLEX REQUIRED\)$", server, re.MULTILINE) + self.assertEqual(required_flex, ["find_package(FLEX REQUIRED)"]) + self.assertIn("flex_target(${name}", server) + integrated = (ROOT / "CMakeLists.txt").read_text() + self.assertIn('option(ATRINIK_BUILD_SERVER "Build the classic server" ON)', integrated) + self.assertIn("add_subdirectory(server)", integrated) + check = self.text("check.yml") + for name, next_name, source in ( + ("Cross-build and stage standalone tests", "Cross-build Windows server", "."), + ("Cross-build Windows server", "Build portable Windows server package", "server"), + ): + with self.subTest(step=name): + step = check[check.index(f" - name: {name}\n"): + check.index(f" - name: {next_name}\n")] + self.assertIn(WINDOWS_GENERAL_IMAGE, step) + self.assertNotIn(WINDOWS_FOCUSED_IMAGE, step) + commands = re.sub(r"\\\n\s*", " ", step) + configure = next(line for line in commands.splitlines() + if line.strip().startswith("cmake ") and f"-S {source} " in line) + self.assertIn("-DENABLE_PYTHON_PLUGIN=OFF", configure) + self.assertIn("-DFETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP=/workspace/build/libpcpnatpmp-mingw-source", configure) + if source == ".": + self.assertIn("-DATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders", configure) + self.assertIn('-DPATCH_EXECUTABLE="$(command -v patch)"', step) + self.assertIn("-P /workspace/server/cmake/apply_patch_idempotent.cmake", step) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fixture = root / "required-generators.cmake" + fixture.write_text("\n".join(( + "set(ENABLE_PYTHON_PLUGIN OFF)", + "set(CMAKE_FIND_USE_SYSTEM_ENVIRONMENT_PATH FALSE)", + "set(CMAKE_FIND_USE_CMAKE_SYSTEM_PATH FALSE)", + required_flex[0], + # Bison belongs to the general producer's host generator cohort; + # the current server directly invokes Flex for its three lexers. + "find_package(BISON REQUIRED)", + )) + "\n") + for role in ("focused", "general"): + sdk = root / role + sdk.mkdir() + if role == "general": + for tool, version in (("flex", "flex 2.6.4"), ("bison", "bison (GNU Bison) 3.8.2")): + executable = sdk / tool + executable.write_text(f"#!/bin/sh\nprintf '%s\\n' '{version}'\n") + executable.chmod(0o755) + result = subprocess.run([shutil.which("cmake"), f"-DCMAKE_PROGRAM_PATH={sdk}", + "-P", str(fixture)], capture_output=True, text=True) + with self.subTest(sdk=role): + if role == "focused": + self.assertNotEqual(result.returncode, 0) + self.assertIn("FLEX_EXECUTABLE", result.stdout + result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Found FLEX", result.stdout) + self.assertIn("Found BISON", result.stdout) + def test_native_consumers_require_llvm_and_native_lcov(self) -> None: for name in ("check.yml", "pr-benchmarks.yml", "daily-client-performance.yml"): workflow = self.text(name) @@ -1564,8 +1626,8 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) aggregate = workflow[workflow.index(" classic-validation:") :] self.assertIn("if: needs.changes.outputs.windows == 'true'", build) - self.assertEqual(build.count(WINDOWS_FOCUSED_IMAGE), 3) - self.assertEqual(build.count(WINDOWS_GENERAL_IMAGE), 2) + self.assertNotIn(WINDOWS_FOCUSED_IMAGE, build) + self.assertEqual(build.count(WINDOWS_GENERAL_IMAGE), 4) self.assertNotIn("windows-build:1.16.0", build) self.assertNotIn("ghcr.io/atrinik/windows-build:1.0.5", build) self.assertEqual(build.count("--network none"), 4) From 8dadfdc90fc595459b49dd7ce8e2cfcb1d4a1ec6 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 05:20:26 -0500 Subject: [PATCH 088/148] test(ci): isolate Windows SDK host generator fixtures --- tools/tests/test_workflow_contracts.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 721368505..e31f03a26 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -109,13 +109,14 @@ def test_windows_server_sdk_roles_include_host_lexer_generators_with_python_off( fixture.write_text("\n".join(( "set(ENABLE_PYTHON_PLUGIN OFF)", "set(CMAKE_FIND_USE_SYSTEM_ENVIRONMENT_PATH FALSE)", + "set(CMAKE_FIND_USE_CMAKE_ENVIRONMENT_PATH FALSE)", "set(CMAKE_FIND_USE_CMAKE_SYSTEM_PATH FALSE)", required_flex[0], # Bison belongs to the general producer's host generator cohort; # the current server directly invokes Flex for its three lexers. "find_package(BISON REQUIRED)", )) + "\n") - for role in ("focused", "general"): + for role in ("general", "focused"): sdk = root / role sdk.mkdir() if role == "general": @@ -123,8 +124,12 @@ def test_windows_server_sdk_roles_include_host_lexer_generators_with_python_off( executable = sdk / tool executable.write_text(f"#!/bin/sh\nprintf '%s\\n' '{version}'\n") executable.chmod(0o755) + # Ambient host SDK selectors must not supply missing generators + # to the focused fixture. Only its explicit CLI path is eligible. result = subprocess.run([shutil.which("cmake"), f"-DCMAKE_PROGRAM_PATH={sdk}", - "-P", str(fixture)], capture_output=True, text=True) + "-P", str(fixture)], capture_output=True, text=True, + env=dict(os.environ, CMAKE_PROGRAM_PATH=str(root / "general"), + CMAKE_PREFIX_PATH=str(root / "general"))) with self.subTest(sdk=role): if role == "focused": self.assertNotEqual(result.returncode, 0) From 0c3a34d29422a5a9356c0fdb204949054a6b4048 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 05:53:34 -0500 Subject: [PATCH 089/148] fix(libatrinik): accept CRLF console commands with binary stdin --- .github/workflows/check.yml | 8 +- libatrinik/CMakeLists.txt | 8 ++ libatrinik/console.c | 49 ++++--- libatrinik/porting.c | 29 ++-- libatrinik/tests/console.c | 152 +++++++++++++++++++++ libatrinik/tests/wrapped/console.c | 18 +++ libatrinik/tests/wrapped/porting_getline.c | 30 ++++ 7 files changed, 261 insertions(+), 33 deletions(-) create mode 100644 libatrinik/tests/console.c create mode 100644 libatrinik/tests/wrapped/console.c create mode 100644 libatrinik/tests/wrapped/porting_getline.c diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e08ec6dcf..387e60372 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -473,7 +473,7 @@ jobs: -DCMAKE_BUILD_TYPE=Release \ -DATRINIK_PROTOCOL_SOURCE_DIR=/workspace/protocol cmake --build libatrinik/build/windows-tests \ - --target libatrinik-path libatrinik-rendezvous libatrinik-signals \ + --target libatrinik-console libatrinik-path libatrinik-rendezvous libatrinik-signals \ libatrinik-metaserver-publisher \ libatrinik-metaserver-url libatrinik-socket-address libatrinik-socket-quic \ libatrinik-stun \ @@ -539,6 +539,7 @@ jobs: --readobj llvm-readobj \ --runtime-dir "${ATRINIK_WINDOWS_RUNTIME_DIR}" \ --output-dir "${stage}" \ + libatrinik/build/windows-tests/libatrinik-console.exe \ libatrinik/build/windows-tests/libatrinik-path.exe \ libatrinik/build/windows-tests/libatrinik-rendezvous.exe \ libatrinik/build/windows-tests/libatrinik-signals.exe \ @@ -710,6 +711,11 @@ jobs: $junction = Join-Path $bundle "junction" New-Item -ItemType Directory -Path $junctionTarget | Out-Null New-Item -ItemType Junction -Path $junction -Target $junctionTarget | Out-Null + & (Join-Path $bundle "libatrinik-console.exe") + if ($LASTEXITCODE -ne 0) { + throw "libatrinik-console.exe failed with exit code ${LASTEXITCODE}" + } + & (Join-Path $bundle "libatrinik-path.exe") $junction if ($LASTEXITCODE -ne 0) { throw "libatrinik-path.exe failed with exit code ${LASTEXITCODE}" diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index 8b9cf583f..22ed91f2c 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -611,6 +611,14 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR target_link_libraries(libatrinik-clioptions PRIVATE Atrinik::Core) add_test(NAME libatrinik-clioptions COMMAND libatrinik-clioptions) + # Exercise binary console input and the Windows getline fallback on every + # platform, including Linux ASan builds whose libc already has getline. + add_executable(libatrinik-console tests/console.c + tests/wrapped/console.c tests/wrapped/porting_getline.c) + atrinik_configure_target(libatrinik-console) + target_link_libraries(libatrinik-console PRIVATE Atrinik::Core) + add_test(NAME libatrinik-console COMMAND libatrinik-console) + add_executable(libatrinik-string tests/string.c) atrinik_configure_target(libatrinik-string) target_link_libraries(libatrinik-string PRIVATE Atrinik::Core) diff --git a/libatrinik/console.c b/libatrinik/console.c index 9154a6c45..9bd4da7ed 100644 --- a/libatrinik/console.c +++ b/libatrinik/console.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -297,6 +297,25 @@ static void console_print(const char *str) { #endif +#ifndef HAVE_READLINE +/** Read and queue one line; EOF or an input error ends the reader. */ +static bool console_read_line(FILE *stream) { + char *line = NULL; + size_t len = 0; + ssize_t numread = getline(&line, &len, stream); + if (numread <= 0) { + free(line); + return false; + } + + pthread_mutex_lock(&command_process_queue_mutex); + utarray_push_back(command_process_queue, &line); + pthread_mutex_unlock(&command_process_queue_mutex); + free(line); + return true; +} +#endif + /** * Thread for acquiring stdin data. * @param dummy @@ -305,11 +324,7 @@ static void console_print(const char *str) { * NULL. */ static void *do_thread(void *dummy) { -#ifndef HAVE_READLINE - char *line; - ssize_t numread; - size_t len; -#else +#ifdef HAVE_READLINE fd_set fds; FD_ZERO(&fds); @@ -327,16 +342,9 @@ static void *do_thread(void *dummy) { pthread_mutex_unlock(&rl_mutex); } #else - line = NULL; - numread = getline(&line, &len, stdin); - - if (numread <= 0) { - continue; + if (!console_read_line(stdin)) { + break; } - - pthread_mutex_lock(&command_process_queue_mutex); - utarray_push_back(command_process_queue, &line); - pthread_mutex_unlock(&command_process_queue_mutex); #endif usleep(10000); @@ -417,12 +425,13 @@ void console_command_handle(void) { return; } - /* Remove the newline. */ - cp = strchr(*line, '\n'); - - if (cp) { - *cp = '\0'; + /* Embedded Python switches Windows CRT stdin to binary mode. Accept + * redirected CRLF input as well as LF, independent of stream translation. */ + size_t length = strcspn(*line, "\n"); + if ((*line)[length] == '\n' && length > 0 && (*line)[length - 1] == '\r') { + length--; } + (*line)[length] = '\0'; /* Remove the command from the parameters. */ cp = strchr(*line, ' '); diff --git a/libatrinik/porting.c b/libatrinik/porting.c index e3cb45e74..ef2762668 100644 --- a/libatrinik/porting.c +++ b/libatrinik/porting.c @@ -1,7 +1,7 @@ /************************************************************************* * Atrinik, a Multiplayer Online Role Playing Game * * * - * Copyright (C) 2009-2014 Zoey Rose and Atrinik Development Team * + * Copyright (C) 2009-2026 Zoey Rose and Atrinik Development Team * * * * Fork from Crossfire (Multiplayer game for X-windows). * * * @@ -218,33 +218,38 @@ ssize_t _getline(char **lineptr, size_t *n, FILE *stream) { } buf = *lineptr; - bufsize = *n; + bufsize = buf != NULL ? *n : 0; numread = 0; c = fgetc(stream); if (c == EOF) { - errno = EINVAL; return -1; } - if (!buf) { + if (!buf || bufsize == 0) { bufsize = 1; - buf = malloc(bufsize); - - if (!buf) { + char *grown = realloc(buf, bufsize); + if (grown == NULL) { return -1; } + buf = *lineptr = grown; + *n = bufsize; } while (c != EOF) { - if (numread > bufsize - 1) { - bufsize += 1; - buf = realloc(buf, bufsize); - - if (!buf) { + /* Reserve the terminating NUL in addition to the next input byte. */ + if (numread >= bufsize - 1) { + if (bufsize == SIZE_MAX) { + errno = EOVERFLOW; + return -1; + } + char *grown = realloc(buf, bufsize + 1); + if (grown == NULL) { return -1; } + buf = *lineptr = grown; + *n = ++bufsize; } buf[numread++] = c; diff --git a/libatrinik/tests/console.c b/libatrinik/tests/console.c new file mode 100644 index 000000000..e72e2ab2a --- /dev/null +++ b/libatrinik/tests/console.c @@ -0,0 +1,152 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ + +#include +#ifdef WIN32 +#include +#endif + +#define require(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "%s:%d: requirement failed: %s\n", \ + __FILE__, __LINE__, #condition); \ + return 1; \ + } \ + } while (0) + +bool console_test_read_line(FILE *stream); +ssize_t console_test_getline(char **line, size_t *size, FILE *stream); +void console_test_fail_realloc_after(size_t count); + +static unsigned calls; +static char observed[512]; + +static void capture(const char *params) { + calls++; + snprintf(observed, sizeof(observed), "%s", params != NULL ? params : ""); +} + +static FILE *input(const char *text) { + int descriptors[2]; + /* Match redirected binary stdin without requiring a writable root drive + * (the Windows CRT tmpfile default) or altering the test's own stdin. */ +#ifdef WIN32 + if (_pipe(descriptors, 1024, _O_BINARY) != 0) { +#else + if (pipe(descriptors) != 0) { +#endif + return NULL; + } + size_t length = strlen(text); + if (write(descriptors[1], text, (unsigned int)length) != (ssize_t)length) { + close(descriptors[0]); + close(descriptors[1]); + return NULL; + } + close(descriptors[1]); + FILE *stream = fdopen(descriptors[0], "rb"); + if (stream == NULL) { + close(descriptors[0]); + } + return stream; +} + +static int test_dispatch(void) { + static const struct { + const char *text; + const char *params; + } cases[] = { + {"shutdown\n", ""}, + {"shutdown\r\n", ""}, + {"shutdown reason with spaces\r\n", "reason with spaces"}, + {"shutdown \r\n", ""}, + {"shutdown", ""}, + {"shutdown reason\rwith spaces\r\n", "reason\rwith spaces"}, + }; + toolkit_import(console); + console_command_add("shutdown", capture, "test", "test"); + for (size_t i = 0; i < sizeof(cases) / sizeof(*cases); i++) { + FILE *stream = input(cases[i].text); + require(stream != NULL); + require(console_test_read_line(stream)); + console_command_handle(); + require(calls == i + 1); + require(strcmp(observed, cases[i].params) == 0); + require(!console_test_read_line(stream)); + console_command_handle(); + require(calls == i + 1); + require(fclose(stream) == 0); + } + FILE *stream = input("\r\nunknown\r\nshutdown\r\nshutdown\n"); + require(stream != NULL); + while (console_test_read_line(stream)) { + console_command_handle(); + } + require(calls == 8); + require(fclose(stream) == 0); + stream = input(""); + require(stream != NULL); + require(!console_test_read_line(stream)); + console_command_handle(); + require(calls == 8); + require(fclose(stream) == 0); + toolkit_deinit(); + return 0; +} + +static int test_fallback_bounds(void) { + char text[258]; + memset(text, 'x', sizeof(text) - 2); + text[sizeof(text) - 2] = '\n'; + text[sizeof(text) - 1] = '\0'; + FILE *stream = input(text); + require(stream != NULL); + /* A one-byte allocation forces capacity growth and checks the terminator + * contract explicitly, as well as through sanitizer instrumentation. */ + char *line = malloc(1); + require(line != NULL); + size_t capacity = 1; + ssize_t length = console_test_getline(&line, &capacity, stream); + require(length == (ssize_t)strlen(text)); + require(capacity > (size_t)length); + require(strcmp(line, text) == 0); + require(console_test_getline(&line, &capacity, stream) == -1); + require(feof(stream)); + free(line); + require(fclose(stream) == 0); + + stream = input("\nreused\n"); + require(stream != NULL); + line = malloc(1); + require(line != NULL); + capacity = 0; + require(console_test_getline(&line, &capacity, stream) == 1); + require(capacity >= 2 && strcmp(line, "\n") == 0); + require(console_test_getline(&line, &capacity, stream) == 7); + require(capacity >= 8 && strcmp(line, "reused\n") == 0); + free(line); + require(fclose(stream) == 0); + + stream = input("growth\n"); + require(stream != NULL); + line = malloc(1); + require(line != NULL); + capacity = 1; + console_test_fail_realloc_after(2); + require(console_test_getline(&line, &capacity, stream) == -1); + require(errno == ENOMEM); + require(capacity == 3); + require(memcmp(line, "gr", 2) == 0); + /* This frees the last successful growth, including after realloc moved it. */ + free(line); + console_test_fail_realloc_after(SIZE_MAX); + require(fclose(stream) == 0); + return 0; +} + +int main(void) { + require(test_dispatch() == 0); + require(test_fallback_bounds() == 0); + return 0; +} diff --git a/libatrinik/tests/wrapped/console.c b/libatrinik/tests/wrapped/console.c new file mode 100644 index 000000000..bcdb328da --- /dev/null +++ b/libatrinik/tests/wrapped/console.c @@ -0,0 +1,18 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ + +#include +#include + +/* Force the same binary-stream reader used by Windows even when the test + * platform has Readline and a native getline. */ +#undef HAVE_READLINE +#undef getline +ssize_t console_test_getline(char **line, size_t *size, FILE *stream); +#define getline console_test_getline +#include "../../console.c" + +bool console_test_read_line(FILE *stream); +bool console_test_read_line(FILE *stream) { + return console_read_line(stream); +} diff --git a/libatrinik/tests/wrapped/porting_getline.c b/libatrinik/tests/wrapped/porting_getline.c new file mode 100644 index 000000000..062d14ee6 --- /dev/null +++ b/libatrinik/tests/wrapped/porting_getline.c @@ -0,0 +1,30 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ + +#include + +static size_t successful_allocations = SIZE_MAX; + +void console_test_fail_realloc_after(size_t count); +void console_test_fail_realloc_after(size_t count) { + successful_allocations = count; +} + +static void *console_test_realloc(void *pointer, size_t size) { + if (successful_allocations == 0) { + errno = ENOMEM; + return NULL; + } + if (successful_allocations != SIZE_MAX) { + successful_allocations--; + } + return realloc(pointer, size); +} + +/* Keep the fallback independently testable on hosts with libc getline. */ +#undef HAVE_GETLINE +#undef _getline +ssize_t console_test_getline(char **line, size_t *size, FILE *stream); +#define _getline console_test_getline +#define realloc console_test_realloc +#include "../../porting.c" From 108844a5f038e99bcbdbecb6eef59ff4d40d37b7 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 05:57:23 -0500 Subject: [PATCH 090/148] test(ci): require native Windows console regression coverage --- tools/tests/test_workflow_contracts.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index e31f03a26..9bbce75bd 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -1686,7 +1686,7 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) self.assertIn("/data/discord-application-id", build) self.assertIn("not in archive.read(executable)", build) self.assertIn( - "--target libatrinik-path libatrinik-rendezvous " + "--target libatrinik-console libatrinik-path libatrinik-rendezvous " "libatrinik-signals \\", build, ) @@ -1697,6 +1697,12 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) build, ) self.assertIn("libatrinik-stun \\", build) + staged_tests = build.split("stage=libatrinik/build/windows-test-bundle", 1)[1] + staged_tests = staged_tests.split("cmake -E copy_directory", 1)[0] + self.assertIn("python3 tools/ci/stage_windows_runtime.py", staged_tests) + self.assertIn( + "libatrinik/build/windows-tests/libatrinik-console.exe", staged_tests + ) self.assertIn( "libatrinik/build/windows-tests/libatrinik-socket-address.exe", build ) @@ -1755,6 +1761,13 @@ def test_native_windows_security_tests_are_cross_built_and_run_on_windows(self) ) self.assertNotIn('"atrinik-classic-issue-477-windows-one-click-*.zip"', run) self.assertRegex(run, r"actions/download-artifact@[0-9a-f]{40}\b") + self.assertRegex( + run, + r'& \(Join-Path \$bundle "libatrinik-console\.exe"\)\s+' + r'if \(\$LASTEXITCODE -ne 0\) \{\s+' + r'throw "libatrinik-console\.exe failed with exit code ' + r'\$\{LASTEXITCODE\}"\s+\}', + ) self.assertIn('"libatrinik-path.exe"', run) self.assertIn("New-Item -ItemType Junction", run) self.assertIn('"libatrinik-path.exe") $junction', run) From 2a3a759e70c7dd104d0c7faa2d5af13e0deb548d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 06:21:47 -0500 Subject: [PATCH 091/148] fix(client): create font probe outputs across Windows CRTs --- .github/workflows/check.yml | 6 ++ client/CMakeLists.txt | 12 ++++ client/src/tests/font_output.h | 54 ++++++++++++++++++ client/src/tests/font_output_cleanup.c | 52 +++++++++++++++++ client/src/tests/font_render.c | 29 ++++++---- client/tools/tests/test_font_output.py | 69 +++++++++++++++++++++++ tools/ci/font-render-probe/CMakeLists.txt | 4 ++ tools/tests/test_workflow_contracts.py | 3 + 8 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 client/src/tests/font_output.h create mode 100644 client/src/tests/font_output_cleanup.c create mode 100644 client/tools/tests/test_font_output.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 387e60372..f6abdca34 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -556,6 +556,7 @@ jobs: client/build/windows-tests/client-font-render-probe.exe cmake -E copy_directory client/fonts "${stage}/font-inputs" cmake -E copy tools/ci/compare_font_render.py "${stage}" + cmake -E copy client/tools/tests/test_font_output.py "${stage}" cmake -E copy_directory libatrinik/tests/fixtures "${stage}/fixtures" cmake -E copy tools/ci/smoke_windows_server_package.ps1 "${stage}" cmake -E copy tools/ci/smoke_windows_review_bundle.ps1 "${stage}" @@ -857,6 +858,11 @@ jobs: $ErrorActionPreference = "Stop" $bundle = (Resolve-Path "build/native-windows-tests").Path $fonts = Join-Path $bundle "font-inputs" + foreach ($producer in @("font-baseline", "font-candidate")) { + python (Join-Path $bundle "test_font_output.py") ` + (Join-Path $bundle "$producer/client-font-render-probe.exe") + if ($LASTEXITCODE -ne 0) { throw "Font output regression failed: $producer" } + } $baseline = Join-Path $bundle "font-baseline-result" $candidate = Join-Path $bundle "font-candidate-result" New-Item -ItemType Directory -Path $baseline, $candidate | Out-Null diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 2b0b6d522..56844ee4e 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -446,6 +446,18 @@ if (BUILD_TESTING) atrinik_configure_target(client-font-render-probe) target_link_libraries(client-font-render-probe PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf) + add_test(NAME client-font-output + COMMAND "${ATRINIK_PYTHON3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_font_output.py" + "$") + set_tests_properties(client-font-output PROPERTIES TIMEOUT 60) + add_executable(client-font-output-cleanup-tests src/tests/font_output_cleanup.c) + atrinik_configure_target(client-font-output-cleanup-tests) + add_test(NAME client-font-output-cleanup + COMMAND "${ATRINIK_PYTHON3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_font_output.py" --cleanup + "$") + set_tests_properties(client-font-output-cleanup PROPERTIES TIMEOUT 30) set(ATRINIK_GPU_FIXTURE_PROVENANCE_COMMAND "${ATRINIK_PYTHON3_EXECUTABLE}" diff --git a/client/src/tests/font_output.h b/client/src/tests/font_output.h new file mode 100644 index 000000000..45a0b4d03 --- /dev/null +++ b/client/src/tests/font_output.h @@ -0,0 +1,54 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + */ +#ifndef ATRINIK_FONT_OUTPUT_H +#define ATRINIK_FONT_OUTPUT_H + +#include +#include +#include +#include +#ifdef _WIN32 +#include +#else +#include +#endif + +static FILE *font_output(const char *directory, const char *name) { + char path[4096]; + int length = snprintf(path, sizeof(path), "%s/%s", directory, name); + if (length < 0 || (size_t)length >= sizeof(path)) { + errno = ENAMETOOLONG; + return NULL; + } + /* MSVCRT does not support fopen's C11 "x" mode. Create atomically before + * attaching a binary stream, preserving existing evidence on every CRT. */ +#ifdef _WIN32 + int descriptor = _open(path, _O_WRONLY | _O_CREAT | _O_EXCL | _O_BINARY, + _S_IREAD | _S_IWRITE); +#else + int descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR); +#endif + if (descriptor < 0) { + return NULL; + } +#ifdef _WIN32 + FILE *file = _fdopen(descriptor, "wb"); +#else + FILE *file = fdopen(descriptor, "wb"); +#endif + if (file == NULL) { + int error = errno; +#ifdef _WIN32 + _close(descriptor); +#else + close(descriptor); +#endif + /* Retain the created file as failed evidence. Unlinking its pathname + * could remove a replacement belonging to another process. */ + errno = error; + } + return file; +} + +#endif diff --git a/client/src/tests/font_output_cleanup.c b/client/src/tests/font_output_cleanup.c new file mode 100644 index 000000000..d2e09aa8c --- /dev/null +++ b/client/src/tests/font_output_cleanup.c @@ -0,0 +1,52 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + * Inject a stream allocation failure after the real exclusive file open. + */ +#ifndef _WIN32 +#define _POSIX_C_SOURCE 200809L +#endif +#include +#include +#ifdef _WIN32 +#include +#include +static HANDLE opened_handle; +#else +#include +static int opened_descriptor; +#endif + +static FILE *fail_fdopen(int descriptor, const char *mode) { + (void)mode; +#ifdef _WIN32 + opened_handle = (HANDLE)_get_osfhandle(descriptor); +#else + opened_descriptor = descriptor; +#endif + errno = ENOMEM; + return NULL; +} + +#define fdopen fail_fdopen +#define _fdopen fail_fdopen +#include "font_output.h" +#undef fdopen +#undef _fdopen + +int main(int argc, char **argv) { + if (argc != 2 || font_output(argv[1], "font-output-test.bin") != NULL || errno != ENOMEM) { + return 1; + } +#ifdef _WIN32 + DWORD flags; + if (GetHandleInformation(opened_handle, &flags) || GetLastError() != ERROR_INVALID_HANDLE) { + return 1; + } +#else + if (fcntl(opened_descriptor, F_GETFD) != -1 || errno != EBADF) { + return 1; + } +#endif + /* Earlier failed evidence must still exclude a later writer. */ + return font_output(argv[1], "font-output-test.bin") == NULL && errno == EEXIST ? 0 : 1; +} diff --git a/client/src/tests/font_render.c b/client/src/tests/font_render.c index 47d955d3d..3346a92e2 100644 --- a/client/src/tests/font_render.c +++ b/client/src/tests/font_render.c @@ -2,10 +2,15 @@ * SPDX-License-Identifier: GPL-2.0-or-later * Headless, bounded SDL_ttf equivalence evidence; no window or GPU is created. */ +#ifndef _WIN32 +#define _POSIX_C_SOURCE 200809L +#endif #include #include #include #include +#include +#include "font_output.h" static const char *fonts[] = { "arial.ttf", "courier.otf", "fanwood.otf", "logisoso.ttf", @@ -25,15 +30,10 @@ static void fail(const char *message) { } static FILE *output(const char *directory, const char *name) { - char path[4096]; - int length = snprintf(path, sizeof(path), "%s/%s", directory, name); - if (length < 0 || (size_t)length >= sizeof(path)) { - fail("output path too long"); - } - /* Exclusive creation preserves earlier evidence on failed/repeated runs. */ - FILE *file = fopen(path, "wbx"); + FILE *file = font_output(directory, name); if (file == NULL) { - fail("cannot exclusively create output"); + fprintf(stderr, "font probe: cannot exclusively create output: %s\n", strerror(errno)); + exit(EXIT_FAILURE); } return file; } @@ -103,6 +103,15 @@ static void write_pixels(SDL_Surface *surface, const char *directory, int id) { } int main(int argc, char **argv) { + if (argc == 3 && strcmp(argv[1], "--test-output") == 0) { + const unsigned char bytes[] = {0, '\n', '\r', 0x1a, 0xff}; + FILE *file = output(argv[2], "font-output-test.bin"); + if (fwrite(bytes, 1, sizeof(bytes), file) != sizeof(bytes)) { + fclose(file); + return EXIT_FAILURE; + } + return fclose(file) == 0 ? EXIT_SUCCESS : EXIT_FAILURE; + } if (argc != 3) { fprintf(stderr, "usage: %s FONT_DIR OUTPUT_DIR\n", argv[0]); return EXIT_FAILURE; @@ -152,11 +161,11 @@ int main(int argc, char **argv) { write_pixels(surface, argv[2], id); fprintf(manifest, "%s{\"id\":%d,\"font\":\"%s\",\"point\":%d," - "\"style\":\"%s\",\"mode\":\"%s\",\"sample\":%zu," + "\"style\":\"%s\",\"mode\":\"%s\",\"sample\":%u," "\"text_size\":[%d,%d],\"surface_size\":[%d,%d]," "\"font_metrics\":[%d,%d,%d,%d],\"glyphs\":[", id == 0 ? "" : ",\n", id, fonts[f], points[p], style_names[s], - mode == 0 ? "solid" : "blended", t, width, height, + mode == 0 ? "solid" : "blended", (unsigned)t, width, height, surface->w, surface->h, TTF_GetFontHeight(font), TTF_GetFontAscent(font), TTF_GetFontDescent(font), TTF_GetFontLineSkip(font)); for (size_t g = 0; g < SDL_arraysize(glyphs); g++) { diff --git a/client/tools/tests/test_font_output.py b/client/tools/tests/test_font_output.py new file mode 100644 index 000000000..b6a5f59e4 --- /dev/null +++ b/client/tools/tests/test_font_output.py @@ -0,0 +1,69 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Exercise the probe's real output path on either native CRT.""" +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import subprocess +import sys +import tempfile + + +PAYLOAD = bytes((0, 10, 13, 26, 255)) + + +def invoke(probe: Path, directory: Path) -> int: + result = subprocess.run( + [str(probe), "--test-output", str(directory)], + capture_output=True, timeout=15, check=False, + ) + if result.returncode not in (0, 1): + raise AssertionError(f"unexpected probe exit {result.returncode}: {result.stderr!r}") + return result.returncode + + +def check(probe: Path) -> None: + with tempfile.TemporaryDirectory(prefix="font-output-") as temporary: + root = Path(temporary) + directory = root / "output with spaces" + directory.mkdir() + output = directory / "font-output-test.bin" + assert invoke(probe, directory) == 0, "valid exclusive creation failed" + assert output.read_bytes() == PAYLOAD, "binary output was translated" + output.write_bytes(b"earlier evidence\x00\xff") + assert invoke(probe, directory) == 1, "existing output was accepted" + assert output.read_bytes() == b"earlier evidence\x00\xff", "existing output changed" + + missing = root / "missing" / "directory" + assert invoke(probe, missing) == 1, "missing parent was accepted" + assert not (root / "missing").exists(), "failed open created a parent" + parent_file = root / "regular-file" + parent_file.write_bytes(PAYLOAD) + assert invoke(probe, parent_file) == 1, "regular file parent was accepted" + assert parent_file.read_bytes() == PAYLOAD, "invalid parent changed" + invalid = root / "directory-output" + invalid.mkdir() + (invalid / output.name).mkdir() + assert invoke(probe, invalid) == 1, "directory output was accepted" + assert (invalid / output.name).is_dir(), "directory output changed" + assert invoke(probe, root / ("x" * 4096)) == 1, "path bound was ignored" + + competing = root / "competing" + competing.mkdir() + with ThreadPoolExecutor(max_workers=8) as workers: + results = list(workers.map(lambda _: invoke(probe, competing), range(8))) + assert results.count(0) == 1, f"exclusive creators: {results}" + assert (competing / output.name).read_bytes() == PAYLOAD, "competing creator clobbered output" + assert list(competing.iterdir()) == [competing / output.name], "unexpected failed-open artifact" + print(f"font output regression passed: {probe.name}") + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--cleanup": + with tempfile.TemporaryDirectory(prefix="font-output-cleanup-") as temporary: + subprocess.run([sys.argv[2], temporary], check=True, timeout=15) + assert (Path(temporary) / "font-output-test.bin").read_bytes() == b"", "failed evidence changed" + print("font output stream failure cleanup passed") + elif len(sys.argv) == 2: + check(Path(sys.argv[1]).resolve(strict=True)) + else: + raise SystemExit("usage: test_font_output.py FONT_PROBE") diff --git a/tools/ci/font-render-probe/CMakeLists.txt b/tools/ci/font-render-probe/CMakeLists.txt index 93c56ebd2..dc09ab9c8 100644 --- a/tools/ci/font-render-probe/CMakeLists.txt +++ b/tools/ci/font-render-probe/CMakeLists.txt @@ -10,3 +10,7 @@ add_executable(client-font-render-probe set_target_properties(client-font-render-probe PROPERTIES C_STANDARD 17 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) target_link_libraries(client-font-render-probe PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf) +add_executable(client-font-output-cleanup-tests + ../../../client/src/tests/font_output_cleanup.c) +set_target_properties(client-font-output-cleanup-tests PROPERTIES + C_STANDARD 17 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 9bbce75bd..173484a3c 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -199,6 +199,9 @@ def test_windows_font_baseline_is_separate_and_native_comparison_is_required(sel self.assertIn("font-baseline/client-font-render-probe.exe", native) self.assertIn("font-candidate/client-font-render-probe.exe", native) self.assertIn("compare_font_render.py", native) + self.assertIn('cmake -E copy client/tools/tests/test_font_output.py "${stage}"', build) + self.assertIn('foreach ($producer in @("font-baseline", "font-candidate"))', native) + self.assertIn('throw "Font output regression failed:', native) self.assertIn('throw "LLVM font rendering differs:', native) self.assertIn("$process.WaitForExit(120000)", native) self.assertIn("$process.Kill($true)", native) From ffe1af81eda13c9e2f02071027ccc5c33f1af8e6 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 06:50:40 -0500 Subject: [PATCH 092/148] chore(client): trace Windows close and shutdown stages --- client/src/client/main.c | 9 +++ client/src/client/wrapper.c | 9 +++ client/src/events/event.c | 12 ++++ tools/ci/smoke_windows_review_bundle.ps1 | 64 +++++++++++++++++++ .../test_compose_windows_review_bundle.py | 30 ++++++++- 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/client/src/client/main.c b/client/src/client/main.c index 0993d6d8d..7e35ef92a 100644 --- a/client/src/client/main.c +++ b/client/src/client/main.c @@ -1064,6 +1064,7 @@ int main(int argc, char *argv[]) { int done = 0, update, frames; int old_cursor_x = -1, old_cursor_y = -1; unsigned int gpu_recovery_attempts = 0; + bool gameplay_frame_presented_logged = false; uint32_t anim_tick, frame_start_time, elapsed_time, fps_limit, last_frame_ticks; presentation_clock_t presentation_clock; int fps_limits[] = {30, 60, 120, 0}; @@ -1327,6 +1328,9 @@ int main(int argc, char *argv[]) { uint64_t profile_events_started = render_profiler_begin(); done = Event_PollInputDevice(); + if (done) { + LOG(INFO, "Client quit event polling completed."); + } render_profiler_end(RENDER_PROFILE_EVENTS, profile_events_started); if (done && live_movement_enabled()) { live_movement_abort("window close requested"); @@ -1534,6 +1538,10 @@ int main(int argc, char *argv[]) { } } else { gpu_recovery_attempts = 0; + if (cpl.state == ST_PLAY && !gameplay_frame_presented_logged) { + LOG(INFO, "First gameplay frame presented."); + gameplay_frame_presented_logged = true; + } } } render_profiler_end(RENDER_PROFILE_PRESENT, profile_present_started); @@ -1595,6 +1603,7 @@ int main(int argc, char *argv[]) { } } + LOG(INFO, "Client main loop exited."); video_recording_shutdown(); char recording_notice[4352]; bool recording_failed; diff --git a/client/src/client/wrapper.c b/client/src/client/wrapper.c index 37d62a078..7d212181c 100644 --- a/client/src/client/wrapper.c +++ b/client/src/client/wrapper.c @@ -84,6 +84,7 @@ void system_start(void) { * End the system. */ void system_end(void) { + LOG(INFO, "Client shutdown stage: UI cleanup."); screen_completed_frame_deinit(); rich_presence_deinit(); tooltip_dismiss(); @@ -94,12 +95,16 @@ void system_end(void) { popup_destroy_all(); resources_deinit(); toolkit_widget_deinit(); + LOG(INFO, "Client shutdown stage: network connection."); client_socket_deinitialize(); + LOG(INFO, "Client shutdown stage: directory workers."); metaserver_deinit(); effects_deinit(); sound_ambient_clear(); interface_deinit(); + LOG(INFO, "Client shutdown stage: audio."); sound_deinit(); + LOG(INFO, "Client shutdown stage: settings and assets."); intro_deinit(); cmd_aliases_deinit(); server_settings_deinit(); @@ -108,6 +113,7 @@ void system_end(void) { hfiles_deinit(); settings_deinit(); keybind_deinit(); + LOG(INFO, "Client shutdown stage: image workers."); image_bmaps_deinit(); anims_deinit(); skills_deinit(); @@ -115,9 +121,12 @@ void system_end(void) { connection_preferences_deinit(); clioption_settings_deinit(); server_files_deinit(); + LOG(INFO, "Client shutdown stage: image packs."); image_deinit(); + LOG(INFO, "Client shutdown stage: asset requests."); asset_requests_deinit(); LOG(INFO, "Client shutdown complete."); + LOG(INFO, "Client shutdown stage: toolkit, GPU, window and SDL."); toolkit_deinit(); gpu_renderer_destroy(); SDL_DestroyWindow(ScreenWindow); diff --git a/client/src/events/event.c b/client/src/events/event.c index bad81f83a..2dd30d880 100644 --- a/client/src/events/event.c +++ b/client/src/events/event.c @@ -170,6 +170,7 @@ int Event_PollInputDevice(void) { SDL_Event event; int x = 0, y = 0, done = 0; static Uint32 Ticks = 0; + static bool close_logged = false, quit_logged = false; /* Execute mouse actions, even if mouse button is being held. */ if ((SDL_GetTicks() - Ticks > 125) || !Ticks) { @@ -302,7 +303,18 @@ int Event_PollInputDevice(void) { break; + case SDL_EVENT_WINDOW_CLOSE_REQUESTED: + if (!close_logged) { + LOG(INFO, "Client window close event received."); + close_logged = true; + } + break; + case SDL_EVENT_QUIT: + if (!quit_logged) { + LOG(INFO, "Client quit event received."); + quit_logged = true; + } done = 1; break; diff --git a/tools/ci/smoke_windows_review_bundle.ps1 b/tools/ci/smoke_windows_review_bundle.ps1 index b756bddf8..8d6313b24 100644 --- a/tools/ci/smoke_windows_review_bundle.ps1 +++ b/tools/ci/smoke_windows_review_bundle.ps1 @@ -120,6 +120,68 @@ public static class AtrinikReviewWindowProbe { } } +# Timeout-only observation of the owned main window thread. WM_NULL requests no +# application action; its single bounded probe never changes close acceptance. +function Get-ClientCloseTimeoutSnapshot($Client) { + try { + if (-not ("AtrinikReviewCloseTimeoutProbe" -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +public static class AtrinikReviewCloseTimeoutProbe { + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", ExactSpelling = true)] + private static extern IntPtr SendMessageTimeoutW(IntPtr window, uint message, + UIntPtr wParam, IntPtr lParam, uint flags, uint timeout, out UIntPtr result); + + public static string Snapshot(int processId, IntPtr mainWindow) { + uint owner; + uint mainThread = GetWindowThreadProcessId(mainWindow, out owner); + if (mainWindow == IntPtr.Zero || mainThread == 0 || owner != (uint)processId) + return "owned_main_window_unavailable"; + string state = "unavailable", wait = "unavailable"; + using (Process process = Process.GetProcessById(processId)) { + // A cross-process window cannot share this observer's message queue. + if (process.Id == Process.GetCurrentProcess().Id) return "invalid_observer_pid"; + ProcessThreadCollection threads = process.Threads; + for (int index = 0; index < threads.Count && index < 4096; ++index) { + using (ProcessThread thread = threads[index]) { + if ((uint)thread.Id != mainThread) continue; + try { + System.Diagnostics.ThreadState observed = thread.ThreadState; + state = observed.ToString(); + wait = observed == System.Diagnostics.ThreadState.Wait + ? thread.WaitReason.ToString() : "not_waiting"; + } catch { /* A thread may exit between observations. */ } + break; + } + } + } + // Recheck ownership immediately before the only message probe. + uint currentThread = GetWindowThreadProcessId(mainWindow, out owner); + if (owner != (uint)processId || currentThread != mainThread) + return "owned_main_window_changed"; + UIntPtr result; + // WM_NULL, SMTO_ABORTIFHUNG | SMTO_ERRORONEXIT, hard 250 ms timeout. + bool acknowledged = SendMessageTimeoutW(mainWindow, 0, UIntPtr.Zero, + IntPtr.Zero, 0x0002 | 0x0020, 250, out result) != IntPtr.Zero; + return String.Format("pid={0} main_hwnd={1} thread={2} state={3} wait={4} wm_null_ack={5}", + processId, mainWindow.ToInt64(), mainThread, state, wait, acknowledged); + } +} +'@ -ErrorAction Stop + } + if ($Client.HasExited) { + return "client_exited=True" + } + return [AtrinikReviewCloseTimeoutProbe]::Snapshot($Client.Id, $Client.MainWindowHandle) + } catch { + return "" + } +} + function Write-CloseFailureDiagnostics { try { try { @@ -476,6 +538,8 @@ try { } Write-Host "Client normal close request accepted; waiting up to 30 seconds." if (-not $launcherClient.WaitForExit(30000)) { + Write-Host ("Client close timeout thread: " + + (Get-ClientCloseTimeoutSnapshot $launcherClient)) throw "One-click client did not exit after the normal close request" } $launcherClient.Refresh() diff --git a/tools/tests/test_compose_windows_review_bundle.py b/tools/tests/test_compose_windows_review_bundle.py index f5a9ec16e..28d6c24f8 100644 --- a/tools/tests/test_compose_windows_review_bundle.py +++ b/tools/tests/test_compose_windows_review_bundle.py @@ -299,7 +299,12 @@ def test_close_failure_diagnostics_preserve_shutdown_acceptance(self) -> None: acceptance, containment = close.split("} finally {", 1) self.assertEqual(smoke.count("$launcherClient.CloseMainWindow()"), 1) self.assertRegex(acceptance, r"if \(-not \$launcherClient.CloseMainWindow\(\)\) \{\s*throw ") - self.assertRegex(acceptance, r"if \(-not \$launcherClient.WaitForExit\(30000\)\) \{\s*throw ") + self.assertRegex( + acceptance, + r'if \(-not \$launcherClient.WaitForExit\(30000\)\) \{\s*' + r'Write-Host \("Client close timeout thread: " \+\s*' + r'\(Get-ClientCloseTimeoutSnapshot \$launcherClient\)\)\s*throw ', + ) self.assertIn("$launcherClientExitCode -ne 0", acceptance) self.assertIn('Client shutdown complete', acceptance) self.assertRegex(acceptance, r"} catch \{\s*if \(\$clientCloseStarted\)") @@ -341,7 +346,7 @@ def sanitized(value: str) -> str: def test_window_diagnostics_are_bounded_read_only_and_process_scoped(self) -> None: smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() - probe = smoke.split("function Get-ClientWindowSnapshot", 1)[1].split("function Write-CloseFailureDiagnostics", 1)[0] + probe = smoke.split("function Get-ClientWindowSnapshot", 1)[1].split("function Get-ClientCloseTimeoutSnapshot", 1)[0] self.assertIn("if (++visited > 4096 || rows.Count >= 32) return false;", probe) self.assertLess(probe.index("if (owner != (uint)processId) return true;"), probe.index("GetClassNameW(window")) self.assertIn("new StringBuilder(256)", probe) @@ -351,6 +356,27 @@ def test_window_diagnostics_are_bounded_read_only_and_process_scoped(self) -> No for forbidden in ("GetWindowText", "MainWindowTitle", "SendMessage", "PostMessage", "CloseMainWindow()", "Get-Process", ".Kill("): self.assertNotIn(forbidden, probe) + def test_close_timeout_probe_is_owned_bounded_and_observational(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + probe = smoke.split("function Get-ClientCloseTimeoutSnapshot", 1)[1].split("function Write-CloseFailureDiagnostics", 1)[0] + self.assertIn("mainWindow == IntPtr.Zero || mainThread == 0 || owner != (uint)processId", probe) + self.assertIn("process.Id == Process.GetCurrentProcess().Id", probe) + self.assertIn("index < threads.Count && index < 4096", probe) + self.assertIn("if ((uint)thread.Id != mainThread) continue;", probe) + self.assertIn("observed == System.Diagnostics.ThreadState.Wait", probe) + self.assertIn('thread.WaitReason.ToString() : "not_waiting"', probe) + ownership = probe.index("owner != (uint)processId || currentThread != mainThread") + message = probe.index("bool acknowledged = SendMessageTimeoutW") + self.assertLess(ownership, message) + self.assertIn("SendMessageTimeoutW(mainWindow, 0, UIntPtr.Zero,\n IntPtr.Zero, 0x0002 | 0x0020, 250, out result)", probe) + self.assertEqual(probe.count("SendMessageTimeoutW("), 2) # declaration and one call + self.assertIn("Snapshot($Client.Id, $Client.MainWindowHandle)", probe) + self.assertIn("", probe) + self.assertEqual(smoke.count("(Get-ClientCloseTimeoutSnapshot $launcherClient)"), 1) + for forbidden in ("PostMessage", "CloseMainWindow()", "GetWindowText", "MainWindowTitle", + "Suspend", "Resume", "Terminate", "Dump", "ReadProcessMemory", ".Kill("): + self.assertNotIn(forbidden, probe) + def test_rejects_path_traversal(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 9d6be21885b18311fbd4b59e2c66c77e67cd82d7 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 06:52:38 -0500 Subject: [PATCH 093/148] ci: retain Windows font probe diagnostics per producer --- .github/workflows/check.yml | 62 ++++++++++++++++++++------ tools/tests/test_workflow_contracts.py | 32 +++++++++++++ 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f6abdca34..d8be4af29 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -863,21 +863,57 @@ jobs: (Join-Path $bundle "$producer/client-font-render-probe.exe") if ($LASTEXITCODE -ne 0) { throw "Font output regression failed: $producer" } } - $baseline = Join-Path $bundle "font-baseline-result" - $candidate = Join-Path $bundle "font-candidate-result" - New-Item -ItemType Directory -Path $baseline, $candidate | Out-Null - function Invoke-FontProbe($executable, $output) { - $process = Start-Process -FilePath $executable -PassThru -NoNewWindow ` - -ArgumentList @("`"$fonts`"", "`"$output`"") - if (-not $process.WaitForExit(120000)) { - $process.Kill($true) - $process.WaitForExit() - throw "Font probe exceeded its 120-second deadline" + $baselineResult = Join-Path $bundle "font-baseline-result" + $candidateResult = Join-Path $bundle "font-candidate-result" + foreach ($result in @($baselineResult, $candidateResult)) { + if (Test-Path -LiteralPath $result) { throw "Font result directory already exists: $result" } + New-Item -ItemType Directory -Path $result | Out-Null + } + $baseline = Join-Path $baselineResult "render" + $candidate = Join-Path $candidateResult "render" + function Invoke-FontProbe($producer, $executable, $result) { + if ((Get-ChildItem -LiteralPath $result -Force | Measure-Object).Count -ne 0) { + throw "Font result directory is not empty: $producer" + } + $output = Join-Path $result "render" + New-Item -ItemType Directory -Path $output | Out-Null + $stdout = Join-Path $result "probe.stdout.log" + $stderr = Join-Path $result "probe.stderr.log" + $process = $null + Write-Host "Font probe start: producer=$producer executable=$executable output=$output" + try { + $process = Start-Process -FilePath $executable -PassThru -NoNewWindow ` + -ArgumentList @("`"$fonts`"", "`"$output`"") ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Write-Host "Font probe launched: producer=$producer native_pid=$($process.Id)" + if (-not $process.WaitForExit(120000)) { + $process.Kill($true) + $process.WaitForExit() + throw "Font probe exceeded its 120-second deadline" + } + if ($process.ExitCode -ne 0) { throw "Font probe failed: $($process.ExitCode)" } + } finally { + $nativePid = if ($null -ne $process) { $process.Id } else { "not-started" } + $exitCode = if ($null -eq $process) { "not-started" } elseif ($process.HasExited) { $process.ExitCode } else { "running" } + Write-Host "Font probe finish: producer=$producer native_pid=$nativePid exit_code=$exitCode" + foreach ($stream in @("stdout", "stderr")) { + $log = Join-Path $result "probe.$stream.log" + Write-Host "Font probe log tail: producer=$producer stream=$stream path=$log" + if (Test-Path -LiteralPath $log) { + try { + Get-Content -LiteralPath $log -Tail 40 | ForEach-Object { + $line = if ($_.Length -gt 1024) { $_.Substring(0, 1024) + " [truncated]" } else { $_ } + Write-Host "[$producer/$stream] $line" + } + } catch { + Write-Warning "Cannot read font probe log tail: producer=$producer stream=$stream error=$_" + } + } + } } - if ($process.ExitCode -ne 0) { throw "Font probe failed: $($process.ExitCode)" } } - Invoke-FontProbe (Join-Path $bundle "font-baseline/client-font-render-probe.exe") $baseline - Invoke-FontProbe (Join-Path $bundle "font-candidate/client-font-render-probe.exe") $candidate + Invoke-FontProbe "baseline" (Join-Path $bundle "font-baseline/client-font-render-probe.exe") $baselineResult + Invoke-FontProbe "candidate" (Join-Path $bundle "font-candidate/client-font-render-probe.exe") $candidateResult python (Join-Path $bundle "compare_font_render.py") $baseline $candidate if ($LASTEXITCODE -ne 0) { throw "LLVM font rendering differs: $LASTEXITCODE" } - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 173484a3c..d6ed02d7c 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -210,6 +210,38 @@ def test_windows_font_baseline_is_separate_and_native_comparison_is_required(sel self.assertNotIn("continue-on-error", comparison) self.assertIn("if: always()", comparison) + def test_windows_font_probe_logs_preserve_strict_comparison_and_failure_evidence(self) -> None: + comparison = self.text("check.yml").split( + " - name: Require identical baseline and LLVM font rendering\n", 1)[1].split( + " server:\n name: Server validation", 1)[0] + capture = comparison.split(" function Invoke-FontProbe(", 1)[1].split( + ' Invoke-FontProbe "baseline"', 1)[0] + self.assertLess(capture.index("Font result directory is not empty:"), + capture.index("Start-Process -FilePath")) + self.assertIn("Font result directory already exists:", comparison) + self.assertNotIn("New-Item -ItemType Directory -Force", comparison) + self.assertIn('$output = Join-Path $result "render"', capture) + self.assertIn('-RedirectStandardOutput $stdout -RedirectStandardError $stderr', capture) + self.assertIn('$stdout = Join-Path $result "probe.stdout.log"', capture) + self.assertIn('$stderr = Join-Path $result "probe.stderr.log"', capture) + self.assertIn('Font probe start: producer=$producer executable=$executable', capture) + self.assertIn('Font probe launched: producer=$producer native_pid=$($process.Id)', capture) + self.assertIn('} finally {', capture) + self.assertIn('Font probe finish: producer=$producer native_pid=$nativePid exit_code=$exitCode', capture) + self.assertIn('Get-Content -LiteralPath $log -Tail 40', capture) + self.assertIn('$_.Length -gt 1024', capture) + self.assertIn('Write-Warning "Cannot read font probe log tail:', capture) + self.assertNotIn('ReadToEnd', capture) + self.assertNotIn('2>&1', capture) + self.assertIn('$baseline = Join-Path $baselineResult "render"', comparison) + self.assertIn('$candidate = Join-Path $candidateResult "render"', comparison) + self.assertIn('client-font-render-probe.exe") $baselineResult', comparison) + self.assertIn('client-font-render-probe.exe") $candidateResult', comparison) + self.assertIn('"compare_font_render.py") $baseline $candidate', comparison) + self.assertIn('build/native-windows-tests/font-baseline-result', comparison) + self.assertIn('build/native-windows-tests/font-candidate-result', comparison) + self.assertIn('if: always()', comparison) + def test_native_version_package_acceptance_runs_in_pinned_core_container(self) -> None: core = self.text("check.yml").split(" core:\n", 1)[1].split(" windows-test-build:\n", 1)[0] self.assertIn("CI= python3 -m coverage run --append", core) From e7b80b69400fcbeabd94b0e89e6f5eb1f8ff122d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 07:23:33 -0500 Subject: [PATCH 094/148] ci: retain bounded Windows client lifecycle evidence --- .github/workflows/check.yml | 1 + tools/ci/smoke_windows_review_bundle.ps1 | 39 +++++++++++++ .../test_compose_windows_review_bundle.py | 58 +++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d8be4af29..ed57681bf 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -921,6 +921,7 @@ jobs: with: name: native-windows-font-comparison-${{ env.COVERAGE_SHA }}-${{ github.run_attempt }} path: | + build/native-windows-tests/review-client-lifecycle.log build/native-windows-tests/font-baseline-result build/native-windows-tests/font-candidate-result build/native-windows-tests/font-baseline/staging-helper.sha256 diff --git a/tools/ci/smoke_windows_review_bundle.ps1 b/tools/ci/smoke_windows_review_bundle.ps1 index 8d6313b24..57f5bf96a 100644 --- a/tools/ci/smoke_windows_review_bundle.ps1 +++ b/tools/ci/smoke_windows_review_bundle.ps1 @@ -17,6 +17,7 @@ $launcherOutputTask = $null $launcherErrorTask = $null $launcherServer = $null $launcherClient = $null +$launcherClientLog = $null $bodySucceeded = $false $clientCloseStarted = $false $portProbe = [System.Net.Sockets.UdpClient]::new(0) @@ -60,6 +61,43 @@ function Get-LauncherLogTail([string]$Path) { } } +# Retain only fixed lifecycle messages from the actual launcher log before its +# temporary directory is removed. The closed grammar excludes private payloads. +function Save-ClientLifecycleEvidence([string]$Path) { + try { + $lines = @("") + if ($Path -and (Test-Path -LiteralPath $Path -PathType Leaf)) { + # Bound the read as well as the retained output. Oversize evidence + # remains unavailable; it must never change smoke acceptance. + $reader = [System.IO.StreamReader]::new([System.IO.File]::Open( + $Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite)) + try { + $buffer = [char[]]::new(16777217) + $count = $reader.ReadBlock($buffer, 0, $buffer.Length) + } finally { + $reader.Dispose() + } + if ($count -le 16777216) { + $text = [string]::new($buffer, 0, $count) + $pattern = "(?m)^\[[0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}\] INFO[ ]+\[(?:socket_command_player|main|Event_PollInputDevice|system_end):[0-9]{1,10}\] (?:Gameplay ready\.|First gameplay frame presented\.|Client (?:window close event received|quit event received|quit event polling completed|main loop exited|shutdown complete)\.|Client shutdown stage: (?:UI cleanup|network connection|directory workers|audio|settings and assets|image workers|image packs|asset requests|toolkit, GPU, window and SDL)\.)\r?$" + $lines = @([regex]::Matches($text, $pattern) | Select-Object -First 32 | + ForEach-Object { $_.Value.TrimEnd([char]13) }) + if ($lines.Count -eq 0) { $lines = @("") } + } else { + $lines = @("") + } + } + $evidence = Join-Path (Split-Path -Parent $packagePath) "review-client-lifecycle.log" + @("revision=$Revision") + $lines | Set-Content -LiteralPath $evidence -Encoding utf8 -ErrorAction Stop + Write-Host ("Client lifecycle evidence:" + [System.Environment]::NewLine + + ($lines -join [System.Environment]::NewLine)) + } catch { + # Evidence is best effort and cannot replace an acceptance failure. + Write-Warning "Client lifecycle evidence could not be retained" + } +} + # Read-only HWND evidence: never read titles or send messages from this probe. # CloseMainWindow below remains the single normal close request. function Get-ClientWindowSnapshot($Client) { @@ -583,6 +621,7 @@ try { } throw } finally { + Save-ClientLifecycleEvidence $launcherClientLog $cleanupFailures = [System.Collections.Generic.List[string]]::new() if ($null -ne $process) { try { diff --git a/tools/tests/test_compose_windows_review_bundle.py b/tools/tests/test_compose_windows_review_bundle.py index 28d6c24f8..19a741daf 100644 --- a/tools/tests/test_compose_windows_review_bundle.py +++ b/tools/tests/test_compose_windows_review_bundle.py @@ -377,6 +377,64 @@ def test_close_timeout_probe_is_owned_bounded_and_observational(self) -> None: "Suspend", "Resume", "Terminate", "Dump", "ReadProcessMemory", ".Kill("): self.assertNotIn(forbidden, probe) + def test_lifecycle_evidence_is_bounded_and_uploaded_before_cleanup(self) -> None: + root = MODULE_PATH.parents[2] + smoke = (root / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + helper = smoke.split("function Save-ClientLifecycleEvidence", 1)[1].split("# Read-only HWND evidence:", 1)[0] + self.assertIn("[System.IO.FileAccess]::Read", helper) + self.assertIn("[System.IO.FileShare]::ReadWrite", helper) + self.assertIn("[char[]]::new(16777217)", helper) + self.assertIn("$reader.ReadBlock($buffer, 0, $buffer.Length)", helper) + self.assertIn("if ($count -le 16777216)", helper) + self.assertIn("$reader.Dispose()", helper) + self.assertIn("Select-Object -First 32", helper) + self.assertIn('Join-Path (Split-Path -Parent $packagePath) "review-client-lifecycle.log"', helper) + self.assertIn('Write-Warning "Client lifecycle evidence could not be retained"', helper) + self.assertNotIn("throw", helper) + self.assertIn('$launcherClientLog = Join-Path $reviewRoot "client.log"', smoke) + cleanup = smoke.split("} finally {\n Save-ClientLifecycleEvidence $launcherClientLog", 1)[1] + self.assertIn("Remove-Item -LiteralPath $smokeRoot", cleanup) + self.assertEqual(smoke.count("Save-ClientLifecycleEvidence $launcherClientLog"), 1) + workflow = (root / ".github/workflows/check.yml").read_text() + upload = workflow.split("name: native-windows-font-comparison-", 1)[1].split(" server:", 1)[0] + self.assertIn("build/native-windows-tests/review-client-lifecycle.log", upload) + self.assertIn("if: always()", workflow.split("name: native-windows-font-comparison-", 1)[0].rsplit("- uses:", 1)[1]) + + def test_lifecycle_evidence_keeps_only_fixed_raw_stage_lines(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + pattern = re.search(r'\$pattern = "([^"]+)"', smoke).group(1) + stamp = "[2026/10/11 11:36:14.936572] INFO " + messages = ( + ("socket_command_player", "Gameplay ready."), + ("main", "First gameplay frame presented."), + ("Event_PollInputDevice", "Client window close event received."), + ("Event_PollInputDevice", "Client quit event received."), + ("main", "Client quit event polling completed."), + ("main", "Client main loop exited."), + ("system_end", "Client shutdown complete."), + ) + tuple(("system_end", "Client shutdown stage: " + stage + ".") for stage in ( + "UI cleanup", "network connection", "directory workers", "audio", + "settings and assets", "image workers", "image packs", "asset requests", + "toolkit, GPU, window and SDL", + )) + expected = [stamp + "[" + function + ":123] " + message for function, message in messages] + rejected = [ + stamp + "[main:123] password=private-value", + expected[0] + " token=private-value", + "private-value " + expected[0], + expected[0].replace("INFO", "\x1bINFO"), + expected[0].replace("socket_command_player", "private_value"), + stamp + "[system_end:123] Client shutdown stage: private-value.", + stamp + "[main:123] https://example.invalid/private-value", + ] + for newline in ("\n", "\r\n"): + log = newline.join(rejected + expected) + newline + selected = [match.group(0).rstrip("\r") for match in re.finditer(pattern, log)][:32] + self.assertEqual(selected, expected) + self.assertNotIn("private-value", "\n".join(selected)) + repeated = "\n".join(expected * 4) + self.assertEqual(len(list(re.finditer(pattern, repeated))[:32]), 32) + def test_rejects_path_traversal(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 2e1432d2b2ba952de22646d6bb13f77799058d2c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 11:24:25 -0500 Subject: [PATCH 095/148] build(ci): pin the released LLVM Classic Linux SDK --- .github/workflows/build-release-candidate.yml | 2 +- .github/workflows/check.yml | 4 ++-- .github/workflows/daily-client-performance.yml | 4 ++-- .github/workflows/gpu-qualification.yml | 2 +- .github/workflows/package-release.yml | 2 +- .github/workflows/pr-benchmarks.yml | 4 ++-- tools/ci/appimage/packaging.lock.json | 1 + tools/tests/test_workflow_contracts.py | 11 +++++++---- 8 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index e047c035d..153c4e922 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -50,7 +50,7 @@ permissions: packages: read env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-1d82af6036b831d97cc470e2230357047e4c78f8-914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 concurrency: diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ed57681bf..de1166262 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -31,8 +31,8 @@ concurrency: env: COVERAGE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 - CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 + CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 CLASSIC_GPU_COVERAGE_IMAGE: ghcr.io/atrinik/linux-build:1.16.0@sha256:89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e # Historical baseline for the paired native Windows font comparison. CLASSIC_WINDOWS_FONT_BASELINE_IMAGE: ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 diff --git a/.github/workflows/daily-client-performance.yml b/.github/workflows/daily-client-performance.yml index db3e3b7d7..f5522eff1 100644 --- a/.github/workflows/daily-client-performance.yml +++ b/.github/workflows/daily-client-performance.yml @@ -22,8 +22,8 @@ concurrency: cancel-in-progress: false env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 - CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 + CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt CLASSIC_LINUX_CCACHE_EPOCH: "2" FINAL_BENCHMARK_DATA_COMMIT: bab40ecefefa5b6052d42eab6390c504b9482e81 diff --git a/.github/workflows/gpu-qualification.yml b/.github/workflows/gpu-qualification.yml index dec3cdecf..9425f5eef 100644 --- a/.github/workflows/gpu-qualification.yml +++ b/.github/workflows/gpu-qualification.yml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: false env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 CLASSIC_LINUX_RUNTIME_IMAGE: ghcr.io/atrinik/linux-build:1.16.0@sha256:89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e CLASSIC_WINDOWS_IMAGE: ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 4981340ba..476b2d5f8 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -25,7 +25,7 @@ permissions: packages: read env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 concurrency: group: classic-release-publication diff --git a/.github/workflows/pr-benchmarks.yml b/.github/workflows/pr-benchmarks.yml index d8809eb5b..7260b82bd 100644 --- a/.github/workflows/pr-benchmarks.yml +++ b/.github/workflows/pr-benchmarks.yml @@ -13,8 +13,8 @@ concurrency: cancel-in-progress: true env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 - CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 + CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt CLASSIC_LINUX_CCACHE_EPOCH: "2" diff --git a/tools/ci/appimage/packaging.lock.json b/tools/ci/appimage/packaging.lock.json index afc3f827b..d98be3118 100644 --- a/tools/ci/appimage/packaging.lock.json +++ b/tools/ci/appimage/packaging.lock.json @@ -2275,6 +2275,7 @@ }, "compiler": { "family": "Clang", + "image": "ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1", "version": "23.1.3", "prefix": "/opt/llvm-23.1.3", "source": { diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index d6ed02d7c..ea29127cd 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -24,13 +24,16 @@ def text(self, name: str) -> str: return (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") def test_final_published_provider_pins_and_installed_library_lane(self) -> None: - classic = "ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2" - for name in ("check.yml", "pr-benchmarks.yml", "daily-client-performance.yml", "build-release-candidate.yml"): + classic = "ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1" + for name in ("check.yml", "pr-benchmarks.yml", "daily-client-performance.yml", + "build-release-candidate.yml", "package-release.yml", "gpu-qualification.yml"): with self.subTest(workflow=name): workflow = self.text(name) self.assertIn(f"CLASSIC_LINUX_IMAGE: {classic}", workflow) - if name != "build-release-candidate.yml": - self.assertIn("CLASSIC_LINUX_IMAGE_DIGEST: sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2", workflow) + if name in ("check.yml", "pr-benchmarks.yml", "daily-client-performance.yml"): + self.assertIn("CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1", workflow) + compiler = json.loads((ROOT / "tools/ci/appimage/packaging.lock.json").read_text())["compiler"] + self.assertEqual(compiler["image"], classic) candidate = self.text("build-release-candidate.yml") sources = candidate[candidate.index(" sources:"):candidate.index(" client-linux:")] lane = sources[sources.index(" - name: Pull the immutable installed-library"):] From 41ccb8623224b1b52d6bc018f80e519b15cfc5f7 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 11:32:26 -0500 Subject: [PATCH 096/148] build(server): bind runtime providers to the released LLVM SDK --- docs/CARES_PROVIDER.md | 21 ++++++++++---- server/Dockerfile | 2 +- server/docker/runtime-provider.lock.json | 31 +++++++++++---------- tools/tests/test_server_runtime_provider.py | 2 ++ 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/docs/CARES_PROVIDER.md b/docs/CARES_PROVIDER.md index eeb526692..50d50c10b 100644 --- a/docs/CARES_PROVIDER.md +++ b/docs/CARES_PROVIDER.md @@ -1,9 +1,19 @@ # Qualified c-ares providers -The standalone server compiler stage uses the published -`ghcr.io/atrinik/classic-build:1.16.0` index +The standalone server compiler stage uses the released LLVM 23.1.3 SDK +`ghcr.io/atrinik/classic-build:1.17.0` index +`sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1`, +Linux amd64 platform +`sha256:67a0b1243b0cf860f72e39623a6ea92414c2531677bc55a01b3f2f62c57de93d`, +from producer source `0571f500ede75e7661b508d8bb40354256113223`. +Its measured curl/c-ares payload and actual consumer loader paths match the +current runtime provider lock. All 86 locked distro package versions and their +recorded dependency expressions match this SDK; the runtime base and snapshot +retain their existing qualification. + +Historical qualification used `ghcr.io/atrinik/classic-build:1.16.0` index `sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2`. -Its qualified Linux amd64 platform is +That qualified Linux amd64 platform is `sha256:66a637c76f07d32ff933886a96bc5bd720fc695cf20a4acb9c1710983fdb7478`, from producer source `4be36a1f1eebb667aad77c227f7855a4a656d150`. This supplies curl 8.18.0, c-ares 1.34.6 and OpenSSL 3.5.5. The resolver contract @@ -42,8 +52,9 @@ locked package set. The complete 86-package closure additionally locks the base' from the same published producer. Its recorded package inventory receipt is `source_4be36_base_family_closure` in the lock. Every selected package version must satisfy its recorded dependency expression, including util-linux's exact -`libuuid1`, `libblkid1`, `libmount1`, and `libsmartcols1` relations. The qualified -application package versions and copied provider bytes remain unchanged. +`libuuid1`, `libblkid1`, `libmount1`, and `libsmartcols1` relations. The current LLVM SDK retains these qualified application package versions. +Its rebuilt curl/c-ares payload hashes and build records are measured separately +in the current lock; the named historical closure receipts remain preserved. The application closure includes indirect GD image/X11 libraries, Python FFI and compression libraries, and `openssl-provider-legacy` because the actual `libssl3t64` package declares that dependency. This is package dependency closure, diff --git a/server/Dockerfile b/server/Dockerfile index 87c3a471b..8c4b7b9a8 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 -FROM ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2 AS build +FROM ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 AS build # The qualified build image defaults to an unprivileged compiler user. USER root diff --git a/server/docker/runtime-provider.lock.json b/server/docker/runtime-provider.lock.json index 83c0158a2..166ef9fe0 100644 --- a/server/docker/runtime-provider.lock.json +++ b/server/docker/runtime-provider.lock.json @@ -1,16 +1,17 @@ { "schema_version": 1, - "build_image": "ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2", - "platform_digest": "sha256:66a637c76f07d32ff933886a96bc5bd720fc695cf20a4acb9c1710983fdb7478", - "producer_source": "4be36a1f1eebb667aad77c227f7855a4a656d150", + "build_image": "ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1", + "platform_digest": "sha256:67a0b1243b0cf860f72e39623a6ea92414c2531677bc55a01b3f2f62c57de93d", + "producer_source": "0571f500ede75e7661b508d8bb40354256113223", "snapshot": "20260810T000000Z", "openssl_version": "3.5.5", "evidence_sha256": { - "payload": "c5e71a9bf7e8f80fa80887d36057ad59942c40a85b8b122d18b450964e61e89b", - "packages": "e8e8121787389444029abc0e2f9225e7456ce11e74f3908f615f900da3677fd2", - "runtime_closure": "6739957d17e0fe6e780c4daaccb1d2d82422833c603d219aace58ed62f59c10f", + "payload": "6ba5d89f9c0246683f173c41e17255712ab3591aa3c97bcf764ddb319e885aa6", + "packages": "37763a219bc25c8b263d5e49a1732627ea615b3f6bebea30c67abc38cc3cef73", + "runtime_closure": "4ff80feae2bd437a77444436eca419b4f2b21f12276e150fa63d86a047c996f2", "source_9136_server_python_closure": "b70a977a3ffaf4c2d3d3fefd9a6e4a1cff4ee63692024a68418c5d6c22dfcd3b", - "source_4be36_base_family_closure": "7c6d141dd048015fd4a07c68adbb78e30d057305ff40a5c6c0acc5b6cd814d51" + "source_4be36_base_family_closure": "7c6d141dd048015fd4a07c68adbb78e30d057305ff40a5c6c0acc5b6cd814d51", + "source_0571_package_dependency_fields": "01b93feedb60e771ad8350535514abd267ad4d2b9c6488e40d5777599373d4c6" }, "files": [ { @@ -26,8 +27,8 @@ { "path": "/usr/local/lib/libcares.so.2.19.5", "mode": "0o644", - "bytes": 332496, - "sha256": "94663719ed375379fb7a92d3d94d6e46e5775c866c3ee8231cef0cbef625925a" + "bytes": 287168, + "sha256": "3f4a9602779f197eea06c509136d9da54a3c9bb33527975b493cc94196fa1aaf" }, { "path": "/usr/local/lib/libcurl.so", @@ -42,20 +43,20 @@ { "path": "/usr/local/lib/libcurl.so.4.8.0", "mode": "0o755", - "bytes": 1011192, - "sha256": "e57453fa28bcd39a4fd6b40d98bccecacc27393ff96057d7cdbb75941733247a" + "bytes": 935376, + "sha256": "dd2852e0a1ccdbdef215b8dce5608b4e01ba440890babef1caeab91b671f126f" }, { "path": "/usr/local/share/atrinik/classic-curl-toolchain.json", "mode": "0o644", - "bytes": 1750, - "sha256": "64cdcc72137713828229dd5cc1d2a89cdf96c2a9beeb7738abd2a644216ee3b5" + "bytes": 1892, + "sha256": "de7c36e5ff2c181984bb934d8194154d80672128126f8af38d8c7ee70a2fa6b8" }, { "path": "/usr/local/share/atrinik/curl/config.log", "mode": "0o644", - "bytes": 280986, - "sha256": "2db9c573eb0af9925327d2ce5f6efc53c0a33ac71721ab2bc3640979b3d9bde0" + "bytes": 280315, + "sha256": "652cdc5cc17586508fdc8ba05f13ad3dffcc08714cf589f1a6cd1a15bd98fdb6" }, { "path": "/usr/local/share/atrinik/curl/curl_config.h", diff --git a/tools/tests/test_server_runtime_provider.py b/tools/tests/test_server_runtime_provider.py index 516a986c5..1f9fd3fbf 100644 --- a/tools/tests/test_server_runtime_provider.py +++ b/tools/tests/test_server_runtime_provider.py @@ -328,6 +328,8 @@ def test_docker_apt_install_failure_propagates(self): def test_docker_uses_pinned_images_and_checks_without_tls_bypass(self): docker = (ROOT / "server/Dockerfile").read_text() self.assertIn("FROM " + LOCK["build_image"] + " AS build", docker) + compiler = json.loads((ROOT / "tools/ci/appimage/packaging.lock.json").read_text())["compiler"] + self.assertEqual(LOCK["build_image"], compiler["image"]) # Dependabot owns the runtime image pin in Dockerfile. Validate an # immutable Ubuntu reference without duplicating its current digest. self.assertRegex(docker, r"(?m)^FROM ubuntu:[^\s@]+@sha256:[0-9a-f]{64}$") From a037263190e282586012151b62b5a2e0168be52e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 12:56:29 -0500 Subject: [PATCH 097/148] fix(ci): quote the empty tooling test CI assignment --- .github/workflows/check.yml | 2 +- tools/tests/test_workflow_contracts.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index de1166262..7e0bd38c9 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -226,7 +226,7 @@ jobs: tools/verify_import_history.py \ --release-history-ref "${release_history_ref}" # Real native package acceptance runs separately in the pinned image. - CI= python3 -m coverage run --append --branch --source=tools \ + CI='' python3 -m coverage run --append --branch --source=tools \ -m unittest discover -s tools/tests -p 'test_*.py' python3 -m coverage run --append --branch --source=tools \ -m unittest discover -s tools/ci/appimage -p 'test_*.py' diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index ea29127cd..6ea92b608 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -247,7 +247,7 @@ def test_windows_font_probe_logs_preserve_strict_comparison_and_failure_evidence def test_native_version_package_acceptance_runs_in_pinned_core_container(self) -> None: core = self.text("check.yml").split(" core:\n", 1)[1].split(" windows-test-build:\n", 1)[0] - self.assertIn("CI= python3 -m coverage run --append", core) + self.assertIn("CI='' python3 -m coverage run --append", core) native = core.split(" - name: Build and test core packages in the pinned environment", 1)[1].split(" - uses:", 1)[0] self.assertIn('"${CLASSIC_LINUX_IMAGE}"', native) self.assertIn("CI=1 python3 -m unittest -v", native) From c2a27714e17a9ee675fb649e9e5e2377c9691456 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:01:53 -0500 Subject: [PATCH 098/148] build(ci): pin broad Linux GPU SDK to released 1.17 --- .github/workflows/check.yml | 2 +- .github/workflows/gpu-qualification.yml | 2 +- tools/tests/test_workflow_contracts.py | 10 +++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7e0bd38c9..f6e17fb28 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -33,7 +33,7 @@ env: COVERAGE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 - CLASSIC_GPU_COVERAGE_IMAGE: ghcr.io/atrinik/linux-build:1.16.0@sha256:89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e + CLASSIC_GPU_COVERAGE_IMAGE: ghcr.io/atrinik/linux-build:1.17.0@sha256:4ca8e921b616d20b18fcef901bd554488b4d5a68a7c4304268dedef23723a3bf # Historical baseline for the paired native Windows font comparison. CLASSIC_WINDOWS_FONT_BASELINE_IMAGE: ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt diff --git a/.github/workflows/gpu-qualification.yml b/.github/workflows/gpu-qualification.yml index 9425f5eef..4be23ef9b 100644 --- a/.github/workflows/gpu-qualification.yml +++ b/.github/workflows/gpu-qualification.yml @@ -13,7 +13,7 @@ concurrency: env: CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 - CLASSIC_LINUX_RUNTIME_IMAGE: ghcr.io/atrinik/linux-build:1.16.0@sha256:89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e + CLASSIC_LINUX_RUNTIME_IMAGE: ghcr.io/atrinik/linux-build:1.17.0@sha256:4ca8e921b616d20b18fcef901bd554488b4d5a68a7c4304268dedef23723a3bf CLASSIC_WINDOWS_IMAGE: ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 jobs: diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 6ea92b608..58a0ef415 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -2404,10 +2404,14 @@ def test_linux_checks_pin_image_and_isolate_compiler_caches(self) -> None: self.assertIn("--network none", gpu_coverage) self.assertIn("tools/ci/run_gpu_coverage.sh", client) self.assertIn("tools/ci/run_gpu_coverage.sh", gpu_coverage_job) + broad_image = ( + "ghcr.io/atrinik/linux-build:1.17.0@sha256:" + "4ca8e921b616d20b18fcef901bd554488b4d5a68a7c4304268dedef23723a3bf" + ) + self.assertIn(f"CLASSIC_GPU_COVERAGE_IMAGE: {broad_image}", workflow) self.assertIn( - "ghcr.io/atrinik/linux-build:1.16.0@sha256:" - "89fae13a18407d2341aea4199ab0d44ab319efb8138cb95da1b221e6748faa7e", - workflow, + f"CLASSIC_LINUX_RUNTIME_IMAGE: {broad_image}", + self.text("gpu-qualification.yml"), ) measurement = (ROOT / "tools" / "ci" / "measure_linux_image.sh").read_text( From 58e7b21d0f4b5b68c2e83ea13566cb31dc8c2d5c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:30:17 -0500 Subject: [PATCH 099/148] ci(server): qualify final LLVM server image without publishing --- .github/workflows/qualify-server-image.yml | 110 +++++++++ docs/RELEASING.md | 44 ++++ tools/ci/qualify_server_image.py | 252 +++++++++++++++++++++ tools/tests/test_qualify_server_image.py | 186 +++++++++++++++ 4 files changed, 592 insertions(+) create mode 100644 .github/workflows/qualify-server-image.yml create mode 100644 tools/ci/qualify_server_image.py create mode 100644 tools/tests/test_qualify_server_image.py diff --git a/.github/workflows/qualify-server-image.yml b/.github/workflows/qualify-server-image.yml new file mode 100644 index 000000000..a544681eb --- /dev/null +++ b/.github/workflows/qualify-server-image.yml @@ -0,0 +1,110 @@ +name: Qualify Server Image + +# Trusted branch bootstrap and maintainer dispatch; no publication or aliases. +on: + push: + branches: [build/llvm-classic-server-image-qualification] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: classic-server-image-qualification-${{ github.run_id }} + cancel-in-progress: false + +jobs: + qualify: + name: Qualify final Classic server container + if: github.repository == 'atrinik/classic' && github.ref_type == 'branch' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: read + attestations: read + packages: read + env: + SOURCE_REVISION: ${{ github.sha }} + DOCKER_BUILD_SUMMARY: true + DOCKER_BUILD_RECORD_UPLOAD: true + DOCKER_BUILD_RECORD_RETENTION_DAYS: 14 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Bind source and compiler input + run: | + test "$(git rev-parse HEAD)" = "${SOURCE_REVISION}" + test -z "$(git status --porcelain)" + grep -Fx 'FROM ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 AS build' server/Dockerfile + mkdir -p build/server-image-evidence + git show -s --format=fuller HEAD > build/server-image-evidence/source.txt + docker version > build/server-image-evidence/docker-version.txt + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Acquire and verify durable dependency inputs + env: + GH_TOKEN: ${{ github.token }} + run: tools/release/install_dependency_bundle.sh + - id: inputs + name: Record locked input labels + run: >- + python3 tools/release/locked_inputs.py --version 0.0.0 + --component server --github-output "${GITHUB_OUTPUT}" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 + - id: image + name: Build and load the actual final server image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 + with: + context: . + file: server/Dockerfile + platforms: linux/amd64 + build-args: | + ATRINIK_PACKAGE_VERSION=0.0.0 + ATRINIK_SOURCE_REVISION=${{ github.sha }} + ATRINIK_SOURCE_DIRTY=false + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=https://github.com/atrinik/classic + org.opencontainers.image.version=0.0.0 + ${{ steps.inputs.outputs.labels }} + load: true + push: false + # Docker's local image store does not retain OCI attestations. This + # run keeps build records and runtime evidence, not release provenance. + provenance: false + sbom: false + tags: classic-server-qualification:${{ github.run_id }}-${{ github.run_attempt }} + - name: Retain build identity + env: + IMAGE_ID: ${{ steps.image.outputs.imageid }} + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + BUILD_METADATA: ${{ steps.image.outputs.metadata }} + run: | + python3 - <<'PY' + import json, os + from pathlib import Path + Path('build/server-image-evidence/build.json').write_text(json.dumps({ + 'source_revision': os.environ['SOURCE_REVISION'], + 'image_id': os.environ['IMAGE_ID'], 'digest': os.environ['IMAGE_DIGEST'], + 'metadata': json.loads(os.environ['BUILD_METADATA']), + }, indent=2) + '\n') + PY + - name: Qualify final image behavior and lifecycle + env: + IMAGE_ID: ${{ steps.image.outputs.imageid }} + run: >- + python3 tools/ci/qualify_server_image.py --image-id "${IMAGE_ID}" + --revision "${SOURCE_REVISION}" --output build/server-image-evidence/runtime + - name: Retain bounded qualification evidence, including failures + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: server-image-qualification-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} + path: build/server-image-evidence/ + if-no-files-found: error + retention-days: 14 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b92bc8919..798cb71a4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -725,6 +725,50 @@ gh attestation verify \ --repo atrinik/classic ``` +### Hosted final server-image qualification + +`Qualify Server Image` is a bounded, non-publishing hosted Buildx path for the +actual root-context `server/Dockerfile`. Maintainers can dispatch it on a reviewed +branch. The explicit `build/llvm-classic-server-image-qualification` push trigger +bootstraps pre-merge qualification before the workflow is available for manual +dispatch on the default branch. The checkout, compiler pin and retained evidence +bind to the event's exact commit; this job cannot publish an image or update a +release/development alias. + +The job verifies the attested, digest-pinned durable dependency bundle and loads +the final Linux amd64 image with `push: false` and `load: true`. The Dockerfile +continues to verify cached source archives and configure offline. Its final-stage +APT install still needs the signed, locked Ubuntu snapshot; dependency-bundle +verification does not claim that the entire Docker build is network-free. + +`tools/ci/qualify_server_image.py` consumes the build's immutable local image ID. +It uses UUID-labelled containers, the image's default UID 10001, no host mounts, +no published ports, no external network, dropped capabilities, bounded resources +and disposable container state. It exercises the existing offline benchmark on +the packaged `/shattered_islands/world_0_70` map for one iteration, requiring the +server's load/warm-lookup/swap/reload identity checks and exact completion record. +A separate normal-entrypoint container must pass runtime-provider verification, +reach readiness and Docker health, produce a fresh heartbeat, exit zero after +SIGTERM with the checked save-completion message, then repeat startup and shutdown +using its retained disposable state. Restart log and heartbeat evidence must +belong to the new start. Every cleanup rechecks container ID, image, source labels +and ownership; missing cleanup or any missing behavioral evidence fails the job. + +The 14-day `server-image-qualification-COMMIT-RUN-ATTEMPT` artifact contains source, +Docker/build identity, SDK and dependency/provider hashes, packaged executable and +map hashes, bounded runtime logs, behavior results and cleanup receipts. Buildx +also retains its build record for 14 days. Inspect both the successful job and +`runtime/qualification.json` for the exact reviewed commit. A failed or timed-out +run is incomplete acceptance, even when some artifacts were uploaded. No image +is published or retained as a release artifact; local Docker export intentionally +does not claim registry attestations or release provenance. + +This proves packaged map swap/reload identity and the isolated server lifecycle; +it does not prove semantic map/player persistence, authenticated client login, +gameplay, external networking, GPU rendering or audio. Those retain their separate +acceptance paths. The normal wrapper lifecycle above builds a different artifact +and cannot substitute for this final-container evidence. + ## Failure, recovery, and rollback Published release assets and versioned images are write-once. Before a draft is diff --git a/tools/ci/qualify_server_image.py b/tools/ci/qualify_server_image.py new file mode 100644 index 000000000..8783672ac --- /dev/null +++ b/tools/ci/qualify_server_image.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2026 The Atrinik Project +"""Qualify one freshly loaded final server image, without publication or host mounts.""" + +import argparse +from datetime import datetime +import hashlib +import json +from pathlib import Path +import re +import subprocess +import time +import uuid + +ROOT = Path(__file__).resolve().parents[2] +OWNER_LABEL = "org.atrinik.server-image-qualification" +MAP = "/shattered_islands/world_0_70" +READY = "Server ready. Waiting for connections" +SAVED = "Server saves complete; releasing resources." +DIGEST = re.compile(r"sha256:[0-9a-f]{64}") + + +class QualificationError(RuntimeError): + pass + + +def require(condition, message): + if not condition: + raise QualificationError(message) + + +def command(*args, timeout=30): + result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False) + require(result.returncode == 0, f"{args[0]} {args[1]} failed: {result.stderr[-4000:]}") + return result.stdout + + +def validate_image(image, image_id, revision): + require(image.get("Id") == image_id, "Loaded image ID differs from the build output") + require(image.get("Architecture") == "amd64" and image.get("Os") == "linux", "Wrong image platform") + config = image["Config"] + require(config.get("User") == "atrinik", "Final image must default to the nonroot atrinik user") + require(config.get("Entrypoint") == ["atrinik-server-entrypoint"], "Wrong final image entrypoint") + require(not config.get("Volumes"), "Unexpected image volumes") + labels = config.get("Labels") or {} + require(labels.get("org.opencontainers.image.revision") == revision, "Image source revision mismatch") + require(labels.get("org.opencontainers.image.version") == "0.0.0", "Qualification version must be 0.0.0") + + +def validate_container(container, container_id, image_id, owner): + require(container.get("Id") == container_id, "Container ID changed") + require(container.get("Image") == image_id, "Container image changed") + config = container["Config"] + require((config.get("Labels") or {}).get(OWNER_LABEL) == owner, "Container ownership changed") + require(config.get("User") == "atrinik", "Container user changed") + host = container["HostConfig"] + require(host.get("NetworkMode") == "none" and not host.get("PortBindings"), "Container network is not isolated") + require(not container.get("Mounts") and not host.get("Privileged"), "Unexpected container mounts or privilege") + require("ALL" in host.get("CapDrop", []) and "no-new-privileges" in host.get("SecurityOpt", []), + "Container security restrictions changed") + + +def validate_benchmark(output): + rows = [line.split("\t")[1:] for line in output.splitlines() + if line.startswith("ATRINIK_CONTENT_BENCHMARK\t")] + for expected in (["format", "1"], ["mode", "offline-authored-content"], ["iterations", "1"]): + require(rows.count(expected) == 1, f"Missing or duplicate benchmark evidence: {expected[0]}") + maps = [row for row in rows if row[0] == "map"] + require(len(maps) == 1, "Expected exactly one completed map sample") + row = maps[0] + require(len(row) == 7 and row[1:3] == [MAP, "0"] and all(re.fullmatch(r"[0-9]+", x) for x in row[3:]), + "Malformed or wrong map load/swap/reload evidence") + return {"map": MAP, "sample": 0, "load_us": int(row[3]), "warm_us": int(row[4]), + "swap_us": int(row[5]), "reload_us": int(row[6])} + + +class Qualification: + def __init__(self, image_id, revision, output): + self.image_id = image_id + self.revision = revision + self.output = output + self.owner = uuid.uuid4().hex + self.containers = [] + self.evidence = {"schema_version": 1, "source_revision": revision, "image_id": image_id, + "owner": self.owner, "result": "failed", "containers": [], "cleanup": []} + + def write(self, name, value): + (self.output / name).write_text(json.dumps(value, indent=2) + "\n") + + def inspect(self, container_id): + image = json.loads(command("docker", "image", "inspect", self.image_id))[0] + validate_image(image, self.image_id, self.revision) + container = json.loads(command("docker", "container", "inspect", container_id))[0] + validate_container(container, container_id, self.image_id, self.owner) + return container + + def create(self, purpose, *args): + container_id = command( + "docker", "create", "--name", f"classic-qualification-{self.owner}-{purpose}", + "--label", f"{OWNER_LABEL}={self.owner}", "--network", "none", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--cpus", "2", "--memory", "4g", "--pids-limit", "256", + "--log-driver", "json-file", "--log-opt", "max-size=4m", "--log-opt", "max-file=1", + "--env", "ATRINIK_NETWORK_STACK=ipv4=127.0.0.1", "--env", "ATRINIK_PORT_MAPPING=off", + self.image_id, "--stun_server=off", "--metaserver_publish_origin=http://127.0.0.1:9", + "--metaserver_rendezvous_origin=http://127.0.0.1:9/v1/classic", *args).strip() + require(re.fullmatch(r"[0-9a-f]{64}", container_id), "Invalid created container ID") + self.containers.append(container_id) + self.inspect(container_id) + self.evidence["containers"].append({"id": container_id, "purpose": purpose}) + return container_id + + def start(self, container_id): + self.inspect(container_id) + command("docker", "start", container_id) + return self.inspect(container_id)["State"]["StartedAt"] + + def logs(self, container_id, name, since=None): + self.inspect(container_id) + args = ["docker", "logs", "--tail", "2000"] + if since: + args += ["--since", since] + # Docker separates container stderr; retain both streams as evidence. + result = subprocess.run([*args, container_id], capture_output=True, text=True, timeout=30) + require(result.returncode == 0, "Cannot collect container logs") + output = result.stdout + result.stderr + (self.output / name).write_text(output[-1024 * 1024:]) + require(len(output) < 1024 * 1024 and len(output.splitlines()) < 2000, + "Container logs exceed qualification evidence bounds") + return output + + def exec(self, container_id, *args, timeout=60): + self.inspect(container_id) + return command("docker", "exec", container_id, *args, timeout=timeout) + + def stopped(self, container_id): + state = self.inspect(container_id)["State"] + require(not state["Running"] and state["Status"] == "exited" and state["ExitCode"] == 0 + and not state.get("OOMKilled") and not state.get("Error"), "Container did not exit cleanly") + return {key: state[key] for key in ("Status", "ExitCode", "OOMKilled", "StartedAt", "FinishedAt")} + + def benchmark(self): + container_id = self.create("content", f"--content_benchmark={MAP}", "--content_benchmark_iterations=1") + self.start(container_id) + deadline = time.monotonic() + 120 + while self.inspect(container_id)["State"]["Running"]: + require(time.monotonic() < deadline, "Content behavior timed out") + time.sleep(1) + output = self.logs(container_id, "content.log") + self.evidence["benchmark_exit"] = self.stopped(container_id) + self.evidence["content_behavior"] = validate_benchmark(output) + + def lifecycle(self): + container_id = self.create("lifecycle") + self.evidence["lifecycle"] = [] + for iteration in range(2): + started = self.start(container_id) + deadline = time.monotonic() + 120 + while True: + state = self.inspect(container_id)["State"] + require(state["Running"], "Server exited before readiness") + output = self.logs(container_id, f"runtime-{iteration}.log", started) + if READY in output and state.get("Health", {}).get("Status") == "healthy": + break + require(time.monotonic() < deadline, "Server readiness/health timed out") + time.sleep(2) + require(f"Build identity: revision={self.revision} dirty=false" in output, + "Packaged executable source identity differs") + # Require a heartbeat from this start, not a healthy retained file. + heartbeat = self.exec(container_id, "cat", "data/tmp/server-heartbeat").strip() + require(heartbeat.isdecimal(), "Invalid heartbeat") + require(int(heartbeat) > int(datetime.fromisoformat(started.replace("Z", "+00:00")).timestamp()), + "Heartbeat predates this server start") + require(self.exec(container_id, "id", "-u").strip() == "10001", "Runtime UID is not 10001") + if iteration == 0: + self.exec(container_id, "python3", "/usr/local/share/atrinik/runtime-provider.py", "verify-runtime", + "--server", "/opt/atrinik/server") + # Hash only public packaged inputs; never copy generated keys or account state. + hashes = self.exec(container_id, "sha256sum", "atrinik-server", "libplugin_arena.so", + "libplugin_python.so", "/usr/local/share/atrinik/runtime-provider.lock.json", + f"/opt/atrinik/maps{MAP}") + (self.output / "runtime-hashes.txt").write_text(hashes) + self.evidence["provider_verified"] = True + self.inspect(container_id) + command("docker", "stop", "--time", "30", container_id, timeout=45) + state = self.stopped(container_id) + output = self.logs(container_id, f"runtime-{iteration}.log", started) + require(READY in output and SAVED in output, "Missing readiness or checked persistence shutdown evidence") + self.evidence["lifecycle"].append({"iteration": iteration, "heartbeat": int(heartbeat), **state}) + + def cleanup(self): + errors = [] + for container_id in self.containers: + try: + self.logs(container_id, f"final-{container_id[:12]}.log") + except (QualificationError, OSError, subprocess.SubprocessError) as error: + errors.append(str(error)) + try: + self.inspect(container_id) # Fresh ID/image/source/owner fence before removal. + command("docker", "rm", "--force", container_id) + remaining = command("docker", "ps", "--all", "--quiet", "--no-trunc", "--filter", f"id={container_id}") + require(not remaining.strip(), "Container remains after cleanup") + self.evidence["cleanup"].append({"id": container_id, "removed": True}) + except (QualificationError, OSError, subprocess.SubprocessError) as error: + errors.append(str(error)) + self.evidence["cleanup"].append({"id": container_id, "removed": False, "error": str(error)}) + require(not errors, "; ".join(errors)) + + def run(self): + self.output.mkdir(parents=True, exist_ok=False) + try: + require(re.fullmatch(r"[0-9a-f]{40}", self.revision), "Expected full source commit") + require(DIGEST.fullmatch(self.image_id), "Expected immutable build image ID") + require(command("git", "-C", str(ROOT), "rev-parse", "HEAD").strip() == self.revision, "Source HEAD differs") + require(not command("git", "-C", str(ROOT), "status", "--porcelain", "--untracked-files=no").strip(), + "Tracked source is dirty") + image = json.loads(command("docker", "image", "inspect", self.image_id))[0] + validate_image(image, self.image_id, self.revision) + self.write("image.json", {key: image[key] for key in ("Id", "Architecture", "Os", "Created", "Config")}) + self.evidence["source_inputs"] = { + name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() + for name in ("server/Dockerfile", "server/docker/runtime-provider.lock.json", "dependencies.bundle.json")} + self.write("dependency-bundle.json", json.loads((ROOT / "dependencies.bundle.json").read_text())) + self.evidence["sdk"] = (ROOT / "server/Dockerfile").read_text().split("FROM ", 1)[1].split()[0] + self.benchmark() + self.lifecycle() + self.evidence["result"] = "passed" + except (QualificationError, OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + self.evidence["error"] = str(error) + raise + finally: + try: + self.cleanup() + except (QualificationError, OSError, subprocess.SubprocessError) as error: + self.evidence["result"] = "failed" + self.evidence["cleanup_error"] = str(error) + raise + finally: + self.write("qualification.json", self.evidence) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--image-id", required=True) + parser.add_argument("--revision", required=True) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + Qualification(args.image_id, args.revision, args.output).run() + + +if __name__ == "__main__": + main() diff --git a/tools/tests/test_qualify_server_image.py b/tools/tests/test_qualify_server_image.py new file mode 100644 index 000000000..b29bb57f2 --- /dev/null +++ b/tools/tests/test_qualify_server_image.py @@ -0,0 +1,186 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2026 The Atrinik Project +"""Final-image qualification rejects incomplete behavior and foreign resources.""" + +import copy +from datetime import datetime, timedelta, timezone +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("qualify_server_image", ROOT / "tools/ci/qualify_server_image.py") +q = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(q) +REVISION = "a" * 40 +IMAGE_ID = "sha256:" + "b" * 64 +IMAGE = {"Id": IMAGE_ID, "Os": "linux", "Architecture": "amd64", "Created": "today", + "Config": {"User": "atrinik", "Entrypoint": ["atrinik-server-entrypoint"], "Volumes": None, + "Labels": {"org.opencontainers.image.revision": REVISION, + "org.opencontainers.image.version": "0.0.0"}}} +BENCHMARK = "\n".join("ATRINIK_CONTENT_BENCHMARK\t" + line for line in ( + "format\t1", "mode\toffline-authored-content", "iterations\t1", f"map\t{q.MAP}\t0\t10\t0\t20\t30")) + + +class DockerFixture: + def __init__(self, fault=None): + self.fault = fault + self.containers = {} + self.commands = [] + self.starts = 0 + + def run(self, args, **kwargs): + args = list(args) + self.commands.append(args) + output = "" + code = 0 + if args[0] == "git": + output = REVISION if "rev-parse" in args else "" + elif args[1:3] == ["image", "inspect"]: + output = json.dumps([IMAGE]) + elif args[1] == "create": + cid = str(len(self.containers) + 1) * 64 + label = args[args.index("--label") + 1].split("=", 1)[1] + self.containers[cid] = { + "Id": cid, "Image": IMAGE_ID, "Config": {"User": "atrinik", "Labels": {q.OWNER_LABEL: label}}, + "Mounts": [], "HostConfig": {"NetworkMode": "none", "PortBindings": {}, + "Privileged": False, "CapDrop": ["ALL"], "SecurityOpt": ["no-new-privileges"]}, + "State": {"Running": False, "Status": "created", "ExitCode": 0, "OOMKilled": False, + "StartedAt": "", "FinishedAt": ""}, + "benchmark": any(x.startswith("--content_benchmark=") for x in args), "logs": ""} + output = cid + elif args[1:3] == ["container", "inspect"]: + output = json.dumps([self.containers[args[-1]]]) + elif args[1] == "start": + c = self.containers[args[-1]] + self.starts += 1 + c["State"].update(Running=not c["benchmark"], Status="exited" if c["benchmark"] else "running", + StartedAt=(datetime.now(timezone.utc) - timedelta(seconds=2)).isoformat(), Health={"Status": "healthy"}) + c["logs"] = BENCHMARK if c["benchmark"] else q.READY + f"\nBuild identity: revision={REVISION} dirty=false" + if c["benchmark"] and self.fault == "missing-benchmark": + c["logs"] = "startup only" + if not c["benchmark"] and self.fault == "source-identity": + c["logs"] = q.READY + "\nBuild identity: revision=unknown dirty=unknown" + if not c["benchmark"] and self.fault == "early-exit": + c["State"]["Running"] = False + if not c["benchmark"] and self.fault == "stale-readiness" and self.starts == 3: + c["logs"] = "" + elif args[1] == "logs": + output = self.containers[args[-1]]["logs"] + elif args[1] == "exec": + if args[3] == "cat": + output = "1" if self.fault == "stale-heartbeat" else str(int(datetime.now(timezone.utc).timestamp())) + elif args[3] == "id": + output = "0" if self.fault == "root" else "10001" + elif args[3] == "python3" and self.fault == "provider": + code = 1 + elif args[3] == "sha256sum": + output = "fixture packaged hashes" + elif args[1] == "stop": + c = self.containers[args[-1]] + c["State"].update(Running=False, Status="exited", ExitCode=137 if self.fault == "killed" else 0) + if self.fault != "missing-save": + c["logs"] += "\n" + q.SAVED + elif args[1] == "rm": + del self.containers[args[-1]] + elif args[1] != "ps": + raise AssertionError(args) + return subprocess.CompletedProcess(args, code, output, "fixture error" if code else "") + + +class QualificationTests(unittest.TestCase): + def test_image_identity_platform_user_entrypoint_and_volumes(self): + q.validate_image(IMAGE, IMAGE_ID, REVISION) + for field, value in (("Id", "sha256:" + "c" * 64), ("Architecture", "arm64")): + image = copy.deepcopy(IMAGE) + image[field] = value + with self.assertRaises(q.QualificationError): + q.validate_image(image, IMAGE_ID, REVISION) + for field, value in (("User", "root"), ("Entrypoint", ["sh"]), ("Volumes", {"/host": {}}), + ("Labels", {"org.opencontainers.image.revision": "c" * 40})): + image = copy.deepcopy(IMAGE) + image["Config"][field] = value + with self.assertRaises(q.QualificationError): + q.validate_image(image, IMAGE_ID, REVISION) + + def test_benchmark_requires_complete_exact_sample(self): + self.assertEqual(q.validate_benchmark(BENCHMARK)["map"], q.MAP) + for output in ("", BENCHMARK + "\n" + BENCHMARK, BENCHMARK.replace(q.MAP, "/other"), + BENCHMARK.replace("\t0\t10", "\t1\t10"), BENCHMARK.replace("\t30", "\tNaN"), + BENCHMARK.replace("format\t1", "format\t2")): + with self.subTest(output=output), self.assertRaises(q.QualificationError): + q.validate_benchmark(output) + + def exercise(self, fault=None): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + output = Path(temporary.name) / "evidence" + fixture = DockerFixture(fault) + qualifier = q.Qualification(IMAGE_ID, REVISION, output) + # Advancing test clock makes missing readiness hit a bounded deadline. + with patch.object(q.subprocess, "run", side_effect=fixture.run), \ + patch.object(q.time, "sleep"), patch.object(q.time, "monotonic", side_effect=range(0, 10000, 61)): + if fault: + with self.assertRaises(q.QualificationError): + qualifier.run() + else: + qualifier.run() + evidence = json.loads((output / "qualification.json").read_text()) + self.assertEqual(evidence["result"], "failed" if fault else "passed") + self.assertFalse(fixture.containers) + self.assertTrue(all(row["removed"] for row in evidence["cleanup"])) + return fixture, evidence + + def test_full_behavior_two_starts_and_checked_cleanup(self): + fixture, evidence = self.exercise() + self.assertEqual(len(evidence["lifecycle"]), 2) + self.assertEqual(fixture.starts, 3) + for command in fixture.commands: + if command[1] == "create": + self.assertNotIn("--volume", command) + self.assertNotIn("--publish", command) + self.assertIn("none", command) + self.assertGreaterEqual(sum("--since" in c for c in fixture.commands), 4) + + def test_behavior_provider_readiness_heartbeat_and_shutdown_fail_closed(self): + for fault in ("missing-benchmark", "early-exit", "stale-readiness", "stale-heartbeat", "root", + "provider", "killed", "missing-save", "source-identity"): + with self.subTest(fault=fault): + self.exercise(fault) + + def test_foreign_container_is_never_removed(self): + fixture = DockerFixture() + with tempfile.TemporaryDirectory() as temporary: + qualifier = q.Qualification(IMAGE_ID, REVISION, Path(temporary)) + with patch.object(q.subprocess, "run", side_effect=fixture.run): + cid = qualifier.create("fixture") + fixture.containers[cid]["Config"]["Labels"][q.OWNER_LABEL] = "foreign" + with self.assertRaisesRegex(q.QualificationError, "ownership"): + qualifier.cleanup() + self.assertFalse(any(c[1] == "rm" for c in fixture.commands)) + self.assertFalse(qualifier.evidence["cleanup"][0]["removed"]) + + def test_network_mount_and_image_drift_rejected(self): + fixture = DockerFixture() + with tempfile.TemporaryDirectory() as temporary: + qualifier = q.Qualification(IMAGE_ID, REVISION, Path(temporary)) + with patch.object(q.subprocess, "run", side_effect=fixture.run): + cid = qualifier.create("fixture") + original = copy.deepcopy(fixture.containers[cid]) + for field, value in (("Image", "sha256:" + "c" * 64), ("Mounts", [{"Source": "/host"}])): + fixture.containers[cid] = copy.deepcopy(original) + fixture.containers[cid][field] = value + with self.assertRaises(q.QualificationError): + qualifier.inspect(cid) + fixture.containers[cid] = copy.deepcopy(original) + fixture.containers[cid]["HostConfig"]["NetworkMode"] = "host" + with self.assertRaises(q.QualificationError): + qualifier.inspect(cid) + + +if __name__ == "__main__": + unittest.main() From 0cea3514950adfb3190a88c852bfba13b2af0d54 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:31:03 -0500 Subject: [PATCH 100/148] fix(ci): read packaged server identity through version command --- docs/RELEASING.md | 3 ++- tools/ci/qualify_server_image.py | 6 ++++-- tools/tests/test_qualify_server_image.py | 9 +++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 798cb71a4..def1fd4b3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -747,7 +747,8 @@ no published ports, no external network, dropped capabilities, bounded resources and disposable container state. It exercises the existing offline benchmark on the packaged `/shattered_islands/world_0_70` map for one iteration, requiring the server's load/warm-lookup/swap/reload identity checks and exact completion record. -A separate normal-entrypoint container must pass runtime-provider verification, +A separate normal-entrypoint container must report the exact clean source revision +from its packaged `--version` command and pass runtime-provider verification, reach readiness and Docker health, produce a fresh heartbeat, exit zero after SIGTERM with the checked save-completion message, then repeat startup and shutdown using its retained disposable state. Restart log and heartbeat evidence must diff --git a/tools/ci/qualify_server_image.py b/tools/ci/qualify_server_image.py index 8783672ac..788370608 100644 --- a/tools/ci/qualify_server_image.py +++ b/tools/ci/qualify_server_image.py @@ -164,8 +164,6 @@ def lifecycle(self): break require(time.monotonic() < deadline, "Server readiness/health timed out") time.sleep(2) - require(f"Build identity: revision={self.revision} dirty=false" in output, - "Packaged executable source identity differs") # Require a heartbeat from this start, not a healthy retained file. heartbeat = self.exec(container_id, "cat", "data/tmp/server-heartbeat").strip() require(heartbeat.isdecimal(), "Invalid heartbeat") @@ -173,6 +171,10 @@ def lifecycle(self): "Heartbeat predates this server start") require(self.exec(container_id, "id", "-u").strip() == "10001", "Runtime UID is not 10001") if iteration == 0: + version = self.exec(container_id, "./atrinik-server", "--version") + (self.output / "server-version.txt").write_text(version) + require(f"Build identity: revision={self.revision} dirty=false" in version + and "This is Atrinik v0.0.0" in version, "Packaged executable source identity differs") self.exec(container_id, "python3", "/usr/local/share/atrinik/runtime-provider.py", "verify-runtime", "--server", "/opt/atrinik/server") # Hash only public packaged inputs; never copy generated keys or account state. diff --git a/tools/tests/test_qualify_server_image.py b/tools/tests/test_qualify_server_image.py index b29bb57f2..4ae5e9da8 100644 --- a/tools/tests/test_qualify_server_image.py +++ b/tools/tests/test_qualify_server_image.py @@ -60,11 +60,9 @@ def run(self, args, **kwargs): self.starts += 1 c["State"].update(Running=not c["benchmark"], Status="exited" if c["benchmark"] else "running", StartedAt=(datetime.now(timezone.utc) - timedelta(seconds=2)).isoformat(), Health={"Status": "healthy"}) - c["logs"] = BENCHMARK if c["benchmark"] else q.READY + f"\nBuild identity: revision={REVISION} dirty=false" + c["logs"] = BENCHMARK if c["benchmark"] else q.READY if c["benchmark"] and self.fault == "missing-benchmark": c["logs"] = "startup only" - if not c["benchmark"] and self.fault == "source-identity": - c["logs"] = q.READY + "\nBuild identity: revision=unknown dirty=unknown" if not c["benchmark"] and self.fault == "early-exit": c["State"]["Running"] = False if not c["benchmark"] and self.fault == "stale-readiness" and self.starts == 3: @@ -72,7 +70,10 @@ def run(self, args, **kwargs): elif args[1] == "logs": output = self.containers[args[-1]]["logs"] elif args[1] == "exec": - if args[3] == "cat": + if args[3] == "./atrinik-server": + revision = "unknown" if self.fault == "source-identity" else REVISION + output = f"This is Atrinik v0.0.0\nBuild identity: revision={revision} dirty=false\n" + elif args[3] == "cat": output = "1" if self.fault == "stale-heartbeat" else str(int(datetime.now(timezone.utc).timestamp())) elif args[3] == "id": output = "0" if self.fault == "root" else "10001" From 67896bdcc18931b1fe1dbe965093979d1c151689 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:42:37 -0500 Subject: [PATCH 101/148] fix(libatrinik): preserve admission HTTPS tests on macOS --- libatrinik/CMakeLists.txt | 9 +- libatrinik/tests/rendezvous_admission_https.c | 14 ++ .../test_rendezvous_admission_platform.py | 168 ++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 tools/tests/test_rendezvous_admission_platform.py diff --git a/libatrinik/CMakeLists.txt b/libatrinik/CMakeLists.txt index 280e1de6c..b701e3875 100644 --- a/libatrinik/CMakeLists.txt +++ b/libatrinik/CMakeLists.txt @@ -676,8 +676,13 @@ if ((CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR OR add_executable(libatrinik-rendezvous-admission-https tests/rendezvous_admission_https.c) atrinik_configure_target(libatrinik-rendezvous-admission-https) - target_link_options(libatrinik-rendezvous-admission-https PRIVATE - "-Wl,--wrap=curl_easy_init") + if (APPLE) + target_compile_definitions(libatrinik-rendezvous-admission-https PRIVATE + ATRINIK_TEST_SOURCE_WRAP) + else () + target_link_options(libatrinik-rendezvous-admission-https PRIVATE + "-Wl,--wrap=curl_easy_init") + endif () target_link_libraries(libatrinik-rendezvous-admission-https PRIVATE Atrinik::Core) add_test(NAME libatrinik-rendezvous-admission-https COMMAND "${Python3_EXECUTABLE}" diff --git a/libatrinik/tests/rendezvous_admission_https.c b/libatrinik/tests/rendezvous_admission_https.c index 53b8eabef..a91d3b849 100644 --- a/libatrinik/tests/rendezvous_admission_https.c +++ b/libatrinik/tests/rendezvous_admission_https.c @@ -2,7 +2,17 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ /* The private prepare seam preserves the production HTTPS/parser path while * selecting eight-bit work solely in this independently linked test. */ +#ifdef ATRINIK_TEST_SOURCE_WRAP +/* Mach-O has no GNU --wrap. Load declarations before redirecting only calls + * in the production implementation included below. */ +#include +CURL *__wrap_curl_easy_init(void); +#define curl_easy_init __wrap_curl_easy_init +#endif #include "../rendezvous_admission.c" +#ifdef ATRINIK_TEST_SOURCE_WRAP +#undef curl_easy_init +#endif #include #include "../socket_private.h" @@ -18,7 +28,11 @@ static const char ticket[] = "22222222222222222222222222222222222222222222222222 static const char server_id[] = "1111111111111111111111111111111111111111111111111111111111111111"; static unsigned int solver_calls; +#ifdef ATRINIK_TEST_SOURCE_WRAP +#define __real_curl_easy_init curl_easy_init +#else CURL *__real_curl_easy_init(void); +#endif CURL *__wrap_curl_easy_init(void) { CURL *handle = __real_curl_easy_init(); const char *ca = getenv("ATRINIK_TEST_ADMISSION_CA"); diff --git a/tools/tests/test_rendezvous_admission_platform.py b/tools/tests/test_rendezvous_admission_platform.py new file mode 100644 index 000000000..739e38551 --- /dev/null +++ b/tools/tests/test_rendezvous_admission_platform.py @@ -0,0 +1,168 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +HTTPS = ROOT / "libatrinik/tests/rendezvous_admission_https.c" + + +@unittest.skipUnless(shutil.which("cc"), "A synthetic fixture C compiler is required") +class AdmissionPlatformTests(unittest.TestCase): + """Test instrumentation fixtures; these do not qualify a native platform.""" + + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="admission-platform-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + for path in ("curl", "toolkit", "openssl"): + (self.root / path).mkdir() + (self.root / "curl/curl.h").write_text('''#ifndef CURL_FIXTURE_H +#define CURL_FIXTURE_H +#include +typedef struct { int unused; } CURL; +typedef int CURLcode; +#define CURLOPT_CAINFO 1 +#define CURLE_OK 0 +CURL *curl_easy_init(void); +CURLcode curl_easy_setopt(CURL *, int, const char *); +void curl_easy_cleanup(CURL *); +#endif +''') + for path in ("toolkit/logger.h", "openssl/crypto.h", "openssl/evp.h"): + (self.root / path).touch() + self.guards = ["TOOLKIT_RENDEZVOUS_ADMISSION_H", "TOOLKIT_DATETIME_H", + "TOOLKIT_METASERVER_URL_H", "TOOLKIT_STRING_H", + "TOOLKIT_SOCKET_PRIVATE_H"] + + def run_checked(self, command): + result = subprocess.run(command, cwd=self.root, text=True, + capture_output=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + def test_source_wrap_redirects_actual_admission_and_restores_real_curl(self): + for source_wrap in (False, True): + options = ["-DATRINIK_TEST_SOURCE_WRAP"] if source_wrap else [] + output = self.run_checked([ + shutil.which("cc"), "-E", "-P", *options, + *(f"-D{guard}" for guard in self.guards), "-I", str(self.root), + str(HTTPS), + ]) + admission = output.split("static rendezvous_admission_result_t\nadmission_prepare(", 1)[1] + admission = admission.split("rendezvous_admission_result_t\nrendezvous_admission_prepare(", 1)[0] + wrapper = output.split("CURL *__wrap_curl_easy_init(void) {", 1)[1].split( + "static rendezvous_admission_result_t solve_fast", 1)[0] + with self.subTest(source_wrap=source_wrap): + called = "__wrap_curl_easy_init" if source_wrap else "curl_easy_init" + real = "curl_easy_init" if source_wrap else "__real_curl_easy_init" + self.assertIn(f"CURL *curl = {called}();", admission) + self.assertIn(f"CURL *handle = {real}();", wrapper) + self.assertNotIn("CURL *handle = __wrap_curl_easy_init();", wrapper) + for option in ("CURLOPT_SSL_VERIFYPEER, 1L", "CURLOPT_SSL_VERIFYHOST, 2L", + "CURLOPT_FOLLOWLOCATION, 0L", "CURLOPT_MAXREDIRS, 0L"): + self.assertIn(option, admission) + + def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): + preamble = HTTPS.read_text().split("static rendezvous_admission_result_t solve_fast", 1)[0] + # Only this fixture replaces dependencies. The preceding preprocessing + # test separately exercises the actual included production source. + preamble = preamble.replace('#include "../rendezvous_admission.c"', ''' +#include +CURL *fixture_admission_init(void) { return curl_easy_init(); } +''').replace('#include "../socket_private.h"', "") + (self.root / "wrapper.c").write_text(preamble + ''' +#include +#include +static CURL fixture_handle; +static unsigned int init_calls, setopt_calls, cleanup_calls; +static int fail_init, fail_setopt; +static const char *selected_ca; +CURL *curl_easy_init(void) { + assert(++init_calls < 10); + return fail_init ? NULL : &fixture_handle; +} +CURLcode curl_easy_setopt(CURL *handle, int option, const char *ca) { + assert(handle == &fixture_handle && option == CURLOPT_CAINFO); + setopt_calls++; + selected_ca = ca; + return fail_setopt ? 1 : CURLE_OK; +} +void curl_easy_cleanup(CURL *handle) { + assert(handle == &fixture_handle); + cleanup_calls++; +} +int main(void) { + (void)ticket; + (void)server_id; + (void)solver_calls; + unsetenv("ATRINIK_TEST_ADMISSION_CA"); + assert(fixture_admission_init() == &fixture_handle); + assert(init_calls == 1 && setopt_calls == 0 && cleanup_calls == 0); + assert(setenv("ATRINIK_TEST_ADMISSION_CA", "fixture-ca.pem", 1) == 0); + assert(fixture_admission_init() == &fixture_handle); + assert(init_calls == 2 && setopt_calls == 1 && cleanup_calls == 0); + assert(strcmp(selected_ca, "fixture-ca.pem") == 0); + fail_setopt = 1; + assert(fixture_admission_init() == NULL); + assert(init_calls == 3 && setopt_calls == 2 && cleanup_calls == 1); + fail_init = 1; + assert(fixture_admission_init() == NULL); + assert(init_calls == 4 && setopt_calls == 2 && cleanup_calls == 1); + return 0; +} +''') + binary = self.root / "wrapper" + self.run_checked([shutil.which("cc"), "-std=c17", "-D_POSIX_C_SOURCE=200809L", + "-DATRINIK_TEST_SOURCE_WRAP", "-Wall", "-Wextra", "-Werror", + "-I", str(self.root), str(self.root / "wrapper.c"), "-o", str(binary)]) + self.run_checked([str(binary)]) + + @unittest.skipUnless(shutil.which("cmake"), "CMake is required") + def test_platform_configure_keeps_https_test_on_apple_without_gnu_wrap(self): + text = (ROOT / "libatrinik/CMakeLists.txt").read_text() + block = text.split(' if (UNIX AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")', 1)[1] + block = 'if (UNIX AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")' + block.split( + " add_executable(libatrinik-rendezvous tests/rendezvous.c)", 1)[0] + block = block.replace("tests/rendezvous_admission_https.c", str(HTTPS)) + for platform, unix, apple in (("Darwin", "ON", "ON"), ("Linux", "ON", "OFF"), + ("Windows", "OFF", "OFF")): + (self.root / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.21) +project(AdmissionPlatformFixture LANGUAGES C) +enable_testing() +set(UNIX {unix}) +set(APPLE {apple}) +set(CMAKE_C_COMPILER_ID Clang) +set(ATRINIK_TEST_OPENSSL "{self.root}/openssl-fixture") +add_library(Atrinik::Core INTERFACE IMPORTED) +function(atrinik_configure_target target) +endfunction() +{block} +if(TARGET libatrinik-rendezvous-admission-https) + file(GENERATE OUTPUT "${{CMAKE_BINARY_DIR}}/definitions.txt" + CONTENT "$") + file(GENERATE OUTPUT "${{CMAKE_BINARY_DIR}}/links.txt" + CONTENT "$") +endif() +''') + build = self.root / platform + self.run_checked([shutil.which("cmake"), "-S", str(self.root), "-B", str(build)]) + with self.subTest(platform=platform): + if platform == "Windows": + self.assertFalse((build / "definitions.txt").exists()) + continue + tests = (build / "CTestTestfile.cmake").read_text() + self.assertIn("libatrinik-rendezvous-admission-https", tests) + self.assertIn("rendezvous_admission_https.py", tests) + definitions = (build / "definitions.txt").read_text() + links = (build / "links.txt").read_text() + self.assertEqual("ATRINIK_TEST_SOURCE_WRAP" in definitions, platform == "Darwin") + self.assertEqual("--wrap=curl_easy_init" in links, platform == "Linux") + + +if __name__ == "__main__": + unittest.main() From 6c8ef17e20efb67614fc4a2e1edafa3757388ee4 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:34:17 -0500 Subject: [PATCH 102/148] fix(release): inspect AppImages with trusted LLVM readelf --- .github/workflows/build-release-candidate.yml | 4 +- .github/workflows/check.yml | 4 +- .github/workflows/package-release.yml | 2 +- .github/workflows/promote-latest.yml | 2 +- tools/release/appimage.py | 8 +-- tools/tests/test_appimage.py | 58 ++++++++++++++++++- tools/tests/test_appimage_workflow.py | 12 ++-- tools/tests/test_workflow_contracts.py | 2 +- 8 files changed, 72 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 153c4e922..8546afed0 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -483,7 +483,7 @@ jobs: RELEASE_REVISION: ${{ needs.metadata.outputs.commit }} run: | sudo apt-get update - sudo apt-get install --yes --no-install-recommends squashfs-tools binutils + sudo apt-get install --yes --no-install-recommends squashfs-tools llvm python3 tools/release/appimage.py \ "build/appimage-packages/atrinik-classic-client-${RELEASE_VERSION}-linux-x86_64.AppImage" \ "${RELEASE_VERSION}" \ @@ -798,7 +798,7 @@ jobs: if: needs.metadata.outputs.artifact_schema == '3' run: | sudo apt-get update - sudo apt-get install --yes --no-install-recommends squashfs-tools binutils + sudo apt-get install --yes --no-install-recommends squashfs-tools llvm - name: Check out the trusted compiler-image resolver uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f6e17fb28..6587ebaec 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -207,8 +207,8 @@ jobs: - name: Install trusted AppImage fixture inspection tools run: | sudo apt-get update - sudo apt-get install --yes --no-install-recommends squashfs-tools binutils libzstd1 libssl3t64 - test -x /usr/bin/readelf + sudo apt-get install --yes --no-install-recommends squashfs-tools llvm libzstd1 libssl3t64 + test -x /usr/bin/llvm-readelf test -x /usr/bin/unsquashfs test -x /usr/bin/mksquashfs test -x /usr/bin/true diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 476b2d5f8..d2e33097c 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -235,7 +235,7 @@ jobs: - name: Install trusted AppImage inspection tools run: | sudo apt-get update - sudo apt-get install --yes --no-install-recommends squashfs-tools binutils + sudo apt-get install --yes --no-install-recommends squashfs-tools llvm - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: github-token: ${{ github.token }} diff --git a/.github/workflows/promote-latest.yml b/.github/workflows/promote-latest.yml index ec782fbcc..cf5cff6d1 100644 --- a/.github/workflows/promote-latest.yml +++ b/.github/workflows/promote-latest.yml @@ -35,7 +35,7 @@ jobs: - name: Install trusted AppImage inspection tools run: | sudo apt-get update - sudo apt-get install --yes --no-install-recommends squashfs-tools binutils + sudo apt-get install --yes --no-install-recommends squashfs-tools llvm - id: latest name: Resolve the highest immutable release env: diff --git a/tools/release/appimage.py b/tools/release/appimage.py index 611b9727c..afef8f929 100644 --- a/tools/release/appimage.py +++ b/tools/release/appimage.py @@ -243,7 +243,7 @@ def _elf(path: Path, *, executable: bool = False, lock: dict | None = None) -> t data = path.read_bytes() if len(data) < 64 or data[:6] != b"\x7fELF\x02\x01" or data[18:20] != b"\x3e\x00" or data[16:18] not in {b"\x02\0", b"\x03\0"}: raise ValueError(f"AppImage ELF must be little-endian x86_64: {path.name}") - dynamic = _run("readelf", "--wide", "--dynamic", str(path)) + dynamic = _run("llvm-readelf", "--wide", "--dynamic", str(path)) needed = set(re.findall(r"\(NEEDED\).*Shared library: \[([^\]]+)\]", dynamic)) sonames = re.findall(r"\(SONAME\).*Library soname: \[([^\]]+)\]", dynamic) if len(sonames) > 1 or any(not _safe_name(n) or "/" in n for n in needed | set(sonames)): @@ -276,13 +276,13 @@ def _elf(path: Path, *, executable: bool = False, lock: dict | None = None) -> t for item in search_path.split(":"): if item not in {"$ORIGIN", "${ORIGIN}", "$ORIGIN/../lib", "${ORIGIN}/../lib", "$ORIGIN/..", "${ORIGIN}/.."}: raise ValueError(f"unsafe ELF library search path: {path.name}") - versions = _run("readelf", "--wide", "--version-info", str(path)) + versions = _run("llvm-readelf", "--wide", "--version-info", str(path)) for major, minor in re.findall(r"\bGLIBC_(\d+)\.(\d+)(?:\.\d+)?\b", versions): if (int(major), int(minor)) > (2, 39): raise ValueError(f"AppImage requires glibc newer than 2.39: {path.name}") if "GLIBC_PRIVATE" in versions: raise ValueError(f"AppImage requires private glibc symbols: {path.name}") - symbols = _run("readelf", "--wide", "--symbols", str(path)) + symbols = _run("llvm-readelf", "--wide", "--symbols", str(path)) if accepted is not None: for symbol in accepted.get("weak_undefined_symbols", []): row = r"^[ \t]*[0-9]+:[ \t]+0+[ \t]+(?:0|0x0)[ \t]+NOTYPE[ \t]+WEAK[ \t]+DEFAULT[ \t]+UND[ \t]+" + re.escape(symbol) + r"[ \t]*$" @@ -291,7 +291,7 @@ def _elf(path: Path, *, executable: bool = False, lock: dict | None = None) -> t raise ValueError(f"AppImage ELF inspection exception has unexpected symbols: {path.name}") if TEST_FEATURES.search(symbols.encode()): raise ValueError(f"AppImage ELF contains test instrumentation: {path.name}") - program = _run("readelf", "--wide", "--program-headers", str(path)) + program = _run("llvm-readelf", "--wide", "--program-headers", str(path)) interpreters = re.findall(r"\[Requesting program interpreter: ([^\]]+)\]", program) # This locked libcap build deliberately supports direct execution while # remaining a shared library. Preserve the library rule for every other diff --git a/tools/tests/test_appimage.py b/tools/tests/test_appimage.py index 0c0f148e0..dd616e8ce 100644 --- a/tools/tests/test_appimage.py +++ b/tools/tests/test_appimage.py @@ -67,7 +67,39 @@ def test_unsafe_filesystem_root_rejected(self): appimage._listing(row) -@unittest.skipUnless(all(Path("/usr/bin", tool).is_file() for tool in ("readelf", "unsquashfs", "mksquashfs")) +class TrustedToolTests(unittest.TestCase): + def test_llvm_inspector_ignores_candidate_tool_and_loader_environment(self): + environment = {"PATH": "/candidate/bin", "READELF": "/candidate/readelf", + "LD_LIBRARY_PATH": "/candidate/lib", "LD_PRELOAD": "/candidate/hook.so", + "PYTHONPATH": "/candidate/python", "PYTHONHOME": "/candidate/home", + "LC_ALL": "candidate", "GIT_CONFIG": "/candidate/git"} + result = subprocess.CompletedProcess([], 0, stdout=b"LLVM fixture", stderr=b"") + with mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(Path, "is_file", return_value=True), \ + mock.patch.object(appimage.subprocess, "run", return_value=result) as run: + self.assertEqual(appimage._run("llvm-readelf", "--dynamic", "fixture"), "LLVM fixture") + arguments, options = run.call_args + self.assertEqual(arguments[0], ["/usr/bin/llvm-readelf", "--dynamic", "fixture"]) + self.assertEqual(options["env"]["PATH"], "/usr/bin:/bin") + self.assertEqual(options["env"]["LC_ALL"], "C") + for name in ("LD_LIBRARY_PATH", "LD_PRELOAD", "PYTHONPATH", "PYTHONHOME", "GIT_CONFIG"): + self.assertNotIn(name, options["env"]) + + def test_missing_or_failed_llvm_inspector_has_no_gnu_fallback(self): + with mock.patch.object(Path, "is_file", return_value=False), \ + mock.patch.object(appimage.subprocess, "run") as run: + with self.assertRaisesRegex(ValueError, "/usr/bin/llvm-readelf"): + appimage._run("llvm-readelf", "--dynamic", "fixture") + run.assert_not_called() + result = subprocess.CompletedProcess([], 1, stdout=b"", stderr=b"malformed ELF") + with mock.patch.object(Path, "is_file", return_value=True), \ + mock.patch.object(appimage.subprocess, "run", return_value=result) as run: + with self.assertRaisesRegex(ValueError, "llvm-readelf inspection failed: malformed ELF"): + appimage._run("llvm-readelf", "--dynamic", "fixture") + self.assertEqual(run.call_count, 1) + + +@unittest.skipUnless(all(Path("/usr/bin", tool).is_file() for tool in ("llvm-readelf", "unsquashfs", "mksquashfs")) and Path("/usr/bin/true").is_file() and Path("/usr/lib/x86_64-linux-gnu/libzstd.so.1").is_file() and Path("/usr/lib/x86_64-linux-gnu/ossl-modules/legacy.so").is_file(), @@ -244,7 +276,7 @@ def test_valid_real_squashfs_without_payload_execution(self): image = self.image(padding=b"\0" * 4096) with mock.patch.object(appimage, "_run", wraps=appimage._run) as run: self.assertIsNone(self.validate(image)) - self.assertTrue(all(call.args[0] in {"unsquashfs", "readelf"} for call in run.call_args_list)) + self.assertTrue(all(call.args[0] in {"unsquashfs", "llvm-readelf"} for call in run.call_args_list)) inventory = appimage.read_appimage_inventory(image, self.version, revision=self.revision, source_root=self.source) self.assertEqual(inventory, self.metadata) @@ -623,9 +655,29 @@ def test_real_llvm_readelf_output_formats(self): fixtures = Path(__file__).parent / "fixtures/llvm-readelf-23.1.3" outputs = [(fixtures / (name + ".txt")).read_text() for name in ("dynamic", "versions", "symbols", "program")] - with mock.patch.object(appimage, "_run", side_effect=outputs): + with mock.patch.object(appimage, "_run", side_effect=outputs) as run: needed, _ = appimage._elf(Path("/usr/bin/true"), executable=True) self.assertIn("libc.so.6", needed) + self.assertEqual([call.args[0] for call in run.call_args_list], ["llvm-readelf"] * 4) + # Mutate the recorded LLVM output itself: changing inspectors must + # preserve every security/compatibility field consumed by the parser. + mutations = ( + (0, outputs[0] + "\n 0x000000000000000e (SONAME) Library soname: [../unsafe.so]", "library identity"), + (0, outputs[0] + "\n 0x000000000000000e (SONAME) Library soname: [libone.so]\n 0x000000000000000e (SONAME) Library soname: [libtwo.so]", "library identity"), + (0, outputs[0] + "\n 0x000000000000001d (RUNPATH) Library runpath: [/tmp/unsafe]", "search path"), + (1, outputs[1].replace("GLIBC_2.34", "GLIBC_2.40"), "glibc newer"), + (1, outputs[1].replace("GLIBC_2.34", "GLIBC_PRIVATE"), "private glibc"), + (2, outputs[2] + "\n 50: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __asan_init", "instrumentation"), + (3, outputs[3].replace("/lib64/ld-linux-x86-64.so.2", "/unsafe/ld.so"), "interpreter"), + (3, outputs[3] + "\n [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]", "interpreter"), + ) + for index, output, expected in mutations: + changed = outputs.copy() + changed[index] = output + with self.subTest(expected=expected, index=index), \ + mock.patch.object(appimage, "_run", side_effect=changed), \ + self.assertRaisesRegex(ValueError, expected): + appimage._elf(Path("/usr/bin/true"), executable=True) # Real LLVM SONAME/dynamic output drives the SDK closure parser too. import importlib.util spec = importlib.util.spec_from_file_location("appimage_assemble", Path(__file__).parents[1] / "ci/appimage/assemble.py") diff --git a/tools/tests/test_appimage_workflow.py b/tools/tests/test_appimage_workflow.py index 272f10c4e..56e9491ec 100644 --- a/tools/tests/test_appimage_workflow.py +++ b/tools/tests/test_appimage_workflow.py @@ -130,19 +130,19 @@ def test_required_trusted_validator_tools_and_attestation_cover_appimage(self) - self.assertIn('python3 tools/release/appimage.py', job) self.assertLess(job.index('payload-validation.log'), job.index('bash /smoke.sh')) finalizer = self.workflow().split(' candidate:\n', 1)[1] - self.assertLess(finalizer.index('squashfs-tools binutils'), finalizer.index('finalize_artifacts.py')) + self.assertLess(finalizer.index('squashfs-tools llvm'), finalizer.index('finalize_artifacts.py')) for name, first_consumer in (('package-release.yml', 'release_artifacts.py'), ('promote-latest.yml', 'check_latest_release.py')): workflow = (ROOT / '.github/workflows' / name).read_text() - self.assertLess(workflow.index('squashfs-tools binutils'), workflow.index(first_consumer)) + self.assertLess(workflow.index('squashfs-tools llvm'), workflow.index(first_consumer)) check = (ROOT / '.github/workflows/check.yml').read_text() core = check.split(' core:\n', 1)[1].split(' windows-test-build:\n', 1)[0] - self.assertIn('squashfs-tools binutils libzstd1', core) - self.assertLess(core.index('squashfs-tools binutils libzstd1'), core.index('-m unittest discover -s tools/tests')) - for prerequisite in ('readelf', 'unsquashfs', 'mksquashfs', 'true'): + self.assertIn('squashfs-tools llvm libzstd1', core) + self.assertLess(core.index('squashfs-tools llvm libzstd1'), core.index('-m unittest discover -s tools/tests')) + for prerequisite in ('llvm-readelf', 'unsquashfs', 'mksquashfs', 'true'): self.assertIn(f'test -x /usr/bin/{prerequisite}', core) self.assertIn('test -f /usr/lib/x86_64-linux-gnu/libzstd.so.1', core) - self.assertIn('squashfs-tools binutils libzstd1 libssl3t64', core) + self.assertIn('squashfs-tools llvm libzstd1 libssl3t64', core) self.assertIn('test -f /usr/lib/x86_64-linux-gnu/ossl-modules/legacy.so', core) package = (ROOT / '.github/workflows/package-release.yml').read_text() self.assertIn(' build/release/*.AppImage\n', package) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 58a0ef415..19437aeb1 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -2366,7 +2366,7 @@ def test_linux_checks_pin_image_and_isolate_compiler_caches(self) -> None: # Host-only payload fixtures need SquashFS inspection. # Native compilation still uses only the pinned image. preparation, native = job.split(" - name: Install Python test coverage tooling", 1) - self.assertIn("squashfs-tools binutils libzstd1", preparation) + self.assertIn("squashfs-tools llvm libzstd1", preparation) self.assertNotIn("apt-get", native) else: self.assertNotIn("apt-get", job) From b19eb4057be98b5478deccd61e50dd717b6cf725 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:37:09 -0500 Subject: [PATCH 103/148] test(release): always validate recorded LLVM inspection guards --- tools/tests/test_appimage.py | 74 ++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/tools/tests/test_appimage.py b/tools/tests/test_appimage.py index dd616e8ce..71cd64f02 100644 --- a/tools/tests/test_appimage.py +++ b/tools/tests/test_appimage.py @@ -99,6 +99,44 @@ def test_missing_or_failed_llvm_inspector_has_no_gnu_fallback(self): self.assertEqual(run.call_count, 1) +class RecordedLLVMTests(unittest.TestCase): + def test_real_llvm_readelf_output_formats(self): + fixtures = Path(__file__).parent / "fixtures/llvm-readelf-23.1.3" + outputs = [(fixtures / (name + ".txt")).read_text() + for name in ("dynamic", "versions", "symbols", "program")] + with mock.patch.object(appimage, "_run", side_effect=outputs) as run: + needed, _ = appimage._elf(Path("/usr/bin/true"), executable=True) + self.assertIn("libc.so.6", needed) + self.assertEqual([call.args[0] for call in run.call_args_list], ["llvm-readelf"] * 4) + # Mutate the recorded LLVM output itself: changing inspectors must + # preserve every security/compatibility field consumed by the parser. + mutations = ( + (0, outputs[0] + "\n 0x000000000000000e (SONAME) Library soname: [../unsafe.so]", "library identity"), + (0, outputs[0] + "\n 0x000000000000000e (SONAME) Library soname: [libone.so]\n 0x000000000000000e (SONAME) Library soname: [libtwo.so]", "library identity"), + (0, outputs[0] + "\n 0x000000000000001d (RUNPATH) Library runpath: [/tmp/unsafe]", "search path"), + (1, outputs[1].replace("GLIBC_2.34", "GLIBC_2.40"), "glibc newer"), + (1, outputs[1].replace("GLIBC_2.34", "GLIBC_PRIVATE"), "private glibc"), + (2, outputs[2] + "\n 50: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __asan_init", "instrumentation"), + (3, outputs[3].replace("/lib64/ld-linux-x86-64.so.2", "/unsafe/ld.so"), "interpreter"), + (3, outputs[3] + "\n [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]", "interpreter"), + ) + for index, output, expected in mutations: + changed = outputs.copy() + changed[index] = output + with self.subTest(expected=expected, index=index), \ + mock.patch.object(appimage, "_run", side_effect=changed), \ + self.assertRaisesRegex(ValueError, expected): + appimage._elf(Path("/usr/bin/true"), executable=True) + # Real LLVM SONAME/dynamic output drives the SDK closure parser too. + import importlib.util + spec = importlib.util.spec_from_file_location("appimage_assemble", Path(__file__).parents[1] / "ci/appimage/assemble.py") + assemble = importlib.util.module_from_spec(spec) + spec.loader.exec_module(assemble) + with mock.patch.object(assemble, "run", return_value=outputs[0]) as run: + self.assertEqual(assemble.dynamic(Path("fixture")), (["libc.so.6"], None)) + self.assertEqual(run.call_args.args[0], "llvm-readelf") + + @unittest.skipUnless(all(Path("/usr/bin", tool).is_file() for tool in ("llvm-readelf", "unsquashfs", "mksquashfs")) and Path("/usr/bin/true").is_file() and Path("/usr/lib/x86_64-linux-gnu/libzstd.so.1").is_file() @@ -651,42 +689,6 @@ def test_llvm_runtime_notices_and_producer_are_bound_to_lock(self): with self.assertRaisesRegex(ValueError, "producer identity"): self.validate() - def test_real_llvm_readelf_output_formats(self): - fixtures = Path(__file__).parent / "fixtures/llvm-readelf-23.1.3" - outputs = [(fixtures / (name + ".txt")).read_text() - for name in ("dynamic", "versions", "symbols", "program")] - with mock.patch.object(appimage, "_run", side_effect=outputs) as run: - needed, _ = appimage._elf(Path("/usr/bin/true"), executable=True) - self.assertIn("libc.so.6", needed) - self.assertEqual([call.args[0] for call in run.call_args_list], ["llvm-readelf"] * 4) - # Mutate the recorded LLVM output itself: changing inspectors must - # preserve every security/compatibility field consumed by the parser. - mutations = ( - (0, outputs[0] + "\n 0x000000000000000e (SONAME) Library soname: [../unsafe.so]", "library identity"), - (0, outputs[0] + "\n 0x000000000000000e (SONAME) Library soname: [libone.so]\n 0x000000000000000e (SONAME) Library soname: [libtwo.so]", "library identity"), - (0, outputs[0] + "\n 0x000000000000001d (RUNPATH) Library runpath: [/tmp/unsafe]", "search path"), - (1, outputs[1].replace("GLIBC_2.34", "GLIBC_2.40"), "glibc newer"), - (1, outputs[1].replace("GLIBC_2.34", "GLIBC_PRIVATE"), "private glibc"), - (2, outputs[2] + "\n 50: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __asan_init", "instrumentation"), - (3, outputs[3].replace("/lib64/ld-linux-x86-64.so.2", "/unsafe/ld.so"), "interpreter"), - (3, outputs[3] + "\n [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]", "interpreter"), - ) - for index, output, expected in mutations: - changed = outputs.copy() - changed[index] = output - with self.subTest(expected=expected, index=index), \ - mock.patch.object(appimage, "_run", side_effect=changed), \ - self.assertRaisesRegex(ValueError, expected): - appimage._elf(Path("/usr/bin/true"), executable=True) - # Real LLVM SONAME/dynamic output drives the SDK closure parser too. - import importlib.util - spec = importlib.util.spec_from_file_location("appimage_assemble", Path(__file__).parents[1] / "ci/appimage/assemble.py") - assemble = importlib.util.module_from_spec(spec) - spec.loader.exec_module(assemble) - with mock.patch.object(assemble, "run", return_value=outputs[0]) as run: - self.assertEqual(assemble.dynamic(Path("fixture")), (["libc.so.6"], None)) - self.assertEqual(run.call_args.args[0], "llvm-readelf") - def test_pre_llvm_inventory_preserves_all_llvm_notices_without_new_metadata(self): self.assertNotIn("compiler", self.lock) native_inputs = json.loads(json.dumps(self.metadata["native_inputs"])) From 49198c76c49d5bde6db08f2802ec09d236b7177d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:44:32 -0500 Subject: [PATCH 104/148] fix(build): use LLVM objdump for Debian dependency inspection --- .github/workflows/build-release-candidate.yml | 2 ++ .github/workflows/package-release.yml | 1 + .github/workflows/promote-latest.yml | 1 + docs/RELEASING.md | 13 ++++++---- tools/ci/debian-client/Dockerfile | 6 ++++- tools/ci/debian-client/objdump | 5 ++++ tools/tests/test_appimage_workflow.py | 23 +++++++++++++++--- tools/tests/test_linux_deb_workflow.py | 24 +++++++++++++++++++ 8 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 tools/ci/debian-client/objdump diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 8546afed0..2e1359385 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -484,6 +484,7 @@ jobs: run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends squashfs-tools llvm + test -x /usr/bin/llvm-readelf python3 tools/release/appimage.py \ "build/appimage-packages/atrinik-classic-client-${RELEASE_VERSION}-linux-x86_64.AppImage" \ "${RELEASE_VERSION}" \ @@ -799,6 +800,7 @@ jobs: run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends squashfs-tools llvm + test -x /usr/bin/llvm-readelf - name: Check out the trusted compiler-image resolver uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index d2e33097c..4f5037246 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -236,6 +236,7 @@ jobs: run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends squashfs-tools llvm + test -x /usr/bin/llvm-readelf - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: github-token: ${{ github.token }} diff --git a/.github/workflows/promote-latest.yml b/.github/workflows/promote-latest.yml index cf5cff6d1..78e1716be 100644 --- a/.github/workflows/promote-latest.yml +++ b/.github/workflows/promote-latest.yml @@ -36,6 +36,7 @@ jobs: run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends squashfs-tools llvm + test -x /usr/bin/llvm-readelf - id: latest name: Resolve the highest immutable release env: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index def1fd4b3..f1e39bce5 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -566,11 +566,14 @@ current checkout's trusted inputs. LLVM packages require the approved compiler image from the trusted caller or an exact image pin in the source lock; inspection rejects a different well-formed digest. Candidate code cannot replace that caller's validator or redefine the supported host-library boundary. -SDK assembly uses the matching `llvm-readelf`. The trusted host inspector -uses distribution `readelf` only for read-only ELF inspection, preserving -historical release recovery on hosts without the SDK; it accepts the verified -LLVM output formats too. Host inspection tools never compile, link or strip -new application or dependency binaries. +SDK assembly uses the matching LLVM 23 `llvm-readelf`. The trusted host +inspector uses `/usr/bin/llvm-readelf` from the distribution's `llvm` package +only for read-only artifact inspection, preserving historical release recovery +on hosts without the SDK. This platform inspector can be an older LLVM version +(Ubuntu 24 supplies LLVM 18); it is separate from the pinned LLVM 23 compiler, +linker and binary utilities used to build application and dependency binaries. +Both inspector output formats retain the same ELF compatibility and safety +guards. Candidate `PATH` and `READELF` cannot select the host inspector. The trusted caller supplies `tools/ci/appimage/prepare.py` with required `--compiler-image ghcr.io/atrinik/classic-build:VERSION@sha256:DIGEST`, its Dockerfile and `smoke.sh`. The exact candidate checkout and staged inputs are read-only diff --git a/tools/ci/debian-client/Dockerfile b/tools/ci/debian-client/Dockerfile index 68ba495ce..888c1a247 100644 --- a/tools/ci/debian-client/Dockerfile +++ b/tools/ci/debian-client/Dockerfile @@ -21,7 +21,11 @@ RUN printf '%s\n' 'path-include=/usr/share/doc/atrinik' \ FROM runtime AS build COPY --from=compiler /opt/llvm-23.1.3 /opt/llvm-23.1.3 -ENV PATH="/opt/llvm-23.1.3/bin:${PATH}" CC=clang CXX=clang++ AR=llvm-ar RANLIB=llvm-ranlib NM=llvm-nm STRIP=llvm-strip +COPY objdump /opt/atrinik-packaging/bin/objdump +RUN chmod 755 /opt/atrinik-packaging/bin/objdump +# dpkg-shlibdeps resolves the literal name objdump through PATH; OBJDUMP alone +# does not select its inspector. Keep that package helper on the same LLVM SDK. +ENV PATH="/opt/atrinik-packaging/bin:/opt/llvm-23.1.3/bin:${PATH}" CC=clang CXX=clang++ AR=llvm-ar RANLIB=llvm-ranlib NM=llvm-nm STRIP=llvm-strip OBJCOPY=llvm-objcopy OBJDUMP=llvm-objdump READELF=llvm-readelf RUN apt-get -o Acquire::Retries=3 -o APT::Update::Error-Mode=any update \ && apt-get install -y --no-install-recommends \ binutils build-essential cmake dpkg-dev file libcurl4-openssl-dev \ diff --git a/tools/ci/debian-client/objdump b/tools/ci/debian-client/objdump new file mode 100644 index 000000000..7f024c93c --- /dev/null +++ b/tools/ci/debian-client/objdump @@ -0,0 +1,5 @@ +#!/bin/sh +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +# dpkg-shlibdeps calls objdump directly; never fall back to a host binary. +exec /opt/llvm-23.1.3/bin/llvm-objdump "$@" diff --git a/tools/tests/test_appimage_workflow.py b/tools/tests/test_appimage_workflow.py index 56e9491ec..e5452a385 100644 --- a/tools/tests/test_appimage_workflow.py +++ b/tools/tests/test_appimage_workflow.py @@ -135,6 +135,8 @@ def test_required_trusted_validator_tools_and_attestation_cover_appimage(self) - ('promote-latest.yml', 'check_latest_release.py')): workflow = (ROOT / '.github/workflows' / name).read_text() self.assertLess(workflow.index('squashfs-tools llvm'), workflow.index(first_consumer)) + self.assertLess(workflow.index('test -x /usr/bin/llvm-readelf'), workflow.index(first_consumer)) + self.assertEqual(self.workflow().count('test -x /usr/bin/llvm-readelf'), 2) check = (ROOT / '.github/workflows/check.yml').read_text() core = check.split(' core:\n', 1)[1].split(' windows-test-build:\n', 1)[0] self.assertIn('squashfs-tools llvm libzstd1', core) @@ -156,8 +158,18 @@ def test_host_payload_validation_binds_candidate_git_coordinates_before_smoke(se validation = job.split(' - name: Validate AppImage payload with trusted host tools', 1)[1].split(' - name:', 1)[0] self.assertIn('RELEASE_REVISION: ${{ needs.metadata.outputs.commit }}', validation) script = textwrap.dedent(validation.split(' run: |\n', 1)[1]) - for fail_validation in ('0', '1'): - with self.subTest(fail_validation=fail_validation), tempfile.TemporaryDirectory() as temporary: + # The apt fixture cannot install a host inspector. Stub only its + # presence check, retaining the workflow's actual failure propagation. + fixture_check = '''test() { + if [[ $# == 2 && $1 == -x && $2 == /usr/bin/llvm-readelf ]]; then + [[ ${INSPECTOR_AVAILABLE} == 1 ]] + else + builtin test "$@" + fi +} +''' + for fail_validation, inspector_available in (('0', '1'), ('1', '1'), ('0', '0')): + with self.subTest(fail_validation=fail_validation, inspector_available=inspector_available), tempfile.TemporaryDirectory() as temporary: workspace = Path(temporary) (workspace / 'bin').mkdir() (workspace / 'build/appimage-evidence').mkdir(parents=True) @@ -170,13 +182,18 @@ def test_host_payload_validation_binds_candidate_git_coordinates_before_smoke(se sys.exit(7 if os.environ['FAIL_VALIDATION'] == '1' else 0) """) python.chmod(0o755) - result = subprocess.run(['bash', '-euo', 'pipefail', '-c', script], + result = subprocess.run(['bash', '-euo', 'pipefail', '-c', fixture_check + script], cwd=workspace, capture_output=True, text=True, env=dict(os.environ, GITHUB_WORKSPACE=str(workspace), RELEASE_VERSION='5.17.0', RELEASE_REVISION='a' * 40, CLASSIC_LINUX_IMAGE="ghcr.io/atrinik/classic-build:1.16.0@sha256:" + "a" * 64, FAIL_VALIDATION=fail_validation, + INSPECTOR_AVAILABLE=inspector_available, PATH=f"{workspace / 'bin'}:{os.environ['PATH']}")) + if inspector_available == '0': + self.assertNotEqual(result.returncode, 0) + self.assertFalse((workspace / 'validator-args.json').exists()) + continue self.assertEqual(result.returncode, 7 if fail_validation == '1' else 0, result.stderr) args = json.loads((workspace / 'validator-args.json').read_text()) self.assertEqual(args, ['tools/release/appimage.py', diff --git a/tools/tests/test_linux_deb_workflow.py b/tools/tests/test_linux_deb_workflow.py index 6b69f01e9..a64e1cffc 100644 --- a/tools/tests/test_linux_deb_workflow.py +++ b/tools/tests/test_linux_deb_workflow.py @@ -133,6 +133,30 @@ def test_build_and_install_share_a_pinned_distribution_snapshot(self) -> None: self.assertNotIn('Verify-Peer=false', dockerfile) self.assertIn('/build-packages.tsv', dockerfile) + def test_dpkg_inspector_uses_exact_sdk_and_has_no_path_fallback(self) -> None: + dockerfile = (ROOT / 'tools/ci/debian-client/Dockerfile').read_text() + wrapper = ROOT / 'tools/ci/debian-client/objdump' + self.assertIn('COPY objdump /opt/atrinik-packaging/bin/objdump', dockerfile) + self.assertIn('PATH="/opt/atrinik-packaging/bin:/opt/llvm-23.1.3/bin:', dockerfile) + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + # A candidate's generic or LLVM-named binary must not satisfy a + # missing SDK. Exercise the actual package helper wrapper. + for name in ('objdump', 'llvm-objdump'): + tool = directory / name + tool.write_text('#!/bin/sh\nprintf "candidate inspector selected\\n"\n') + tool.chmod(0o755) + result = subprocess.run(['/bin/sh', str(wrapper), '--version'], + env=dict(os.environ, PATH=str(directory)), + capture_output=True, text=True, timeout=30) + self.assertNotIn('candidate inspector selected', result.stdout) + if Path('/opt/llvm-23.1.3/bin/llvm-objdump').is_file(): + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('LLVM version 23.1.3', result.stdout) + else: + self.assertNotEqual(result.returncode, 0) + self.assertIn('/opt/llvm-23.1.3/bin/llvm-objdump', result.stderr) + def test_publication_binds_validation_to_source_and_attests_deb(self) -> None: workflow = (ROOT / '.github/workflows/package-release.yml').read_text() self.assertNotIn('-eq 12', workflow) From 6c4508c495f9365d81580c989a9524e8a29d6cf0 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:07:28 -0500 Subject: [PATCH 105/148] test(build): verify Windows runtime CMake argument boundaries --- .../test_windows_runtime_cmake_arguments.py | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tools/tests/test_windows_runtime_cmake_arguments.py diff --git a/tools/tests/test_windows_runtime_cmake_arguments.py b/tools/tests/test_windows_runtime_cmake_arguments.py new file mode 100644 index 000000000..cde281532 --- /dev/null +++ b/tools/tests/test_windows_runtime_cmake_arguments.py @@ -0,0 +1,97 @@ +"""Check the argv produced by the generated Windows runtime install script.""" + +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +@unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") +class WindowsRuntimeCMakeArgumentsTests(unittest.TestCase): + def test_generated_install_preserves_argument_boundaries(self): + for python_runtime in (False, True): + with self.subTest(python_runtime=python_runtime), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "source with spaces" + root.mkdir() + runtime = root / "toolchain with spaces/bin" + runtime.mkdir(parents=True) + share = runtime.parent / "share" + llvm_notices = share / "licenses/llvm-mingw" + llvm_notices.mkdir(parents=True) + for name in ("LICENSE.TXT", "COPYING", "COPYING.MinGW-w64-runtime.txt", + "COPYING.MinGW-w64.txt", "COPYING.winpthreads.txt", + "COPYING.winstorecompat.txt"): + (llvm_notices / name).write_text("fixture notice\n") + dependency_notices = share / "licenses/fixture" + dependency_notices.mkdir() + (dependency_notices / "LICENSE").write_text("fixture notice\n") + inventory = share / "atrinik/windows-dependencies" + inventory.mkdir(parents=True) + (inventory / "dependencies.json").write_text(json.dumps({ + "schema_version": 1, + "dependencies": [{"name": "fixture", "licenses": ["LICENSE"]}], + })) + (inventory / "pe-closure.json").write_text("{}") + inspector = root / "llvm readobj" + inspector.write_text("fixture inspector\n") + helper = root / "tools/ci/stage_windows_runtime.py" + helper.parent.mkdir(parents=True) + helper.write_text( + "import argparse, json, pathlib, sys\n" + "parser = argparse.ArgumentParser()\n" + "parser.add_argument('--readobj', required=True)\n" + "parser.add_argument('--dlls-only', action='store_true')\n" + "parser.add_argument('--output-dir', required=True)\n" + "parser.add_argument('--runtime-dir', required=True)\n" + "parser.add_argument('--producer-inventory', required=True)\n" + "parser.add_argument('--python-runtime')\n" + "parser.add_argument('--python-inventory')\n" + "parser.add_argument('binaries', nargs='+')\n" + "args = parser.parse_args()\n" + "output = pathlib.Path(args.output_dir)\n" + "output.mkdir()\n" + "(output / 'argv.json').write_text(json.dumps(sys.argv[1:]))\n" + ) + python = root / "python runtime with spaces" + binaries = [root / "client binary.exe", root / "arena plugin.dll"] + cmake_source = ( + "cmake_minimum_required(VERSION 3.21)\n" + "project(runtime_arguments NONE)\n" + f'include("{ROOT}/cmake/InstallWindowsRuntime.cmake")\n' + f'set(ATRINIK_WINDOWS_RUNTIME_DIR "{runtime}")\n' + f'set(ATRINIK_LLVM_READOBJ "{inspector}")\n' + ) + if python_runtime: + cmake_source += f'set(ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR "{python}")\n' + for index, binary in enumerate(binaries): + cmake_source += ( + f"add_executable(binary{index} IMPORTED)\n" + f'set_target_properties(binary{index} PROPERTIES IMPORTED_LOCATION "{binary}")\n' + ) + cmake_source += ('atrinik_install_windows_runtime("runtime destination"\n' + ' "$" "$")\n') + (root / "CMakeLists.txt").write_text(cmake_source) + build = root / "build with spaces" + prefix = root / "install with spaces" + for command in (["cmake", "-S", str(root), "-B", str(build)], + ["cmake", "--install", str(build), "--prefix", str(prefix)]): + result = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + expected = ["--readobj", str(inspector), "--dlls-only", "--output-dir", + str(build / "windows-runtime-stage"), "--runtime-dir", str(runtime), + "--producer-inventory", + str(runtime / "../share/atrinik/windows-dependencies/pe-closure.json")] + if python_runtime: + expected += ["--python-runtime", str(python), "--python-inventory", + str(runtime / "../share/atrinik/windows-dependencies/python-runtime.json")] + expected += [str(binary) for binary in binaries] + actual = json.loads((prefix / "runtime destination/argv.json").read_text()) + self.assertEqual(actual, expected) + + +if __name__ == "__main__": + unittest.main() From 210241e46e4e32f36b5615412965b1b6c346f585 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:09:24 -0500 Subject: [PATCH 106/148] fix(ci): validate Docker manifest and config identities separately --- .github/workflows/qualify-server-image.yml | 16 ++- docs/RELEASING.md | 9 +- tools/ci/qualify_server_image.py | 87 ++++++++++-- tools/tests/test_qualify_server_image.py | 146 +++++++++++++++++++-- 4 files changed, 226 insertions(+), 32 deletions(-) diff --git a/.github/workflows/qualify-server-image.yml b/.github/workflows/qualify-server-image.yml index a544681eb..03e88b988 100644 --- a/.github/workflows/qualify-server-image.yml +++ b/.github/workflows/qualify-server-image.yml @@ -25,6 +25,7 @@ jobs: packages: read env: SOURCE_REVISION: ${{ github.sha }} + QUALIFICATION_IMAGE_TAG: classic-server-qualification:${{ github.run_id }}-${{ github.run_attempt }} DOCKER_BUILD_SUMMARY: true DOCKER_BUILD_RECORD_UPLOAD: true DOCKER_BUILD_RECORD_RETENTION_DAYS: 14 @@ -78,11 +79,11 @@ jobs: # run keeps build records and runtime evidence, not release provenance. provenance: false sbom: false - tags: classic-server-qualification:${{ github.run_id }}-${{ github.run_attempt }} + tags: ${{ env.QUALIFICATION_IMAGE_TAG }} - name: Retain build identity env: - IMAGE_ID: ${{ steps.image.outputs.imageid }} - IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + CONFIG_DIGEST: ${{ steps.image.outputs.imageid }} + MANIFEST_DIGEST: ${{ steps.image.outputs.digest }} BUILD_METADATA: ${{ steps.image.outputs.metadata }} run: | python3 - <<'PY' @@ -90,15 +91,16 @@ jobs: from pathlib import Path Path('build/server-image-evidence/build.json').write_text(json.dumps({ 'source_revision': os.environ['SOURCE_REVISION'], - 'image_id': os.environ['IMAGE_ID'], 'digest': os.environ['IMAGE_DIGEST'], + 'image_tag': os.environ['QUALIFICATION_IMAGE_TAG'], + 'config_digest': os.environ['CONFIG_DIGEST'], + 'manifest_digest': os.environ['MANIFEST_DIGEST'], 'metadata': json.loads(os.environ['BUILD_METADATA']), }, indent=2) + '\n') PY - name: Qualify final image behavior and lifecycle - env: - IMAGE_ID: ${{ steps.image.outputs.imageid }} run: >- - python3 tools/ci/qualify_server_image.py --image-id "${IMAGE_ID}" + python3 tools/ci/qualify_server_image.py --image-tag "${QUALIFICATION_IMAGE_TAG}" + --build-evidence build/server-image-evidence/build.json --revision "${SOURCE_REVISION}" --output build/server-image-evidence/runtime - name: Retain bounded qualification evidence, including failures if: always() diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f1e39bce5..b63a2861d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -744,7 +744,14 @@ continues to verify cached source archives and configure offline. Its final-stag APT install still needs the signed, locked Ubuntu snapshot; dependency-bundle verification does not claim that the entire Docker build is network-free. -`tools/ci/qualify_server_image.py` consumes the build's immutable local image ID. +`tools/ci/qualify_server_image.py` resolves this run's unique local image tag and +checks it against the action's config and manifest digests plus decoded BuildKit +metadata. Legacy Docker must report the config digest as its image ID with no +manifest descriptor; modern Docker must report the manifest digest as its ID and +a matching typed single-manifest descriptor. Indexes, rewritten digests and +unknown identity shapes fail closed. Evidence retains expected config and manifest +digests separately from the observed Docker ID; modern inspect does not independently +expose the config digest. All later operations use the validated immutable Docker ID. It uses UUID-labelled containers, the image's default UID 10001, no host mounts, no published ports, no external network, dropped capabilities, bounded resources and disposable container state. It exercises the existing offline benchmark on diff --git a/tools/ci/qualify_server_image.py b/tools/ci/qualify_server_image.py index 788370608..9bfe5e538 100644 --- a/tools/ci/qualify_server_image.py +++ b/tools/ci/qualify_server_image.py @@ -19,6 +19,8 @@ READY = "Server ready. Waiting for connections" SAVED = "Server saves complete; releasing resources." DIGEST = re.compile(r"sha256:[0-9a-f]{64}") +MANIFEST_TYPES = {"application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"} class QualificationError(RuntimeError): @@ -36,16 +38,59 @@ def command(*args, timeout=30): return result.stdout -def validate_image(image, image_id, revision): - require(image.get("Id") == image_id, "Loaded image ID differs from the build output") +def validate_descriptor(descriptor, manifest_digest): + require(isinstance(descriptor, dict), "Expected decoded manifest descriptor object") + require(isinstance(descriptor.get("mediaType"), str) and descriptor["mediaType"] in MANIFEST_TYPES, + "Expected a single image manifest, not an index or artifact") + require(descriptor.get("digest") == manifest_digest, "Manifest descriptor digest mismatch") + require(type(descriptor.get("size")) is int and descriptor["size"] > 0, "Invalid manifest descriptor size") + require("artifactType" not in descriptor, "Artifact descriptors are not runtime images") + if "platform" in descriptor: + platform = descriptor["platform"] + require(isinstance(platform, dict) and platform.get("os") == "linux" + and platform.get("architecture") == "amd64", "Wrong descriptor platform") + + +def validate_build(build, image_tag, revision): + require(isinstance(image_tag, str) and re.fullmatch(r"classic-server-qualification:[1-9][0-9]*-[1-9][0-9]*", image_tag), + "Expected the owned run/attempt qualification tag") + require(isinstance(build, dict) and build.get("source_revision") == revision + and build.get("image_tag") == image_tag, "Build evidence source or tag mismatch") + for name in ("config_digest", "manifest_digest"): + require(isinstance(build.get(name), str) and DIGEST.fullmatch(build[name]), f"Invalid expected {name}") + require(build["config_digest"] != build["manifest_digest"], "Config and manifest identities must be distinct") + metadata = build.get("metadata") + require(isinstance(metadata, dict), "Expected BuildKit metadata object") + require(metadata.get("containerimage.config.digest") == build["config_digest"], + "Build config output differs from metadata") + require(metadata.get("containerimage.digest") == build["manifest_digest"], + "Build manifest output differs from metadata") + validate_descriptor(metadata.get("containerimage.descriptor"), build["manifest_digest"]) + + +def validate_image(image, build, revision): + require(isinstance(image, dict), "Expected Docker image object") + descriptor = image.get("Descriptor") + if descriptor is None: + require(image.get("Id") == build["config_digest"], "Legacy Docker ID differs from expected config digest") + identity_kind = "legacy-config" + else: + validate_descriptor(descriptor, build["manifest_digest"]) + expected = build["metadata"]["containerimage.descriptor"] + require(all(descriptor[key] == expected[key] for key in ("digest", "mediaType", "size")), + "Docker manifest descriptor differs from BuildKit metadata") + require(image.get("Id") == build["manifest_digest"], "Docker ID differs from expected manifest digest") + identity_kind = "manifest" require(image.get("Architecture") == "amd64" and image.get("Os") == "linux", "Wrong image platform") - config = image["Config"] + config = image.get("Config") + require(isinstance(config, dict), "Expected Docker image configuration") require(config.get("User") == "atrinik", "Final image must default to the nonroot atrinik user") require(config.get("Entrypoint") == ["atrinik-server-entrypoint"], "Wrong final image entrypoint") require(not config.get("Volumes"), "Unexpected image volumes") labels = config.get("Labels") or {} require(labels.get("org.opencontainers.image.revision") == revision, "Image source revision mismatch") require(labels.get("org.opencontainers.image.version") == "0.0.0", "Qualification version must be 0.0.0") + return identity_kind def validate_container(container, container_id, image_id, owner): @@ -76,26 +121,43 @@ def validate_benchmark(output): class Qualification: - def __init__(self, image_id, revision, output): - self.image_id = image_id + def __init__(self, image_tag, build, revision, output): + self.image_tag = image_tag + self.build = build + self.image_id = None self.revision = revision self.output = output self.owner = uuid.uuid4().hex self.containers = [] - self.evidence = {"schema_version": 1, "source_revision": revision, "image_id": image_id, + self.evidence = {"schema_version": 1, "source_revision": revision, "image_tag": image_tag, "owner": self.owner, "result": "failed", "containers": [], "cleanup": []} def write(self, name, value): (self.output / name).write_text(json.dumps(value, indent=2) + "\n") + def resolve_image(self): + validate_build(self.build, self.image_tag, self.revision) + self.evidence.update(expected_config_digest=self.build["config_digest"], + expected_manifest_digest=self.build["manifest_digest"]) + images = json.loads(command("docker", "image", "inspect", self.image_tag)) + require(isinstance(images, list) and len(images) == 1, "Expected one image for the owned tag") + image = images[0] + identity_kind = validate_image(image, self.build, self.revision) + self.image_id = image["Id"] + self.evidence.update(image_id=self.image_id, image_identity_kind=identity_kind) + self.write("image.json", {key: image[key] for key in + ("Id", "Descriptor", "Architecture", "Os", "Created", "Config") if key in image}) + def inspect(self, container_id): image = json.loads(command("docker", "image", "inspect", self.image_id))[0] - validate_image(image, self.image_id, self.revision) + validate_image(image, self.build, self.revision) + require(image["Id"] == self.image_id, "Resolved Docker image ID changed") container = json.loads(command("docker", "container", "inspect", container_id))[0] validate_container(container, container_id, self.image_id, self.owner) return container def create(self, purpose, *args): + require(self.image_id is not None, "Resolve and validate the built image before creating containers") container_id = command( "docker", "create", "--name", f"classic-qualification-{self.owner}-{purpose}", "--label", f"{OWNER_LABEL}={self.owner}", "--network", "none", "--cap-drop", "ALL", @@ -212,13 +274,10 @@ def run(self): self.output.mkdir(parents=True, exist_ok=False) try: require(re.fullmatch(r"[0-9a-f]{40}", self.revision), "Expected full source commit") - require(DIGEST.fullmatch(self.image_id), "Expected immutable build image ID") require(command("git", "-C", str(ROOT), "rev-parse", "HEAD").strip() == self.revision, "Source HEAD differs") require(not command("git", "-C", str(ROOT), "status", "--porcelain", "--untracked-files=no").strip(), "Tracked source is dirty") - image = json.loads(command("docker", "image", "inspect", self.image_id))[0] - validate_image(image, self.image_id, self.revision) - self.write("image.json", {key: image[key] for key in ("Id", "Architecture", "Os", "Created", "Config")}) + self.resolve_image() self.evidence["source_inputs"] = { name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in ("server/Dockerfile", "server/docker/runtime-provider.lock.json", "dependencies.bundle.json")} @@ -243,11 +302,13 @@ def run(self): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--image-id", required=True) + parser.add_argument("--image-tag", required=True) + parser.add_argument("--build-evidence", required=True, type=Path) parser.add_argument("--revision", required=True) parser.add_argument("--output", required=True, type=Path) args = parser.parse_args() - Qualification(args.image_id, args.revision, args.output).run() + build = json.loads(args.build_evidence.read_text()) + Qualification(args.image_tag, build, args.revision, args.output).run() if __name__ == "__main__": diff --git a/tools/tests/test_qualify_server_image.py b/tools/tests/test_qualify_server_image.py index 4ae5e9da8..b9e6f737b 100644 --- a/tools/tests/test_qualify_server_image.py +++ b/tools/tests/test_qualify_server_image.py @@ -18,6 +18,13 @@ SPEC.loader.exec_module(q) REVISION = "a" * 40 IMAGE_ID = "sha256:" + "b" * 64 +MANIFEST_DIGEST = "sha256:" + "c" * 64 +IMAGE_TAG = "classic-server-qualification:12345-1" +DESCRIPTOR = {"mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": MANIFEST_DIGEST, "size": 1234} +BUILD = {"source_revision": REVISION, "image_tag": IMAGE_TAG, "config_digest": IMAGE_ID, + "manifest_digest": MANIFEST_DIGEST, "metadata": { + "containerimage.config.digest": IMAGE_ID, "containerimage.digest": MANIFEST_DIGEST, + "containerimage.descriptor": DESCRIPTOR}} IMAGE = {"Id": IMAGE_ID, "Os": "linux", "Architecture": "amd64", "Created": "today", "Config": {"User": "atrinik", "Entrypoint": ["atrinik-server-entrypoint"], "Volumes": None, "Labels": {"org.opencontainers.image.revision": REVISION, @@ -27,8 +34,11 @@ class DockerFixture: - def __init__(self, fault=None): + def __init__(self, fault=None, modern=False): self.fault = fault + self.image = copy.deepcopy(IMAGE) + if modern: + self.image.update(Id=MANIFEST_DIGEST, Descriptor=copy.deepcopy(DESCRIPTOR)) self.containers = {} self.commands = [] self.starts = 0 @@ -41,12 +51,16 @@ def run(self, args, **kwargs): if args[0] == "git": output = REVISION if "rev-parse" in args else "" elif args[1:3] == ["image", "inspect"]: - output = json.dumps([IMAGE]) + # On modern stores only the tag and manifest ID are inspectable. + if args[-1] not in (IMAGE_TAG, self.image["Id"]): + code = 1 + else: + output = json.dumps([self.image]) elif args[1] == "create": cid = str(len(self.containers) + 1) * 64 label = args[args.index("--label") + 1].split("=", 1)[1] self.containers[cid] = { - "Id": cid, "Image": IMAGE_ID, "Config": {"User": "atrinik", "Labels": {q.OWNER_LABEL: label}}, + "Id": cid, "Image": self.image["Id"], "Config": {"User": "atrinik", "Labels": {q.OWNER_LABEL: label}}, "Mounts": [], "HostConfig": {"NetworkMode": "none", "PortBindings": {}, "Privileged": False, "CapDrop": ["ALL"], "SecurityOpt": ["no-new-privileges"]}, "State": {"Running": False, "Status": "created", "ExitCode": 0, "OOMKilled": False, @@ -95,18 +109,113 @@ def run(self, args, **kwargs): class QualificationTests(unittest.TestCase): def test_image_identity_platform_user_entrypoint_and_volumes(self): - q.validate_image(IMAGE, IMAGE_ID, REVISION) + q.validate_image(IMAGE, BUILD, REVISION) for field, value in (("Id", "sha256:" + "c" * 64), ("Architecture", "arm64")): image = copy.deepcopy(IMAGE) image[field] = value with self.assertRaises(q.QualificationError): - q.validate_image(image, IMAGE_ID, REVISION) + q.validate_image(image, BUILD, REVISION) for field, value in (("User", "root"), ("Entrypoint", ["sh"]), ("Volumes", {"/host": {}}), ("Labels", {"org.opencontainers.image.revision": "c" * 40})): image = copy.deepcopy(IMAGE) image["Config"][field] = value with self.assertRaises(q.QualificationError): - q.validate_image(image, IMAGE_ID, REVISION) + q.validate_image(image, BUILD, REVISION) + + def test_supported_image_identity_shapes_are_distinct(self): + q.validate_build(BUILD, IMAGE_TAG, REVISION) + for null_descriptor in (False, True): + image = copy.deepcopy(IMAGE) + if null_descriptor: + image["Descriptor"] = None + self.assertEqual(q.validate_image(image, BUILD, REVISION), "legacy-config") + for media_type in q.MANIFEST_TYPES: + with self.subTest(media_type=media_type): + build = copy.deepcopy(BUILD) + build["metadata"]["containerimage.descriptor"]["mediaType"] = media_type + image = copy.deepcopy(IMAGE) + image.update(Id=MANIFEST_DIGEST, Descriptor=copy.deepcopy(build["metadata"]["containerimage.descriptor"])) + self.assertEqual(q.validate_image(image, build, REVISION), "manifest") + image["Descriptor"]["platform"] = {"os": "linux", "architecture": "amd64"} + self.assertEqual(q.validate_image(image, build, REVISION), "manifest") + + def test_build_identity_requires_matching_outputs_source_tag_and_descriptor(self): + changes = [ + ("config_digest", "sha256:" + "d" * 64), ("manifest_digest", "sha256:" + "d" * 64), + ("config_digest", "not-a-digest"), ("manifest_digest", None), + ("source_revision", "d" * 40), ("image_tag", "classic-server-qualification:12345-2"), + ("metadata", None), + ] + for key, value in changes: + with self.subTest(key=key, value=value): + build = copy.deepcopy(BUILD) + build[key] = value + with self.assertRaises(q.QualificationError): + q.validate_build(build, IMAGE_TAG, REVISION) + for descriptor in (None, "base64-not-decoded", {}, {**DESCRIPTOR, "size": True}, + {**DESCRIPTOR, "size": 0}, {**DESCRIPTOR, "size": -1}, + {**DESCRIPTOR, "mediaType": "application/vnd.oci.image.index.v1+json"}, + {**DESCRIPTOR, "mediaType": []}, + {**DESCRIPTOR, "artifactType": "application/example"}, + {**DESCRIPTOR, "platform": {"os": "linux", "architecture": "arm64"}}): + build = copy.deepcopy(BUILD) + build["metadata"]["containerimage.descriptor"] = descriptor + with self.subTest(descriptor=descriptor), self.assertRaises(q.QualificationError): + q.validate_build(build, IMAGE_TAG, REVISION) + for tag in ("ghcr.io/atrinik/classic-server:development", "classic-server-qualification:12345-0", "sha256:abcd"): + with self.subTest(tag=tag), self.assertRaises(q.QualificationError): + q.validate_build(BUILD, tag, REVISION) + + def test_modern_rewritten_unknown_index_and_mixed_identities_fail_closed(self): + modern = copy.deepcopy(IMAGE) + modern.update(Id=MANIFEST_DIGEST, Descriptor=copy.deepcopy(DESCRIPTOR)) + variants = [] + for value in (None, False, {}, [], {**DESCRIPTOR, "digest": "sha256:" + "d" * 64}, + {**DESCRIPTOR, "size": DESCRIPTOR["size"] + 1}, {**DESCRIPTOR, "size": True}, + {**DESCRIPTOR, "mediaType": "application/vnd.docker.distribution.manifest.v2+json"}, + {**DESCRIPTOR, "mediaType": "application/vnd.oci.image.index.v1+json"}, + {**DESCRIPTOR, "platform": {"os": "windows", "architecture": "amd64"}}): + image = copy.deepcopy(modern) + image["Descriptor"] = value + variants.append(image) + for image_id in (IMAGE_ID, "sha256:" + "d" * 64, "not-a-digest"): + image = copy.deepcopy(modern) + image["Id"] = image_id + variants.append(image) + absent = copy.deepcopy(modern) + del absent["Descriptor"] + variants.append(absent) + for image in variants: + with self.subTest(image=image), self.assertRaises(q.QualificationError): + q.validate_image(image, BUILD, REVISION) + + def test_modern_runtime_uses_manifest_id_without_inventing_config_observation(self): + fixture, evidence = self.exercise(modern=True) + self.assertEqual(evidence["expected_config_digest"], IMAGE_ID) + self.assertEqual(evidence["expected_manifest_digest"], MANIFEST_DIGEST) + self.assertEqual(evidence["image_id"], MANIFEST_DIGEST) + self.assertEqual(evidence["image_identity_kind"], "manifest") + inspections = [command[-1] for command in fixture.commands if command[1:3] == ["image", "inspect"]] + self.assertEqual(inspections[0], IMAGE_TAG) + self.assertTrue(all(value == MANIFEST_DIGEST for value in inspections[1:])) + for command in fixture.commands: + if command[1] == "create": + self.assertIn(MANIFEST_DIGEST, command) + self.assertNotIn(IMAGE_TAG, command) + self.assertNotIn(IMAGE_ID, command) + + def test_rewritten_tag_fails_before_any_container_creation(self): + fixture = DockerFixture(modern=True) + fixture.image["Id"] = "sha256:" + "d" * 64 + with tempfile.TemporaryDirectory() as temporary: + qualifier = q.Qualification(IMAGE_TAG, copy.deepcopy(BUILD), REVISION, Path(temporary) / "evidence") + with patch.object(q.subprocess, "run", side_effect=fixture.run): + with self.assertRaises(q.QualificationError): + qualifier.run() + evidence = json.loads((qualifier.output / "qualification.json").read_text()) + self.assertEqual(evidence["result"], "failed") + self.assertNotIn("image_id", evidence) + self.assertFalse(any(command[1] == "create" for command in fixture.commands)) def test_benchmark_requires_complete_exact_sample(self): self.assertEqual(q.validate_benchmark(BENCHMARK)["map"], q.MAP) @@ -116,12 +225,12 @@ def test_benchmark_requires_complete_exact_sample(self): with self.subTest(output=output), self.assertRaises(q.QualificationError): q.validate_benchmark(output) - def exercise(self, fault=None): + def exercise(self, fault=None, modern=False): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) output = Path(temporary.name) / "evidence" - fixture = DockerFixture(fault) - qualifier = q.Qualification(IMAGE_ID, REVISION, output) + fixture = DockerFixture(fault, modern) + qualifier = q.Qualification(IMAGE_TAG, copy.deepcopy(BUILD), REVISION, output) # Advancing test clock makes missing readiness hit a bounded deadline. with patch.object(q.subprocess, "run", side_effect=fixture.run), \ patch.object(q.time, "sleep"), patch.object(q.time, "monotonic", side_effect=range(0, 10000, 61)): @@ -156,8 +265,9 @@ def test_behavior_provider_readiness_heartbeat_and_shutdown_fail_closed(self): def test_foreign_container_is_never_removed(self): fixture = DockerFixture() with tempfile.TemporaryDirectory() as temporary: - qualifier = q.Qualification(IMAGE_ID, REVISION, Path(temporary)) + qualifier = q.Qualification(IMAGE_TAG, copy.deepcopy(BUILD), REVISION, Path(temporary)) with patch.object(q.subprocess, "run", side_effect=fixture.run): + qualifier.resolve_image() cid = qualifier.create("fixture") fixture.containers[cid]["Config"]["Labels"][q.OWNER_LABEL] = "foreign" with self.assertRaisesRegex(q.QualificationError, "ownership"): @@ -165,11 +275,25 @@ def test_foreign_container_is_never_removed(self): self.assertFalse(any(c[1] == "rm" for c in fixture.commands)) self.assertFalse(qualifier.evidence["cleanup"][0]["removed"]) + def test_modern_descriptor_drift_blocks_cleanup(self): + fixture = DockerFixture(modern=True) + with tempfile.TemporaryDirectory() as temporary: + qualifier = q.Qualification(IMAGE_TAG, copy.deepcopy(BUILD), REVISION, Path(temporary)) + with patch.object(q.subprocess, "run", side_effect=fixture.run): + qualifier.resolve_image() + qualifier.create("fixture") + fixture.image["Descriptor"]["size"] += 1 + with self.assertRaisesRegex(q.QualificationError, "descriptor differs"): + qualifier.cleanup() + self.assertFalse(any(command[1] == "rm" for command in fixture.commands)) + self.assertFalse(qualifier.evidence["cleanup"][0]["removed"]) + def test_network_mount_and_image_drift_rejected(self): fixture = DockerFixture() with tempfile.TemporaryDirectory() as temporary: - qualifier = q.Qualification(IMAGE_ID, REVISION, Path(temporary)) + qualifier = q.Qualification(IMAGE_TAG, copy.deepcopy(BUILD), REVISION, Path(temporary)) with patch.object(q.subprocess, "run", side_effect=fixture.run): + qualifier.resolve_image() cid = qualifier.create("fixture") original = copy.deepcopy(fixture.containers[cid]) for field, value in (("Image", "sha256:" + "c" * 64), ("Mounts", [{"Source": "/host"}])): From f0e055cdcdb4aa8f25cd6a92c469175ac5710870 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:11:01 -0500 Subject: [PATCH 107/148] fix(ci): retain the pinned historical Debian GCC producer --- .github/workflows/build-release-candidate.yml | 12 +- docs/RELEASING.md | 8 ++ tools/ci/debian-client/Dockerfile.historical | 33 +++++ tools/ci/debian-client/historical_producer.py | 58 ++++++++ .../tests/test_historical_debian_producer.py | 131 ++++++++++++++++++ tools/tests/test_linux_deb_workflow.py | 7 +- 6 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 tools/ci/debian-client/Dockerfile.historical create mode 100644 tools/ci/debian-client/historical_producer.py create mode 100644 tools/tests/test_historical_debian_producer.py diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 2e1359385..5b4aac199 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -239,8 +239,6 @@ jobs: client-linux: name: Build and install Debian client package - env: - CLASSIC_LINUX_IMAGE: ${{ needs.metadata.outputs.compiler_image }} needs: [metadata, dependencies, gpu-shaders] if: needs.metadata.outputs.artifact_schema == '2' permissions: @@ -275,13 +273,15 @@ jobs: name: discord-application-id-${{ inputs.tag }} path: build/discord-config - name: Prepare pinned Debian testing build and clean runtime + env: + RELEASE_REVISION: ${{ needs.metadata.outputs.commit }} run: | + debian_producer=$(python3 tools/ci/debian-client/historical_producer.py \ + --source-root "${GITHUB_WORKSPACE}/candidate-source" --revision "${RELEASE_REVISION}") docker build --platform linux/amd64 --target build --tag classic-deb-build \ - --build-arg "ATRINIK_COMPILER_IMAGE=${CLASSIC_LINUX_IMAGE}" \ - --file tools/ci/debian-client/Dockerfile tools/ci/debian-client + --file "${debian_producer}" tools/ci/debian-client docker build --platform linux/amd64 --target runtime --tag classic-deb-runtime \ - --build-arg "ATRINIK_COMPILER_IMAGE=${CLASSIC_LINUX_IMAGE}" \ - --file tools/ci/debian-client/Dockerfile tools/ci/debian-client + --file "${debian_producer}" tools/ci/debian-client mkdir -p build/linux-package-evidence build/linux-packages build/discord-config docker image inspect classic-deb-build classic-deb-runtime \ --format '{{.Id}}' > build/linux-package-evidence/images.txt diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b63a2861d..e3c1b1d56 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -648,6 +648,14 @@ place of the Debian package. Historical schema 2 retains its 13 files including 12 files. Publication and recovery select the schema of the original source revision, validate the manifest, locked inputs and every artifact hash, and never append or replace a platform artifact in an already published release. +Schema-2 Debian rebuilds use the original GCC producer retained in trusted +automation as `tools/ci/debian-client/Dockerfile.historical`. Its Debian image +digest and APT snapshot remain pinned. Before building, trusted automation +requires the exact candidate's committed producer and the retained copy to match +the approved SHA-256; an unknown contract fails instead of selecting another +compiler. Candidate sources and dependency locks remain unchanged. The current +LLVM producer and source-locked AppImage compiler selection retain their own +contracts. Automatic main releases use the complete schema-3 contract; the checked-batch queue, immutable publication and recovery safeguards remain unchanged. diff --git a/tools/ci/debian-client/Dockerfile.historical b/tools/ci/debian-client/Dockerfile.historical new file mode 100644 index 000000000..5e96dda41 --- /dev/null +++ b/tools/ci/debian-client/Dockerfile.historical @@ -0,0 +1,33 @@ +# Debian testing supplies the SDL 3.4 and SDL3_mixer APIs used by Classic. +# Freeze build and install inputs together; runtime dependencies remain ranges. +FROM debian:forky-slim@sha256:3c80e257eff6274d593eb33a94bda5174ccd619836e6a728a73e11c97f2213e3 AS runtime + +RUN rm -f /etc/apt/sources.list.d/debian.sources \ + && printf '%s\n' \ + 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20261009T000000Z/ forky main' \ + > /etc/apt/sources.list \ + && apt-get -o Acquire::Retries=3 -o APT::Update::Error-Mode=any update \ + && apt-get install -y --no-install-recommends ca-certificates util-linux \ + && sed -i 's|http://snapshot.debian.org|https://snapshot.debian.org|g' /etc/apt/sources.list \ + && rm -rf /var/lib/apt/lists/* + +# The slim image excludes documentation by default. Retain the client notices +# so the clean-install smoke can inspect the installed package's license files. +RUN printf '%s\n' 'path-include=/usr/share/doc/atrinik' \ + 'path-include=/usr/share/doc/atrinik/*' > /etc/dpkg/dpkg.cfg.d/zz-atrinik-notices + +FROM runtime AS build +RUN apt-get -o Acquire::Retries=3 -o APT::Update::Error-Mode=any update \ + && apt-get install -y --no-install-recommends \ + binutils build-essential cmake dpkg-dev file libcurl4-openssl-dev \ + libidn2-dev libsdl3-dev libsdl3-image-dev libsdl3-mixer-dev \ + libsdl3-ttf-dev libssl-dev libvulkan1 libxml2-dev ninja-build \ + pkgconf python3 zlib1g-dev \ + && dpkg-query -W -f='${binary:Package}\t${Version}\n' > /build-packages.tsv \ + && rm -rf /var/lib/apt/lists/* + +# Git records the exact source identity; ShellCheck validates packaging scripts. +RUN apt-get -o Acquire::Retries=3 -o APT::Update::Error-Mode=any update \ + && apt-get install -y --no-install-recommends git shellcheck \ + && dpkg-query -W -f='${binary:Package}\t${Version}\n' > /build-packages.tsv \ + && rm -rf /var/lib/apt/lists/* diff --git a/tools/ci/debian-client/historical_producer.py b/tools/ci/debian-client/historical_producer.py new file mode 100644 index 000000000..4e6431d07 --- /dev/null +++ b/tools/ci/debian-client/historical_producer.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Select the approved frozen GCC producer for historical Debian recovery.""" +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import re +import subprocess + + +SOURCE_PRODUCER = "tools/ci/debian-client/Dockerfile" +TRUSTED_PRODUCER = "tools/ci/debian-client/Dockerfile.historical" +# Original schema-2 producer at 994e6fdc20; frozen Debian digest and APT snapshot. +PRODUCER_SHA256 = "9ff6f7e13e736b7ee68e42bf0fd19107143460c8784401525e984222ef845323" + + +def resolve_producer(source_root: Path, revision: str, trusted_root: Path) -> str: + if re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise ValueError("release revision must be an exact lowercase commit SHA") + + def git(*arguments: str) -> bytes: + return subprocess.run(["git", "-C", str(source_root), *arguments], check=True, + capture_output=True, timeout=30).stdout + + if git("rev-parse", "--verify", revision + "^{commit}").decode().strip() != revision: + raise ValueError("release revision is not the selected commit") + if git("rev-parse", "HEAD").decode().strip() != revision or git("status", "--porcelain", "--untracked-files=all"): + raise ValueError("candidate source checkout must be clean at the exact release revision") + entry = git("ls-tree", revision, "--", SOURCE_PRODUCER).decode().split() + if entry[:2] != ["100644", "blob"]: + raise ValueError("historical Debian producer must be a regular Git blob") + source = git("show", revision + ":" + SOURCE_PRODUCER) + if hashlib.sha256(source).hexdigest() != PRODUCER_SHA256: + raise ValueError("source has no approved historical Debian producer") + trusted = trusted_root / TRUSTED_PRODUCER + if trusted.is_symlink() or hashlib.sha256(trusted.read_bytes()).hexdigest() != PRODUCER_SHA256: + raise ValueError("trusted historical Debian producer differs from its approved pin") + return TRUSTED_PRODUCER + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--revision", required=True) + args = parser.parse_args() + try: + print(resolve_producer(args.source_root, args.revision, Path(__file__).resolve().parents[3])) + except (ValueError, OSError, subprocess.SubprocessError) as error: + parser.exit(1, f"cannot select historical Debian producer: {error}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_historical_debian_producer.py b/tools/tests/test_historical_debian_producer.py new file mode 100644 index 000000000..5ec2bbb46 --- /dev/null +++ b/tools/tests/test_historical_debian_producer.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import runpy +import shutil +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SELECTOR = ROOT / "tools/ci/debian-client/historical_producer.py" +PRODUCER = runpy.run_path(str(SELECTOR)) +BASELINE = "994e6fdc20da962be9c11055bda28ef5561a1dea" + + +class HistoricalDebianProducerTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.workspace = Path(temporary.name) + self.source = self.workspace / "candidate-source" + self.source.mkdir() + self.git("init", "--quiet") + self.git("config", "user.name", "Historical producer fixture") + self.git("config", "user.email", "fixture@example.invalid") + for name in ("Dockerfile.historical", "historical_producer.py"): + target = self.workspace / "tools/ci/debian-client" / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(SELECTOR.parent / name, target) + self.path = self.source / PRODUCER["SOURCE_PRODUCER"] + self.path.parent.mkdir(parents=True) + self.path.write_bytes((ROOT / PRODUCER["TRUSTED_PRODUCER"]).read_bytes()) + self.revision = self.commit() + + def git(self, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(self.source), *args], text=True).strip() + + def commit(self) -> str: + self.git("add", ".") + self.git("-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "fixture") + return self.git("rev-parse", "HEAD") + + def resolve(self, revision: str | None = None) -> str: + return PRODUCER["resolve_producer"](self.source, revision or self.revision, self.workspace) + + def test_exact_original_source_producer_is_retained(self) -> None: + original = subprocess.check_output(["git", "-C", str(ROOT), "show", + BASELINE + ":" + PRODUCER["SOURCE_PRODUCER"]]) + retained = (ROOT / PRODUCER["TRUSTED_PRODUCER"]).read_bytes() + self.assertEqual(retained, original) + self.assertIn(b"binutils build-essential", retained) + self.assertNotIn(b"ATRINIK_COMPILER_IMAGE", retained) + self.assertNotIn(b"ENV PATH=", retained) + schema = subprocess.check_output(["python3", str(ROOT / "tools/release/release_artifacts.py"), + "--print-source-schema", "--revision", BASELINE, "--source-root", str(ROOT)], text=True) + self.assertEqual(schema.strip(), "2") + self.assertEqual(self.resolve(), PRODUCER["TRUSTED_PRODUCER"]) + + def test_checkout_must_match_the_immutable_selected_source(self) -> None: + self.path.write_text("FROM unsupported:latest\n") + with self.assertRaisesRegex(ValueError, "must be clean at the exact release revision"): + self.resolve() + newer = self.commit() + with self.assertRaisesRegex(ValueError, "must be clean at the exact release revision"): + self.resolve() + with self.assertRaisesRegex(ValueError, "no approved historical"): + self.resolve(newer) + with self.assertRaisesRegex(ValueError, "exact lowercase"): + self.resolve("HEAD") + + def test_source_symlink_and_missing_producer_fail_closed(self) -> None: + self.path.unlink() + self.path.symlink_to("Dockerfile.historical") + with self.assertRaisesRegex(ValueError, "regular Git blob"): + self.resolve(self.commit()) + self.path.unlink() + with self.assertRaisesRegex(ValueError, "regular Git blob"): + self.resolve(self.commit()) + + def test_trusted_producer_drift_and_symlink_fail_closed(self) -> None: + retained = self.workspace / PRODUCER["TRUSTED_PRODUCER"] + retained.write_text("FROM unexpected:latest\n") + with self.assertRaisesRegex(ValueError, "differs from its approved pin"): + self.resolve() + retained.unlink() + retained.symlink_to(self.path) + with self.assertRaisesRegex(ValueError, "differs from its approved pin"): + self.resolve() + + def test_workflow_selects_pinned_gcc_and_rejects_unknown_source_before_docker(self) -> None: + workflow = (ROOT / ".github/workflows/build-release-candidate.yml").read_text() + step = workflow.split(" - name: Prepare pinned Debian", 1)[1].split(" - name:", 1)[0] + script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + binary = self.workspace / "bin" + binary.mkdir() + docker = binary / "docker" + docker.write_text("""#!/usr/bin/env python3 +import json, pathlib, sys +with pathlib.Path('docker-calls.jsonl').open('a') as output: + output.write(json.dumps(sys.argv[1:]) + '\\n') +""") + docker.chmod(0o755) + environment = dict(os.environ, GITHUB_WORKSPACE=str(self.workspace), + RELEASE_REVISION=self.revision, CLASSIC_LINUX_IMAGE="must-not-use-current-LLVM", + PATH=str(binary) + ":" + os.environ["PATH"]) + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], + cwd=self.workspace, env=environment, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.workspace / "docker-calls.jsonl" + builds = [json.loads(line) for line in calls.read_text().splitlines() + if json.loads(line)[0] == "build"] + self.assertEqual(len(builds), 2) + for build in builds: + self.assertEqual(build[build.index("--file") + 1], PRODUCER["TRUSTED_PRODUCER"]) + self.assertNotIn("--build-arg", build) + calls.unlink() + self.path.write_text("FROM unapproved:latest\n") + environment["RELEASE_REVISION"] = self.commit() + rejected = subprocess.run(["bash", "-euo", "pipefail", "-c", script], + cwd=self.workspace, env=environment, capture_output=True, text=True) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn("no approved historical Debian producer", rejected.stderr) + self.assertFalse(calls.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_linux_deb_workflow.py b/tools/tests/test_linux_deb_workflow.py index a64e1cffc..db72a940d 100644 --- a/tools/tests/test_linux_deb_workflow.py +++ b/tools/tests/test_linux_deb_workflow.py @@ -52,9 +52,10 @@ def test_candidate_automation_and_source_have_separate_authority(self) -> None: self.assertNotIn('actions/cache@', job) self.assertNotIn('github.token', job) preparation = job.split('name: Prepare pinned Debian', 1)[1].split(' - name:', 1)[0] - self.assertNotIn('candidate-source', preparation) - self.assertEqual(preparation.count('--build-arg "ATRINIK_COMPILER_IMAGE=${CLASSIC_LINUX_IMAGE}"'), 2) - self.assertIn('--file tools/ci/debian-client/Dockerfile tools/ci/debian-client', preparation) + self.assertIn('historical_producer.py', preparation) + self.assertIn('--source-root "${GITHUB_WORKSPACE}/candidate-source" --revision "${RELEASE_REVISION}"', preparation) + self.assertNotIn('ATRINIK_COMPILER_IMAGE', preparation) + self.assertEqual(preparation.count('--file "${debian_producer}" tools/ci/debian-client'), 2) smoke = job.split('name: Install through APT', 1)[1].split(' - uses:', 1)[0] self.assertNotIn('candidate-source', smoke) self.assertIn('$GITHUB_WORKSPACE/tools/ci/smoke_linux_client_package.sh,target=/smoke.sh,readonly', smoke) From 344f54ac64f402b59f65f98578360fed6da74352 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:14:55 -0500 Subject: [PATCH 108/148] test(ci): observe owned Windows client wait chains after close timeout --- tools/ci/smoke_windows_review_bundle.ps1 | 149 +++++++++++++++++- .../test_compose_windows_review_bundle.py | 53 ++++++- tools/tests/windows_wait_chain_fixture.ps1 | 74 +++++++++ 3 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 tools/tests/windows_wait_chain_fixture.ps1 diff --git a/tools/ci/smoke_windows_review_bundle.ps1 b/tools/ci/smoke_windows_review_bundle.ps1 index 57f5bf96a..e22cd3ded 100644 --- a/tools/ci/smoke_windows_review_bundle.ps1 +++ b/tools/ci/smoke_windows_review_bundle.ps1 @@ -174,7 +174,149 @@ public static class AtrinikReviewCloseTimeoutProbe { private static extern IntPtr SendMessageTimeoutW(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, uint flags, uint timeout, out UIntPtr result); - public static string Snapshot(int processId, IntPtr mainWindow) { + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenThread(uint access, bool inherit, uint threadId); + [DllImport("kernel32.dll")] + private static extern uint GetProcessIdOfThread(IntPtr thread); + [DllImport("kernel32.dll")] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + private delegate void WaitChainCallback(IntPtr session, UIntPtr context, uint status, + IntPtr count, IntPtr nodes, IntPtr cycle); + [DllImport("advapi32.dll", SetLastError = true)] + private static extern IntPtr OpenThreadWaitChainSession(uint flags, WaitChainCallback callback); + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetThreadWaitChain(IntPtr session, UIntPtr context, uint flags, + uint threadId, IntPtr count, IntPtr nodes, IntPtr cycle); + [DllImport("advapi32.dll")] + private static extern void CloseThreadWaitChainSession(IntPtr session); + + // ABI and async lifetime contracts: + // https://learn.microsoft.com/windows/win32/api/wct/ns-wct-waitchain_node_info + // https://learn.microsoft.com/windows/win32/api/wct/nf-wct-closethreadwaitchainsession + // Windows SDK wct.h: two DWORD enums followed by an 8-byte-aligned union + // containing WCHAR[128], LARGE_INTEGER and BOOL (280 bytes on Windows x64). + // No object-name bytes are decoded. Only the thread arm is ever read. + private const int NodeSize = 280, MaxNodes = 16; + private static string FormatWaitChain(uint processId, uint status, IntPtr count, + IntPtr nodes, IntPtr cycle) { + if (status != 0) return "wct_error=" + status; + int length = Marshal.ReadInt32(count); + if (length < 1 || length > MaxNodes) return "wct_invalid_count"; + var rows = new System.Collections.Generic.List(); + string[] types = { "Unknown", "CriticalSection", "SendMessage", "Mutex", "Alpc", + "Com", "ThreadWait", "ProcessWait", "Thread", "ComActivation", "Unknown", + "SocketIo", "SmbIo" }; + string[] statuses = { "Unknown", "NoAccess", "Running", "Blocked", "PidOnly", + "PidOnlyRpcss", "Owned", "NotOwned", "Abandoned", "Unknown", "Error" }; + for (int index = 0; index < length; ++index) { + IntPtr node = IntPtr.Add(nodes, index * NodeSize); + int type = Marshal.ReadInt32(node), state = Marshal.ReadInt32(node, 4); + string row = "type=" + (type >= 0 && type < types.Length ? types[type] : "Unknown") + + " status=" + (state >= 0 && state < statuses.Length ? statuses[state] : "Unknown"); + if (type == 8) { // WctThreadType + if (unchecked((uint)Marshal.ReadInt32(node, 8)) != processId) { + rows.Add("external_thread_boundary"); + break; // Neither follow nor expose another process/thread identity. + } + row += " thread=" + unchecked((uint)Marshal.ReadInt32(node, 12)); + } + rows.Add(row); + } + return "wct_nodes=" + length + " cycle=" + (Marshal.ReadInt32(cycle) != 0) + + "; " + String.Join("; ", rows); + } + + private static string WaitChain(Process client, IntPtr mainWindow, uint mainThread) { + if (IntPtr.Size != 8) return "wct_unsupported_architecture"; + // Pin the original Process object's handle, not a rediscovered PID. The + // observer owns this reference even if its bounded caller returns early. + int processId = client.Id; + var processHandle = client.SafeHandle; + bool retained = false; + string result = "wct_unavailable"; + var observer = new System.Threading.Thread(delegate() { + IntPtr thread = IntPtr.Zero, session = IntPtr.Zero; + IntPtr count = IntPtr.Zero, nodes = IntPtr.Zero, cycle = IntPtr.Zero; + WaitChainCallback callback = null; + string callbackResult = "wct_callback_unavailable"; + using (var completed = new System.Threading.ManualResetEvent(false)) { + try { + // QUERY_LIMITED_INFORMATION | SYNCHRONIZE; no suspend or write access. + thread = OpenThread(0x00100800, false, mainThread); + if (thread == IntPtr.Zero) { + result = "wct_thread_error=" + Marshal.GetLastWin32Error(); + return; + } + count = Marshal.AllocHGlobal(4); + nodes = Marshal.AllocHGlobal(NodeSize * MaxNodes); + cycle = Marshal.AllocHGlobal(4); + Marshal.WriteInt32(count, MaxNodes); + Marshal.WriteInt32(cycle, 0); + callback = delegate(IntPtr handle, UIntPtr context, uint status, + IntPtr observedCount, IntPtr observedNodes, IntPtr observedCycle) { + try { + callbackResult = FormatWaitChain((uint)processId, status, observedCount, + observedNodes, observedCycle); + } catch { callbackResult = "wct_callback_unavailable"; } + finally { completed.Set(); } + }; + session = OpenThreadWaitChainSession(1, callback); // WCT_ASYNC_OPEN_FLAG + if (session == IntPtr.Zero) { + result = "wct_session_error=" + Marshal.GetLastWin32Error(); + return; + } + uint owner; + // Held native handles prevent ID reuse; recheck HWND/thread + // ownership and both lifetimes immediately before the query. + if (GetWindowThreadProcessId(mainWindow, out owner) != mainThread || + owner != (uint)processId || GetProcessIdOfThread(thread) != owner || + WaitForSingleObject(processHandle.DangerousGetHandle(), 0) != 258 || + WaitForSingleObject(thread, 0) != 258) { + result = "wct_owned_thread_changed"; + return; + } + // Inspect the target's critical sections, but do not traverse + // other processes or enumerate out-of-process COM servers. + bool accepted = GetThreadWaitChain(session, UIntPtr.Zero, 0x4, + mainThread, count, nodes, cycle); // WCT_OUT_OF_PROC_CS_FLAG + int error = Marshal.GetLastWin32Error(); + if (!accepted && error != 997) { // ERROR_IO_PENDING + result = "wct_query_error=" + error; + return; + } + result = completed.WaitOne(1000) ? callbackResult : "wct_query_timeout"; + } catch { result = "wct_unavailable"; } + finally { + // Close cancels outstanding async work and waits for callbacks. + // Keep callback, buffers and event alive until it returns. + if (session != IntPtr.Zero) CloseThreadWaitChainSession(session); + GC.KeepAlive(callback); + if (cycle != IntPtr.Zero) Marshal.FreeHGlobal(cycle); + if (nodes != IntPtr.Zero) Marshal.FreeHGlobal(nodes); + if (count != IntPtr.Zero) Marshal.FreeHGlobal(count); + if (thread != IntPtr.Zero) CloseHandle(thread); + processHandle.DangerousRelease(); + } + } + }); + observer.IsBackground = true; + try { + processHandle.DangerousAddRef(ref retained); + observer.Start(); + } + catch { + if (retained) processHandle.DangerousRelease(); + return "wct_observer_unavailable"; + } + // Also bound cancellation/interop; a pending observer retains its own + // resources, prints nothing later, and cannot hold up smoke containment. + return observer.Join(1500) ? result : "wct_observer_timeout"; + } + + public static string Snapshot(Process client, IntPtr mainWindow) { + int processId = client.Id; uint owner; uint mainThread = GetWindowThreadProcessId(mainWindow, out owner); if (mainWindow == IntPtr.Zero || mainThread == 0 || owner != (uint)processId) @@ -206,7 +348,8 @@ public static class AtrinikReviewCloseTimeoutProbe { bool acknowledged = SendMessageTimeoutW(mainWindow, 0, UIntPtr.Zero, IntPtr.Zero, 0x0002 | 0x0020, 250, out result) != IntPtr.Zero; return String.Format("pid={0} main_hwnd={1} thread={2} state={3} wait={4} wm_null_ack={5}", - processId, mainWindow.ToInt64(), mainThread, state, wait, acknowledged); + processId, mainWindow.ToInt64(), mainThread, state, wait, acknowledged) + + "; " + WaitChain(client, mainWindow, mainThread); } } '@ -ErrorAction Stop @@ -214,7 +357,7 @@ public static class AtrinikReviewCloseTimeoutProbe { if ($Client.HasExited) { return "client_exited=True" } - return [AtrinikReviewCloseTimeoutProbe]::Snapshot($Client.Id, $Client.MainWindowHandle) + return [AtrinikReviewCloseTimeoutProbe]::Snapshot($Client, $Client.MainWindowHandle) } catch { return "" } diff --git a/tools/tests/test_compose_windows_review_bundle.py b/tools/tests/test_compose_windows_review_bundle.py index 19a741daf..3706a6acd 100644 --- a/tools/tests/test_compose_windows_review_bundle.py +++ b/tools/tests/test_compose_windows_review_bundle.py @@ -370,13 +370,64 @@ def test_close_timeout_probe_is_owned_bounded_and_observational(self) -> None: self.assertLess(ownership, message) self.assertIn("SendMessageTimeoutW(mainWindow, 0, UIntPtr.Zero,\n IntPtr.Zero, 0x0002 | 0x0020, 250, out result)", probe) self.assertEqual(probe.count("SendMessageTimeoutW("), 2) # declaration and one call - self.assertIn("Snapshot($Client.Id, $Client.MainWindowHandle)", probe) + self.assertIn("Snapshot($Client, $Client.MainWindowHandle)", probe) self.assertIn("", probe) self.assertEqual(smoke.count("(Get-ClientCloseTimeoutSnapshot $launcherClient)"), 1) for forbidden in ("PostMessage", "CloseMainWindow()", "GetWindowText", "MainWindowTitle", "Suspend", "Resume", "Terminate", "Dump", "ReadProcessMemory", ".Kill("): self.assertNotIn(forbidden, probe) + def test_wait_chain_probe_pins_identity_and_does_not_expand_access(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + probe = smoke.split("private static string WaitChain(", 1)[1].split("public static string Snapshot(", 1)[0] + self.assertIn("var processHandle = client.SafeHandle;", probe) + self.assertIn("processHandle.DangerousAddRef(ref retained)", probe) + self.assertIn("OpenThread(0x00100800, false, mainThread)", probe) + query = probe.index("bool accepted = GetThreadWaitChain") + for identity in ( + "GetWindowThreadProcessId(mainWindow, out owner) != mainThread", + "owner != (uint)processId || GetProcessIdOfThread(thread) != owner", + "WaitForSingleObject(processHandle.DangerousGetHandle(), 0) != 258", + "WaitForSingleObject(thread, 0) != 258", + ): + self.assertLess(probe.index(identity), query) + self.assertIn("GetThreadWaitChain(session, UIntPtr.Zero, 0x4,", probe) + for forbidden in ("AdjustTokenPrivileges", "DebugActiveProcess", "ReadProcessMemory", + "GetThreadContext", "SuspendThread", "CreateRemoteThread"): + self.assertNotIn(forbidden, smoke) + + def test_wait_chain_callback_lifetime_and_observer_are_bounded(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + probe = smoke.split("private static string WaitChain(", 1)[1].split("public static string Snapshot(", 1)[0] + self.assertIn("OpenThreadWaitChainSession(1, callback)", probe) + self.assertIn("Marshal.AllocHGlobal(NodeSize * MaxNodes)", probe) + self.assertIn("completed.WaitOne(1000) ? callbackResult : \"wct_query_timeout\"", probe) + self.assertIn("observer.IsBackground = true", probe) + self.assertIn('observer.Join(1500) ? result : "wct_observer_timeout"', probe) + cancel = probe.index("CloseThreadWaitChainSession(session)") + for release in ("GC.KeepAlive(callback)", "Marshal.FreeHGlobal(nodes)", + "CloseHandle(thread)", "processHandle.DangerousRelease()"): + self.assertLess(cancel, probe.index(release)) + self.assertIn('if (!accepted && error != 997)', probe) + self.assertIn('"wct_thread_error=" + Marshal.GetLastWin32Error()', probe) + self.assertIn('"wct_session_error=" + Marshal.GetLastWin32Error()', probe) + self.assertIn('"wct_query_error=" + error', probe) + + def test_wait_chain_output_excludes_object_names_and_external_identities(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + formatter = smoke.split("private static string FormatWaitChain(", 1)[1].split("private static string WaitChain(", 1)[0] + self.assertIn("if (status != 0) return \"wct_error=\" + status", formatter) + self.assertIn("if (length < 1 || length > MaxNodes)", formatter) + boundary = formatter.index('rows.Add("external_thread_boundary")') + thread_output = formatter.index('row += " thread="') + self.assertLess(boundary, thread_output) + self.assertIn("break;", formatter[boundary:thread_output]) + for forbidden in ("PtrToString", "ObjectName", "WaitTime", "ContextSwitches", + '" process="', '" pid="'): + self.assertNotIn(forbidden, formatter) + self.assertIn('type < types.Length ? types[type] : "Unknown"', formatter) + self.assertIn('state < statuses.Length ? statuses[state] : "Unknown"', formatter) + def test_lifecycle_evidence_is_bounded_and_uploaded_before_cleanup(self) -> None: root = MODULE_PATH.parents[2] smoke = (root / "tools/ci/smoke_windows_review_bundle.ps1").read_text() diff --git a/tools/tests/windows_wait_chain_fixture.ps1 b/tools/tests/windows_wait_chain_fixture.ps1 new file mode 100644 index 000000000..91fcbb955 --- /dev/null +++ b/tools/tests/windows_wait_chain_fixture.ps1 @@ -0,0 +1,74 @@ +# Copyright (C) 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +# Compile the actual smoke observer and exercise its formatter without invoking +# any native Windows API. Run with PowerShell 7 on Linux or Windows. +$ErrorActionPreference = "Stop" +$smoke = Join-Path $PSScriptRoot "../ci/smoke_windows_review_bundle.ps1" +$tokens = $null +$errors = $null +$null = [System.Management.Automation.Language.Parser]::ParseFile( + $smoke, [ref]$tokens, [ref]$errors) +if ($errors.Count -ne 0) { throw "Smoke script has PowerShell parse errors" } +$source = Get-Content -Raw -LiteralPath $smoke +$blocks = @([regex]::Matches($source, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@") | + Where-Object { $_.Groups[1].Value.Contains("class AtrinikReviewCloseTimeoutProbe") }) +if ($blocks.Count -ne 1) { throw "Expected one actual timeout observer" } +Add-Type -TypeDefinition $blocks[0].Groups[1].Value +$formatter = [AtrinikReviewCloseTimeoutProbe].GetMethod("FormatWaitChain", + [System.Reflection.BindingFlags]"NonPublic,Static") +if ($null -eq $formatter) { throw "Expected wait-chain formatter" } +$count = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(4) +$cycle = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(4) +$nodes = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(280 * 16) +try { + [System.Runtime.InteropServices.Marshal]::Copy([byte[]]::new(280 * 16), 0, $nodes, 280 * 16) + [System.Runtime.InteropServices.Marshal]::WriteInt32($count, 3) + [System.Runtime.InteropServices.Marshal]::WriteInt32($cycle, 1) + # Main thread -> named critical section -> another owned thread. + foreach ($entry in @( + @(0, 8), @(4, 3), @(8, 101), @(12, 202), + @(280, 1), @(284, 6), + @(560, 8), @(564, 2), @(568, 101), @(572, 303) + )) { + [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, $entry[0], $entry[1]) + } + $privateName = [System.Text.Encoding]::Unicode.GetBytes("PRIVATE-OBJECT-NAME") + [System.Runtime.InteropServices.Marshal]::Copy($privateName, 0, + [IntPtr]::Add($nodes, 288), $privateName.Length) + function Format-Chain([uint32]$Status = 0) { + return $formatter.Invoke($null, @([uint32]101, $Status, $count, $nodes, $cycle)) + } + $expected = "wct_nodes=3 cycle=True; type=Thread status=Blocked thread=202; " + + "type=CriticalSection status=Owned; type=Thread status=Running thread=303" + if ((Format-Chain) -cne $expected) { throw "Owned wait-chain fixture mismatch" } + + [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, 568, 404) + [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, 572, 505) + $expected = "wct_nodes=3 cycle=True; type=Thread status=Blocked thread=202; " + + "type=CriticalSection status=Owned; external_thread_boundary" + if ((Format-Chain) -cne $expected) { throw "External identity boundary mismatch" } + + [System.Runtime.InteropServices.Marshal]::WriteInt32($count, 1) + [System.Runtime.InteropServices.Marshal]::WriteInt32($cycle, 0) + [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, 0, 2147483647) + [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, 4, -1) + if ((Format-Chain) -cne "wct_nodes=1 cycle=False; type=Unknown status=Unknown") { + throw "Unknown enum fixture mismatch" + } + foreach ($invalidCount in @(0, 17, 2147483647, -1)) { + [System.Runtime.InteropServices.Marshal]::WriteInt32($count, $invalidCount) + if ((Format-Chain) -cne "wct_invalid_count") { throw "Node bound fixture mismatch" } + } + # Error callbacks need not provide valid result pointers. Never dereference them. + foreach ($status in @(5, 50, 234, 1223)) { + $observed = $formatter.Invoke($null, @([uint32]101, [uint32]$status, + [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero)) + if ($observed -cne "wct_error=$status") { throw "Error callback fixture mismatch" } + } + Write-Host "PowerShell parse, actual C# compilation and wait-chain formatter fixtures passed." + Write-Host "Native WCT APIs were not invoked; Windows execution remains required." +} finally { + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($nodes) + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($cycle) + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($count) +} From be8ee0009cd56f90541d5e68bfd8fcafdc2b704b Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:17:19 -0500 Subject: [PATCH 109/148] style(ci): normalize wait-chain fixture copyright --- tools/tests/windows_wait_chain_fixture.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tests/windows_wait_chain_fixture.ps1 b/tools/tests/windows_wait_chain_fixture.ps1 index 91fcbb955..af58be4d4 100644 --- a/tools/tests/windows_wait_chain_fixture.ps1 +++ b/tools/tests/windows_wait_chain_fixture.ps1 @@ -1,4 +1,4 @@ -# Copyright (C) 2026 The Atrinik Project +# Copyright 2026 The Atrinik Project # SPDX-License-Identifier: GPL-2.0-or-later # Compile the actual smoke observer and exercise its formatter without invoking # any native Windows API. Run with PowerShell 7 on Linux or Windows. From 616781f51e435c1f6d62ea68f651df248eb144a4 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:27:10 -0500 Subject: [PATCH 110/148] fix(build): normalize Debian LLVM notice permissions --- client/CMakeLists.txt | 3 +- .../tests/test_linux_package_contract.py | 43 ++++++++++++------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 56844ee4e..619ed5b07 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -1383,7 +1383,8 @@ if (PACKAGE_TYPE STREQUAL "deb") # default permissions to their final destination. Normalize only the owned # package layout, including /usr, for standalone cpack invocations too. foreach (directory IN ITEMS "." games share share/applications share/pixmaps - share/doc share/doc/atrinik share/games share/games/atrinik) + share/doc share/doc/atrinik share/doc/atrinik/licenses + share/doc/atrinik/licenses/llvm share/games share/games/atrinik) install(DIRECTORY DESTINATION "${directory}" COMPONENT AtrinikClient DIRECTORY_PERMISSIONS ${CMAKE_INSTALL_DEFAULT_DIRECTORY_PERMISSIONS}) endforeach () diff --git a/client/tools/tests/test_linux_package_contract.py b/client/tools/tests/test_linux_package_contract.py index 3808ccfeb..2e74330f9 100644 --- a/client/tools/tests/test_linux_package_contract.py +++ b/client/tools/tests/test_linux_package_contract.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -"""Exercise Debian packaging with an existing system ELF, without a client build.""" +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Exercise Debian packaging with a tiny selected-Clang ELF, without a client build.""" import os from pathlib import Path @@ -12,6 +14,8 @@ ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "tools/build-linux-package.sh" +COMPILER = shutil.which(os.environ.get("CC", "clang")) +RUNTIME_NOTICES = ("llvm", "clang", "clang-tools-extra", "lld", "compiler-rt", "libunwind") class LinuxPackageContractTests(unittest.TestCase): @@ -85,9 +89,9 @@ def test_invalid_inputs_fail_before_build(self): self.assertIn("Required sibling source is missing", result.stderr) self.assertFalse((Path(directory) / "build").exists()) - @unittest.skipUnless(all(shutil.which(tool) for tool in + @unittest.skipUnless(COMPILER and all(shutil.which(tool) for tool in ("cmake", "cpack", "dpkg-deb", "dpkg-shlibdeps", "file")), - "Debian packaging tools are required") + "Clang and Debian packaging tools are required") def test_real_cpack_metadata_and_runtime_payload(self): with tempfile.TemporaryDirectory() as directory: source = Path(directory) / "source" @@ -95,16 +99,11 @@ def test_real_cpack_metadata_and_runtime_payload(self): cmake = (ROOT / "CMakeLists.txt").read_text() installer = cmake.split("# Installer.\n", 1)[1].split( "# Configure the .h file", 1)[0] - # Reuse every authored install/CPack rule, substituting only the ELF - # target so the fixture never compiles application code on the host. - installer = installer.replace( - "install(TARGETS atrinik DESTINATION ${INSTALL_SUBDIR_BIN}\n" - " COMPONENT AtrinikClient)", - 'install(PROGRAMS "${CMAKE_CURRENT_SOURCE_DIR}/fixture-elf" DESTINATION ${INSTALL_SUBDIR_BIN}\n' - ' RENAME atrinik COMPONENT AtrinikClient)', - ) + # Reuse every authored install/CPack rule with a tiny C target and + # the selected compiler's genuine runtime notices. prefix = '''cmake_minimum_required(VERSION 3.21) -project(package-fixture LANGUAGES NONE) +project(package-fixture LANGUAGES C) +add_executable(atrinik fixture.c) set(PACKAGE_TYPE deb) set(PACKAGE_VERSION 5.1.0) set(PACKAGE_VERSION_MAJOR 5) @@ -116,7 +115,12 @@ def test_real_cpack_metadata_and_runtime_payload(self): install(FILES LICENSE.md DESTINATION include/atrinik) ''' (source / "CMakeLists.txt").write_text(prefix + installer) - shutil.copyfile("/bin/true", source / "fixture-elf") + (source / "fixture.c").write_text( + '#include \nint main(void) { return puts("package fixture") < 0; }\n') + (source / "cmake").mkdir() + shutil.copyfile(ROOT.parent / "cmake/InstallLLVMRuntime.cmake", + source / "cmake/InstallLLVMRuntime.cmake") + notices = Path(COMPILER).resolve().parent.parent / "share/licenses/llvm" for name in ("textures", "cache", "data", "fonts", "gfx_user", "settings", "sound", "srv_files"): (source / name).mkdir() @@ -137,7 +141,9 @@ def run(*arguments): ) return result run("cmake", "-S", str(source), "-B", str(build), - "-DCMAKE_INSTALL_PREFIX=/usr", "-DCMAKE_SYSTEM_PROCESSOR=x86_64") + "-DCMAKE_INSTALL_PREFIX=/usr", "-DCMAKE_SYSTEM_PROCESSOR=x86_64", + f"-DCMAKE_C_COMPILER={COMPILER}") + run("cmake", "--build", str(build), "--parallel", "2") config = (build / "CPackConfig.cmake").read_text() self.assertIn('set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS "ON")', config) run("cpack", "--config", str(build / "CPackConfig.cmake"), @@ -170,6 +176,10 @@ def run(*arguments): (source / "client.cfg").read_bytes()) self.assertFalse((payload / "usr/include").exists()) self.assertFalse((payload / "usr/lib").exists()) + for name in RUNTIME_NOTICES: + notice = name + "-LICENSE.TXT" + installed = payload / "usr/share/doc/atrinik/licenses/llvm" / notice + self.assertEqual(installed.read_bytes(), (notices / notice).read_bytes()) for path in payload.rglob("*"): if path.is_dir(): self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o755, @@ -180,7 +190,7 @@ def run(*arguments): self.assertTrue((payload / "usr/games/atrinik").stat().st_mode & stat.S_IXOTH) self.assertFalse((payload / "usr/share/games/atrinik/data/discord-application-id").exists()) self.assertEqual((payload / "usr/games/atrinik").read_bytes(), - Path("/bin/true").read_bytes()) + (build / "atrinik").read_bytes()) for name, argument, message in ( ("prefix", "-DCMAKE_INSTALL_PREFIX=/opt", "CMAKE_INSTALL_PREFIX=/usr"), ("architecture", "-DCMAKE_SYSTEM_PROCESSOR=aarch64", "Linux amd64"), @@ -189,7 +199,8 @@ def run(*arguments): str(Path(directory) / name), "-DCMAKE_SYSTEM_NAME=Linux", "-DCMAKE_SYSTEM_PROCESSOR=x86_64", - "-DCMAKE_INSTALL_PREFIX=/usr", argument], + "-DCMAKE_INSTALL_PREFIX=/usr", + f"-DCMAKE_C_COMPILER={COMPILER}", argument], capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) self.assertIn(message, result.stderr) From 7252785b85468801d8377aa32dbdf9df5c867b49 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:34:33 -0500 Subject: [PATCH 111/148] fix(ci): select native jobs for shared Windows runtime tests --- tools/ci/classify_changes.py | 6 ++++++ tools/tests/test_classify_changes.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index 8ae6c7c07..98e9d74cd 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -29,6 +29,9 @@ "tools/tests/test_llvm_runtime_notices.py", "tools/tests/test_macos_llvm_toolchain.py", "tools/tests/test_gpu_qualification_runtime.py", + "tools/tests/test_stage_windows_runtime.py", + "tools/tests/test_windows_runtime_cmake_arguments.py", + "tools/tests/test_windows_trust_notices.py", "LICENSE.md", "docs/history/component-release-map.json", "docs/history/release-tags.json", @@ -52,6 +55,9 @@ "cmake/InstallWindowsRuntime.cmake", "cmake/toolchains/llvm.cmake", "cmake/toolchains/llvm-macos.cmake", "tools/tests/test_macos_llvm_toolchain.py", "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", + "tools/tests/test_stage_windows_runtime.py", + "tools/tests/test_windows_runtime_cmake_arguments.py", + "tools/tests/test_windows_trust_notices.py", } BENCHMARK_CONTRACT_PATHS = { ".github/workflows/pr-benchmarks.yml", diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index b2317d49a..0a6f2c8c8 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -122,6 +122,34 @@ def test_codecov_configuration_selects_both_coverage_components(self) -> None: self.assertTrue(result["client"]) self.assertTrue(result["server"]) + def test_shared_windows_runtime_tests_select_both_native_consumers_and_windows(self) -> None: + for path in ( + "tools/tests/test_stage_windows_runtime.py", + "tools/tests/test_windows_runtime_cmake_arguments.py", + "tools/tests/test_windows_trust_notices.py", + ): + with self.subTest(path=path): + result = classify_changes.classify([path]) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + self.assertTrue(result["windows"]) + self.assertEqual(result["codeql_languages"], "python") + self.assertFalse(result["benchmark_client"]) + self.assertFalse(result["benchmark_server"]) + + def test_unrelated_tools_tests_keep_native_builds_skipped(self) -> None: + for path in ( + "tools/tests/test_select_release_batch.py", + "tools/tests/test_verify_next_version.py", + "tools/tests/test_sync_release_assets.py", + ): + with self.subTest(path=path): + result = classify_changes.classify([path]) + self.assertFalse(result["client"]) + self.assertFalse(result["server"]) + self.assertFalse(result["windows"]) + self.assertEqual(result["codeql_languages"], "python") + def test_release_configuration_selects_both_native_components(self) -> None: result = classify_changes.classify([".releaserc.cjs"]) self.assertTrue(result["client"]) From 963ba52b8e374a68aad6cd856822b73eb5186d61 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:35:18 -0500 Subject: [PATCH 112/148] fix(ci): enable bounded Windows wait-chain traversal --- tools/ci/smoke_windows_review_bundle.ps1 | 21 +++++++++++++------ .../test_compose_windows_review_bundle.py | 6 +++++- tools/tests/windows_wait_chain_fixture.ps1 | 18 +++++++++++++++- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/tools/ci/smoke_windows_review_bundle.ps1 b/tools/ci/smoke_windows_review_bundle.ps1 index e22cd3ded..ce7fbd94e 100644 --- a/tools/ci/smoke_windows_review_bundle.ps1 +++ b/tools/ci/smoke_windows_review_bundle.ps1 @@ -205,6 +205,7 @@ public static class AtrinikReviewCloseTimeoutProbe { int length = Marshal.ReadInt32(count); if (length < 1 || length > MaxNodes) return "wct_invalid_count"; var rows = new System.Collections.Generic.List(); + bool externalBoundary = false; string[] types = { "Unknown", "CriticalSection", "SendMessage", "Mutex", "Alpc", "Com", "ThreadWait", "ProcessWait", "Thread", "ComActivation", "Unknown", "SocketIo", "SmbIo" }; @@ -218,13 +219,17 @@ public static class AtrinikReviewCloseTimeoutProbe { if (type == 8) { // WctThreadType if (unchecked((uint)Marshal.ReadInt32(node, 8)) != processId) { rows.Add("external_thread_boundary"); - break; // Neither follow nor expose another process/thread identity. + externalBoundary = true; + break; // Retain no identities or later rows beyond the client boundary. } row += " thread=" + unchecked((uint)Marshal.ReadInt32(node, 12)); } rows.Add(row); } - return "wct_nodes=" + length + " cycle=" + (Marshal.ReadInt32(cycle) != 0) + + // WCT may have traversed farther. Do not report its hidden node count or + // attribute a cycle involving omitted nodes to the retained client rows. + return "wct_nodes=" + rows.Count + " cycle=" + + (externalBoundary ? "unavailable" : (Marshal.ReadInt32(cycle) != 0).ToString()) + "; " + String.Join("; ", rows); } @@ -277,10 +282,14 @@ public static class AtrinikReviewCloseTimeoutProbe { result = "wct_owned_thread_changed"; return; } - // Inspect the target's critical sections, but do not traverse - // other processes or enumerate out-of-process COM servers. - bool accepted = GetThreadWaitChain(session, UIntPtr.Zero, 0x4, - mainThread, count, nodes, cycle); // WCT_OUT_OF_PROC_CS_FLAG + // Allow cross-process chain traversal and critical-section + // lookup from this external observer of the verified client. + // WCT may traverse beyond the client; the formatter retains + // rows only up to its first external-client thread boundary. + // Do not enable out-of-process COM server enumeration. + // https://learn.microsoft.com/windows/win32/api/wct/nf-wct-getthreadwaitchain + bool accepted = GetThreadWaitChain(session, UIntPtr.Zero, 0x1 | 0x4, + mainThread, count, nodes, cycle); // OUT_OF_PROC | OUT_OF_PROC_CS int error = Marshal.GetLastWin32Error(); if (!accepted && error != 997) { // ERROR_IO_PENDING result = "wct_query_error=" + error; diff --git a/tools/tests/test_compose_windows_review_bundle.py b/tools/tests/test_compose_windows_review_bundle.py index 3706a6acd..60c961279 100644 --- a/tools/tests/test_compose_windows_review_bundle.py +++ b/tools/tests/test_compose_windows_review_bundle.py @@ -391,7 +391,9 @@ def test_wait_chain_probe_pins_identity_and_does_not_expand_access(self) -> None "WaitForSingleObject(thread, 0) != 258", ): self.assertLess(probe.index(identity), query) - self.assertIn("GetThreadWaitChain(session, UIntPtr.Zero, 0x4,", probe) + # The owned client is external to PowerShell; traversal and critical + # section lookup are separate SDK flags. COM expansion stays disabled. + self.assertIn("GetThreadWaitChain(session, UIntPtr.Zero, 0x1 | 0x4,", probe) for forbidden in ("AdjustTokenPrivileges", "DebugActiveProcess", "ReadProcessMemory", "GetThreadContext", "SuspendThread", "CreateRemoteThread"): self.assertNotIn(forbidden, smoke) @@ -422,6 +424,8 @@ def test_wait_chain_output_excludes_object_names_and_external_identities(self) - thread_output = formatter.index('row += " thread="') self.assertLess(boundary, thread_output) self.assertIn("break;", formatter[boundary:thread_output]) + self.assertIn('"wct_nodes=" + rows.Count', formatter) + self.assertIn('externalBoundary ? "unavailable"', formatter) for forbidden in ("PtrToString", "ObjectName", "WaitTime", "ContextSwitches", '" process="', '" pid="'): self.assertNotIn(forbidden, formatter) diff --git a/tools/tests/windows_wait_chain_fixture.ps1 b/tools/tests/windows_wait_chain_fixture.ps1 index af58be4d4..e680dda97 100644 --- a/tools/tests/windows_wait_chain_fixture.ps1 +++ b/tools/tests/windows_wait_chain_fixture.ps1 @@ -44,10 +44,26 @@ try { [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, 568, 404) [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, 572, 505) - $expected = "wct_nodes=3 cycle=True; type=Thread status=Blocked thread=202; " + + $expected = "wct_nodes=3 cycle=unavailable; type=Thread status=Blocked thread=202; " + "type=CriticalSection status=Owned; external_thread_boundary" if ((Format-Chain) -cne $expected) { throw "External identity boundary mismatch" } + # Native traversal may continue beyond the first external thread, including + # a later return to the client. Neither those rows nor aggregate information + # about the omitted chain may escape the formatter boundary. + [System.Runtime.InteropServices.Marshal]::WriteInt32($count, 5) + foreach ($entry in @( + @(840, 3), @(844, 6), + @(1120, 8), @(1124, 3), @(1128, 101), @(1132, 606) + )) { + [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, $entry[0], $entry[1]) + } + [System.Runtime.InteropServices.Marshal]::Copy($privateName, 0, + [IntPtr]::Add($nodes, 848), $privateName.Length) + if ((Format-Chain) -cne $expected) { throw "Post-boundary rows or count escaped" } + [System.Runtime.InteropServices.Marshal]::WriteInt32($cycle, 0) + if ((Format-Chain) -cne $expected) { throw "Post-boundary cycle escaped" } + [System.Runtime.InteropServices.Marshal]::WriteInt32($count, 1) [System.Runtime.InteropServices.Marshal]::WriteInt32($cycle, 0) [System.Runtime.InteropServices.Marshal]::WriteInt32($nodes, 0, 2147483647) From eabb5cea44388e1a1cdde11717769a2ceb1ad7b7 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:47:00 -0500 Subject: [PATCH 113/148] fix(libatrinik): complete admission HTTPS certificate profiles --- libatrinik/tests/rendezvous_admission_https.c | 7 +++ .../tests/rendezvous_admission_https.py | 24 +++++++- ...test_rendezvous_admission_https_fixture.py | 60 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 tools/tests/test_rendezvous_admission_https_fixture.py diff --git a/libatrinik/tests/rendezvous_admission_https.c b/libatrinik/tests/rendezvous_admission_https.c index a91d3b849..c3c6e7d80 100644 --- a/libatrinik/tests/rendezvous_admission_https.c +++ b/libatrinik/tests/rendezvous_admission_https.c @@ -133,6 +133,13 @@ int main(int argc, char **argv) { solve_fast); uint64_t elapsed = datetime_monotonic_ms() - start; if (strcmp(mode, "valid") == 0) { + if (result != RENDEZVOUS_ADMISSION_OK || solver_calls != 1) { + fprintf(stderr, + "HTTPS admission outcome result=%d solver_calls=%u elapsed_ms=%llu\n", + (int)result, + solver_calls, + (unsigned long long)elapsed); + } REQUIRE(result == RENDEZVOUS_ADMISSION_OK && solver_calls == 1); char *separator = strrchr(proof, '.'); REQUIRE(separator != NULL && string_is_hex_fixed(separator + 1, 16, true)); diff --git a/libatrinik/tests/rendezvous_admission_https.py b/libatrinik/tests/rendezvous_admission_https.py index eda09a6df..404a6cb96 100644 --- a/libatrinik/tests/rendezvous_admission_https.py +++ b/libatrinik/tests/rendezvous_admission_https.py @@ -8,6 +8,7 @@ import json import os from pathlib import Path +import re import ssl import subprocess import sys @@ -35,7 +36,8 @@ def certificates(root): ca_key, ca_cert = root / "ca.key", root / "ca.pem" openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=Atrinik admission test CA", "-keyout", ca_key, - "-out", ca_cert, "-addext", "basicConstraints=critical,CA:TRUE") + "-out", ca_cert, "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign") key, csr, cert = root / "server.key", root / "server.csr", root / "server.pem" openssl("req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=localhost", "-keyout", key, "-out", csr) @@ -43,6 +45,7 @@ def certificates(root): extensions.write_text( "subjectAltName=DNS:localhost\n" "basicConstraints=critical,CA:FALSE\n" + "keyUsage=critical,digitalSignature,keyEncipherment\n" "extendedKeyUsage=serverAuth\n", encoding="ascii", ) openssl("x509", "-req", "-in", csr, "-CA", ca_cert, "-CAkey", ca_key, @@ -50,7 +53,8 @@ def certificates(root): other_ca = root / "other-ca.pem" openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=Unrelated admission test CA", "-keyout", root / "other.key", - "-out", other_ca, "-addext", "basicConstraints=critical,CA:TRUE") + "-out", other_ca, "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign") return ca_cert, other_ca, cert, key @@ -141,6 +145,20 @@ def do_POST(self): pass +def native_failure(result, mode, requests): + # Native output can contain URLs or attempt secrets. Retain only these + # bounded test-owned numeric diagnostics, never arbitrary stdout/stderr. + diagnostics = [] + for line in result.stderr[:4096].decode("ascii", errors="replace").splitlines(): + if re.fullmatch(r"HTTPS admission assertion at line [0-9]{1,6}|" + r"HTTPS admission outcome result=[0-9]{1,3} " + r"solver_calls=[0-9]{1,10} elapsed_ms=[0-9]{1,20}", line): + diagnostics.append(line) + detail = "; ".join(diagnostics[:3]) or "no numeric native diagnostics" + return (f"native HTTPS admission case failed: {mode}; " + f"exit={result.returncode}; requests={requests}; {detail}") + + def run_case(binary, mode, ca, other_ca, cert, key): with Fixture(("127.0.0.1", 0), Handler) as server: server.mode = mode @@ -164,7 +182,7 @@ def run_case(binary, mode, ca, other_ca, cert, key): result = subprocess.run([binary, url, mode], env=env, capture_output=True, timeout=8, check=False) if result.returncode: - raise RuntimeError(f"native HTTPS admission case failed: {mode}") + raise RuntimeError(native_failure(result, mode, server.requests)) if server.errors: raise RuntimeError(f"HTTPS fixture request check failed: {mode}: " + ",".join(server.errors[:3])) diff --git a/tools/tests/test_rendezvous_admission_https_fixture.py b/tools/tests/test_rendezvous_admission_https_fixture.py new file mode 100644 index 000000000..4761aba21 --- /dev/null +++ b/tools/tests/test_rendezvous_admission_https_fixture.py @@ -0,0 +1,60 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +import importlib.util +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + "admission_https_fixture", ROOT / "libatrinik/tests/rendezvous_admission_https.py") +FIXTURE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(FIXTURE) + + +class AdmissionHTTPSFixtureTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("openssl"), "OpenSSL is required") + def test_certificate_chain_obeys_strict_server_verification(self): + with tempfile.TemporaryDirectory(prefix="admission-certificate-") as directory: + ca, other_ca, certificate, _ = FIXTURE.certificates(Path(directory)) + for authority, hostname, expected in ( + (ca, "localhost", 0), (other_ca, "localhost", 2), + (ca, "wrong.invalid", 2)): + with self.subTest(authority=authority.name, hostname=hostname): + result = subprocess.run( + [shutil.which("openssl"), "verify", "-x509_strict", + "-purpose", "sslserver", "-verify_hostname", hostname, + "-CAfile", str(authority), str(certificate)], + capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + + def test_native_failure_retains_only_bounded_numeric_diagnostics(self): + result = subprocess.CompletedProcess( + [], 1, stdout=b"synthetic attempt secret", + stderr=(b"arbitrary URL https://private.invalid/secret\n" + b"HTTPS admission outcome result=1 solver_calls=0 elapsed_ms=52\n" + b"HTTPS admission assertion at line 149\n" + b"HTTPS admission assertion at line 150 secret\n")) + message = FIXTURE.native_failure(result, "valid", 0) + self.assertIn("exit=1; requests=0", message) + self.assertIn("result=1 solver_calls=0 elapsed_ms=52", message) + self.assertIn("assertion at line 149", message) + self.assertNotIn("private.invalid", message) + self.assertNotIn("secret", message) + self.assertNotIn("line 150", message) + + def test_native_failure_handles_missing_and_excessive_diagnostics(self): + result = subprocess.CompletedProcess([], -6, stdout=b"", stderr=b"\xffarbitrary\n") + self.assertIn("exit=-6; requests=1; no numeric native diagnostics", + FIXTURE.native_failure(result, "valid", 1)) + result.stderr = b"HTTPS admission assertion at line 149\n" * 200 + self.assertEqual(FIXTURE.native_failure(result, "valid", 0).count("line 149"), 3) + result.stderr = b"x" * 4096 + b"\nHTTPS admission assertion at line 149\n" + self.assertNotIn("line 149", FIXTURE.native_failure(result, "valid", 0)) + + +if __name__ == "__main__": + unittest.main() From 36a3976ded4dbb74bd60f30d9a99151dfd9c9562 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:53:09 -0500 Subject: [PATCH 114/148] fix(ci): select native checks for admission HTTPS fixtures --- tools/ci/classify_changes.py | 1 + tools/tests/test_classify_changes.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index 98e9d74cd..9a51941d5 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -29,6 +29,7 @@ "tools/tests/test_llvm_runtime_notices.py", "tools/tests/test_macos_llvm_toolchain.py", "tools/tests/test_gpu_qualification_runtime.py", + "tools/tests/test_rendezvous_admission_https_fixture.py", "tools/tests/test_stage_windows_runtime.py", "tools/tests/test_windows_runtime_cmake_arguments.py", "tools/tests/test_windows_trust_notices.py", diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index 0a6f2c8c8..fd452a658 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -150,6 +150,19 @@ def test_unrelated_tools_tests_keep_native_builds_skipped(self) -> None: self.assertFalse(result["windows"]) self.assertEqual(result["codeql_languages"], "python") + def test_admission_https_fixture_selects_both_unix_native_consumers(self) -> None: + result = classify_changes.classify( + ["tools/tests/test_rendezvous_admission_https_fixture.py"] + ) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + self.assertFalse(result["windows"]) + self.assertTrue(result["codeql_run"]) + self.assertTrue(result["codeql_python"]) + self.assertEqual(result["codeql_languages"], "python") + self.assertFalse(result["benchmark_client"]) + self.assertFalse(result["benchmark_server"]) + def test_release_configuration_selects_both_native_components(self) -> None: result = classify_changes.classify([".releaserc.cjs"]) self.assertTrue(result["client"]) From dad9c85a45733317186bea2acd32ad685189211f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 15:05:01 -0500 Subject: [PATCH 115/148] test(libatrinik): retain numeric admission HTTPS transport evidence --- libatrinik/tests/rendezvous_admission_https.c | 43 ++++++++++++++- .../tests/rendezvous_admission_https.py | 8 ++- ...test_rendezvous_admission_https_fixture.py | 18 ++++++ .../test_rendezvous_admission_platform.py | 55 ++++++++++++++++++- 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/libatrinik/tests/rendezvous_admission_https.c b/libatrinik/tests/rendezvous_admission_https.c index c3c6e7d80..013d767cc 100644 --- a/libatrinik/tests/rendezvous_admission_https.c +++ b/libatrinik/tests/rendezvous_admission_https.c @@ -1,5 +1,12 @@ /* Copyright 2026 The Atrinik Project * SPDX-License-Identifier: GPL-2.0-or-later */ +#include "../rendezvous_admission.h" + +/* Observe the real cancellable transfer before production cleans up its handle. + * Only numeric metadata is retained; never log URLs, headers or TLS text. */ +static CURLcode test_perform_cancellable(CURL *handle, const curl_cancel_t *cancel); +#define curl_perform_cancellable test_perform_cancellable + /* The private prepare seam preserves the production HTTPS/parser path while * selecting eight-bit work solely in this independently linked test. */ #ifdef ATRINIK_TEST_SOURCE_WRAP @@ -10,6 +17,7 @@ CURL *__wrap_curl_easy_init(void); #define curl_easy_init __wrap_curl_easy_init #endif #include "../rendezvous_admission.c" +#undef curl_perform_cancellable #ifdef ATRINIK_TEST_SOURCE_WRAP #undef curl_easy_init #endif @@ -27,6 +35,9 @@ CURL *__wrap_curl_easy_init(void); static const char ticket[] = "2222222222222222222222222222222222222222222222222222222222222222"; static const char server_id[] = "1111111111111111111111111111111111111111111111111111111111111111"; static unsigned int solver_calls; +static unsigned int ca_init_calls; +static unsigned int ca_set_calls; +static int ca_set_result = -1; #ifdef ATRINIK_TEST_SOURCE_WRAP #define __real_curl_easy_init curl_easy_init @@ -34,10 +45,13 @@ static unsigned int solver_calls; CURL *__real_curl_easy_init(void); #endif CURL *__wrap_curl_easy_init(void) { + ca_init_calls++; CURL *handle = __real_curl_easy_init(); const char *ca = getenv("ATRINIK_TEST_ADMISSION_CA"); if (handle != NULL && ca != NULL) { - if (curl_easy_setopt(handle, CURLOPT_CAINFO, ca) != CURLE_OK) { + ca_set_calls++; + ca_set_result = (int)curl_easy_setopt(handle, CURLOPT_CAINFO, ca); + if (ca_set_result != CURLE_OK) { curl_easy_cleanup(handle); return NULL; } @@ -45,6 +59,33 @@ CURL *__wrap_curl_easy_init(void) { return handle; } +static CURLcode test_perform_cancellable(CURL *handle, const curl_cancel_t *cancel) { + CURLcode result = curl_perform_cancellable(handle, cancel); + long verify = -1, status = -1, os_error = -1; + CURLcode verify_info = curl_easy_getinfo(handle, CURLINFO_SSL_VERIFYRESULT, &verify); + CURLcode status_info = curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status); + CURLcode os_info = curl_easy_getinfo(handle, CURLINFO_OS_ERRNO, &os_error); + const curl_version_info_data *version = curl_version_info(CURLVERSION_NOW); + fprintf(stderr, + "HTTPS admission transport init_calls=%u ca_calls=%u ca_result=%d " + "curl_code=%d verify=%ld verify_info=%d status=%ld status_info=%d " + "os_errno=%ld os_info=%d version=%u async_dns=%d ares=%d\n", + ca_init_calls, + ca_set_calls, + ca_set_result, + (int)result, + verify, + (int)verify_info, + status, + (int)status_info, + os_error, + (int)os_info, + version != NULL ? version->version_num : 0U, + version != NULL && (version->features & CURL_VERSION_ASYNCHDNS) != 0, + version != NULL && version->ares != NULL); + return result; +} + static rendezvous_admission_result_t solve_fast(const rendezvous_admission_challenge_t *challenge, uint64_t deadline_ms, const curl_cancel_t *cancel, diff --git a/libatrinik/tests/rendezvous_admission_https.py b/libatrinik/tests/rendezvous_admission_https.py index 404a6cb96..b810c1b25 100644 --- a/libatrinik/tests/rendezvous_admission_https.py +++ b/libatrinik/tests/rendezvous_admission_https.py @@ -152,7 +152,13 @@ def native_failure(result, mode, requests): for line in result.stderr[:4096].decode("ascii", errors="replace").splitlines(): if re.fullmatch(r"HTTPS admission assertion at line [0-9]{1,6}|" r"HTTPS admission outcome result=[0-9]{1,3} " - r"solver_calls=[0-9]{1,10} elapsed_ms=[0-9]{1,20}", line): + r"solver_calls=[0-9]{1,10} elapsed_ms=[0-9]{1,20}|" + r"HTTPS admission transport init_calls=[0-9]{1,10} ca_calls=[0-9]{1,10} " + r"ca_result=-?[0-9]{1,3} curl_code=[0-9]{1,3} " + r"verify=-?[0-9]{1,20} verify_info=[0-9]{1,3} " + r"status=-?[0-9]{1,20} status_info=[0-9]{1,3} " + r"os_errno=-?[0-9]{1,20} os_info=[0-9]{1,3} " + r"version=[0-9]{1,10} async_dns=[01] ares=[01]", line): diagnostics.append(line) detail = "; ".join(diagnostics[:3]) or "no numeric native diagnostics" return (f"native HTTPS admission case failed: {mode}; " diff --git a/tools/tests/test_rendezvous_admission_https_fixture.py b/tools/tests/test_rendezvous_admission_https_fixture.py index 4761aba21..b318b470b 100644 --- a/tools/tests/test_rendezvous_admission_https_fixture.py +++ b/tools/tests/test_rendezvous_admission_https_fixture.py @@ -46,6 +46,24 @@ def test_native_failure_retains_only_bounded_numeric_diagnostics(self): self.assertNotIn("secret", message) self.assertNotIn("line 150", message) + def test_native_failure_retains_transport_evidence_without_text_fields(self): + transport = (b"HTTPS admission transport init_calls=1 ca_calls=1 ca_result=0 " + b"curl_code=60 verify=20 verify_info=0 status=0 status_info=0 " + b"os_errno=-1 os_info=0 version=530944 async_dns=1 ares=1") + result = subprocess.CompletedProcess( + [], 1, stdout=b"private", stderr=transport + b"\n" + transport + b" secret\n") + message = FIXTURE.native_failure(result, "valid", 0) + self.assertIn(transport.decode("ascii"), message) + self.assertEqual(message.count("HTTPS admission transport"), 1) + self.assertNotIn("secret", message) + self.assertNotIn("private", message) + for invalid in (transport.replace(b"ca_calls=1", b"ca_calls=99999999999"), + transport.replace(b"ares=1", b"ares=2"), + transport.replace(b"verify=20", b"verify=private")): + result.stderr = invalid + self.assertIn("no numeric native diagnostics", + FIXTURE.native_failure(result, "valid", 0)) + def test_native_failure_handles_missing_and_excessive_diagnostics(self): result = subprocess.CompletedProcess([], -6, stdout=b"", stderr=b"\xffarbitrary\n") self.assertIn("exit=-6; requests=1; no numeric native diagnostics", diff --git a/tools/tests/test_rendezvous_admission_platform.py b/tools/tests/test_rendezvous_admission_platform.py index 739e38551..9b6e2a331 100644 --- a/tools/tests/test_rendezvous_admission_platform.py +++ b/tools/tests/test_rendezvous_admission_platform.py @@ -24,8 +24,19 @@ def setUp(self): (self.root / "curl/curl.h").write_text('''#ifndef CURL_FIXTURE_H #define CURL_FIXTURE_H #include +#include typedef struct { int unused; } CURL; typedef int CURLcode; +typedef struct { int marker; } curl_cancel_t; +typedef struct { unsigned int version_num; int features; const char *ares; } curl_version_info_data; +#define CURL_VERSION_ASYNCHDNS 1 +#define CURLVERSION_NOW 0 +#define CURLINFO_SSL_VERIFYRESULT 2 +#define CURLINFO_RESPONSE_CODE 3 +#define CURLINFO_OS_ERRNO 4 +CURLcode curl_perform_cancellable(CURL *, const curl_cancel_t *); +CURLcode curl_easy_getinfo(CURL *, int, long *); +const curl_version_info_data *curl_version_info(int); #define CURLOPT_CAINFO 1 #define CURLE_OK 0 CURL *curl_easy_init(void); @@ -61,6 +72,8 @@ def test_source_wrap_redirects_actual_admission_and_restores_real_curl(self): called = "__wrap_curl_easy_init" if source_wrap else "curl_easy_init" real = "curl_easy_init" if source_wrap else "__real_curl_easy_init" self.assertIn(f"CURL *curl = {called}();", admission) + self.assertIn("test_perform_cancellable(curl, &bounded)", admission) + self.assertIn("CURLcode result = curl_perform_cancellable(handle, cancel);", wrapper) self.assertIn(f"CURL *handle = {real}();", wrapper) self.assertNotIn("CURL *handle = __wrap_curl_easy_init();", wrapper) for option in ("CURLOPT_SSL_VERIFYPEER, 1L", "CURLOPT_SSL_VERIFYHOST, 2L", @@ -71,9 +84,13 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): preamble = HTTPS.read_text().split("static rendezvous_admission_result_t solve_fast", 1)[0] # Only this fixture replaces dependencies. The preceding preprocessing # test separately exercises the actual included production source. + preamble = preamble.replace('#include "../rendezvous_admission.h"', '#include ') preamble = preamble.replace('#include "../rendezvous_admission.c"', ''' #include CURL *fixture_admission_init(void) { return curl_easy_init(); } +CURLcode fixture_admission_perform(CURL *handle, const curl_cancel_t *cancel) { + return curl_perform_cancellable(handle, cancel); +} ''').replace('#include "../socket_private.h"', "") (self.root / "wrapper.c").write_text(preamble + ''' #include @@ -96,6 +113,25 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): assert(handle == &fixture_handle); cleanup_calls++; } +static CURLcode transfer_result; +static const curl_cancel_t *expected_cancel; +static int fail_info, no_version; +CURLcode curl_perform_cancellable(CURL *handle, const curl_cancel_t *cancel) { + assert(handle == &fixture_handle && cancel == expected_cancel); + return transfer_result; +} +CURLcode curl_easy_getinfo(CURL *handle, int option, long *value) { + assert(handle == &fixture_handle); + if (fail_info) return 48; + *value = option == CURLINFO_SSL_VERIFYRESULT ? 20 : + option == CURLINFO_RESPONSE_CODE ? 0 : 61; + return CURLE_OK; +} +const curl_version_info_data *curl_version_info(int age) { + assert(age == CURLVERSION_NOW); + static const curl_version_info_data version = {530944, CURL_VERSION_ASYNCHDNS, "synthetic"}; + return no_version ? NULL : &version; +} int main(void) { (void)ticket; (void)server_id; @@ -113,6 +149,14 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): fail_init = 1; assert(fixture_admission_init() == NULL); assert(init_calls == 4 && setopt_calls == 2 && cleanup_calls == 1); + assert(ca_init_calls == 4 && ca_set_calls == 2 && ca_set_result == 1); + curl_cancel_t cancel = {42}; + expected_cancel = &cancel; + transfer_result = 60; + assert(fixture_admission_perform(&fixture_handle, &cancel) == 60); + fail_info = no_version = 1; + transfer_result = 4; + assert(fixture_admission_perform(&fixture_handle, &cancel) == 4); return 0; } ''') @@ -120,7 +164,16 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): self.run_checked([shutil.which("cc"), "-std=c17", "-D_POSIX_C_SOURCE=200809L", "-DATRINIK_TEST_SOURCE_WRAP", "-Wall", "-Wextra", "-Werror", "-I", str(self.root), str(self.root / "wrapper.c"), "-o", str(binary)]) - self.run_checked([str(binary)]) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr.splitlines(), [ + "HTTPS admission transport init_calls=4 ca_calls=2 ca_result=1 " + "curl_code=60 verify=20 verify_info=0 status=0 status_info=0 " + "os_errno=61 os_info=0 version=530944 async_dns=1 ares=1", + "HTTPS admission transport init_calls=4 ca_calls=2 ca_result=1 " + "curl_code=4 verify=-1 verify_info=48 status=-1 status_info=48 " + "os_errno=-1 os_info=48 version=0 async_dns=0 ares=0", + ]) @unittest.skipUnless(shutil.which("cmake"), "CMake is required") def test_platform_configure_keeps_https_test_on_apple_without_gnu_wrap(self): From ae72b407b88b51ee875d00b6084a1ecd220bbda7 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 15:07:33 -0500 Subject: [PATCH 116/148] test(ci): select native validation for admission platform fixture --- tools/ci/classify_changes.py | 1 + tools/tests/test_classify_changes.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index 9a51941d5..2a7817d77 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -30,6 +30,7 @@ "tools/tests/test_macos_llvm_toolchain.py", "tools/tests/test_gpu_qualification_runtime.py", "tools/tests/test_rendezvous_admission_https_fixture.py", + "tools/tests/test_rendezvous_admission_platform.py", "tools/tests/test_stage_windows_runtime.py", "tools/tests/test_windows_runtime_cmake_arguments.py", "tools/tests/test_windows_trust_notices.py", diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index fd452a658..4f451845a 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -163,6 +163,19 @@ def test_admission_https_fixture_selects_both_unix_native_consumers(self) -> Non self.assertFalse(result["benchmark_client"]) self.assertFalse(result["benchmark_server"]) + def test_admission_platform_fixture_selects_both_unix_native_consumers(self) -> None: + result = classify_changes.classify( + ["tools/tests/test_rendezvous_admission_platform.py"] + ) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + self.assertFalse(result["windows"]) + self.assertTrue(result["codeql_run"]) + self.assertTrue(result["codeql_python"]) + self.assertEqual(result["codeql_languages"], "python") + self.assertFalse(result["benchmark_client"]) + self.assertFalse(result["benchmark_server"]) + def test_release_configuration_selects_both_native_components(self) -> None: result = classify_changes.classify([".releaserc.cjs"]) self.assertTrue(result["client"]) From 7b77a65d5ecaaeddc5872010e12c6e79eb7ddc9e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 15:24:01 -0500 Subject: [PATCH 117/148] test(libatrinik): compare admission CA trust paths on failure --- libatrinik/tests/rendezvous_admission_https.c | 46 ++++++++++- .../tests/rendezvous_admission_https.py | 56 +++++++++++-- ...test_rendezvous_admission_https_fixture.py | 80 ++++++++++++++++--- .../test_rendezvous_admission_platform.py | 67 ++++++++++++++-- 4 files changed, 220 insertions(+), 29 deletions(-) diff --git a/libatrinik/tests/rendezvous_admission_https.c b/libatrinik/tests/rendezvous_admission_https.c index 013d767cc..5a75eea51 100644 --- a/libatrinik/tests/rendezvous_admission_https.c +++ b/libatrinik/tests/rendezvous_admission_https.c @@ -38,6 +38,29 @@ static unsigned int solver_calls; static unsigned int ca_init_calls; static unsigned int ca_set_calls; static int ca_set_result = -1; +static bool diagnostic_easy; +static unsigned int ca_file_seen, ca_file_matches, native_store_seen; + +/* libcurl debug data is not NUL-terminated and can include attempt secrets. + * Inspect only exact test-owned trust messages; discard every other byte. */ +static int trust_debug(CURL *handle, curl_infotype type, char *data, size_t size, void *context) { + (void)handle; + (void)context; + if (type != CURLINFO_TEXT) + return 0; + const char prefix[] = " CAfile: "; + const char native[] = " Native: Apple SecTrust\n"; + if (size >= sizeof(prefix) - 1 && memcmp(data, prefix, sizeof(prefix) - 1) == 0) { + ca_file_seen++; + const char *ca = getenv("ATRINIK_TEST_ADMISSION_CA"); + if (ca != NULL && size == sizeof(prefix) - 1 + strlen(ca) + 1 && + memcmp(data + sizeof(prefix) - 1, ca, strlen(ca)) == 0 && data[size - 1] == '\n') + ca_file_matches++; + } + if (size == sizeof(native) - 1 && memcmp(data, native, size) == 0) + native_store_seen++; + return 0; +} #ifdef ATRINIK_TEST_SOURCE_WRAP #define __real_curl_easy_init curl_easy_init @@ -60,7 +83,14 @@ CURL *__wrap_curl_easy_init(void) { } static CURLcode test_perform_cancellable(CURL *handle, const curl_cancel_t *cancel) { - CURLcode result = curl_perform_cancellable(handle, cancel); + CURLcode debug_result = curl_easy_setopt(handle, CURLOPT_DEBUGFUNCTION, trust_debug); + /* Never enable default verbose output if the private callback was rejected. */ + CURLcode verbose_result = debug_result == CURLE_OK + ? curl_easy_setopt(handle, CURLOPT_VERBOSE, 1L) + : debug_result; + CURLcode result = verbose_result != CURLE_OK ? verbose_result + : diagnostic_easy ? curl_easy_perform(handle) + : curl_perform_cancellable(handle, cancel); long verify = -1, status = -1, os_error = -1; CURLcode verify_info = curl_easy_getinfo(handle, CURLINFO_SSL_VERIFYRESULT, &verify); CURLcode status_info = curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status); @@ -69,7 +99,8 @@ static CURLcode test_perform_cancellable(CURL *handle, const curl_cancel_t *canc fprintf(stderr, "HTTPS admission transport init_calls=%u ca_calls=%u ca_result=%d " "curl_code=%d verify=%ld verify_info=%d status=%ld status_info=%d " - "os_errno=%ld os_info=%d version=%u async_dns=%d ares=%d\n", + "os_errno=%ld os_info=%d version=%u async_dns=%d ares=%d " + "ca_seen=%u ca_matches=%u native_seen=%u debug_result=%d verbose_result=%d easy=%d\n", ca_init_calls, ca_set_calls, ca_set_result, @@ -82,7 +113,13 @@ static CURLcode test_perform_cancellable(CURL *handle, const curl_cancel_t *canc (int)os_info, version != NULL ? version->version_num : 0U, version != NULL && (version->features & CURL_VERSION_ASYNCHDNS) != 0, - version != NULL && version->ares != NULL); + version != NULL && version->ares != NULL, + ca_file_seen, + ca_file_matches, + native_store_seen, + (int)debug_result, + (int)verbose_result, + diagnostic_easy); return result; } @@ -150,6 +187,7 @@ int main(int argc, char **argv) { toolkit_import(datetime); toolkit_import(curl); const char *mode = argv[2]; + diagnostic_easy = strcmp(mode, "diagnostic-easy") == 0; if (strcmp(mode, "rate-socket") == 0) { int result = test_rate_limit_mapping(argv[1]); toolkit_deinit(); @@ -173,7 +211,7 @@ int main(int argc, char **argv) { &retry_after_seconds, solve_fast); uint64_t elapsed = datetime_monotonic_ms() - start; - if (strcmp(mode, "valid") == 0) { + if (strcmp(mode, "valid") == 0 || diagnostic_easy) { if (result != RENDEZVOUS_ADMISSION_OK || solver_calls != 1) { fprintf(stderr, "HTTPS admission outcome result=%d solver_calls=%u elapsed_ms=%llu\n", diff --git a/libatrinik/tests/rendezvous_admission_https.py b/libatrinik/tests/rendezvous_admission_https.py index b810c1b25..d31f6d220 100644 --- a/libatrinik/tests/rendezvous_admission_https.py +++ b/libatrinik/tests/rendezvous_admission_https.py @@ -145,7 +145,7 @@ def do_POST(self): pass -def native_failure(result, mode, requests): +def native_failure(result, mode, requests, *, diagnostic=False): # Native output can contain URLs or attempt secrets. Retain only these # bounded test-owned numeric diagnostics, never arbitrary stdout/stderr. diagnostics = [] @@ -158,14 +158,21 @@ def native_failure(result, mode, requests): r"verify=-?[0-9]{1,20} verify_info=[0-9]{1,3} " r"status=-?[0-9]{1,20} status_info=[0-9]{1,3} " r"os_errno=-?[0-9]{1,20} os_info=[0-9]{1,3} " - r"version=[0-9]{1,10} async_dns=[01] ares=[01]", line): + r"version=[0-9]{1,10} async_dns=[01] ares=[01] " + r"ca_seen=[0-9]{1,10} ca_matches=[0-9]{1,10} native_seen=[0-9]{1,10} " + r"debug_result=[0-9]{1,3} verbose_result=[0-9]{1,3} easy=[01]", line): diagnostics.append(line) detail = "; ".join(diagnostics[:3]) or "no numeric native diagnostics" - return (f"native HTTPS admission case failed: {mode}; " + kind = "diagnostic only" if diagnostic else "case failed" + return (f"native HTTPS admission {kind}: {mode}; " f"exit={result.returncode}; requests={requests}; {detail}") -def run_case(binary, mode, ca, other_ca, cert, key): +def run_case(binary, mode, ca, other_ca, cert, key, *, diagnostic=False, deadline=None): + if deadline is None: + deadline = time.monotonic() + 8 + if time.monotonic() >= deadline: + raise RuntimeError("HTTPS diagnostic deadline exhausted") with Fixture(("127.0.0.1", 0), Handler) as server: server.mode = mode server.errors = [] @@ -185,8 +192,13 @@ def run_case(binary, mode, ca, other_ca, cert, key): # Keep localhost evidence independent of machine proxy configuration. env["NO_PROXY"] = env["no_proxy"] = "localhost,127.0.0.1" try: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError("HTTPS diagnostic deadline exhausted") result = subprocess.run([binary, url, mode], env=env, capture_output=True, - timeout=8, check=False) + timeout=remaining, check=False) + if diagnostic: + return native_failure(result, mode, server.requests, diagnostic=True) if result.returncode: raise RuntimeError(native_failure(result, mode, server.requests)) if server.errors: @@ -205,6 +217,23 @@ def run_case(binary, mode, ca, other_ca, cert, key): raise RuntimeError("TLS fixture did not stop") +def verify_certificates(ca, other_ca, cert): + cases = ((ca, "localhost", 0), (other_ca, "localhost", 2), (ca, "wrong.invalid", 2)) + for index, (authority, hostname, expected) in enumerate(cases, 1): + try: + result = subprocess.run( + [OPENSSL, "verify", "-x509_strict", "-purpose", "sslserver", + "-verify_hostname", hostname, "-CAfile", str(authority), str(cert)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15, check=False) + except (subprocess.TimeoutExpired, OSError) as error: + raise RuntimeError(f"HTTPS fixture certificate preflight case={index} " + f"error={type(error).__name__}") from None + if result.returncode != expected: + raise RuntimeError(f"HTTPS fixture certificate preflight case={index} " + f"exit={result.returncode} expected={expected}") + print("HTTPS fixture certificate preflight trusted=0 untrusted=2 wrong_host=2", flush=True) + + def main(): global OPENSSL if len(sys.argv) != 3: @@ -212,6 +241,7 @@ def main(): OPENSSL = sys.argv[2] with tempfile.TemporaryDirectory(prefix="atrinik-admission-https-") as directory: ca, other_ca, cert, key = certificates(Path(directory)) + verify_certificates(ca, other_ca, cert) modes = ("valid", "malformed", "missing-no-store", "oversized", "redirect", "deadline", "cancel", "wrong-host", "untrusted", "rate-valid", "rate-max", "rate-malformed", "rate-overflow", @@ -219,7 +249,21 @@ def main(): "rate-socket", "media-type-wrong", "media-type-missing", "media-type-duplicate", "media-type-case", "media-type-parameters") for mode in modes: - run_case(sys.argv[1], mode, ca, other_ca, cert, key) + deadline = time.monotonic() + 8 + try: + run_case(sys.argv[1], mode, ca, other_ca, cert, key, deadline=deadline) + except RuntimeError as original: + if mode != "valid": + raise + # Compare the exact generated chain and admission request with + # curl_easy_perform only after acceptance already failed. This + # never replaces the cancellable case or converts failure to success. + try: + diagnostic = run_case(sys.argv[1], "diagnostic-easy", ca, other_ca, + cert, key, diagnostic=True, deadline=deadline) + except (RuntimeError, subprocess.TimeoutExpired, OSError) as error: + diagnostic = "HTTPS diagnostic unavailable: " + type(error).__name__ + raise RuntimeError(str(original) + "; " + diagnostic) from None print(f"verified HTTPS admission: {len(modes)} transport cases passed") diff --git a/tools/tests/test_rendezvous_admission_https_fixture.py b/tools/tests/test_rendezvous_admission_https_fixture.py index b318b470b..b7e551e23 100644 --- a/tools/tests/test_rendezvous_admission_https_fixture.py +++ b/tools/tests/test_rendezvous_admission_https_fixture.py @@ -1,12 +1,15 @@ # Copyright 2026 The Atrinik Project # SPDX-License-Identifier: GPL-2.0-or-later +import contextlib import importlib.util +import io from pathlib import Path import shutil import subprocess import tempfile import unittest +from unittest import mock ROOT = Path(__file__).resolve().parents[2] SPEC = importlib.util.spec_from_file_location( @@ -20,16 +23,13 @@ class AdmissionHTTPSFixtureTests(unittest.TestCase): def test_certificate_chain_obeys_strict_server_verification(self): with tempfile.TemporaryDirectory(prefix="admission-certificate-") as directory: ca, other_ca, certificate, _ = FIXTURE.certificates(Path(directory)) - for authority, hostname, expected in ( - (ca, "localhost", 0), (other_ca, "localhost", 2), - (ca, "wrong.invalid", 2)): - with self.subTest(authority=authority.name, hostname=hostname): - result = subprocess.run( - [shutil.which("openssl"), "verify", "-x509_strict", - "-purpose", "sslserver", "-verify_hostname", hostname, - "-CAfile", str(authority), str(certificate)], - capture_output=True, text=True, timeout=15) - self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + output = io.StringIO() + with contextlib.redirect_stdout(output): + FIXTURE.verify_certificates(ca, other_ca, certificate) + self.assertEqual(output.getvalue(), + "HTTPS fixture certificate preflight trusted=0 untrusted=2 wrong_host=2\n") + with self.assertRaisesRegex(RuntimeError, "preflight case=1 exit=2 expected=0"): + FIXTURE.verify_certificates(other_ca, ca, certificate) def test_native_failure_retains_only_bounded_numeric_diagnostics(self): result = subprocess.CompletedProcess( @@ -49,7 +49,8 @@ def test_native_failure_retains_only_bounded_numeric_diagnostics(self): def test_native_failure_retains_transport_evidence_without_text_fields(self): transport = (b"HTTPS admission transport init_calls=1 ca_calls=1 ca_result=0 " b"curl_code=60 verify=20 verify_info=0 status=0 status_info=0 " - b"os_errno=-1 os_info=0 version=530944 async_dns=1 ares=1") + b"os_errno=-1 os_info=0 version=530944 async_dns=1 ares=1 " + b"ca_seen=1 ca_matches=1 native_seen=0 debug_result=0 verbose_result=0 easy=0") result = subprocess.CompletedProcess( [], 1, stdout=b"private", stderr=transport + b"\n" + transport + b" secret\n") message = FIXTURE.native_failure(result, "valid", 0) @@ -64,6 +65,63 @@ def test_native_failure_retains_transport_evidence_without_text_fields(self): self.assertIn("no numeric native diagnostics", FIXTURE.native_failure(result, "valid", 0)) + def test_successful_diagnostic_preserves_original_failure_and_deadline(self): + with mock.patch.object(FIXTURE, "certificates", return_value=(1, 2, 3, 4)), \ + mock.patch.object(FIXTURE, "verify_certificates"), \ + mock.patch.object(FIXTURE.sys, "argv", ["fixture", "binary", "openssl"]), \ + mock.patch.object(FIXTURE, "run_case", side_effect=[ + RuntimeError("original exit=1; requests=0; curl_code=60"), + "diagnostic only: exit=0; requests=1; curl_code=0"]) as run: + with self.assertRaisesRegex(RuntimeError, "original exit=1; requests=0; curl_code=60") as error: + FIXTURE.main() + self.assertIn("diagnostic only: exit=0; requests=1", str(error.exception)) + self.assertEqual(run.call_count, 2) + self.assertEqual(run.call_args_list[0].args[1], "valid") + self.assertEqual(run.call_args_list[1].args[1], "diagnostic-easy") + self.assertTrue(run.call_args_list[1].kwargs["diagnostic"]) + self.assertEqual(run.call_args_list[0].kwargs["deadline"], + run.call_args_list[1].kwargs["deadline"]) + + def test_exhausted_deadline_does_not_launch_a_diagnostic(self): + with mock.patch.object(FIXTURE, "Fixture") as server, \ + mock.patch.object(FIXTURE.subprocess, "run") as run: + with self.assertRaisesRegex(RuntimeError, "deadline exhausted"): + FIXTURE.run_case("private binary", "diagnostic-easy", 1, 2, 3, 4, + diagnostic=True, deadline=0) + server.assert_not_called() + run.assert_not_called() + + def test_certificate_preflight_timeout_is_sanitized(self): + with mock.patch.object(FIXTURE.subprocess, "run", side_effect= + subprocess.TimeoutExpired("private certificate path", 15)): + with self.assertRaises(RuntimeError) as error: + FIXTURE.verify_certificates("private CA", "other CA", "private cert") + self.assertEqual(str(error.exception), + "HTTPS fixture certificate preflight case=1 error=TimeoutExpired") + + def test_diagnostic_timeout_preserves_original_failure(self): + with mock.patch.object(FIXTURE, "certificates", return_value=(1, 2, 3, 4)), \ + mock.patch.object(FIXTURE, "verify_certificates"), \ + mock.patch.object(FIXTURE.sys, "argv", ["fixture", "binary", "openssl"]), \ + mock.patch.object(FIXTURE, "run_case", side_effect=[ + RuntimeError("original exit=1; requests=0"), + subprocess.TimeoutExpired("private command", 1)]): + with self.assertRaises(RuntimeError) as error: + FIXTURE.main() + self.assertIn("original exit=1; requests=0", str(error.exception)) + self.assertIn("HTTPS diagnostic unavailable: TimeoutExpired", str(error.exception)) + self.assertNotIn("private command", str(error.exception)) + + def test_negative_cases_never_run_diagnostic(self): + with mock.patch.object(FIXTURE, "certificates", return_value=(1, 2, 3, 4)), \ + mock.patch.object(FIXTURE, "verify_certificates"), \ + mock.patch.object(FIXTURE.sys, "argv", ["fixture", "binary", "openssl"]), \ + mock.patch.object(FIXTURE, "run_case", side_effect=[None, RuntimeError("malformed")]) as run: + with self.assertRaisesRegex(RuntimeError, "malformed"): + FIXTURE.main() + self.assertEqual(run.call_count, 2) + self.assertEqual(run.call_args_list[1].args[1], "malformed") + def test_native_failure_handles_missing_and_excessive_diagnostics(self): result = subprocess.CompletedProcess([], -6, stdout=b"", stderr=b"\xffarbitrary\n") self.assertIn("exit=-6; requests=1; no numeric native diagnostics", diff --git a/tools/tests/test_rendezvous_admission_platform.py b/tools/tests/test_rendezvous_admission_platform.py index 9b6e2a331..3199d204b 100644 --- a/tools/tests/test_rendezvous_admission_platform.py +++ b/tools/tests/test_rendezvous_admission_platform.py @@ -25,8 +25,17 @@ def setUp(self): #define CURL_FIXTURE_H #include #include +#include +#include +#include typedef struct { int unused; } CURL; typedef int CURLcode; +typedef int curl_infotype; +#define CURLINFO_TEXT 0 +#define CURLOPT_DEBUGFUNCTION 2 +#define CURLOPT_VERBOSE 3 +typedef int (*curl_debug_callback)(CURL *, curl_infotype, char *, size_t, void *); +CURLcode curl_easy_perform(CURL *); typedef struct { int marker; } curl_cancel_t; typedef struct { unsigned int version_num; int features; const char *ares; } curl_version_info_data; #define CURL_VERSION_ASYNCHDNS 1 @@ -40,7 +49,7 @@ def setUp(self): #define CURLOPT_CAINFO 1 #define CURLE_OK 0 CURL *curl_easy_init(void); -CURLcode curl_easy_setopt(CURL *, int, const char *); +CURLcode curl_easy_setopt(CURL *, int, ...); void curl_easy_cleanup(CURL *); #endif ''') @@ -73,7 +82,7 @@ def test_source_wrap_redirects_actual_admission_and_restores_real_curl(self): real = "curl_easy_init" if source_wrap else "__real_curl_easy_init" self.assertIn(f"CURL *curl = {called}();", admission) self.assertIn("test_perform_cancellable(curl, &bounded)", admission) - self.assertIn("CURLcode result = curl_perform_cancellable(handle, cancel);", wrapper) + self.assertIn(": curl_perform_cancellable(handle, cancel);", wrapper) self.assertIn(f"CURL *handle = {real}();", wrapper) self.assertNotIn("CURL *handle = __wrap_curl_easy_init();", wrapper) for option in ("CURLOPT_SSL_VERIFYPEER, 1L", "CURLOPT_SSL_VERIFYHOST, 2L", @@ -103,10 +112,22 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): assert(++init_calls < 10); return fail_init ? NULL : &fixture_handle; } -CURLcode curl_easy_setopt(CURL *handle, int option, const char *ca) { - assert(handle == &fixture_handle && option == CURLOPT_CAINFO); - setopt_calls++; - selected_ca = ca; +static curl_debug_callback debug_callback; +static int verbose_enabled; +CURLcode curl_easy_setopt(CURL *handle, int option, ...) { + assert(handle == &fixture_handle); + va_list args; + va_start(args, option); + if (option == CURLOPT_DEBUGFUNCTION) + debug_callback = va_arg(args, curl_debug_callback); + else if (option == CURLOPT_VERBOSE) + verbose_enabled = (int)va_arg(args, long); + else { + assert(option == CURLOPT_CAINFO); + setopt_calls++; + selected_ca = va_arg(args, const char *); + } + va_end(args); return fail_setopt ? 1 : CURLE_OK; } void curl_easy_cleanup(CURL *handle) { @@ -116,8 +137,18 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): static CURLcode transfer_result; static const curl_cancel_t *expected_cancel; static int fail_info, no_version; +static unsigned int easy_calls, cancellable_calls; CURLcode curl_perform_cancellable(CURL *handle, const curl_cancel_t *cancel) { assert(handle == &fixture_handle && cancel == expected_cancel); + cancellable_calls++; + assert(verbose_enabled == 1 && debug_callback != NULL); + char ca_message[] = " CAfile: fixture-ca.pem\\n"; + debug_callback(handle, CURLINFO_TEXT, ca_message, sizeof(ca_message) - 1, NULL); + return transfer_result; +} +CURLcode curl_easy_perform(CURL *handle) { + assert(handle == &fixture_handle); + easy_calls++; return transfer_result; } CURLcode curl_easy_getinfo(CURL *handle, int option, long *value) { @@ -150,6 +181,7 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): assert(fixture_admission_init() == NULL); assert(init_calls == 4 && setopt_calls == 2 && cleanup_calls == 1); assert(ca_init_calls == 4 && ca_set_calls == 2 && ca_set_result == 1); + fail_setopt = 0; curl_cancel_t cancel = {42}; expected_cancel = &cancel; transfer_result = 60; @@ -157,6 +189,19 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): fail_info = no_version = 1; transfer_result = 4; assert(fixture_admission_perform(&fixture_handle, &cancel) == 4); + assert(cancellable_calls == 2 && easy_calls == 0); + diagnostic_easy = true; + transfer_result = 0; + assert(fixture_admission_perform(&fixture_handle, &cancel) == 0); + assert(cancellable_calls == 2 && easy_calls == 1); + char secret[] = "private URL certificate and attempt secret"; + trust_debug(&fixture_handle, CURLINFO_TEXT, secret, sizeof(secret) - 1, NULL); + trust_debug(&fixture_handle, 1, secret, sizeof(secret) - 1, NULL); + char mismatch[] = " CAfile: wrong-ca.pem\\n"; + trust_debug(&fixture_handle, CURLINFO_TEXT, mismatch, sizeof(mismatch) - 1, NULL); + char native[] = " Native: Apple SecTrust\\n"; + trust_debug(&fixture_handle, CURLINFO_TEXT, native, sizeof(native) - 1, NULL); + assert(ca_file_seen == 3 && ca_file_matches == 2 && native_store_seen == 1); return 0; } ''') @@ -169,10 +214,16 @@ def test_source_wrapper_preserves_ca_selection_and_failure_cleanup(self): self.assertEqual(result.stderr.splitlines(), [ "HTTPS admission transport init_calls=4 ca_calls=2 ca_result=1 " "curl_code=60 verify=20 verify_info=0 status=0 status_info=0 " - "os_errno=61 os_info=0 version=530944 async_dns=1 ares=1", + "os_errno=61 os_info=0 version=530944 async_dns=1 ares=1 " + "ca_seen=1 ca_matches=1 native_seen=0 debug_result=0 verbose_result=0 easy=0", "HTTPS admission transport init_calls=4 ca_calls=2 ca_result=1 " "curl_code=4 verify=-1 verify_info=48 status=-1 status_info=48 " - "os_errno=-1 os_info=48 version=0 async_dns=0 ares=0", + "os_errno=-1 os_info=48 version=0 async_dns=0 ares=0 " + "ca_seen=2 ca_matches=2 native_seen=0 debug_result=0 verbose_result=0 easy=0", + "HTTPS admission transport init_calls=4 ca_calls=2 ca_result=1 " + "curl_code=0 verify=-1 verify_info=48 status=-1 status_info=48 " + "os_errno=-1 os_info=48 version=0 async_dns=0 ares=0 " + "ca_seen=2 ca_matches=2 native_seen=0 debug_result=0 verbose_result=0 easy=1", ]) @unittest.skipUnless(shutil.which("cmake"), "CMake is required") From 538a4d8b1e6adfa297d0645212af010a7e92ca0f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 15:37:36 -0500 Subject: [PATCH 118/148] build: refresh published LLVM SDK consumer pins to 1.18 --- .github/workflows/build-release-candidate.yml | 12 +++++----- .github/workflows/check.yml | 14 +++++------ .../workflows/daily-client-performance.yml | 4 ++-- .github/workflows/gpu-qualification.yml | 6 ++--- .github/workflows/package-release.yml | 2 +- .github/workflows/pr-benchmarks.yml | 4 ++-- .github/workflows/qualify-server-image.yml | 2 +- server/Dockerfile | 2 +- tools/ci/appimage/packaging.lock.json | 2 +- tools/tests/test_workflow_contracts.py | 23 +++++++++++-------- 10 files changed, 38 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 5b4aac199..c81fada37 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -50,8 +50,8 @@ permissions: packages: read env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 - WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-1d82af6036b831d97cc470e2230357047e4c78f8-914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 + WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-4af130578df18a1944880cd279c7f23afe9cc668-01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a concurrency: group: >- @@ -617,7 +617,7 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - run: docker pull ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 + - run: docker pull ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a - name: Build portable package from monorepo siblings env: RELEASE_VERSION: ${{ needs.metadata.outputs.version }} @@ -639,7 +639,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/client \ - ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ + ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a \ bash -c \ 'set -euo pipefail ccache --zero-stats @@ -685,7 +685,7 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - run: docker pull ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 + - run: docker pull ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a - name: Build portable package from monorepo siblings env: RELEASE_VERSION: ${{ needs.metadata.outputs.version }} @@ -705,7 +705,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/server \ - ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ + ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a \ bash -c \ 'set -euo pipefail ccache --zero-stats diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6587ebaec..7ec976727 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -31,9 +31,9 @@ concurrency: env: COVERAGE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 - CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 - CLASSIC_GPU_COVERAGE_IMAGE: ghcr.io/atrinik/linux-build:1.17.0@sha256:4ca8e921b616d20b18fcef901bd554488b4d5a68a7c4304268dedef23723a3bf + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 + CLASSIC_LINUX_IMAGE_DIGEST: sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 + CLASSIC_GPU_COVERAGE_IMAGE: ghcr.io/atrinik/linux-build:1.18.0@sha256:613cf43ab07634db005a28462a009c4c28c90dccb1b5175e13e8072b6dfe8d72 # Historical baseline for the paired native Windows font comparison. CLASSIC_WINDOWS_FONT_BASELINE_IMAGE: ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69 CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt @@ -397,7 +397,7 @@ jobs: build/discord-test-application-id - name: Pull pinned Windows build environment run: | - docker pull ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 + docker pull ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a - name: Check out the immutable historical baseline staging helper uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -455,7 +455,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace \ - ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ + ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a \ bash -c ' set -euo pipefail pcpnatpmp_source="$(cat build/libpcpnatpmp-source-path)" @@ -577,7 +577,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace \ - ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ + ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a \ bash -c ' set -euo pipefail cmake \ @@ -617,7 +617,7 @@ jobs: --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/server \ - ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 \ + ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a \ bash tools/build-windows-package.sh build/windows-pr-package cp server/build/windows-pr-package/*.zip \ libatrinik/build/windows-test-bundle/ diff --git a/.github/workflows/daily-client-performance.yml b/.github/workflows/daily-client-performance.yml index f5522eff1..8603eb2c9 100644 --- a/.github/workflows/daily-client-performance.yml +++ b/.github/workflows/daily-client-performance.yml @@ -22,8 +22,8 @@ concurrency: cancel-in-progress: false env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 - CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 + CLASSIC_LINUX_IMAGE_DIGEST: sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt CLASSIC_LINUX_CCACHE_EPOCH: "2" FINAL_BENCHMARK_DATA_COMMIT: bab40ecefefa5b6052d42eab6390c504b9482e81 diff --git a/.github/workflows/gpu-qualification.yml b/.github/workflows/gpu-qualification.yml index 4be23ef9b..ee577f014 100644 --- a/.github/workflows/gpu-qualification.yml +++ b/.github/workflows/gpu-qualification.yml @@ -12,9 +12,9 @@ concurrency: cancel-in-progress: false env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 - CLASSIC_LINUX_RUNTIME_IMAGE: ghcr.io/atrinik/linux-build:1.17.0@sha256:4ca8e921b616d20b18fcef901bd554488b4d5a68a7c4304268dedef23723a3bf - CLASSIC_WINDOWS_IMAGE: ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 + CLASSIC_LINUX_RUNTIME_IMAGE: ghcr.io/atrinik/linux-build:1.18.0@sha256:613cf43ab07634db005a28462a009c4c28c90dccb1b5175e13e8072b6dfe8d72 + CLASSIC_WINDOWS_IMAGE: ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a jobs: shaders: diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 4f5037246..9c5d5102f 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -25,7 +25,7 @@ permissions: packages: read env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 concurrency: group: classic-release-publication diff --git a/.github/workflows/pr-benchmarks.yml b/.github/workflows/pr-benchmarks.yml index 7260b82bd..48613eee9 100644 --- a/.github/workflows/pr-benchmarks.yml +++ b/.github/workflows/pr-benchmarks.yml @@ -13,8 +13,8 @@ concurrency: cancel-in-progress: true env: - CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 - CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 + CLASSIC_LINUX_IMAGE: ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 + CLASSIC_LINUX_IMAGE_DIGEST: sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 CLASSIC_LINUX_CCACHE_COMPILER: clang-23.1.3-lld-compiler-rt CLASSIC_LINUX_CCACHE_EPOCH: "2" diff --git a/.github/workflows/qualify-server-image.yml b/.github/workflows/qualify-server-image.yml index 03e88b988..3a3fbaf13 100644 --- a/.github/workflows/qualify-server-image.yml +++ b/.github/workflows/qualify-server-image.yml @@ -38,7 +38,7 @@ jobs: run: | test "$(git rev-parse HEAD)" = "${SOURCE_REVISION}" test -z "$(git status --porcelain)" - grep -Fx 'FROM ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 AS build' server/Dockerfile + grep -Fx 'FROM ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 AS build' server/Dockerfile mkdir -p build/server-image-evidence git show -s --format=fuller HEAD > build/server-image-evidence/source.txt docker version > build/server-image-evidence/docker-version.txt diff --git a/server/Dockerfile b/server/Dockerfile index 8c4b7b9a8..30708bdd2 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 -FROM ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1 AS build +FROM ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449 AS build # The qualified build image defaults to an unprivileged compiler user. USER root diff --git a/tools/ci/appimage/packaging.lock.json b/tools/ci/appimage/packaging.lock.json index d98be3118..27376360e 100644 --- a/tools/ci/appimage/packaging.lock.json +++ b/tools/ci/appimage/packaging.lock.json @@ -2275,7 +2275,7 @@ }, "compiler": { "family": "Clang", - "image": "ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1", + "image": "ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449", "version": "23.1.3", "prefix": "/opt/llvm-23.1.3", "source": { diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 19437aeb1..fdb654fbb 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -1,3 +1,5 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later from __future__ import annotations from pathlib import Path @@ -12,11 +14,11 @@ ROOT = Path(__file__).resolve().parents[2] -WINDOWS_PRODUCER_REVISION = "1d82af6036b831d97cc470e2230357047e4c78f8" -WINDOWS_GENERAL_DIGEST = "914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325" -WINDOWS_FOCUSED_DIGEST = "eb1fbbfbfbe0b784cdc705f3a1a01b8d69b84338adcb6eb9d8e418a743c8ec09" -WINDOWS_GENERAL_IMAGE = f"ghcr.io/atrinik/windows-build:candidate-sha-{WINDOWS_PRODUCER_REVISION}@sha256:{WINDOWS_GENERAL_DIGEST}" -WINDOWS_FOCUSED_IMAGE = f"ghcr.io/atrinik/windows-build:classic-check-candidate-sha-{WINDOWS_PRODUCER_REVISION}@sha256:{WINDOWS_FOCUSED_DIGEST}" +WINDOWS_PRODUCER_REVISION = "4af130578df18a1944880cd279c7f23afe9cc668" +WINDOWS_GENERAL_DIGEST = "01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a" +WINDOWS_FOCUSED_DIGEST = "5140299dee8e89a90c05c8372bc3e41a1819fdef5a9cfc9fcdbb0eaa07859bfd" +WINDOWS_GENERAL_IMAGE = f"ghcr.io/atrinik/windows-build:1.18.0@sha256:{WINDOWS_GENERAL_DIGEST}" +WINDOWS_FOCUSED_IMAGE = f"ghcr.io/atrinik/windows-build:classic-check-1.18.0@sha256:{WINDOWS_FOCUSED_DIGEST}" class WorkflowContractTests(unittest.TestCase): @@ -24,16 +26,19 @@ def text(self, name: str) -> str: return (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") def test_final_published_provider_pins_and_installed_library_lane(self) -> None: - classic = "ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1" + classic = "ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449" for name in ("check.yml", "pr-benchmarks.yml", "daily-client-performance.yml", "build-release-candidate.yml", "package-release.yml", "gpu-qualification.yml"): with self.subTest(workflow=name): workflow = self.text(name) self.assertIn(f"CLASSIC_LINUX_IMAGE: {classic}", workflow) if name in ("check.yml", "pr-benchmarks.yml", "daily-client-performance.yml"): - self.assertIn("CLASSIC_LINUX_IMAGE_DIGEST: sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1", workflow) + self.assertIn("CLASSIC_LINUX_IMAGE_DIGEST: sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449", workflow) compiler = json.loads((ROOT / "tools/ci/appimage/packaging.lock.json").read_text())["compiler"] self.assertEqual(compiler["image"], classic) + self.assertIn(f"FROM {classic} AS build", (ROOT / "server/Dockerfile").read_text()) + self.assertIn(f"grep -Fx 'FROM {classic} AS build' server/Dockerfile", + self.text("qualify-server-image.yml")) candidate = self.text("build-release-candidate.yml") sources = candidate[candidate.index(" sources:"):candidate.index(" client-linux:")] lane = sources[sources.index(" - name: Pull the immutable installed-library"):] @@ -2405,8 +2410,8 @@ def test_linux_checks_pin_image_and_isolate_compiler_caches(self) -> None: self.assertIn("tools/ci/run_gpu_coverage.sh", client) self.assertIn("tools/ci/run_gpu_coverage.sh", gpu_coverage_job) broad_image = ( - "ghcr.io/atrinik/linux-build:1.17.0@sha256:" - "4ca8e921b616d20b18fcef901bd554488b4d5a68a7c4304268dedef23723a3bf" + "ghcr.io/atrinik/linux-build:1.18.0@sha256:" + "613cf43ab07634db005a28462a009c4c28c90dccb1b5175e13e8072b6dfe8d72" ) self.assertIn(f"CLASSIC_GPU_COVERAGE_IMAGE: {broad_image}", workflow) self.assertIn( From a425bf041996f5a674602008072e2957ada4e205 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 15:39:59 -0500 Subject: [PATCH 119/148] fix(build): preserve existing workflow test header convention --- tools/tests/test_workflow_contracts.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index fdb654fbb..67d295246 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -1,5 +1,3 @@ -# Copyright 2026 The Atrinik Project -# SPDX-License-Identifier: GPL-2.0-or-later from __future__ import annotations from pathlib import Path From 3bf6b035f7bc31cbc5156600272d08a9677a9fa3 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 15:40:15 -0500 Subject: [PATCH 120/148] build(server): bind provider lock to measured SDK 1.18 payload --- docs/CARES_PROVIDER.md | 32 ++++++++++++++++-------- server/docker/runtime-provider.lock.json | 12 ++++++--- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/CARES_PROVIDER.md b/docs/CARES_PROVIDER.md index 50d50c10b..03d596c4f 100644 --- a/docs/CARES_PROVIDER.md +++ b/docs/CARES_PROVIDER.md @@ -1,15 +1,24 @@ # Qualified c-ares providers -The standalone server compiler stage uses the released LLVM 23.1.3 SDK -`ghcr.io/atrinik/classic-build:1.17.0` index -`sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1`, +The standalone server compiler stage selects the released LLVM 23.1.3 SDK +`ghcr.io/atrinik/classic-build:1.18.0` index +`sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449`, Linux amd64 platform -`sha256:67a0b1243b0cf860f72e39623a6ea92414c2531677bc55a01b3f2f62c57de93d`, -from producer source `0571f500ede75e7661b508d8bb40354256113223`. -Its measured curl/c-ares payload and actual consumer loader paths match the -current runtime provider lock. All 86 locked distro package versions and their -recorded dependency expressions match this SDK; the runtime base and snapshot -retain their existing qualification. +`sha256:434f7d083cd2fec2344a3a8547c50bf8b4e37e8124f755a144f1259b76d150fb`, +config `sha256:eaca6c3e72c873208bb4da6c6989d40f359de417b716567d03630ea77e09f3fc`, +from producer source `4af130578df18a1944880cd279c7f23afe9cc668`. +Fresh anonymous registry/image inspection and copies from a never-started, +mount-free container measured all eleven locked provider paths, including their +bytes, modes and symlinks, plus the installed dpkg database. The payload is +identical to the previous 1.17 SDK lock. All 86 locked distro package versions +and their `Depends`/`Pre-Depends` expressions also match. The lock retains the +historical evidence keys and adds the `source_4af130_*` measurement receipts. +This measurement did not execute SDK binaries or requalify destination loader +resolution or server behavior; those remain separate assembly/runtime gates. + +The full SDK also contains distro `libcurl4t64` for its build-tool environment. +That package is outside the selected runtime closure and is not copied into the +server image. The final-image verifier continues to reject its installation. Historical qualification used `ghcr.io/atrinik/classic-build:1.16.0` index `sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2`. @@ -53,8 +62,9 @@ from the same published producer. Its recorded package inventory receipt is `source_4be36_base_family_closure` in the lock. Every selected package version must satisfy its recorded dependency expression, including util-linux's exact `libuuid1`, `libblkid1`, `libmount1`, and `libsmartcols1` relations. The current LLVM SDK retains these qualified application package versions. -Its rebuilt curl/c-ares payload hashes and build records are measured separately -in the current lock; the named historical closure receipts remain preserved. +Its curl/c-ares payload hashes and build records were measured again from the +released 1.18 image and match the 1.17 lock; the named historical closure receipts +remain preserved. The application closure includes indirect GD image/X11 libraries, Python FFI and compression libraries, and `openssl-provider-legacy` because the actual `libssl3t64` package declares that dependency. This is package dependency closure, diff --git a/server/docker/runtime-provider.lock.json b/server/docker/runtime-provider.lock.json index 166ef9fe0..e8591a82e 100644 --- a/server/docker/runtime-provider.lock.json +++ b/server/docker/runtime-provider.lock.json @@ -1,8 +1,8 @@ { "schema_version": 1, - "build_image": "ghcr.io/atrinik/classic-build:1.17.0@sha256:5ec3669614fde5ac77ce63e89b6adc35cd27b0584451b97928147facb22aaec1", - "platform_digest": "sha256:67a0b1243b0cf860f72e39623a6ea92414c2531677bc55a01b3f2f62c57de93d", - "producer_source": "0571f500ede75e7661b508d8bb40354256113223", + "build_image": "ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449", + "platform_digest": "sha256:434f7d083cd2fec2344a3a8547c50bf8b4e37e8124f755a144f1259b76d150fb", + "producer_source": "4af130578df18a1944880cd279c7f23afe9cc668", "snapshot": "20260810T000000Z", "openssl_version": "3.5.5", "evidence_sha256": { @@ -11,7 +11,11 @@ "runtime_closure": "4ff80feae2bd437a77444436eca419b4f2b21f12276e150fa63d86a047c996f2", "source_9136_server_python_closure": "b70a977a3ffaf4c2d3d3fefd9a6e4a1cff4ee63692024a68418c5d6c22dfcd3b", "source_4be36_base_family_closure": "7c6d141dd048015fd4a07c68adbb78e30d057305ff40a5c6c0acc5b6cd814d51", - "source_0571_package_dependency_fields": "01b93feedb60e771ad8350535514abd267ad4d2b9c6488e40d5777599373d4c6" + "source_0571_package_dependency_fields": "01b93feedb60e771ad8350535514abd267ad4d2b9c6488e40d5777599373d4c6", + "source_4af130_payload": "6ba5d89f9c0246683f173c41e17255712ab3591aa3c97bcf764ddb319e885aa6", + "source_4af130_packages": "37763a219bc25c8b263d5e49a1732627ea615b3f6bebea30c67abc38cc3cef73", + "source_4af130_package_dependency_fields": "35914491c7ee1978f988cfe397336bfda08227076bf06153add256cc29993626", + "source_4af130_no_start_measurement": "2965fbddcf71790d05549890a86accbf5b0efc927ba2ef6e6bfef85c8aa49cea" }, "files": [ { From 5dc27699ccabea180d932265654c3d7a03b8a943 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 15:57:27 -0500 Subject: [PATCH 121/148] fix(libatrinik): isolate admission HTTPS certificate profiles --- .../tests/rendezvous_admission_https.py | 50 +++++++++++++------ ...test_rendezvous_admission_https_fixture.py | 30 +++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/libatrinik/tests/rendezvous_admission_https.py b/libatrinik/tests/rendezvous_admission_https.py index d31f6d220..bc0b9fa88 100644 --- a/libatrinik/tests/rendezvous_admission_https.py +++ b/libatrinik/tests/rendezvous_admission_https.py @@ -33,28 +33,38 @@ def openssl(*args): def certificates(root): + # req otherwise inherits x509_extensions from the host openssl.cnf, even + # with -addext. Own the complete CA/request profile on every platform. + config = root / "openssl.cnf" + config.write_text( + "[req]\nprompt=no\ndistinguished_name=dn\nx509_extensions=ca\n" + "[dn]\nCN=Atrinik admission test CA\n" + "[ca]\nbasicConstraints=critical,CA:TRUE\n" + "keyUsage=critical,keyCertSign,cRLSign\n" + "subjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid:always\n", + encoding="ascii", + ) ca_key, ca_cert = root / "ca.key", root / "ca.pem" - openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", - "-subj", "/CN=Atrinik admission test CA", "-keyout", ca_key, - "-out", ca_cert, "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign") + openssl("req", "-x509", "-newkey", "rsa:2048", "-sha256", "-nodes", "-days", "1", + "-config", config, "-keyout", ca_key, "-out", ca_cert) key, csr, cert = root / "server.key", root / "server.csr", root / "server.pem" - openssl("req", "-new", "-newkey", "rsa:2048", "-nodes", + openssl("req", "-new", "-newkey", "rsa:2048", "-sha256", "-nodes", "-config", config, "-subj", "/CN=localhost", "-keyout", key, "-out", csr) extensions = root / "extensions.conf" extensions.write_text( - "subjectAltName=DNS:localhost\n" + "[server]\nsubjectAltName=DNS:localhost\n" "basicConstraints=critical,CA:FALSE\n" "keyUsage=critical,digitalSignature,keyEncipherment\n" - "extendedKeyUsage=serverAuth\n", encoding="ascii", + "extendedKeyUsage=serverAuth\n" + "subjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid,issuer\n", encoding="ascii", ) openssl("x509", "-req", "-in", csr, "-CA", ca_cert, "-CAkey", ca_key, - "-CAcreateserial", "-days", "1", "-extfile", extensions, "-out", cert) + "-CAcreateserial", "-days", "1", "-sha256", "-extfile", extensions, + "-extensions", "server", "-out", cert) other_ca = root / "other-ca.pem" - openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", - "-subj", "/CN=Unrelated admission test CA", "-keyout", root / "other.key", - "-out", other_ca, "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign") + openssl("req", "-x509", "-newkey", "rsa:2048", "-sha256", "-nodes", "-days", "1", + "-config", config, "-subj", "/CN=Unrelated admission test CA", + "-keyout", root / "other.key", "-out", other_ca) return ca_cert, other_ca, cert, key @@ -217,6 +227,17 @@ def run_case(binary, mode, ca, other_ca, cert, key, *, diagnostic=False, deadlin raise RuntimeError("TLS fixture did not stop") +def verification_errors(stderr): + # OpenSSL also prints certificate subjects and paths. Retain only numeric + # verification errors/depths, with bounded input and output. + errors = [] + for line in stderr[:4096].decode("ascii", errors="replace").splitlines(): + match = re.fullmatch(r"error ([0-9]{1,3}) at ([0-9]{1,3}) depth lookup:[^\r\n]*", line) + if match: + errors.append(f"verify_error={match[1]} depth={match[2]}") + return "; ".join(errors[:3]) or "no numeric verification errors" + + def verify_certificates(ca, other_ca, cert): cases = ((ca, "localhost", 0), (other_ca, "localhost", 2), (ca, "wrong.invalid", 2)) for index, (authority, hostname, expected) in enumerate(cases, 1): @@ -224,13 +245,14 @@ def verify_certificates(ca, other_ca, cert): result = subprocess.run( [OPENSSL, "verify", "-x509_strict", "-purpose", "sslserver", "-verify_hostname", hostname, "-CAfile", str(authority), str(cert)], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15, check=False) + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=15, check=False) except (subprocess.TimeoutExpired, OSError) as error: raise RuntimeError(f"HTTPS fixture certificate preflight case={index} " f"error={type(error).__name__}") from None if result.returncode != expected: raise RuntimeError(f"HTTPS fixture certificate preflight case={index} " - f"exit={result.returncode} expected={expected}") + f"exit={result.returncode} expected={expected}; " + + verification_errors(result.stderr)) print("HTTPS fixture certificate preflight trusted=0 untrusted=2 wrong_host=2", flush=True) diff --git a/tools/tests/test_rendezvous_admission_https_fixture.py b/tools/tests/test_rendezvous_admission_https_fixture.py index b7e551e23..32d0534fb 100644 --- a/tools/tests/test_rendezvous_admission_https_fixture.py +++ b/tools/tests/test_rendezvous_admission_https_fixture.py @@ -4,6 +4,7 @@ import contextlib import importlib.util import io +import os from pathlib import Path import shutil import subprocess @@ -31,6 +32,35 @@ def test_certificate_chain_obeys_strict_server_verification(self): with self.assertRaisesRegex(RuntimeError, "preflight case=1 exit=2 expected=0"): FIXTURE.verify_certificates(other_ca, ca, certificate) + @unittest.skipUnless(shutil.which("openssl"), "OpenSSL is required") + def test_certificate_profiles_are_independent_of_host_request_extensions(self): + with tempfile.TemporaryDirectory(prefix="admission-host-config-") as directory: + root = Path(directory) + host_config = root / "host-openssl.cnf" + host_config.write_text( + "[req]\ndistinguished_name=dn\nx509_extensions=host_ca\n" + "[dn]\n[host_ca]\nbasicConstraints=critical,CA:TRUE\n" + "subjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid:always\n" + "nameConstraints=critical,excluded;DNS:localhost\n", encoding="ascii") + with mock.patch.dict(os.environ, {"OPENSSL_CONF": str(host_config)}): + ca, other_ca, certificate, _ = FIXTURE.certificates(root) + with contextlib.redirect_stdout(io.StringIO()): + FIXTURE.verify_certificates(ca, other_ca, certificate) + + def test_certificate_failure_retains_only_bounded_numeric_errors(self): + stderr = (b"private certificate subject and file path\n" + b"error 20 at 0 depth lookup: private details\n" + b"error 86 at 1 depth lookup: more private details\n" + b"error 1234 at 0 depth lookup: invalid error width\n") + self.assertEqual(FIXTURE.verification_errors(stderr), + "verify_error=20 depth=0; verify_error=86 depth=1") + self.assertEqual(FIXTURE.verification_errors(b"\xffprivate"), + "no numeric verification errors") + self.assertEqual(FIXTURE.verification_errors(b"x" * 4096 + stderr), + "no numeric verification errors") + repeated = b"error 20 at 0 depth lookup: private\n" * 100 + self.assertEqual(FIXTURE.verification_errors(repeated).count("verify_error=20"), 3) + def test_native_failure_retains_only_bounded_numeric_diagnostics(self): result = subprocess.CompletedProcess( [], 1, stdout=b"synthetic attempt secret", From 180ef3bc059404d65ffba85ebc28b287b8006f43 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 16:45:05 -0500 Subject: [PATCH 122/148] fix(release): bound historical Windows producer selection --- tools/release/windows_producer.py | 131 ++++++++++++++++++++++++ tools/tests/test_windows_producer.py | 142 +++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 tools/release/windows_producer.py create mode 100644 tools/tests/test_windows_producer.py diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py new file mode 100644 index 000000000..4dec31310 --- /dev/null +++ b/tools/release/windows_producer.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Select an authenticated Windows recipe without changing candidate source. + +The registry deliberately admits only two reviewed pre-LLVM source commits. +A source recipe change requires reviewing and updating its digest registry; an +absent compiler descriptor never grants an automatic historical fallback. +""" +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess + +LLVM_IMAGE = "ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a" +MXE_IMAGE = "ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69" +HISTORICAL = { + "fd4943252ac872fce2995c286b10a0c6d249aba3": { + "client/CMakeLists.txt": "da73739b768228f16f3372423ee6d05041f0fb7977544cca85f8bc56b1ea2ca4", + ".github/workflows/build-release-candidate.yml": "a2d0ee01585c89a74cb1f995f85ebedb1836e5c71b3dafe8083aa17e96a05f69", + }, + "2349e0e14276fee251250cd451da6da0e68a3148": { + "client/CMakeLists.txt": "7b96197394a739c1244285c9524775b838814249fe24544b65f8a3ec92da502f", + ".github/workflows/build-release-candidate.yml": "aa39a531b2c8b1248bc1f3aed94a6b44ae013e2bdc0259db2289b9abb242d646", + }, +} +MXE_RECIPE = { + "client/tools/build-windows-package.sh": "b4ca907f6fcb847b5171902d723a22572034e7c763cf51a27f3bb70acc06ba8e", + "server/tools/build-windows-package.sh": "0431158578a9ce0b9d4d0111b78db6ead9871807af97ace83201b402aff9811c", + "server/CMakeLists.txt": "a0aad039d80d5a155288d8e22d64cb27bd1daa0ce5dbd674a0ad876f64fdfa0f", + "tools/ci/stage_windows_runtime.py": "12d315f854726e46ec768c5c4db6a34b0aabb93e5cab959233f5c5ec1650aeca", +} +LLVM_RECIPE = { + "client/tools/build-windows-package.sh": "10e25f9e5dd939fc7b34b14ae0d197956540659fec531b4915b6e0e9f43fddba", + "server/tools/build-windows-package.sh": "fd8c5dc43aaa88a9ea772bcbf151f82ea7cd5f2a77ac33734905f585f4df7a03", + "client/CMakeLists.txt": "f4a8595ffe7f4c4888b77eea2c8ddc248a6e0c74c733bc87b7b7bafb88d19f12", + "server/CMakeLists.txt": "e9bac9c28d7f4f180dcf0f6496e8b429722ac5e8a82a60454381ac9125c21e1b", + "tools/ci/stage_windows_runtime.py": "2640baf4187bce4afca95da560cff08e4bdf8802840245c3cbf26eff372691e9", + "cmake/toolchains/llvm.cmake": "b1eb85208a5f44a5b28956c33b92d86c4f056da1cac9355b483a84b46d55a513", +} +SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + + +def git(root: Path, *arguments: str) -> bytes: + return subprocess.run(["git", "-C", str(root), *arguments], check=True, + capture_output=True, timeout=30).stdout + + +def verify_recipe(root: Path, revision: str, recipe: dict[str, str]) -> None: + for path, expected in recipe.items(): + entry = git(root, "ls-tree", revision, "--", path).decode().split() + if len(entry) != 4 or (entry[0] not in {"100644", "100755"} or entry[1] != "blob") or entry[3] != path: + raise ValueError(f"Windows recipe is not a regular source blob: {path}") + source = git(root, "show", revision + ":" + path) + if hashlib.sha256(source).hexdigest() != expected: + raise ValueError(f"unapproved Windows source recipe: {path}") + # The package scripts execute the checkout, so tagged blobs alone do not + # prove the actual inputs. Never rewrite or repair those inputs here. + local = root / path + if local.is_symlink() or not local.is_file() or local.read_bytes() != source: + raise ValueError(f"Windows checkout differs from selected source: {path}") + + +def select_producer(root: Path, revision: str) -> dict[str, object]: + if re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise ValueError("Windows revision must be an exact lowercase commit SHA") + if git(root, "rev-parse", "--verify", revision + "^{commit}").decode().strip() != revision: + raise ValueError("Windows revision must select a commit") + if git(root, "rev-parse", "HEAD").decode().strip() != revision: + raise ValueError("Windows checkout HEAD differs from selected source") + historical = HISTORICAL.get(revision) + if historical is not None: + verify_recipe(root, revision, MXE_RECIPE | historical) + environment = { + "PATH": "/opt/mxe/.ccache/bin:/opt/mxe/usr/bin:" + SYSTEM_PATH, + "MXE_TARGET": "x86_64-w64-mingw32.shared", + "MXE_TOOLCHAIN_FILE": "/opt/mxe/usr/x86_64-w64-mingw32.shared/share/cmake/mxe-conf.cmake", + "MXE_RUNTIME_DIR": "/opt/mxe/usr/x86_64-w64-mingw32.shared/bin", + } + # The original GENERAL SDK supplies Python include/import/runtime and + # native GCC/Python/worldmaker dependencies. Do not replace its ENV with + # the focused Check SDK or demand receipts that predate neither source + # nor SDK. Original CMake owns DLL glob/c-ares/Python notice staging. + image, mode, epoch = MXE_IMAGE, "mxe", "mxe-gcc-general-1.16-4266256d" + else: + verify_recipe(root, revision, LLVM_RECIPE) + environment = { + "PATH": "/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:" + SYSTEM_PATH, + "ATRINIK_WINDOWS_TOOLCHAIN_FILE": "/opt/atrinik-windows/share/cmake/llvm-mingw.cmake", + "ATRINIK_WINDOWS_TARGET": "x86_64-w64-mingw32", + "ATRINIK_WINDOWS_RUNTIME_DIR": "/opt/atrinik-windows/bin", + } + image, mode, epoch = LLVM_IMAGE, "llvm", "llvm-mingw-1.18-01065f08" + return {"windows_image": image, "windows_mode": mode, + "windows_cache_epoch": epoch, "environment": environment} + + +def docker_environment(producer: dict[str, object]) -> list[str]: + return [argument for name, value in producer["environment"].items() + for argument in ("--env", name + "=" + value)] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--revision", required=True) + parser.add_argument("--github-output", type=Path) + parser.add_argument("--format", choices=("json", "docker-env"), default="json") + options = parser.parse_args() + try: + producer = select_producer(options.source_root, options.revision) + if options.github_output is not None: + with options.github_output.open("a", encoding="utf-8") as output: + for key in ("windows_image", "windows_mode", "windows_cache_epoch"): + output.write(f"{key}={producer[key]}\n") + if options.format == "docker-env": + print("\n".join(docker_environment(producer))) + else: + print(json.dumps(producer, sort_keys=True)) + except (ValueError, OSError, subprocess.SubprocessError) as error: + parser.exit(1, f"cannot select trusted Windows producer: {error}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py new file mode 100644 index 000000000..a3ecc57cc --- /dev/null +++ b/tools/tests/test_windows_producer.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from tools.release import windows_producer as producer + + +class WindowsProducerTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.git('init', '--quiet') + self.git('config', 'user.name', 'Windows producer fixture') + self.git('config', 'user.email', 'fixture@example.invalid') + self.recipe = {'client/CMakeLists.txt': self.blob('client/CMakeLists.txt', b'recipe\n')} + self.revision = self.commit() + self.selector = Path(producer.__file__).resolve() + + def git(self, *arguments: str) -> str: + return subprocess.run(['git', '-C', str(self.root), *arguments], check=True, + capture_output=True, text=True).stdout.strip() + + def blob(self, path: str, contents: bytes) -> str: + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(contents) + return hashlib.sha256(contents).hexdigest() + + def commit(self) -> str: + self.git('add', '.') + self.git('-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'fixture') + return self.git('rev-parse', 'HEAD') + + def test_current_llvm_image_environment_and_compiler_cache(self) -> None: + with patch.object(producer, 'LLVM_RECIPE', self.recipe): + selected = producer.select_producer(self.root, self.revision) + self.assertEqual(selected['windows_image'], producer.LLVM_IMAGE) + self.assertEqual(selected['windows_mode'], 'llvm') + self.assertIn('llvm-mingw', selected['windows_cache_epoch']) + environment = selected['environment'] + self.assertEqual(environment['ATRINIK_WINDOWS_TARGET'], 'x86_64-w64-mingw32') + self.assertTrue(environment['PATH'].startswith('/opt/llvm-mingw/bin:')) + self.assertFalse(any(name.startswith('MXE_') for name in environment)) + + def test_approved_historical_uses_general_sdk_and_inherits_python_worldmaker(self) -> None: + with patch.object(producer, 'HISTORICAL', {self.revision: {}}), \ + patch.object(producer, 'MXE_RECIPE', self.recipe): + selected = producer.select_producer(self.root, self.revision) + self.assertEqual(selected['windows_image'], producer.MXE_IMAGE) + self.assertEqual(selected['windows_mode'], 'mxe') + self.assertIn('mxe-gcc-general', selected['windows_cache_epoch']) + self.assertNotEqual(selected['windows_cache_epoch'], 'llvm-mingw-1.18-01065f08') + self.assertEqual(selected['environment'], { + 'PATH': '/opt/mxe/.ccache/bin:/opt/mxe/usr/bin:' + producer.SYSTEM_PATH, + 'MXE_TARGET': 'x86_64-w64-mingw32.shared', + 'MXE_TOOLCHAIN_FILE': '/opt/mxe/usr/x86_64-w64-mingw32.shared/share/cmake/mxe-conf.cmake', + 'MXE_RUNTIME_DIR': '/opt/mxe/usr/x86_64-w64-mingw32.shared/bin', + }) + # Original image ENV supplies Python and native tools. These overrides + # must not request LLVM receipts, invoke staging or alter package scripts. + self.assertEqual(producer.docker_environment(selected), [ + argument for name, value in selected['environment'].items() + for argument in ('--env', name + '=' + value)]) + + def test_unknown_history_and_descendants_are_not_automatically_approved(self) -> None: + historical = self.revision + self.blob('unrelated', b'next revision') + descendant = self.commit() + with patch.object(producer, 'HISTORICAL', {historical: {}}), \ + patch.object(producer, 'MXE_RECIPE', self.recipe): + with self.assertRaises(ValueError): + producer.select_producer(self.root, descendant) + + def test_tampered_historical_registry_blob_is_rejected(self) -> None: + with patch.object(producer, 'HISTORICAL', {self.revision: {}}), \ + patch.object(producer, 'MXE_RECIPE', {'client/CMakeLists.txt': '0' * 64}): + with self.assertRaisesRegex(ValueError, 'unapproved Windows'): + producer.select_producer(self.root, self.revision) + + def test_dirty_checkout_symlink_missing_blob_and_other_head_are_rejected(self) -> None: + with patch.object(producer, 'LLVM_RECIPE', self.recipe): + (self.root / 'client/CMakeLists.txt').write_text('tampered') + with self.assertRaisesRegex(ValueError, 'checkout differs'): + producer.select_producer(self.root, self.revision) + (self.root / 'client/CMakeLists.txt').unlink() + (self.root / 'client/CMakeLists.txt').symlink_to('/dev/null') + with self.assertRaises(ValueError): + producer.select_producer(self.root, self.revision) + self.git('checkout', '--', 'client/CMakeLists.txt') + with patch.object(producer, 'LLVM_RECIPE', {'missing': '0' * 64}): + with self.assertRaisesRegex(ValueError, 'regular source blob'): + producer.select_producer(self.root, self.revision) + self.blob('new', b'new') + self.commit() + with self.assertRaisesRegex(ValueError, 'HEAD differs'): + producer.select_producer(self.root, self.revision) + + def test_cli_rejects_revision_arguments_and_does_not_write_outputs_on_failure(self) -> None: + output = self.root / 'outputs' + for revision in ('HEAD', '--help', self.revision.upper(), 'a' * 39, self.revision): + result = subprocess.run(['python3', str(self.selector), '--source-root', str(self.root), + '--revision=' + revision, '--github-output', str(output), + '--format', 'docker-env'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, '') + self.assertFalse(output.exists()) + + def test_cli_success_emits_bounded_metadata_and_docker_argument_lines(self) -> None: + output = self.root / 'outputs' + printed = io.StringIO() + with patch.object(producer, 'LLVM_RECIPE', self.recipe), \ + patch('sys.argv', [str(self.selector), '--source-root', str(self.root), + '--revision', self.revision, '--github-output', str(output), + '--format', 'docker-env']), contextlib.redirect_stdout(printed): + self.assertEqual(producer.main(), 0) + self.assertEqual(output.read_text().splitlines(), [ + 'windows_image=' + producer.LLVM_IMAGE, 'windows_mode=llvm', + 'windows_cache_epoch=llvm-mingw-1.18-01065f08']) + arguments = printed.getvalue().splitlines() + self.assertEqual(arguments[::2], ['--env'] * 4) + self.assertTrue(all('=' in argument for argument in arguments[1::2])) + + def test_historical_registry_is_exact_two_source_commits(self) -> None: + self.assertEqual(set(producer.HISTORICAL), { + 'fd4943252ac872fce2995c286b10a0c6d249aba3', + '2349e0e14276fee251250cd451da6da0e68a3148', + }) + self.assertIn('server/CMakeLists.txt', producer.MXE_RECIPE) + self.assertIn('tools/ci/stage_windows_runtime.py', producer.MXE_RECIPE) + + +if __name__ == '__main__': + unittest.main() From 0fefed5450a5d0e13860df13bfbb8aa68059c226 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 16:47:12 -0500 Subject: [PATCH 123/148] fix(release): require current LLVM source descriptor --- tools/release/windows_producer.py | 28 ++++++++++++++++++++++++++++ tools/tests/test_windows_producer.py | 21 +++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py index 4dec31310..9ff00f196 100644 --- a/tools/release/windows_producer.py +++ b/tools/release/windows_producer.py @@ -66,6 +66,33 @@ def verify_recipe(root: Path, revision: str, recipe: dict[str, str]) -> None: raise ValueError(f"Windows checkout differs from selected source: {path}") +def verify_llvm_descriptor(root: Path, revision: str) -> None: + path = "tools/ci/appimage/packaging.lock.json" + entry = git(root, "ls-tree", revision, "--", path).decode().split() + if len(entry) != 4 or entry[:2] != ["100644", "blob"] or entry[3] != path: + raise ValueError("current Windows source requires its LLVM compiler descriptor") + source = git(root, "show", revision + ":" + path) + + def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate LLVM source descriptor key: {key}") + result[key] = value + return result + + descriptor = json.loads(source, object_pairs_hook=unique_object) + compiler = descriptor.get("compiler") if isinstance(descriptor, dict) else None + if (not isinstance(compiler, dict) or compiler.get("family") != "Clang" + or not isinstance(compiler.get("image"), str) + or re.fullmatch(r"ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}", + compiler["image"]) is None): + raise ValueError("current Windows source requires its immutable LLVM compiler descriptor") + local = root / path + if local.is_symlink() or not local.is_file() or local.read_bytes() != source: + raise ValueError("LLVM compiler descriptor checkout differs from selected source") + + def select_producer(root: Path, revision: str) -> dict[str, object]: if re.fullmatch(r"[0-9a-f]{40}", revision) is None: raise ValueError("Windows revision must be an exact lowercase commit SHA") @@ -89,6 +116,7 @@ def select_producer(root: Path, revision: str) -> dict[str, object]: image, mode, epoch = MXE_IMAGE, "mxe", "mxe-gcc-general-1.16-4266256d" else: verify_recipe(root, revision, LLVM_RECIPE) + verify_llvm_descriptor(root, revision) environment = { "PATH": "/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:" + SYSTEM_PATH, "ATRINIK_WINDOWS_TOOLCHAIN_FILE": "/opt/atrinik-windows/share/cmake/llvm-mingw.cmake", diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py index a3ecc57cc..579ff3205 100644 --- a/tools/tests/test_windows_producer.py +++ b/tools/tests/test_windows_producer.py @@ -22,6 +22,10 @@ def setUp(self) -> None: self.git('config', 'user.name', 'Windows producer fixture') self.git('config', 'user.email', 'fixture@example.invalid') self.recipe = {'client/CMakeLists.txt': self.blob('client/CMakeLists.txt', b'recipe\n')} + self.descriptor_path = 'tools/ci/appimage/packaging.lock.json' + self.blob(self.descriptor_path, json.dumps({'compiler': { + 'family': 'Clang', 'image': 'ghcr.io/atrinik/classic-build:1.18.0@sha256:' + 'a' * 64 + }}).encode()) self.revision = self.commit() self.selector = Path(producer.__file__).resolve() @@ -129,6 +133,23 @@ def test_cli_success_emits_bounded_metadata_and_docker_argument_lines(self) -> N self.assertEqual(arguments[::2], ['--env'] * 4) self.assertTrue(all('=' in argument for argument in arguments[1::2])) + def test_current_llvm_rejects_absent_malformed_and_dirty_compiler_descriptor(self) -> None: + with patch.object(producer, 'LLVM_RECIPE', self.recipe): + (self.root / self.descriptor_path).write_text('{}') + with self.assertRaisesRegex(ValueError, 'descriptor checkout differs'): + producer.select_producer(self.root, self.revision) + self.revision = self.commit() + with self.assertRaisesRegex(ValueError, 'immutable LLVM compiler descriptor'): + producer.select_producer(self.root, self.revision) + self.blob(self.descriptor_path, b'{"compiler":{},"compiler":{}}') + self.revision = self.commit() + with self.assertRaisesRegex(ValueError, 'duplicate LLVM source descriptor'): + producer.select_producer(self.root, self.revision) + (self.root / self.descriptor_path).unlink() + self.revision = self.commit() + with self.assertRaisesRegex(ValueError, 'LLVM compiler descriptor'): + producer.select_producer(self.root, self.revision) + def test_historical_registry_is_exact_two_source_commits(self) -> None: self.assertEqual(set(producer.HISTORICAL), { 'fd4943252ac872fce2995c286b10a0c6d249aba3', From 1b121f7d57b9619437873cb2bc0ffe8251faaa66 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 16:48:35 -0500 Subject: [PATCH 124/148] fix(release): retain reviewed schema 2 Windows lineage --- tools/release/windows_producer.py | 15 ++++++++++++++- tools/tests/test_windows_producer.py | 28 +++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py index 9ff00f196..de1c9d1c8 100644 --- a/tools/release/windows_producer.py +++ b/tools/release/windows_producer.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Select an authenticated Windows recipe without changing candidate source. -The registry deliberately admits only two reviewed pre-LLVM source commits. +The registry deliberately admits only five reviewed pre-LLVM source commits. +Schema 1 has no authenticated producer in this registry and fails closed. A source recipe change requires reviewing and updating its digest registry; an absent compiler descriptor never grants an automatic historical fallback. """ @@ -20,6 +21,18 @@ LLVM_IMAGE = "ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a" MXE_IMAGE = "ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69" HISTORICAL = { + "994e6fdc20da962be9c11055bda28ef5561a1dea": { + "client/CMakeLists.txt": "443e654babcb909ef3a78eb4798a23420d732ede073f652fcf98b042ca8433d3", + ".github/workflows/build-release-candidate.yml": "aa39a531b2c8b1248bc1f3aed94a6b44ae013e2bdc0259db2289b9abb242d646", + }, + "24bee6c8a830aa7372a30be6b842210b07639f73": { + "client/CMakeLists.txt": "443e654babcb909ef3a78eb4798a23420d732ede073f652fcf98b042ca8433d3", + ".github/workflows/build-release-candidate.yml": "aa39a531b2c8b1248bc1f3aed94a6b44ae013e2bdc0259db2289b9abb242d646", + }, + "c5a8f9fa2bdcfbae9311093fb69907c8d64b35d4": { + "client/CMakeLists.txt": "443e654babcb909ef3a78eb4798a23420d732ede073f652fcf98b042ca8433d3", + ".github/workflows/build-release-candidate.yml": "aa39a531b2c8b1248bc1f3aed94a6b44ae013e2bdc0259db2289b9abb242d646", + }, "fd4943252ac872fce2995c286b10a0c6d249aba3": { "client/CMakeLists.txt": "da73739b768228f16f3372423ee6d05041f0fb7977544cca85f8bc56b1ea2ca4", ".github/workflows/build-release-candidate.yml": "a2d0ee01585c89a74cb1f995f85ebedb1836e5c71b3dafe8083aa17e96a05f69", diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py index 579ff3205..86872f9f0 100644 --- a/tools/tests/test_windows_producer.py +++ b/tools/tests/test_windows_producer.py @@ -150,8 +150,34 @@ def test_current_llvm_rejects_absent_malformed_and_dirty_compiler_descriptor(sel with self.assertRaisesRegex(ValueError, 'LLVM compiler descriptor'): producer.select_producer(self.root, self.revision) - def test_historical_registry_is_exact_two_source_commits(self) -> None: + def test_actual_reviewed_source_commits_when_history_is_available(self) -> None: + repository = Path(__file__).resolve().parents[2] + for revision in producer.HISTORICAL: + available = subprocess.run(['git', '-C', str(repository), 'cat-file', '-e', + revision + '^{commit}'], capture_output=True) + if available.returncode != 0: + self.skipTest('reviewed source history is absent from this checkout') + source = self.root / 'historical-source' + subprocess.run(['git', 'clone', '--quiet', '--shared', '--no-checkout', + str(repository), str(source)], check=True, capture_output=True) + for revision in producer.HISTORICAL: + with self.subTest(revision=revision): + subprocess.run(['git', '-C', str(source), 'checkout', '--quiet', '--detach', + revision], check=True, capture_output=True) + result = subprocess.run(['python3', str(self.selector), '--source-root', str(source), + '--revision', revision], check=True, capture_output=True, + text=True) + selected = json.loads(result.stdout) + self.assertEqual(selected['windows_image'], producer.MXE_IMAGE) + self.assertEqual(selected['windows_mode'], 'mxe') + self.assertEqual(selected['environment']['MXE_TARGET'], + 'x86_64-w64-mingw32.shared') + + def test_historical_registry_is_exact_five_source_commits(self) -> None: self.assertEqual(set(producer.HISTORICAL), { + '994e6fdc20da962be9c11055bda28ef5561a1dea', + '24bee6c8a830aa7372a30be6b842210b07639f73', + 'c5a8f9fa2bdcfbae9311093fb69907c8d64b35d4', 'fd4943252ac872fce2995c286b10a0c6d249aba3', '2349e0e14276fee251250cd451da6da0e68a3148', }) From 202218b9621828ef538b15d85b358b7538d5cc4d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 16:49:22 -0500 Subject: [PATCH 125/148] fix(release): admit authenticated schema 1 Windows source --- tools/release/windows_producer.py | 9 +++++++-- tools/tests/test_windows_producer.py | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py index de1c9d1c8..837b22c6a 100644 --- a/tools/release/windows_producer.py +++ b/tools/release/windows_producer.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Select an authenticated Windows recipe without changing candidate source. -The registry deliberately admits only five reviewed pre-LLVM source commits. -Schema 1 has no authenticated producer in this registry and fails closed. +The registry deliberately admits only six reviewed pre-LLVM source commits. +Schema 1 is admitted only for its exact authenticated registry entry; unknown +source fails closed. A source recipe change requires reviewing and updating its digest registry; an absent compiler descriptor never grants an automatic historical fallback. """ @@ -21,6 +22,10 @@ LLVM_IMAGE = "ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a" MXE_IMAGE = "ghcr.io/atrinik/windows-build:1.16.0@sha256:4266256d4b4196241df6463e687dd286022e24e3bc46dfafb4f877744a731d69" HISTORICAL = { + "34bbad5c3f4d9b303c17aec5e2c75b3550ec8983": { + "client/CMakeLists.txt": "a75d70efe1d736d10496d993bde83bf49012789dcf370d2b962f7d2bff64de03", + ".github/workflows/build-release-candidate.yml": "8497f85c816911f81ca541b4950843c593b961f58462bef24f533c63beb44f90", + }, "994e6fdc20da962be9c11055bda28ef5561a1dea": { "client/CMakeLists.txt": "443e654babcb909ef3a78eb4798a23420d732ede073f652fcf98b042ca8433d3", ".github/workflows/build-release-candidate.yml": "aa39a531b2c8b1248bc1f3aed94a6b44ae013e2bdc0259db2289b9abb242d646", diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py index 86872f9f0..a7d49344f 100644 --- a/tools/tests/test_windows_producer.py +++ b/tools/tests/test_windows_producer.py @@ -173,8 +173,9 @@ def test_actual_reviewed_source_commits_when_history_is_available(self) -> None: self.assertEqual(selected['environment']['MXE_TARGET'], 'x86_64-w64-mingw32.shared') - def test_historical_registry_is_exact_five_source_commits(self) -> None: + def test_historical_registry_is_exact_six_source_commits(self) -> None: self.assertEqual(set(producer.HISTORICAL), { + '34bbad5c3f4d9b303c17aec5e2c75b3550ec8983', '994e6fdc20da962be9c11055bda28ef5561a1dea', '24bee6c8a830aa7372a30be6b842210b07639f73', 'c5a8f9fa2bdcfbae9311093fb69907c8d64b35d4', From 967eb420d64e3b4b7e99eab78c0b8dec8f139343 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 16:50:47 -0500 Subject: [PATCH 126/148] fix(release): preserve authenticated historical compiler producers --- .github/workflows/build-release-candidate.yml | 60 +- docs/RELEASING.md | 24 + tools/ci/appimage/historical/Dockerfile | 56 + .../appimage/historical/build-dependencies.sh | 98 + .../ci/appimage/historical/graphical-probe.c | 179 ++ .../appimage/historical/packaging.lock.json | 2270 +++++++++++++++++ tools/ci/appimage/historical/runtime-probe.c | 104 + tools/ci/appimage/prepare.py | 50 +- tools/ci/appimage/test_pre_llvm_recovery.py | 22 +- tools/ci/appimage/test_prepare.py | 58 +- tools/release/resolve_compiler_image.py | 69 +- tools/tests/test_appimage_workflow.py | 6 +- tools/tests/test_compiler_image.py | 44 +- tools/tests/test_workflow_contracts.py | 83 +- 14 files changed, 3013 insertions(+), 110 deletions(-) create mode 100644 tools/ci/appimage/historical/Dockerfile create mode 100644 tools/ci/appimage/historical/build-dependencies.sh create mode 100644 tools/ci/appimage/historical/graphical-probe.c create mode 100644 tools/ci/appimage/historical/packaging.lock.json create mode 100644 tools/ci/appimage/historical/runtime-probe.c diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index c81fada37..de414d6d0 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -72,6 +72,9 @@ jobs: version: ${{ steps.metadata.outputs.version }} artifact_schema: ${{ steps.metadata.outputs.artifact_schema }} compiler_image: ${{ steps.metadata.outputs.compiler_image }} + windows_image: ${{ steps.metadata.outputs.windows_image }} + windows_mode: ${{ steps.metadata.outputs.windows_mode }} + windows_cache_epoch: ${{ steps.metadata.outputs.windows_cache_epoch }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -112,6 +115,9 @@ jobs: compiler_image=$(python3 build/release-automation/tools/release/resolve_compiler_image.py \ --source-root "${GITHUB_WORKSPACE}" --revision "${commit}" \ --current-image "${CLASSIC_LINUX_IMAGE}") + python3 build/release-automation/tools/release/windows_producer.py \ + --source-root "${GITHUB_WORKSPACE}" --revision "${commit}" \ + --github-output "${GITHUB_OUTPUT}" { echo "artifact_schema=${artifact_schema}" echo "compiler_image=${compiler_image}" @@ -378,6 +384,7 @@ jobs: ref: ${{ needs.metadata.outputs.commit }} path: candidate-source persist-credentials: false + fetch-depth: 0 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: candidate-dependencies-${{ needs.metadata.outputs.tag }} @@ -577,6 +584,9 @@ jobs: client-windows: name: Build Windows client package + env: + WINDOWS_BUILD_IMAGE: ${{ needs.metadata.outputs.windows_image }} + WINDOWS_BUILD_CACHE_EPOCH: ${{ needs.metadata.outputs.windows_cache_epoch }} needs: [metadata, dependencies, gpu-shaders] runs-on: ubuntu-24.04 timeout-minutes: 90 @@ -584,6 +594,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.metadata.outputs.commit }} + - name: Check out trusted Windows producer selection + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: build/windows-automation + persist-credentials: false + sparse-checkout: tools/release/windows_producer.py - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: candidate-dependencies-${{ needs.metadata.outputs.tag }} @@ -617,12 +634,19 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - run: docker pull ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a + - run: docker pull "${WINDOWS_BUILD_IMAGE}" - name: Build portable package from monorepo siblings env: RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_REVISION: ${{ needs.metadata.outputs.commit }} DISCORD_CONFIG_FILE: ${{ inputs.rehearsal != true && '/workspace/build/discord-config/discord-application-id' || '' }} run: | + arguments_path=$(mktemp "${RUNNER_TEMP}/classic-windows-environment.XXXXXX") + trap 'rm -f "${arguments_path}"' EXIT + python3 build/windows-automation/tools/release/windows_producer.py \ + --source-root "${GITHUB_WORKSPACE}" --revision "${RELEASE_REVISION}" \ + --format docker-env > "${arguments_path}" + mapfile -t producer_environment < "${arguments_path}" docker run --rm \ --network none \ --user "$(id -u):$(id -g)" \ @@ -633,13 +657,10 @@ jobs: --env CCACHE_DIR=/workspace/client/build/ccache \ --env CCACHE_MAXSIZE=500M \ --env CCACHE_TEMPDIR=/tmp/atrinik-client-ccache \ - --env ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake \ - --env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 \ - --env ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin \ - --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + "${producer_environment[@]}" \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/client \ - ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a \ + "${WINDOWS_BUILD_IMAGE}" \ bash -c \ 'set -euo pipefail ccache --zero-stats @@ -655,6 +676,9 @@ jobs: server-windows: name: Build Windows server package + env: + WINDOWS_BUILD_IMAGE: ${{ needs.metadata.outputs.windows_image }} + WINDOWS_BUILD_CACHE_EPOCH: ${{ needs.metadata.outputs.windows_cache_epoch }} needs: [metadata, dependencies] runs-on: ubuntu-24.04 timeout-minutes: 180 @@ -662,6 +686,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.metadata.outputs.commit }} + - name: Check out trusted Windows producer selection + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: build/windows-automation + persist-credentials: false + sparse-checkout: tools/release/windows_producer.py - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: candidate-dependencies-${{ needs.metadata.outputs.tag }} @@ -685,11 +716,18 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - run: docker pull ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a + - run: docker pull "${WINDOWS_BUILD_IMAGE}" - name: Build portable package from monorepo siblings env: RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_REVISION: ${{ needs.metadata.outputs.commit }} run: | + arguments_path=$(mktemp "${RUNNER_TEMP}/classic-windows-environment.XXXXXX") + trap 'rm -f "${arguments_path}"' EXIT + python3 build/windows-automation/tools/release/windows_producer.py \ + --source-root "${GITHUB_WORKSPACE}" --revision "${RELEASE_REVISION}" \ + --format docker-env > "${arguments_path}" + mapfile -t producer_environment < "${arguments_path}" docker run --rm \ --network none \ --user "$(id -u):$(id -g)" \ @@ -699,13 +737,10 @@ jobs: --env CCACHE_DIR=/workspace/server/build/ccache \ --env CCACHE_MAXSIZE=500M \ --env CCACHE_TEMPDIR=/tmp/atrinik-server-ccache \ - --env ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.cmake \ - --env ATRINIK_WINDOWS_TARGET=x86_64-w64-mingw32 \ - --env ATRINIK_WINDOWS_RUNTIME_DIR=/opt/atrinik-windows/bin \ - --env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + "${producer_environment[@]}" \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --workdir /workspace/server \ - ghcr.io/atrinik/windows-build:1.18.0@sha256:01065f081cdf843a6591abf25f2b27734103ab385c5aa003b2eeb9a3a1ca0e5a \ + "${WINDOWS_BUILD_IMAGE}" \ bash -c \ 'set -euo pipefail ccache --zero-stats @@ -790,6 +825,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.metadata.outputs.commit }} + fetch-depth: 0 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: release-*-${{ needs.metadata.outputs.tag }} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e3c1b1d56..fb49cfaa3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -566,6 +566,30 @@ current checkout's trusted inputs. LLVM packages require the approved compiler image from the trusted caller or an exact image pin in the source lock; inspection rejects a different well-formed digest. Candidate code cannot replace that caller's validator or redefine the supported host-library boundary. +Historical source without a compiler descriptor never inherits the current +LLVM image. The trusted resolver authenticates the approved pre-LLVM history, +release schema and original producer inputs before selecting the immutable +Classic Linux SDK 1.16. Schema-3 source at +`fd4943252ac872fce2995c286b10a0c6d249aba3` uses the byte-identical retained +Ubuntu 24.04/GCC AppImage recipe under `tools/ci/appimage/historical/`, its +original packaging lock and six-key dependency-input manifest. Its source +finalizer receives no unsupported compiler-image argument. Schema-2 recovery +retains the authenticated original Debian recipe. Current LLVM source uses +its exact compiler descriptor and matching LLVM recipe; unrecognized history, +missing or malformed descriptors and changed producer inputs fail closed. +Schema-1 source at `34bbad5c3f4d9b303c17aec5e2c75b3550ec8983` retains its +original twelve assets, authenticated workflow, finalizer and installed-library +helper, with no Linux client package recipe. The four approved first-parent +schema-2 revisions from `994e6fdc20da962be9c11055bda28ef5561a1dea` through +`2349e0e14276fee251250cd451da6da0e68a3148` retain their Debian client. Other +schema-1 source requires separate producer authentication before rebuilding. +Windows candidate metadata independently authenticates its producer recipe. +Approved historical source uses the original general SDK 1.16 MXE image and +environment; current source uses the general SDK 1.18 LLVM environment. Both +Windows package jobs consume that selection and partition compiler caches by +producer. Historical source and its DLL, Python and license staging remain +unchanged. Selector and assembler fixtures prove these routing boundaries; +they do not qualify a complete rebuilt historical release. SDK assembly uses the matching LLVM 23 `llvm-readelf`. The trusted host inspector uses `/usr/bin/llvm-readelf` from the distribution's `llvm` package only for read-only artifact inspection, preserving historical release recovery diff --git a/tools/ci/appimage/historical/Dockerfile b/tools/ci/appimage/historical/Dockerfile new file mode 100644 index 000000000..5cd67991e --- /dev/null +++ b/tools/ci/appimage/historical/Dockerfile @@ -0,0 +1,56 @@ +FROM ubuntu:24.04@sha256:f610ab94648195aa356059f5b41d6085c9d4d903c072430cdd1af7bdb646106b AS builder +COPY tools/ca-certificates.deb /tmp/ca-certificates.deb +RUN echo "641de77d8f142cfd62a1a6f964ba67b20754d3337c480efb529d086075a06c9a /tmp/ca-certificates.deb" | sha256sum -c - && dpkg-deb -x /tmp/ca-certificates.deb /tmp/ca-bootstrap && mkdir -p /etc/ssl/certs && cat /tmp/ca-bootstrap/usr/share/ca-certificates/mozilla/*.crt > /etc/ssl/certs/ca-certificates.crt && rm -rf /tmp/ca-bootstrap /tmp/ca-certificates.deb +# Ubuntu's signed archive keys authenticate this immutable snapshot. Do not disable signatures. +RUN rm -f /etc/apt/sources.list.d/ubuntu.sources && printf '%s\n' \ + 'deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg check-valid-until=no] http://snapshot.ubuntu.com/ubuntu/20261010T000000Z noble main universe' \ + 'deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg check-valid-until=no] http://snapshot.ubuntu.com/ubuntu/20261010T000000Z noble-updates main universe' \ + 'deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg check-valid-until=no] http://snapshot.ubuntu.com/ubuntu/20261010T000000Z noble-security main universe' > /etc/apt/sources.list && \ + apt-get -o APT::Update::Error-Mode=any update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build pkg-config python3 perl ca-certificates patchelf file squashfs-tools \ + libxml2-dev libidn2-dev libfreetype-dev libharfbuzz-dev libpng-dev libjpeg-dev \ + libopus-dev libopusfile-dev zlib1g-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev \ + libxi-dev libxfixes-dev libxss-dev libxtst-dev libwayland-dev libxkbcommon-dev \ + libasound2-dev libpulse-dev libdbus-1-dev libudev-dev libdecor-0-dev libdrm-dev \ + libgbm-dev libegl-dev libgl-dev && dpkg-query -W -f='${Package}\t${Version}\t${Architecture}\n' > /build-packages.tsv && rm -rf /var/lib/apt/lists/* +RUN sed -i 's/ main universe$/ main/' /etc/apt/sources.list && apt-get -o APT::Update::Error-Mode=any update && apt-get install -y --no-install-recommends libvulkan-dev desktop-file-utils && dpkg-query -W -f='${Package}\t${Version}\t${Architecture}\n' > /build-packages.tsv && rm -rf /var/lib/apt/lists/* +COPY sources /inputs/sources +COPY tools /inputs/tools +COPY runtime-sources /inputs/runtime-sources +COPY dependency-inputs.lock.json /inputs/packaging.lock.json +COPY build-dependencies.sh /inputs/ +RUN bash /inputs/build-dependencies.sh && rm -rf /inputs/sources /build +COPY packaging.lock.json /opt/atrinik-appimage/packaging.lock.json +RUN chmod -R a+rX /opt/atrinik-appimage && chmod a+r /build-packages.tsv +COPY runtime-probe.c /inputs/runtime-probe.c +RUN PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig cc -Wall -Wextra -Werror -O2 /inputs/runtime-probe.c $(PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig pkg-config --cflags --libs sdl3 sdl3-image sdl3-ttf sdl3-mixer libcurl) -o /appimage-runtime-probe +COPY graphical-probe.c /inputs/graphical-probe.c +RUN PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig cc -Wall -Wextra -Werror -O2 /inputs/graphical-probe.c $(PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig pkg-config --cflags --libs sdl3 sdl3-image sdl3-ttf sdl3-mixer) -o /appimage-graphical-probe +ENV PATH="/opt/atrinik-appimage/bin:/opt/atrinik-appimage/tools:${PATH}" \ + CMAKE_PREFIX_PATH=/opt/atrinik-appimage \ + PKG_CONFIG_PATH=/opt/atrinik-appimage/lib/pkgconfig \ + LD_LIBRARY_PATH=/opt/atrinik-appimage/lib +WORKDIR /work + +FROM builder AS build + +# Minimal test hosts have no application, SDL or graphics/audio development packages. +FROM ubuntu:24.04@sha256:f610ab94648195aa356059f5b41d6085c9d4d903c072430cdd1af7bdb646106b AS smoke-ubuntu24 +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +RUN rm -f /etc/apt/sources.list.d/ubuntu.sources && printf '%s\n' \ + 'deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg check-valid-until=no] https://snapshot.ubuntu.com/ubuntu/20261010T000000Z noble main' \ + 'deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg check-valid-until=no] https://snapshot.ubuntu.com/ubuntu/20261010T000000Z noble-updates main' > /etc/apt/sources.list && \ + apt-get -o Acquire::https::CaInfo=/etc/ssl/certs/ca-certificates.crt -o APT::Update::Error-Mode=any update && apt-get -o Acquire::https::CaInfo=/etc/ssl/certs/ca-certificates.crt install -y --no-install-recommends binutils util-linux coreutils && rm -rf /var/lib/apt/lists/* /etc/ssl/certs/ca-certificates.crt + +FROM ubuntu:26.04@sha256:88a381d5b5eeb2b35d3ad70925a362c37ce569daf43ede89ff818ec20e4d3794 AS smoke-ubuntu26 +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +RUN rm -f /etc/apt/sources.list.d/ubuntu.sources && printf '%s\n' \ + 'deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg check-valid-until=no] https://snapshot.ubuntu.com/ubuntu/20261001T000000Z resolute main' \ + 'deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg check-valid-until=no] https://snapshot.ubuntu.com/ubuntu/20261001T000000Z resolute-updates main' > /etc/apt/sources.list && \ + apt-get -o Acquire::https::CaInfo=/etc/ssl/certs/ca-certificates.crt -o APT::Update::Error-Mode=any update && apt-get -o Acquire::https::CaInfo=/etc/ssl/certs/ca-certificates.crt install -y --no-install-recommends binutils util-linux coreutils && rm -rf /var/lib/apt/lists/* /etc/ssl/certs/ca-certificates.crt + +FROM debian:13-slim@sha256:918311b7b6c4c6f68b232ba516584925f6c78ad82b6fd534b98979df6438e483 AS smoke-debian13 +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +RUN rm -f /etc/apt/sources.list.d/debian.sources && printf '%s\n' \ + 'deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg check-valid-until=no] https://snapshot.debian.org/archive/debian/20261001T000000Z/ trixie main' > /etc/apt/sources.list && \ + apt-get -o Acquire::https::CaInfo=/etc/ssl/certs/ca-certificates.crt -o APT::Update::Error-Mode=any update && apt-get -o Acquire::https::CaInfo=/etc/ssl/certs/ca-certificates.crt install -y --no-install-recommends binutils util-linux coreutils && rm -rf /var/lib/apt/lists/* /etc/ssl/certs/ca-certificates.crt diff --git a/tools/ci/appimage/historical/build-dependencies.sh b/tools/ci/appimage/historical/build-dependencies.sh new file mode 100644 index 000000000..5668d17e9 --- /dev/null +++ b/tools/ci/appimage/historical/build-dependencies.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail +prefix=/opt/atrinik-appimage +export SOURCE_DATE_EPOCH +SOURCE_DATE_EPOCH=$(python3 -c 'import json; print(json.load(open("/inputs/packaging.lock.json"))["base_image"]["build_epoch"])') +mkdir -p /build "${prefix}/tools" +python3 - <<'PY' +import hashlib,json,pathlib +p=pathlib.Path('/inputs'); lock=json.loads((p/'packaging.lock.json').read_text()) +for e in lock['sources']+lock['tools']+[dict(lock['runtime'],name='runtime')]+lock['license_inputs']: + f=p/('sources' if e in lock['sources'] else 'tools')/(e['name']+'.tar.gz' if e in lock['sources'] else e['name']) + assert hashlib.sha256(f.read_bytes()).hexdigest()==e['sha256'],f +PY +for archive in /inputs/sources/*.tar.gz; do tar -xzf "${archive}" -C /build; done +export CFLAGS="-ffile-prefix-map=/build=. -fdebug-prefix-map=/build=. -ffile-prefix-map=/opt/atrinik-appimage=/usr" +export CXXFLAGS="${CFLAGS}" +export CMAKE_PREFIX_PATH=${prefix} LD_LIBRARY_PATH=${prefix}/lib PKG_CONFIG_PATH=${prefix}/lib/pkgconfig +build() { + local source=$1; shift + cmake -S "/build/${source}" -B "/build/${source}-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="${prefix}" -DCMAKE_INSTALL_LIBDIR=lib "$@" + cmake --build "/build/${source}-build" --parallel 4 + cmake --install "/build/${source}-build" +} +( + cd /build/openssl-3.5.5 + python3 - <<'PYBUILDINFO' +from pathlib import Path +path = Path('util/mkbuildinf.pl') +text = path.read_text() +needle = "my $cflags = join(' ', @ARGV);" +assert text.count(needle) == 1 +text = text.replace(needle, needle + "\n$cflags =~ s{\\s+-f(?:file|debug)-prefix-map=\\S+}{}g;") +path.write_text(text) +PYBUILDINFO + ./Configure linux-x86_64 shared --prefix=/usr --libdir=lib --openssldir=/etc/ssl + make -j4 + make DESTDIR=/build/openssl-install install_sw + cp -a /build/openssl-install/usr/. "${prefix}/" + sed -i 's@^prefix=/usr$@prefix=/opt/atrinik-appimage@' "${prefix}"/lib/pkgconfig/{openssl,libssl,libcrypto}.pc +) +build c-ares-1.34.6 -DCARES_SHARED=ON -DCARES_STATIC=OFF -DCARES_BUILD_TESTS=OFF +build curl-8.18.0 -DBUILD_SHARED_LIBS=ON -DBUILD_STATIC_LIBS=OFF -DBUILD_TESTING=OFF \ + -DCURL_USE_OPENSSL=ON -DOPENSSL_ROOT_DIR="${prefix}" -DENABLE_ARES=ON \ + -DCURL_CA_BUNDLE=none -DCURL_CA_PATH=none -DCURL_CA_FALLBACK=ON -DCURL_USE_LIBPSL=OFF -DCURL_ZSTD=OFF -DCURL_BROTLI=OFF +build SDL3-3.4.2 -DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TESTS=OFF -DSDL_EXAMPLES=OFF -DSDL_KMSDRM=OFF -DSDL_WAYLAND_LIBDECOR=OFF +build SDL3_image-3.4.0 -DSDLIMAGE_VENDORED=OFF -DSDLIMAGE_AVIF=OFF -DSDLIMAGE_WEBP=OFF -DSDLIMAGE_TIF=OFF -DSDLIMAGE_JXL=OFF -DSDLIMAGE_TESTS=OFF -DSDLIMAGE_SAMPLES=OFF +build SDL3_ttf-3.2.2 -DSDLTTF_VENDORED=OFF -DSDLTTF_SAMPLES=OFF -DSDLTTF_TESTS=OFF +build SDL3_mixer-3.2.4 -DSDLMIXER_DEPS_SHARED=OFF -DSDLMIXER_EXAMPLES=OFF \ + -DSDLMIXER_AIFF=ON -DSDLMIXER_WAVE=ON -DSDLMIXER_VOC=ON -DSDLMIXER_AU=ON -DSDLMIXER_FLAC=OFF -DSDLMIXER_GME=OFF -DSDLMIXER_INSTALL=ON -DSDLMIXER_MIDI=OFF \ + -DSDLMIXER_MOD=OFF -DSDLMIXER_MP3=ON -DSDLMIXER_MP3_DRMP3=ON -DSDLMIXER_MP3_MPG123=OFF \ + -DSDLMIXER_OPUS=ON -DSDLMIXER_TESTS=OFF -DSDLMIXER_VORBIS_STB=ON \ + -DSDLMIXER_VORBIS_TREMOR=OFF -DSDLMIXER_VORBIS_VORBISFILE=OFF -DSDLMIXER_WAVPACK=OFF +# Preserve complete upstream license texts, including licenses of embedded decoder sources. +python3 - <<'PY' +import json,pathlib +lock=json.loads(pathlib.Path('/inputs/packaging.lock.json').read_text()) +for e in lock['sources']: + root=pathlib.Path('/build')/e['source_directory']; target=pathlib.Path('/opt/atrinik-appimage/share/licenses')/e['name']/'LICENSE';target.parent.mkdir(parents=True,exist_ok=True) + files=sorted(p for p in root.rglob('*') if p.is_file() and any(k in p.name.lower() for k in ('license','copying','copyright','notice'))) + files += sorted(p for p in root.rglob('*.h') if p.name.startswith(('dr_', 'stb_'))) + if root/e['license_file'] not in files: raise RuntimeError('missing license '+e['name']) + with target.open('wb') as out: + for f in files: + out.write(('\n===== '+str(f.relative_to(root))+' =====\n').encode());out.write(f.read_bytes());out.write(b'\n') + if e['name'] == 'openssl': + out.write(b'\nAtrinik AppImage build modification: util/mkbuildinf.pl generated compiler description omits reproducibility path-map flags to avoid exposing absolute build paths. Runtime code and compiler flags retain these mappings.\n') +PY +for tool in linuxdeploy appimagetool; do + chmod +x "/inputs/tools/${tool}" + mkdir -p "/build/tool-${tool}" + (cd "/build/tool-${tool}"; "/inputs/tools/${tool}" --appimage-extract >/dev/null; mv squashfs-root "${prefix}/tools/${tool}.AppDir") + printf '#!/bin/sh\nexec /opt/atrinik-appimage/tools/%s.AppDir/AppRun "$@"\n' "${tool}" > "${prefix}/tools/${tool}" + chmod +x "${prefix}/tools/${tool}" +done +install -m 755 /inputs/tools/runtime "${prefix}/tools/runtime" + + +for name in linuxdeploy appimagetool runtime; do + dest=${name}; [[ ${name} != runtime ]] || dest=appimage-runtime + mkdir -p "${prefix}/share/licenses/${dest}" + cp "/inputs/tools/${name}-LICENSE" "${prefix}/share/licenses/${dest}/LICENSE" +done + +python3 - <<'PYLICENSE' +import json,pathlib,tarfile,hashlib +lock=json.loads(pathlib.Path('/inputs/packaging.lock.json').read_text()) +notice=pathlib.Path('/opt/atrinik-appimage/share/licenses/appimage-runtime/LICENSE') +with notice.open('ab') as out: + for e in lock['runtime_sources']: + archive=pathlib.Path('/inputs/runtime-sources')/e['filename'] + assert hashlib.sha256(archive.read_bytes()).hexdigest()==e['sha256'] + with tarfile.open(archive) as source: + files=[m for m in source.getmembers() if m.isfile() and (any(k in pathlib.PurePosixPath(m.name).name.lower() for k in ('license','copying','copyright','notice','lgpl')) or m.name.endswith('/'+e['license_file']))] + if not files: raise RuntimeError('missing runtime component licenses: '+e['name']) + for m in files: + out.write(('\n===== '+e['name']+' '+m.name+' =====\n').encode());out.write(source.extractfile(m).read());out.write(b'\n') +PYLICENSE diff --git a/tools/ci/appimage/historical/graphical-probe.c b/tools/ci/appimage/historical/graphical-probe.c new file mode 100644 index 000000000..4c65482bf --- /dev/null +++ b/tools/ci/appimage/historical/graphical-probe.c @@ -0,0 +1,179 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + * Trusted software qualification helper, never distributed in the AppImage. + * Production client hardware policy and gameplay are outside this proof. + */ +#include +#include +#include +#include +#include +#include + +static bool asset_path(char *path, size_t size, const char *appdir, const char *asset) { + int length = snprintf(path, size, "%s/%s", appdir, asset); + return length >= 0 && (size_t)length < size; +} + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: graphical-probe APPDIR\n"); + return 2; + } + int status = 1; + SDL_Window *window = NULL; + SDL_GPUDevice *gpu = NULL; + SDL_Renderer *renderer = NULL; + SDL_Surface *image = NULL, *text = NULL, *frame = NULL; + SDL_Texture *image_texture = NULL, *text_texture = NULL; + TTF_Font *font = NULL; + MIX_Mixer *mixer = NULL; + MIX_Audio *audio = NULL; + MIX_Track *track = NULL; + char path[4096]; + SDL_PropertiesID properties = 0; + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) || !TTF_Init() || !MIX_Init()) { + goto finish; + } + window = SDL_CreateWindow("Atrinik AppImage dependency qualification", 800, 600, 0); + properties = SDL_CreateProperties(); + if (window == NULL || properties == 0 || + !SDL_SetStringProperty(properties, SDL_PROP_GPU_DEVICE_CREATE_NAME_STRING, "vulkan") || + !SDL_SetBooleanProperty(properties, SDL_PROP_GPU_DEVICE_CREATE_SHADERS_SPIRV_BOOLEAN, true) || + !SDL_SetBooleanProperty(properties, + SDL_PROP_GPU_DEVICE_CREATE_VULKAN_REQUIRE_HARDWARE_ACCELERATION_BOOLEAN, + false)) { + goto finish; + } + gpu = SDL_CreateGPUDeviceWithProperties(properties); + if (gpu == NULL || strcmp(SDL_GetGPUDeviceDriver(gpu), "vulkan") != 0) { + goto finish; + } + const char *device = SDL_GetStringProperty(SDL_GetGPUDeviceProperties(gpu), + SDL_PROP_GPU_DEVICE_NAME_STRING, "unknown"); + if (strstr(device, "llvmpipe") == NULL && strstr(device, "lavapipe") == NULL) { + SDL_SetError("qualification must use the pinned host's software Vulkan device"); + goto finish; + } + renderer = SDL_CreateGPURenderer(gpu, window); + if (renderer == NULL || + !asset_path(path, sizeof(path), argv[1], "atrinik.png") || + (image = IMG_Load(path)) == NULL || + !asset_path(path, sizeof(path), argv[1], "usr/share/games/atrinik/fonts/arial.ttf") || + (font = TTF_OpenFont(path, 32.0f)) == NULL || + (text = TTF_RenderText_Blended(font, "Atrinik bundled PNG and font", 0, + (SDL_Color){255, 230, 120, 255})) == NULL || + (image_texture = SDL_CreateTextureFromSurface(renderer, image)) == NULL || + (text_texture = SDL_CreateTextureFromSurface(renderer, text)) == NULL) { + goto finish; + } + mixer = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, NULL); + if (mixer == NULL || + !asset_path(path, sizeof(path), argv[1], + "usr/share/games/atrinik/sound/background/intro.ogg") || + (audio = MIX_LoadAudio(mixer, path, true)) == NULL || + (track = MIX_CreateTrack(mixer)) == NULL || !MIX_SetTrackAudio(track, audio) || + !MIX_PlayTrack(track, 0) || + strcmp(SDL_GetCurrentAudioDriver(), "pulseaudio") != 0) { + goto finish; + } + SDL_FRect image_area = {32, 32, 256, 256}; + SDL_FRect text_area = {32, 340, (float)text->w, (float)text->h}; + Uint64 started = SDL_GetTicks(); + bool qualified = false; + bool announced = false; + while (SDL_GetTicks() - started < 5000) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_EVENT_QUIT) { + SDL_SetError("qualification window closed prematurely"); + goto finish; + } + } + if (!SDL_SetRenderDrawColor(renderer, 15, 30, 60, 255) || !SDL_RenderClear(renderer) || + !SDL_RenderTexture(renderer, image_texture, NULL, &image_area) || + !SDL_RenderTexture(renderer, text_texture, NULL, &text_area)) { + goto finish; + } + if (!qualified) { + frame = SDL_RenderReadPixels(renderer, NULL); + if (frame == NULL) { + goto finish; + } + /* Read the actual GPU-rendered asset area, not the source surface. */ + bool colors[256] = {false}; + unsigned int distinct = 0; + for (int y = 32; y < 288; y += 4) { + for (int x = 32; x < 288; x += 4) { + Uint8 red, green, blue, alpha; + if (!SDL_ReadSurfacePixel(frame, x, y, &red, &green, &blue, &alpha)) { + goto finish; + } + unsigned int color = (red & 0xe0U) | ((green & 0xe0U) >> 3) | (blue >> 6); + if (!colors[color]) { + colors[color] = true; + distinct++; + } + } + } + if (distinct < 16) { + SDL_SetError("GPU readback did not contain the decoded PNG's color variation"); + goto finish; + } + unsigned int glyph_pixels = 0; + for (int y = 340; y < 340 + text->h && y < frame->h; y += 2) { + for (int x = 32; x < 32 + text->w && x < frame->w; x += 2) { + Uint8 red, green, blue, alpha; + if (!SDL_ReadSurfacePixel(frame, x, y, &red, &green, &blue, &alpha)) { + goto finish; + } + if (red > 100 && green > 100) { + glyph_pixels++; + } + } + } + if (glyph_pixels < 20) { + SDL_SetError("GPU readback did not contain the rendered font glyphs"); + goto finish; + } + qualified = true; + } + if (!SDL_RenderPresent(renderer)) { + goto finish; + } + if (!announced) { + printf("graphical-ready backend=vulkan device=%s audio=pulseaudio frame=800x600\n", device); + fflush(stdout); + announced = true; + /* Keep the observable window alive for five seconds even when + * the first software shader compilation was slow. */ + started = SDL_GetTicks(); + } + SDL_Delay(40); + } + status = 0; +finish: + if (status != 0) { + fprintf(stderr, "graphical qualification failed: %s\n", SDL_GetError()); + } + MIX_DestroyTrack(track); + MIX_DestroyAudio(audio); + MIX_DestroyMixer(mixer); + SDL_DestroySurface(frame); + SDL_DestroyTexture(text_texture); + SDL_DestroyTexture(image_texture); + SDL_DestroySurface(text); + SDL_DestroySurface(image); + TTF_CloseFont(font); + SDL_DestroyRenderer(renderer); + SDL_DestroyGPUDevice(gpu); + SDL_DestroyProperties(properties); + SDL_DestroyWindow(window); + MIX_Quit(); + TTF_Quit(); + SDL_Quit(); + if (status == 0) { + puts("graphical-complete: software Vulkan presentation, GPU readback, bundled PNG/font/OGG, virtual audio"); + } + return status; +} diff --git a/tools/ci/appimage/historical/packaging.lock.json b/tools/ci/appimage/historical/packaging.lock.json new file mode 100644 index 000000000..e7e434f7f --- /dev/null +++ b/tools/ci/appimage/historical/packaging.lock.json @@ -0,0 +1,2270 @@ +{ + "schema_version": 1, + "architecture": "x86_64", + "glibc_max": "2.39", + "base_image": { + "reference": "ubuntu:24.04", + "digest": "sha256:f610ab94648195aa356059f5b41d6085c9d4d903c072430cdd1af7bdb646106b", + "apt_snapshot": "20261010T000000Z", + "build_epoch": 1791590400 + }, + "sources": [ + { + "name": "SDL3", + "version": "3.4.2", + "url": "https://github.com/libsdl-org/SDL/releases/download/release-3.4.2/SDL3-3.4.2.tar.gz", + "sha256": "ef39a2e3f9a8a78296c40da701967dd1b0d0d6e267e483863ce70f8a03b4050c", + "source_directory": "SDL3-3.4.2", + "license_file": "LICENSE.txt", + "license": "Zlib" + }, + { + "name": "SDL3_image", + "version": "3.4.0", + "url": "https://github.com/libsdl-org/SDL_image/releases/download/release-3.4.0/SDL3_image-3.4.0.tar.gz", + "sha256": "2ceb75eab4235c2c7e93dafc3ef3268ad368ca5de40892bf8cffdd510f29d9d8", + "source_directory": "SDL3_image-3.4.0", + "license_file": "LICENSE.txt", + "license": "Zlib" + }, + { + "name": "SDL3_ttf", + "version": "3.2.2", + "url": "https://github.com/libsdl-org/SDL_ttf/releases/download/release-3.2.2/SDL3_ttf-3.2.2.tar.gz", + "sha256": "63547d58d0185c833213885b635a2c0548201cc8f301e6587c0be1a67e1e045d", + "source_directory": "SDL3_ttf-3.2.2", + "license_file": "LICENSE.txt", + "license": "Zlib" + }, + { + "name": "SDL3_mixer", + "version": "3.2.4", + "url": "https://github.com/libsdl-org/SDL_mixer/releases/download/release-3.2.4/SDL3_mixer-3.2.4.tar.gz", + "sha256": "182a07c745375e113dc740d43964ff21b0be29f29f59876c4dbc4db3d32f6901", + "source_directory": "SDL3_mixer-3.2.4", + "license_file": "LICENSE.txt", + "license": "Zlib" + }, + { + "name": "openssl", + "version": "3.5.5", + "url": "https://github.com/openssl/openssl/releases/download/openssl-3.5.5/openssl-3.5.5.tar.gz", + "sha256": "b28c91532a8b65a1f983b4c28b7488174e4a01008e29ce8e69bd789f28bc2a89", + "source_directory": "openssl-3.5.5", + "license_file": "LICENSE.txt", + "license": "Apache-2.0", + "build_modifications": [ + "util/mkbuildinf.pl generated compiler description omits reproducibility path-map flags; runtime code and actual compile flags retain mappings." + ] + }, + { + "name": "curl", + "version": "8.18.0", + "url": "https://curl.se/download/curl-8.18.0.tar.gz", + "sha256": "e9274a5f8ab5271c0e0e6762d2fce194d5f98acc568e4ce816845b2dcc0cf88f", + "source_directory": "curl-8.18.0", + "license_file": "COPYING", + "license": "curl" + }, + { + "name": "c-ares", + "version": "1.34.6", + "url": "https://github.com/c-ares/c-ares/releases/download/v1.34.6/c-ares-1.34.6.tar.gz", + "sha256": "912dd7cc3b3e8a79c52fd7fb9c0f4ecf0aaa73e45efda880266a2d6e26b84ef5", + "source_directory": "c-ares-1.34.6", + "license_file": "LICENSE.md", + "license": "MIT" + } + ], + "tools": [ + { + "name": "linuxdeploy", + "version": "1-alpha-20251107-1", + "url": "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage", + "sha256": "c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d", + "license_file": "usr/share/doc/linuxdeploy/copyright", + "license": "MIT" + }, + { + "name": "appimagetool", + "version": "1.9.1", + "url": "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage", + "sha256": "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0", + "license_file": "usr/share/doc/appimagetool/copyright", + "license": "MIT" + } + ], + "runtime": { + "version": "20251108", + "url": "https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64", + "sha256": "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d", + "size": 944632, + "license": "MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND BSD-3-Clause AND Zlib", + "license_file": "LICENSE", + "digest_md5_offset": 932096, + "digest_md5_size": 16, + "digest_policy": "zero-field-md5-v1" + }, + "host_libraries": [ + "ld-linux-x86-64.so.2", + "libc.so.6", + "libm.so.6", + "libdl.so.2", + "libpthread.so.0", + "librt.so.1", + "libresolv.so.2", + "libvulkan.so.1", + "libGL.so.1", + "libEGL.so.1", + "libGLX.so.0", + "libGLdispatch.so.0" + ], + "required_libraries": [ + "libSDL3.so.0", + "libSDL3_image.so.0", + "libSDL3_ttf.so.0", + "libSDL3_mixer.so.0", + "libssl.so.3", + "libcrypto.so.3", + "libcurl.so.4", + "libcares.so.2" + ], + "dlopen_libraries": [ + "libX11.so.6", + "libXext.so.6", + "libXcursor.so.1", + "libXi.so.6", + "libXfixes.so.3", + "libXrandr.so.2", + "libXss.so.1", + "libXtst.so.6", + "libwayland-client.so.0", + "libwayland-cursor.so.0", + "libwayland-egl.so.1", + "libxkbcommon.so.0", + "libasound.so.2", + "libpulse.so.0", + "libdbus-1.so.3", + "libudev.so.1" + ], + "notices": [ + "usr/share/doc/atrinik/licenses/SDL3/LICENSE", + "usr/share/doc/atrinik/licenses/SDL3_image/LICENSE", + "usr/share/doc/atrinik/licenses/SDL3_ttf/LICENSE", + "usr/share/doc/atrinik/licenses/SDL3_mixer/LICENSE", + "usr/share/doc/atrinik/licenses/openssl/LICENSE", + "usr/share/doc/atrinik/licenses/curl/LICENSE", + "usr/share/doc/atrinik/licenses/c-ares/LICENSE", + "usr/share/doc/atrinik/licenses/appimage-runtime/LICENSE" + ], + "bootstrap": [ + { + "name": "ca-certificates.deb", + "version": "20240203", + "url": "https://snapshot.ubuntu.com/ubuntu/20261010T000000Z/pool/main/c/ca-certificates/ca-certificates_20240203_all.deb", + "sha256": "641de77d8f142cfd62a1a6f964ba67b20754d3337c480efb529d086075a06c9a", + "license": "MPL-2.0" + } + ], + "license_inputs": [ + { + "name": "runtime-LICENSE", + "url": "https://raw.githubusercontent.com/AppImage/type2-runtime/20251108/LICENSE", + "sha256": "aa154fc9070614bbe7921f89db11efd1dba7a1f3a41685958110e2230f9c0ca1" + }, + { + "name": "appimagetool-LICENSE", + "url": "https://raw.githubusercontent.com/AppImage/appimagetool/1.9.1/LICENSE", + "sha256": "d726eb47bb96b7e7f8971a1431575ab4fd8780b5d2efde3552c70ef71469015f" + }, + { + "name": "linuxdeploy-LICENSE", + "url": "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy/1-alpha-20251107-1/LICENSE.txt", + "sha256": "1edb41ae7601854ae15d113c3700beebd0ea3ed05b85e3786a94a72bb9916091" + } + ], + "runtime_sources": [ + { + "name": "runtime", + "version": "20251108", + "url": "https://github.com/AppImage/type2-runtime/archive/refs/tags/20251108.tar.gz", + "sha256": "4c4f6df4647c9f01f871d7edd3716d8aeeffda9d22ffbebe3fccd95a6ab52c95", + "license": "MIT", + "filename": "runtime.tar.gz", + "license_file": "LICENSE", + "version_evidence": "pinned upstream runtime recipe" + }, + { + "name": "musl", + "version": "1.2.5", + "url": "https://musl.libc.org/releases/musl-1.2.5.tar.gz", + "sha256": "a9a118bbe84d8764da0ea0d28b3ab3fae8477fc7e4085d90102b8596fc7c75e4", + "license": "MIT", + "filename": "musl.tar.gz", + "license_file": "COPYRIGHT", + "version_evidence": "historical Alpine3.21 source package recipe" + }, + { + "name": "libfuse", + "version": "3.15.0", + "url": "https://github.com/libfuse/libfuse/releases/download/fuse-3.15.0/fuse-3.15.0.tar.xz", + "sha256": "70589cfd5e1cff7ccd6ac91c86c01be340b227285c5e200baa284e401eea2ca0", + "license": "LGPL-2.1-or-later", + "filename": "libfuse.tar.xz", + "license_file": "LGPL2.txt", + "version_evidence": "pinned upstream runtime recipe" + }, + { + "name": "squashfuse", + "version": "0.5.2", + "url": "https://github.com/vasi/squashfuse/archive/0.5.2.tar.gz", + "sha256": "db0238c5981dabbd80ee09ae15387f390091668ca060a7bc38047912491443d3", + "license": "BSD-2-Clause", + "filename": "squashfuse.tar.gz", + "license_file": "LICENSE", + "version_evidence": "pinned upstream runtime recipe" + }, + { + "name": "zstd", + "version": "1.5.6", + "url": "https://github.com/facebook/zstd/releases/download/v1.5.6/zstd-1.5.6.tar.gz", + "sha256": "8c29e06cf42aacc1eafc4077ae2ec6c6fcb96a626157e0593d5e82a34fd403c1", + "license": "BSD-3-Clause OR GPL-2.0-only", + "filename": "zstd.tar.gz", + "license_file": "LICENSE", + "version_evidence": "embedded version string" + }, + { + "name": "zlib", + "version": "1.3.1", + "url": "https://zlib.net/fossils/zlib-1.3.1.tar.gz", + "sha256": "9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23", + "license": "Zlib", + "filename": "zlib.tar.gz", + "license_file": "README", + "version_evidence": "embedded version string" + }, + { + "name": "mimalloc", + "version": "2.1.7", + "url": "https://github.com/microsoft/mimalloc/archive/refs/tags/v2.1.7.tar.gz", + "sha256": "0eed39319f139afde8515010ff59baf24de9e47ea316a315398e8027d198202d", + "license": "MIT", + "filename": "mimalloc.tar.gz", + "license_file": "LICENSE", + "version_evidence": "historical Alpine3.21 source package recipe" + } + ], + "runtime_recipes": [ + { + "name": "alpine-musl-APKBUILD", + "filename": "alpine-musl-APKBUILD", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/APKBUILD", + "sha256": "5c31b8e5c25bcc4fe51e913dce8e057bebaf9c8980b88f76bc19d05d67a906ba" + }, + { + "name": "alpine-musl-CVE-2025-26519.patch", + "filename": "alpine-musl-CVE-2025-26519.patch", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/CVE-2025-26519.patch", + "sha256": "ab382da2bb5e1cc32d8904be28fb13f8ad3ce813967bd4bf7f5b1c6baee33e17" + }, + { + "name": "alpine-musl-__stack_chk_fail_local.c", + "filename": "alpine-musl-__stack_chk_fail_local.c", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/__stack_chk_fail_local.c", + "sha256": "299a7d75a09de3e2e11e7fb4acc3182e4a14e868093d2f30938fce9bfcff13da" + }, + { + "name": "alpine-musl-getconf.c", + "filename": "alpine-musl-getconf.c", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/getconf.c", + "sha256": "d87d0cbb3690ae2c5d8cc218349fd8278b93855dd625deaf7ae50e320aad247c" + }, + { + "name": "alpine-musl-getent.c", + "filename": "alpine-musl-getent.c", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/getent.c", + "sha256": "b25f32d306d542246e9358ef65cd67c12b3e3a77656bc467853d3b0c76b5fbbd" + }, + { + "name": "alpine-musl-handle-aux-at_base.patch", + "filename": "alpine-musl-handle-aux-at_base.patch", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/handle-aux-at_base.patch", + "sha256": "2a65e03b94217063d72fe998e5fb2e25cd01716078a3ba1b0ab574ab205111e6" + }, + { + "name": "alpine-musl-iconv.c", + "filename": "alpine-musl-iconv.c", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/iconv.c", + "sha256": "f79a2930a2e5bb0624321589edf8b889d1e9b603e01e6b7ae214616605b3fdd7" + }, + { + "name": "alpine-musl-isatty-glibc-align.patch", + "filename": "alpine-musl-isatty-glibc-align.patch", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/isatty-glibc-align.patch", + "sha256": "1321d542b40921a4c248bb2d15dbd03fcbbf008c8725eb2043d3cab62faeba48" + }, + { + "name": "alpine-musl-ldconfig", + "filename": "alpine-musl-ldconfig", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/ldconfig", + "sha256": "b4a2c06db38742e8c42c3c9838b285a7d8cdac6c091ff3df5ff9a15f1e41b9c7" + }, + { + "name": "alpine-musl-ldso-fix-non-functional-fix-to-early-dynamic-PAGE_SI.patch", + "filename": "alpine-musl-ldso-fix-non-functional-fix-to-early-dynamic-PAGE_SI.patch", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/ldso-fix-non-functional-fix-to-early-dynamic-PAGE_SI.patch", + "sha256": "7f5806124d4a89536e53a07bfe95fcd0a8f1b7a27fc0dfc89c78c724dea1c924" + }, + { + "name": "alpine-musl-loongarch64-Add-lsx-and-lasx-vector-structure-defini.patch", + "filename": "alpine-musl-loongarch64-Add-lsx-and-lasx-vector-structure-defini.patch", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/loongarch64-Add-lsx-and-lasx-vector-structure-defini.patch", + "sha256": "28efacd507c01b89e1725ed33723f3f9fdfe6cb83eb78ffea1e2acb7fda8a52c" + }, + { + "name": "alpine-musl-loongarch64-add-TLSDESC-support.patch", + "filename": "alpine-musl-loongarch64-add-TLSDESC-support.patch", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/loongarch64-add-TLSDESC-support.patch", + "sha256": "1efb4604e0b28b84e10faf37d17bad448510e73f6a3db89c8be47d38b9e85138" + }, + { + "name": "alpine-musl-loongarch64-add-bits-hwcap.h.patch", + "filename": "alpine-musl-loongarch64-add-bits-hwcap.h.patch", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/loongarch64-add-bits-hwcap.h.patch", + "sha256": "8849080b49dff046b1f29db24e54ffe6262b5884078091994db77efdafca2505" + }, + { + "name": "alpine-musl-loongarch64-add-fpu-and-simd-context-to-signal.h.patch", + "filename": "alpine-musl-loongarch64-add-fpu-and-simd-context-to-signal.h.patch", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/efd4d5df7be0ea48d98512671f676fc5ceb14899/main/musl/loongarch64-add-fpu-and-simd-context-to-signal.h.patch", + "sha256": "d73f85a90fe52930dd8b5d16cb7f3369a994918c0e41ae3c3dc9a25df4b45099" + }, + { + "name": "alpine-mimalloc2-APKBUILD", + "filename": "alpine-mimalloc2-APKBUILD", + "url": "https://raw.githubusercontent.com/alpinelinux/aports/5a1a5dc3216a69e3cd66a57ee07cb5ef01b8be39/community/mimalloc2/APKBUILD", + "sha256": "bb2618381e98ef508b41697d9177b7d1fef6f61bf38543aa055b9e0002c3c54b" + } + ], + "system_package_versions": { + "apt": "2.8.3", + "base-files": "13ubuntu10.5", + "base-passwd": "3.6.3build1", + "bash": "5.2.21-2ubuntu4", + "binutils": "2.42-4ubuntu2.10", + "binutils-common": "2.42-4ubuntu2.10", + "binutils-x86-64-linux-gnu": "2.42-4ubuntu2.10", + "bsdutils": "1:2.39.3-9ubuntu6.6", + "build-essential": "12.10ubuntu1", + "bzip2": "1.0.8-5.1ubuntu0.1", + "ca-certificates": "20260601~24.04.1", + "cmake": "3.28.3-1build7", + "cmake-data": "3.28.3-1build7", + "coreutils": "9.4-3ubuntu6.3", + "cpp": "4:13.2.0-7ubuntu1", + "cpp-13": "13.3.0-6ubuntu2~24.04.1", + "cpp-13-x86-64-linux-gnu": "13.3.0-6ubuntu2~24.04.1", + "cpp-x86-64-linux-gnu": "4:13.2.0-7ubuntu1", + "dash": "0.5.12-6ubuntu5", + "debconf": "1.5.86ubuntu1", + "debianutils": "5.17build1", + "diffutils": "1:3.10-1ubuntu0.1", + "dpkg": "1.22.6ubuntu6.6", + "dpkg-dev": "1.22.6ubuntu6.6", + "e2fsprogs": "1.47.0-2.4~exp1ubuntu4.1", + "file": "1:5.45-3build1", + "findutils": "4.9.0-5build1", + "fontconfig-config": "2.15.0-1.1ubuntu2", + "fonts-dejavu-core": "2.37-8", + "fonts-dejavu-mono": "2.37-8", + "g++": "4:13.2.0-7ubuntu1", + "g++-13": "13.3.0-6ubuntu2~24.04.1", + "g++-13-x86-64-linux-gnu": "13.3.0-6ubuntu2~24.04.1", + "g++-x86-64-linux-gnu": "4:13.2.0-7ubuntu1", + "gcc": "4:13.2.0-7ubuntu1", + "gcc-13": "13.3.0-6ubuntu2~24.04.1", + "gcc-13-base": "13.3.0-6ubuntu2~24.04.1", + "gcc-13-x86-64-linux-gnu": "13.3.0-6ubuntu2~24.04.1", + "gcc-14-base": "14.2.0-4ubuntu2~24.04.1", + "gcc-x86-64-linux-gnu": "4:13.2.0-7ubuntu1", + "gir1.2-freedesktop": "1.80.1-1", + "gir1.2-freedesktop-dev": "1.80.1-1", + "gir1.2-glib-2.0": "2.80.0-6ubuntu3.9", + "gir1.2-glib-2.0-dev": "2.80.0-6ubuntu3.9", + "gir1.2-harfbuzz-0.0": "8.3.0-2build2", + "gpgv": "2.4.4-2ubuntu17.6", + "grep": "3.11-4build1", + "gzip": "1.12-1ubuntu3.2", + "hostname": "3.23+nmu2ubuntu2", + "icu-devtools": "74.2-1ubuntu3.1", + "init-system-helpers": "1.66ubuntu1", + "libacl1": "2.3.2-1build1.1", + "libapparmor1": "4.0.1really4.0.1-0ubuntu0.24.04.8", + "libapt-pkg6.0t64": "2.8.3", + "libarchive13t64": "3.7.2-2ubuntu0.9", + "libasan8": "14.2.0-4ubuntu2~24.04.1", + "libasound2-data": "1.2.11-1ubuntu0.3", + "libasound2-dev": "1.2.11-1ubuntu0.3", + "libasound2t64": "1.2.11-1ubuntu0.3", + "libassuan0": "2.5.6-1build1", + "libasyncns0": "0.8-6build4", + "libatomic1": "14.2.0-4ubuntu2~24.04.1", + "libattr1": "1:2.5.2-1ubuntu0.1", + "libaudit-common": "1:3.1.2-2.1build1.1", + "libaudit1": "1:3.1.2-2.1build1.1", + "libbinutils": "2.42-4ubuntu2.10", + "libblkid-dev": "2.39.3-9ubuntu6.6", + "libblkid1": "2.39.3-9ubuntu6.6", + "libbrotli-dev": "1.1.0-2build2", + "libbrotli1": "1.1.0-2build2", + "libbsd0": "0.12.1-1build1.1", + "libbz2-1.0": "1.0.8-5.1ubuntu0.1", + "libbz2-dev": "1.0.8-5.1ubuntu0.1", + "libc-bin": "2.39-0ubuntu8.9", + "libc-dev-bin": "2.39-0ubuntu8.9", + "libc6": "2.39-0ubuntu8.9", + "libc6-dev": "2.39-0ubuntu8.9", + "libcairo-gobject2": "1.18.0-3build1", + "libcairo2": "1.18.0-3build1", + "libcap-ng0": "0.8.4-2build2", + "libcap2": "1:2.66-5ubuntu2.4", + "libcc1-0": "14.2.0-4ubuntu2~24.04.1", + "libcom-err2": "1.47.0-2.4~exp1ubuntu4.1", + "libcrypt-dev": "1:4.4.36-4build1", + "libcrypt1": "1:4.4.36-4build1", + "libctf-nobfd0": "2.42-4ubuntu2.10", + "libctf0": "2.42-4ubuntu2.10", + "libcurl4t64": "8.5.0-2ubuntu10.15", + "libdb5.3t64": "5.3.28+dfsg2-7", + "libdbus-1-3": "1.14.10-4ubuntu4.1", + "libdbus-1-dev": "1.14.10-4ubuntu4.1", + "libdebconfclient0": "0.271ubuntu3", + "libdecor-0-0": "0.2.2-1build2", + "libdecor-0-dev": "0.2.2-1build2", + "libdpkg-perl": "1.22.6ubuntu6.6", + "libdrm-amdgpu1": "2.4.125-1ubuntu0.1~24.04.2", + "libdrm-common": "2.4.125-1ubuntu0.1~24.04.2", + "libdrm-dev": "2.4.125-1ubuntu0.1~24.04.2", + "libdrm-intel1": "2.4.125-1ubuntu0.1~24.04.2", + "libdrm-nouveau2": "2.4.125-1ubuntu0.1~24.04.2", + "libdrm-radeon1": "2.4.125-1ubuntu0.1~24.04.2", + "libdrm2": "2.4.125-1ubuntu0.1~24.04.2", + "libedit2": "3.1-20230828-1build1", + "libegl-dev": "1.7.0-1build1", + "libegl-mesa0": "25.2.8-0ubuntu0.24.04.4", + "libegl1": "1.7.0-1build1", + "libelf1t64": "0.190-1.1ubuntu0.1", + "libexpat1": "2.6.1-2ubuntu0.6", + "libext2fs2t64": "1.47.0-2.4~exp1ubuntu4.1", + "libffi-dev": "3.4.6-1build1", + "libffi8": "3.4.6-1build1", + "libflac12t64": "1.4.3+ds-2.1ubuntu2", + "libfontconfig1": "2.15.0-1.1ubuntu2", + "libfreetype-dev": "2.13.2+dfsg-1ubuntu0.2", + "libfreetype6": "2.13.2+dfsg-1ubuntu0.2", + "libgbm-dev": "25.2.8-0ubuntu0.24.04.4", + "libgbm1": "25.2.8-0ubuntu0.24.04.4", + "libgcc-13-dev": "13.3.0-6ubuntu2~24.04.1", + "libgcc-s1": "14.2.0-4ubuntu2~24.04.1", + "libgcrypt20": "1.10.3-2ubuntu0.2", + "libgdbm-compat4t64": "1.23-5.1build1", + "libgdbm6t64": "1.23-5.1build1", + "libgirepository-2.0-0": "2.80.0-6ubuntu3.9", + "libgl-dev": "1.7.0-1build1", + "libgl1": "1.7.0-1build1", + "libgl1-mesa-dri": "25.2.8-0ubuntu0.24.04.4", + "libglib2.0-0t64": "2.80.0-6ubuntu3.9", + "libglib2.0-bin": "2.80.0-6ubuntu3.9", + "libglib2.0-data": "2.80.0-6ubuntu3.9", + "libglib2.0-dev": "2.80.0-6ubuntu3.9", + "libglib2.0-dev-bin": "2.80.0-6ubuntu3.9", + "libglvnd0": "1.7.0-1build1", + "libglx-dev": "1.7.0-1build1", + "libglx-mesa0": "25.2.8-0ubuntu0.24.04.4", + "libglx0": "1.7.0-1build1", + "libgmp10": "2:6.3.0+dfsg-2ubuntu6.1", + "libgnutls30t64": "3.8.3-1.1ubuntu3.6", + "libgomp1": "14.2.0-4ubuntu2~24.04.1", + "libgpg-error0": "1.47-3build2.1", + "libgprofng0": "2.42-4ubuntu2.10", + "libgraphite2-3": "1.3.14-2ubuntu0.24.04.1", + "libgraphite2-dev": "1.3.14-2ubuntu0.24.04.1", + "libgssapi-krb5-2": "1.20.1-6ubuntu2.10", + "libharfbuzz-cairo0": "8.3.0-2build2", + "libharfbuzz-dev": "8.3.0-2build2", + "libharfbuzz-gobject0": "8.3.0-2build2", + "libharfbuzz-icu0": "8.3.0-2build2", + "libharfbuzz-subset0": "8.3.0-2build2", + "libharfbuzz0b": "8.3.0-2build2", + "libhogweed6t64": "3.9.1-2.2build1.1", + "libhwasan0": "14.2.0-4ubuntu2~24.04.1", + "libicu-dev": "74.2-1ubuntu3.1", + "libicu74": "74.2-1ubuntu3.1", + "libidn2-0": "2.3.7-2build1.1", + "libidn2-dev": "2.3.7-2build1.1", + "libisl23": "0.26-3build1.1", + "libitm1": "14.2.0-4ubuntu2~24.04.1", + "libjansson4": "2.14-2build2", + "libjpeg-dev": "8c-2ubuntu11", + "libjpeg-turbo8": "2.1.5-2ubuntu2", + "libjpeg-turbo8-dev": "2.1.5-2ubuntu2", + "libjpeg8": "8c-2ubuntu11", + "libjpeg8-dev": "8c-2ubuntu11", + "libjsoncpp25": "1.9.5-6build1", + "libk5crypto3": "1.20.1-6ubuntu2.10", + "libkeyutils1": "1.6.3-3build1", + "libkrb5-3": "1.20.1-6ubuntu2.10", + "libkrb5support0": "1.20.1-6ubuntu2.10", + "libldap2": "2.6.10+dfsg-0ubuntu0.24.04.1", + "libllvm20": "1:20.1.2-0ubuntu1~24.04.3", + "liblsan0": "14.2.0-4ubuntu2~24.04.1", + "liblz4-1": "1.9.4-1build1.1", + "liblzma5": "5.6.1+really5.4.5-1ubuntu0.3", + "liblzo2-2": "2.10-2build4", + "libmagic-mgc": "1:5.45-3build1", + "libmagic1t64": "1:5.45-3build1", + "libmd0": "1.1.0-2build1.1", + "libmount-dev": "2.39.3-9ubuntu6.6", + "libmount1": "2.39.3-9ubuntu6.6", + "libmp3lame0": "3.100-6build1", + "libmpc3": "1.3.1-1build1.1", + "libmpfr6": "4.2.1-1build1.1", + "libmpg123-0t64": "1.32.5-1ubuntu1.1", + "libncursesw6": "6.4+20240113-1ubuntu2.2", + "libnettle8t64": "3.9.1-2.2build1.1", + "libnghttp2-14": "1.59.0-1ubuntu0.4", + "libnpth0t64": "1.6-3.1build1", + "libogg-dev": "1.3.5-3build1", + "libogg0": "1.3.5-3build1", + "libopus-dev": "1.4-1build1", + "libopus0": "1.4-1build1", + "libopusfile-dev": "0.12-4build3", + "libopusfile0": "0.12-4build3", + "libp11-kit0": "0.25.3-4ubuntu2.2", + "libpam-modules": "1.5.3-5ubuntu5.7", + "libpam-modules-bin": "1.5.3-5ubuntu5.7", + "libpam-runtime": "1.5.3-5ubuntu5.7", + "libpam0g": "1.5.3-5ubuntu5.7", + "libpciaccess-dev": "0.17-3ubuntu0.24.04.3", + "libpciaccess0": "0.17-3ubuntu0.24.04.3", + "libpcre2-16-0": "10.42-4ubuntu2.1", + "libpcre2-32-0": "10.42-4ubuntu2.1", + "libpcre2-8-0": "10.42-4ubuntu2.1", + "libpcre2-dev": "10.42-4ubuntu2.1", + "libpcre2-posix3": "10.42-4ubuntu2.1", + "libperl5.38t64": "5.38.2-3.2ubuntu0.6", + "libpixman-1-0": "0.42.2-1build1", + "libpkgconf3": "1.8.1-2build1", + "libpng-dev": "1.6.43-5ubuntu0.7", + "libpng16-16t64": "1.6.43-5ubuntu0.7", + "libproc2-0": "2:4.0.4-4ubuntu3.3", + "libpsl5t64": "0.21.2-1.1build1", + "libpthread-stubs0-dev": "0.4-1build3", + "libpulse-dev": "1:16.1+dfsg1-2ubuntu10.1", + "libpulse-mainloop-glib0": "1:16.1+dfsg1-2ubuntu10.1", + "libpulse0": "1:16.1+dfsg1-2ubuntu10.1", + "libpython3-stdlib": "3.12.3-0ubuntu2.1", + "libpython3.12-minimal": "3.12.3-1ubuntu0.17", + "libpython3.12-stdlib": "3.12.3-1ubuntu0.17", + "libquadmath0": "14.2.0-4ubuntu2~24.04.1", + "libreadline8t64": "8.2-4build1", + "librhash0": "1.4.3-3build1", + "librtmp1": "2.4+20151223.gitfa8646d.1-2build7", + "libsasl2-2": "2.1.28+dfsg1-5ubuntu3.1", + "libsasl2-modules-db": "2.1.28+dfsg1-5ubuntu3.1", + "libseccomp2": "2.5.5-1ubuntu3.1", + "libselinux1": "3.5-2ubuntu2.1", + "libselinux1-dev": "3.5-2ubuntu2.1", + "libsemanage-common": "3.5-1build5", + "libsemanage2": "3.5-1build5", + "libsensors-config": "1:3.6.0-9build1", + "libsensors5": "1:3.6.0-9build1", + "libsepol-dev": "3.5-2build1", + "libsepol2": "3.5-2build1", + "libsframe1": "2.42-4ubuntu2.10", + "libsmartcols1": "2.39.3-9ubuntu6.6", + "libsndfile1": "1.2.2-1ubuntu5.24.04.1", + "libsqlite3-0": "3.45.1-1ubuntu2.8", + "libss2": "1.47.0-2.4~exp1ubuntu4.1", + "libssh-4": "0.10.6-2ubuntu0.5", + "libssl3t64": "3.0.13-0ubuntu3.16", + "libstdc++-13-dev": "13.3.0-6ubuntu2~24.04.1", + "libstdc++6": "14.2.0-4ubuntu2~24.04.1", + "libsystemd0": "255.4-1ubuntu8.17", + "libtasn1-6": "4.19.0-3ubuntu0.24.04.2", + "libtinfo6": "6.4+20240113-1ubuntu2.2", + "libtsan2": "14.2.0-4ubuntu2~24.04.1", + "libubsan1": "14.2.0-4ubuntu2~24.04.1", + "libudev-dev": "255.4-1ubuntu8.17", + "libudev1": "255.4-1ubuntu8.17", + "libunistring5": "1.1-2build1.1", + "libuuid1": "2.39.3-9ubuntu6.6", + "libuv1t64": "1.48.0-1.1build1", + "libvorbis0a": "1.3.7-1build3", + "libvorbisenc2": "1.3.7-1build3", + "libvulkan1": "1.3.275.0-1build1", + "libwayland-bin": "1.22.0-2.1build1", + "libwayland-client0": "1.22.0-2.1build1", + "libwayland-cursor0": "1.22.0-2.1build1", + "libwayland-dev": "1.22.0-2.1build1", + "libwayland-egl1": "1.22.0-2.1build1", + "libwayland-server0": "1.22.0-2.1build1", + "libx11-6": "2:1.8.7-1build1", + "libx11-data": "2:1.8.7-1build1", + "libx11-dev": "2:1.8.7-1build1", + "libx11-xcb1": "2:1.8.7-1build1", + "libxau-dev": "1:1.0.9-1build6", + "libxau6": "1:1.0.9-1build6", + "libxcb-dri3-0": "1.15-1ubuntu2", + "libxcb-glx0": "1.15-1ubuntu2", + "libxcb-present0": "1.15-1ubuntu2", + "libxcb-randr0": "1.15-1ubuntu2", + "libxcb-render0": "1.15-1ubuntu2", + "libxcb-shm0": "1.15-1ubuntu2", + "libxcb-sync1": "1.15-1ubuntu2", + "libxcb-xfixes0": "1.15-1ubuntu2", + "libxcb1": "1.15-1ubuntu2", + "libxcb1-dev": "1.15-1ubuntu2", + "libxcursor-dev": "1:1.2.1-1build1", + "libxcursor1": "1:1.2.1-1build1", + "libxdmcp-dev": "1:1.1.3-0ubuntu6", + "libxdmcp6": "1:1.1.3-0ubuntu6", + "libxext-dev": "2:1.3.4-1build2", + "libxext6": "2:1.3.4-1build2", + "libxfixes-dev": "1:6.0.0-2build1", + "libxfixes3": "1:6.0.0-2build1", + "libxi-dev": "2:1.8.1-1build1", + "libxi6": "2:1.8.1-1build1", + "libxkbcommon-dev": "1.6.0-1build1", + "libxkbcommon0": "1.6.0-1build1", + "libxml2": "2.9.14+dfsg-1.3ubuntu3.10", + "libxml2-dev": "2.9.14+dfsg-1.3ubuntu3.10", + "libxrandr-dev": "2:1.5.2-2build1", + "libxrandr2": "2:1.5.2-2build1", + "libxrender-dev": "1:0.9.10-1.1build1", + "libxrender1": "1:0.9.10-1.1build1", + "libxshmfence1": "1.3-1build5", + "libxss-dev": "1:1.2.3-1build3", + "libxss1": "1:1.2.3-1build3", + "libxtst-dev": "2:1.2.3-1.1build1", + "libxtst6": "2:1.2.3-1.1build1", + "libxxf86vm1": "1:1.1.4-1build4", + "libxxhash0": "0.8.2-2build1", + "libzstd1": "1.5.5+dfsg2-2build1.1", + "linux-libc-dev": "6.8.0-146.146", + "login": "1:4.13+dfsg1-4ubuntu3.2", + "logsave": "1.47.0-2.4~exp1ubuntu4.1", + "lto-disabled-list": "47", + "make": "4.3-4.1build2", + "mawk": "1.3.4.20240123-1build1", + "media-types": "10.1.0", + "mesa-libgallium": "25.2.8-0ubuntu0.24.04.4", + "mount": "2.39.3-9ubuntu6.6", + "ncurses-base": "6.4+20240113-1ubuntu2.2", + "ncurses-bin": "6.4+20240113-1ubuntu2.2", + "netbase": "6.4", + "ninja-build": "1.11.1-2", + "openssl": "3.0.13-0ubuntu3.16", + "passwd": "1:4.13+dfsg1-4ubuntu3.2", + "patch": "2.7.6-7build3", + "patchelf": "0.18.0-1.1build1", + "perl": "5.38.2-3.2ubuntu0.6", + "perl-base": "5.38.2-3.2ubuntu0.6", + "perl-modules-5.38": "5.38.2-3.2ubuntu0.6", + "pkg-config": "1.8.1-2build1", + "pkgconf": "1.8.1-2build1", + "pkgconf-bin": "1.8.1-2build1", + "procps": "2:4.0.4-4ubuntu3.3", + "python3": "3.12.3-0ubuntu2.1", + "python3-minimal": "3.12.3-0ubuntu2.1", + "python3-packaging": "24.0-1", + "python3.12": "3.12.3-1ubuntu0.17", + "python3.12-minimal": "3.12.3-1ubuntu0.17", + "readline-common": "8.2-4build1", + "rpcsvc-proto": "1.4.2-0ubuntu7", + "sed": "4.9-2ubuntu0.24.04.1", + "sensible-utils": "0.0.22", + "sgml-base": "1.31", + "squashfs-tools": "1:4.6.1-1build1", + "sysvinit-utils": "3.08-6ubuntu3", + "tar": "1.35+dfsg-3ubuntu0.4", + "tzdata": "2026c-0ubuntu0.24.04.1", + "ubuntu-keyring": "2023.11.28.1", + "unminimize": "0.2.1", + "util-linux": "2.39.3-9ubuntu6.6", + "uuid-dev": "2.39.3-9ubuntu6.6", + "x11-common": "1:7.7+23ubuntu3", + "x11proto-dev": "2023.2-1", + "xkb-data": "2.41-2ubuntu1.1", + "xml-core": "0.19", + "xorg-sgml-doctools": "1:1.11-1.1", + "xtrans-dev": "1.4.0-1", + "xz-utils": "5.6.1+really5.4.5-1ubuntu0.3", + "zlib1g": "1:1.3.dfsg-3.1ubuntu2.2", + "zlib1g-dev": "1:1.3.dfsg-3.1ubuntu2.2", + "desktop-file-utils": "0.27-2build1", + "libvulkan-dev": "1.3.275.0-1build1" + }, + "system_library_packages": { + "ld-linux-x86-64.so.2": "libc6", + "libBrokenLocale.so": "libc6", + "libBrokenLocale.so.1": "libc6", + "libEGL.so": "libegl1", + "libEGL.so.1": "libegl1", + "libEGL_mesa.so.0": "libegl-mesa0", + "libFLAC.so.12": "libflac12t64", + "libGL.so": "libgl1", + "libGL.so.1": "libgl1", + "libGLX.so": "libglx0", + "libGLX.so.0": "libglx0", + "libGLX_mesa.so.0": "libglx-mesa0", + "libGLdispatch.so.0": "libglvnd0", + "libLLVM.so.20.1": "libllvm20", + "libX11-xcb.so.1": "libx11-xcb1", + "libX11.so": "libx11-6", + "libX11.so.6": "libx11-6", + "libXau.so": "libxau6", + "libXau.so.6": "libxau6", + "libXcursor.so": "libxcursor1", + "libXcursor.so.1": "libxcursor1", + "libXdmcp.so": "libxdmcp6", + "libXdmcp.so.6": "libxdmcp6", + "libXext.so": "libxext6", + "libXext.so.6": "libxext6", + "libXfixes.so": "libxfixes3", + "libXfixes.so.3": "libxfixes3", + "libXi.so": "libxi6", + "libXi.so.6": "libxi6", + "libXrandr.so": "libxrandr2", + "libXrandr.so.2": "libxrandr2", + "libXrender.so": "libxrender1", + "libXrender.so.1": "libxrender1", + "libXss.so": "libxss1", + "libXss.so.1": "libxss1", + "libXtst.so": "libxtst6", + "libXtst.so.6": "libxtst6", + "libXxf86vm.so.1": "libxxf86vm1", + "libacl.so.1": "libacl1", + "libanl.so": "libc6", + "libanl.so.1": "libc6", + "libapparmor.so.1": "libapparmor1", + "libapt-pkg.so.6.0": "libapt-pkg6.0t64", + "libapt-private.so.0.0": "apt", + "libarchive.so.13": "libarchive13t64", + "libasan.so.8": "libasan8", + "libasound.so": "libasound2t64", + "libasound.so.2": "libasound2t64", + "libassuan.so.0": "libassuan0", + "libasyncns.so.0": "libasyncns0", + "libatomic.so.1": "libatomic1", + "libattr.so.1": "libattr1", + "libaudit.so.1": "libaudit1", + "libbfd-2.42-system.so": "libbinutils", + "libblkid.so": "libblkid1", + "libblkid.so.1": "libblkid1", + "libbrotlicommon.so": "libbrotli1", + "libbrotlicommon.so.1": "libbrotli1", + "libbrotlidec.so": "libbrotli1", + "libbrotlidec.so.1": "libbrotli1", + "libbrotlienc.so": "libbrotli1", + "libbrotlienc.so.1": "libbrotli1", + "libbsd.so.0": "libbsd0", + "libbz2.so": "libbz2-1.0", + "libbz2.so.1.0": "libbz2-1.0", + "libc.so.6": "libc6", + "libc_malloc_debug.so": "libc6", + "libc_malloc_debug.so.0": "libc6", + "libcairo-gobject.so.2": "libcairo-gobject2", + "libcairo.so.2": "libcairo2", + "libcap-ng.so.0": "libcap-ng0", + "libcap.so.2": "libcap2", + "libcc1.so.0": "libcc1-0", + "libcom_err.so.2": "libcom-err2", + "libcrypt.so": "libcrypt1", + "libcrypt.so.1": "libcrypt1", + "libcrypto.so.3": "libssl3t64", + "libctf-nobfd.so.0": "libctf-nobfd0", + "libctf.so.0": "libctf0", + "libcurl.so.4": "libcurl4t64", + "libdb-5.3.so": "libdb5.3t64", + "libdbus-1.so": "libdbus-1-3", + "libdbus-1.so.3": "libdbus-1-3", + "libdebconfclient.so.0": "libdebconfclient0", + "libdecor-0.so": "libdecor-0-0", + "libdecor-0.so.0": "libdecor-0-0", + "libdl.so.2": "libc6", + "libdrm.so": "libdrm2", + "libdrm.so.2": "libdrm2", + "libdrm_amdgpu.so": "libdrm-amdgpu1", + "libdrm_amdgpu.so.1": "libdrm-amdgpu1", + "libdrm_intel.so": "libdrm-intel1", + "libdrm_intel.so.1": "libdrm-intel1", + "libdrm_nouveau.so": "libdrm-nouveau2", + "libdrm_nouveau.so.2": "libdrm-nouveau2", + "libdrm_radeon.so": "libdrm-radeon1", + "libdrm_radeon.so.1": "libdrm-radeon1", + "libdrop_ambient.so.0": "libcap-ng0", + "libe2p.so.2": "libext2fs2t64", + "libedit.so.2": "libedit2", + "libelf.so.1": "libelf1t64", + "libexpat.so.1": "libexpat1", + "libexpatw.so.1": "libexpat1", + "libext2fs.so.2": "libext2fs2t64", + "libffi.so": "libffi8", + "libffi.so.8": "libffi8", + "libfontconfig.so.1": "libfontconfig1", + "libformw.so.6": "libncursesw6", + "libfreetype.so": "libfreetype6", + "libfreetype.so.6": "libfreetype6", + "libgallium-25.2.8-0ubuntu0.24.04.4.so": "mesa-libgallium", + "libgbm.so": "libgbm1", + "libgbm.so.1": "libgbm1", + "libgcc_s.so.1": "libgcc-s1", + "libgcrypt.so.20": "libgcrypt20", + "libgdbm.so.6": "libgdbm6t64", + "libgdbm_compat.so.4": "libgdbm-compat4t64", + "libgio-2.0.so": "libglib2.0-0t64", + "libgio-2.0.so.0": "libglib2.0-0t64", + "libgirepository-2.0.so.0": "libgirepository-2.0-0", + "libglib-2.0.so": "libglib2.0-0t64", + "libglib-2.0.so.0": "libglib2.0-0t64", + "libgmodule-2.0.so": "libglib2.0-0t64", + "libgmodule-2.0.so.0": "libglib2.0-0t64", + "libgmp.so.10": "libgmp10", + "libgnutls.so.30": "libgnutls30t64", + "libgobject-2.0.so": "libglib2.0-0t64", + "libgobject-2.0.so.0": "libglib2.0-0t64", + "libgomp.so.1": "libgomp1", + "libgpg-error.so.0": "libgpg-error0", + "libgprofng.so.0": "libgprofng0", + "libgraphite2.so": "libgraphite2-3", + "libgraphite2.so.3": "libgraphite2-3", + "libgssapi_krb5.so.2": "libgssapi-krb5-2", + "libgthread-2.0.so": "libglib2.0-0t64", + "libgthread-2.0.so.0": "libglib2.0-0t64", + "libharfbuzz-cairo.so": "libharfbuzz-cairo0", + "libharfbuzz-cairo.so.0": "libharfbuzz-cairo0", + "libharfbuzz-gobject.so": "libharfbuzz-gobject0", + "libharfbuzz-gobject.so.0": "libharfbuzz-gobject0", + "libharfbuzz-icu.so": "libharfbuzz-icu0", + "libharfbuzz-icu.so.0": "libharfbuzz-icu0", + "libharfbuzz-subset.so": "libharfbuzz-subset0", + "libharfbuzz-subset.so.0": "libharfbuzz-subset0", + "libharfbuzz.so": "libharfbuzz0b", + "libharfbuzz.so.0": "libharfbuzz0b", + "libhistory.so.8": "libreadline8t64", + "libhogweed.so.6": "libhogweed6t64", + "libhwasan.so.0": "libhwasan0", + "libicudata.so": "libicu74", + "libicudata.so.74": "libicu74", + "libicui18n.so": "libicu74", + "libicui18n.so.74": "libicu74", + "libicuio.so": "libicu74", + "libicuio.so.74": "libicu74", + "libicutest.so": "libicu74", + "libicutest.so.74": "libicu74", + "libicutu.so": "libicu74", + "libicutu.so.74": "libicu74", + "libicuuc.so": "libicu74", + "libicuuc.so.74": "libicu74", + "libidn2.so": "libidn2-0", + "libidn2.so.0": "libidn2-0", + "libisl.so.23": "libisl23", + "libitm.so.1": "libitm1", + "libjansson.so.4": "libjansson4", + "libjpeg.so": "libjpeg-turbo8", + "libjpeg.so.8": "libjpeg-turbo8", + "libjsoncpp.so.25": "libjsoncpp25", + "libk5crypto.so.3": "libk5crypto3", + "libkeyutils.so.1": "libkeyutils1", + "libkrb5.so.3": "libkrb5-3", + "libkrb5support.so.0": "libkrb5support0", + "liblber.so.2": "libldap2", + "libldap.so.2": "libldap2", + "liblsan.so.0": "liblsan0", + "liblz4.so.1": "liblz4-1", + "liblzma.so.5": "liblzma5", + "liblzo2.so.2": "liblzo2-2", + "libm.so.6": "libc6", + "libmagic.so.1": "libmagic1t64", + "libmd.so.0": "libmd0", + "libmemusage.so": "libc6", + "libmenuw.so.6": "libncursesw6", + "libmount.so": "libmount1", + "libmount.so.1": "libmount1", + "libmp3lame.so.0": "libmp3lame0", + "libmpc.so.3": "libmpc3", + "libmpfr.so.6": "libmpfr6", + "libmpg123.so.0": "libmpg123-0t64", + "libmvec.so": "libc6", + "libmvec.so.1": "libc6", + "libncursesw.so.6": "libncursesw6", + "libnettle.so.8": "libnettle8t64", + "libnghttp2.so.14": "libnghttp2-14", + "libnpth.so.0": "libnpth0t64", + "libnsl.so.1": "libc6", + "libnss_compat.so": "libc6", + "libnss_compat.so.2": "libc6", + "libnss_dns.so.2": "libc6", + "libnss_files.so.2": "libc6", + "libnss_hesiod.so": "libc6", + "libnss_hesiod.so.2": "libc6", + "libogg.so": "libogg0", + "libogg.so.0": "libogg0", + "libopcodes-2.42-system.so": "libbinutils", + "libopus.so": "libopus0", + "libopus.so.0": "libopus0", + "libopusfile.so": "libopusfile0", + "libopusfile.so.0": "libopusfile0", + "libopusurl.so": "libopusfile0", + "libopusurl.so.0": "libopusfile0", + "libp11-kit.so.0": "libp11-kit0", + "libpam.so.0": "libpam0g", + "libpam_misc.so.0": "libpam0g", + "libpamc.so.0": "libpam0g", + "libpanelw.so.6": "libncursesw6", + "libpciaccess.so": "libpciaccess0", + "libpciaccess.so.0": "libpciaccess0", + "libpcprofile.so": "libc6", + "libpcre2-16.so": "libpcre2-16-0", + "libpcre2-16.so.0": "libpcre2-16-0", + "libpcre2-32.so": "libpcre2-32-0", + "libpcre2-32.so.0": "libpcre2-32-0", + "libpcre2-8.so": "libpcre2-8-0", + "libpcre2-8.so.0": "libpcre2-8-0", + "libpcre2-posix.so": "libpcre2-posix3", + "libpcre2-posix.so.3": "libpcre2-posix3", + "libperl.so.5.38": "libperl5.38t64", + "libpixman-1.so.0": "libpixman-1-0", + "libpkgconf.so.3": "libpkgconf3", + "libpng16.so": "libpng16-16t64", + "libpng16.so.16": "libpng16-16t64", + "libproc2.so.0": "libproc2-0", + "libpsl.so.5": "libpsl5t64", + "libpsx.so.2": "libcap2", + "libpthread.so.0": "libc6", + "libpulse-mainloop-glib.so": "libpulse-mainloop-glib0", + "libpulse-mainloop-glib.so.0": "libpulse-mainloop-glib0", + "libpulse-simple.so": "libpulse0", + "libpulse-simple.so.0": "libpulse0", + "libpulse.so": "libpulse0", + "libpulse.so.0": "libpulse0", + "libpulsecommon-16.1.so": "libpulse0", + "libquadmath.so.0": "libquadmath0", + "libreadline.so.8": "libreadline8t64", + "libresolv.so": "libc6", + "libresolv.so.2": "libc6", + "librhash.so.0": "librhash0", + "librt.so.1": "libc6", + "librtmp.so.1": "librtmp1", + "libsasl2.so.2": "libsasl2-2", + "libseccomp.so.2": "libseccomp2", + "libselinux.so": "libselinux1", + "libselinux.so.1": "libselinux1", + "libsemanage.so.2": "libsemanage2", + "libsensors.so.5": "libsensors5", + "libsepol.so": "libsepol2", + "libsepol.so.2": "libsepol2", + "libsframe.so.1": "libsframe1", + "libsmartcols.so.1": "libsmartcols1", + "libsndfile.so.1": "libsndfile1", + "libsqlite3.so.0": "libsqlite3-0", + "libss.so.2": "libss2", + "libssh.so.4": "libssh-4", + "libssl.so.3": "libssl3t64", + "libstdc++.so.6": "libstdc++6", + "libsystemd.so.0": "libsystemd0", + "libtasn1.so.6": "libtasn1-6", + "libthread_db.so": "libc6", + "libthread_db.so.1": "libc6", + "libtic.so.6": "libtinfo6", + "libtinfo.so.6": "libtinfo6", + "libtsan.so.2": "libtsan2", + "libubsan.so.1": "libubsan1", + "libudev.so": "libudev1", + "libudev.so.1": "libudev1", + "libunistring.so.5": "libunistring5", + "libutil.so.1": "libc6", + "libuuid.so": "libuuid1", + "libuuid.so.1": "libuuid1", + "libuv.so.1": "libuv1t64", + "libvorbis.so.0": "libvorbis0a", + "libvorbisenc.so.2": "libvorbisenc2", + "libvulkan.so.1": "libvulkan1", + "libwayland-client.so": "libwayland-client0", + "libwayland-client.so.0": "libwayland-client0", + "libwayland-cursor.so": "libwayland-cursor0", + "libwayland-cursor.so.0": "libwayland-cursor0", + "libwayland-egl.so": "libwayland-egl1", + "libwayland-egl.so.1": "libwayland-egl1", + "libwayland-server.so": "libwayland-server0", + "libwayland-server.so.0": "libwayland-server0", + "libxcb-dri3.so.0": "libxcb-dri3-0", + "libxcb-glx.so.0": "libxcb-glx0", + "libxcb-present.so.0": "libxcb-present0", + "libxcb-randr.so.0": "libxcb-randr0", + "libxcb-render.so.0": "libxcb-render0", + "libxcb-shm.so.0": "libxcb-shm0", + "libxcb-sync.so.1": "libxcb-sync1", + "libxcb-xfixes.so.0": "libxcb-xfixes0", + "libxcb.so": "libxcb1", + "libxcb.so.1": "libxcb1", + "libxkbcommon.so": "libxkbcommon0", + "libxkbcommon.so.0": "libxkbcommon0", + "libxml2.so": "libxml2", + "libxml2.so.2": "libxml2", + "libxshmfence.so.1": "libxshmfence1", + "libxxhash.so.0": "libxxhash0", + "libz.so": "zlib1g", + "libz.so.1": "zlib1g", + "libzstd.so.1": "libzstd1" + }, + "system_package_copyright_sha256": { + "apt": "b4701305243d8d746f4acaafe15d94f946251b05691bd6917fef41dd15e9ee12", + "libacl1": "9a2dfb4a5abc7e84be2cc41f1089be665519c9409549296f6c19de57ab1d37c2", + "libapparmor1": "52b7cf37d674ca47296cf6270a95ecda0ecbef976cc090d702acbd741e36b3b4", + "libapt-pkg6.0t64": "b4701305243d8d746f4acaafe15d94f946251b05691bd6917fef41dd15e9ee12", + "libarchive13t64": "21e7f99bb2808ddf4ac9cb7126a9af6e130d4342f2bcafdbe84fe9e4344cf4c0", + "libasan8": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libasound2t64": "9b356b4fae53b5d5e7668e048f085d68a5039de7b6afbf465f50e81513334062", + "libassuan0": "d704d521eb7ba7b9e3d82d85ac6caac4dfb4c784a4762f61a95fbb2733b05ff3", + "libasyncns0": "26f98be71539b8f1038fbaf51551598d0e2eb16f98db9a62094fc85db5f1c25d", + "libatomic1": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libattr1": "0cbec745d85ea775450b2d54fac55277197f429e52d611f72852ed420450620e", + "libaudit1": "6c7011e93f643b112c4cc3622131e28a30477afad6c4f5efe466b13aa7f603cc", + "libbinutils": "a81bdd422c2c015deca84bf6ad249bf0d7d19885fc01d1894463291b0b7313e1", + "libblkid1": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab", + "libbrotli1": "24a64e5bb83d0960d1835696a1e23c0896ad6055b0ca47c66ab0eb9a766324b1", + "libbsd0": "978426553ab7b9cfb4503d98e578a77b8c3142c099100aa9791fd21be5a95f97", + "libbz2-1.0": "832ed535ff3c3d025a8d2348eb1b697b89addcf2eaadbc17650262040b9145e2", + "libc6": "d3c95b56fa33e28b57860580f0baf4e4f4de2a268a2b80f1d031a5191bade265", + "libcairo-gobject2": "408fb9e704e875cd42d432e7c9b3b378f568e3c89db2b5c5ef3d11c025319574", + "libcairo2": "408fb9e704e875cd42d432e7c9b3b378f568e3c89db2b5c5ef3d11c025319574", + "libcap-ng0": "567997de097e3738564c90e6fe13343b2daae21a7926d6fb161df41322c1840b", + "libcap2": "e60a5642e12e340060e4e519a65b2eb0330be2917854841c644e36268f607d54", + "libcc1-0": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libcom-err2": "1ca415a294c0e56958c4f795b3f1f69ea3479bca53d7811f5cdd020a258949db", + "libcrypt1": "5a5e7ca0e9f3f9679977e3a3e9ede45ad92885a3297ea78e766979f9866c5a16", + "libctf-nobfd0": "a81bdd422c2c015deca84bf6ad249bf0d7d19885fc01d1894463291b0b7313e1", + "libctf0": "a81bdd422c2c015deca84bf6ad249bf0d7d19885fc01d1894463291b0b7313e1", + "libcurl4t64": "a7c131b21e6e73b3b085a7d9f1637b68f5f92b832cc6f462e0c978fbe7841ccb", + "libdb5.3t64": "8b53da37d73289d9df7317b3a2dbe16491f1fa9fec8b77b86d69704b40ce2c26", + "libdbus-1-3": "20d4fee565b0a0910670b8202c15f040ed5dd488a685a3008eb04b65d987f89c", + "libdebconfclient0": "cf63099896d1c0f6529c41b8d95738e6fea342ffba609187d339c4f38ce36ecc", + "libdecor-0-0": "598201e144e867fc6ea003d8418e730db56448825e3989a66f0ac3b79142b979", + "libdrm-amdgpu1": "512c9a57fd8b449aa157351da9df07bf8e19f767e496a72205a4020268ddb2e0", + "libdrm-intel1": "512c9a57fd8b449aa157351da9df07bf8e19f767e496a72205a4020268ddb2e0", + "libdrm-nouveau2": "512c9a57fd8b449aa157351da9df07bf8e19f767e496a72205a4020268ddb2e0", + "libdrm-radeon1": "512c9a57fd8b449aa157351da9df07bf8e19f767e496a72205a4020268ddb2e0", + "libdrm2": "512c9a57fd8b449aa157351da9df07bf8e19f767e496a72205a4020268ddb2e0", + "libedit2": "2b53671050f6319be6fcdcafa434e8ccb8af629359b4afc83185867204f3927d", + "libegl-mesa0": "cbf3d2eb38998daf390f52d49969d1be5607a6aee138351e161c12a6976ba49e", + "libegl1": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3", + "libelf1t64": "657e078ed1e718b57ca839cdc38bb93196a0009488bea43cd0f429e7531530fa", + "libexpat1": "60919fe1a156395ff14511bb6ff79756c50ade2fff59ac60c9bae1d8a7fe6292", + "libext2fs2t64": "1ca415a294c0e56958c4f795b3f1f69ea3479bca53d7811f5cdd020a258949db", + "libffi8": "737ff68f0eb104d3b0d96b291c3a8f40a775a3ec2b53f9b2e4bcfe3188bde118", + "libflac12t64": "b09e581d4a200e49d0c5e032777a3f9c5cfd164b3dfc90f5071f285df9543f18", + "libfontconfig1": "b215a61cdd3e62b5b17cc28b1852c78acb3dd38be0fb30706f7efc050dba91db", + "libfreetype6": "ce6d766883ea111e7f47acc09e9d49be8827daa6a03f2c2707243a425d41f0e9", + "libgbm1": "cbf3d2eb38998daf390f52d49969d1be5607a6aee138351e161c12a6976ba49e", + "libgcc-s1": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libgcrypt20": "40162167dff80843a4b24aa8d0a4ffc50be5338d004ebe38f86ae3397ba9de3e", + "libgdbm-compat4t64": "8bea8cf2f94cd8b37a0b8b3a2b974122b9194c1165e6ad508e366c74679e8817", + "libgdbm6t64": "8bea8cf2f94cd8b37a0b8b3a2b974122b9194c1165e6ad508e366c74679e8817", + "libgirepository-2.0-0": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211", + "libgl1": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3", + "libglib2.0-0t64": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211", + "libglvnd0": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3", + "libglx-mesa0": "cbf3d2eb38998daf390f52d49969d1be5607a6aee138351e161c12a6976ba49e", + "libglx0": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3", + "libgmp10": "7d366aef5ba325150246de1d7820c4c9926cd27ca61a69948492b26cd3b61601", + "libgnutls30t64": "51da5214308b87d6a0a2fbcaea1f7dbd15510be733e5921d9548ff6b88d84dd8", + "libgomp1": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libgpg-error0": "0250e9e7cbf6c68c2bd05b6db1ea684988f91cd95da69f2936064cf302729fe5", + "libgprofng0": "a81bdd422c2c015deca84bf6ad249bf0d7d19885fc01d1894463291b0b7313e1", + "libgraphite2-3": "a33017ba0308c41bdbde4751bff349ab7b26af45fadbb1e3e7ac67c372985572", + "libgssapi-krb5-2": "936728f4181718f42951b881c1e8f1386bf6b2723c4fbc533c374d6f42c71816", + "libharfbuzz-cairo0": "6ffcef5a169fca847bc886b052bbe9a478754850621d8661ae4ea424b7eeb26e", + "libharfbuzz-gobject0": "6ffcef5a169fca847bc886b052bbe9a478754850621d8661ae4ea424b7eeb26e", + "libharfbuzz-icu0": "6ffcef5a169fca847bc886b052bbe9a478754850621d8661ae4ea424b7eeb26e", + "libharfbuzz-subset0": "6ffcef5a169fca847bc886b052bbe9a478754850621d8661ae4ea424b7eeb26e", + "libharfbuzz0b": "6ffcef5a169fca847bc886b052bbe9a478754850621d8661ae4ea424b7eeb26e", + "libhogweed6t64": "2cecdc7ac1913beb31299a41a8092fc936d9e6a1d00872fc3bf6e2263aecf842", + "libhwasan0": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libicu74": "5bbff5f646ce750ac0a347e7f91793279b7422a1119820b5f75d98879c201c68", + "libidn2-0": "41702469f28e8ff406345e3d1dd0c741f11bdfca319264f67036efd6aaa79e4b", + "libisl23": "fb1ea085945df53c756939eecf5d9bf4072316538bc3b314a5408350b679a637", + "libitm1": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libjansson4": "29347e2528ab31d20e8582b09aa89cd878d306c59a273541141c9aede1903cb8", + "libjpeg-turbo8": "3821184e23385bf2cdc01f56bd999b34036fc3aa03d5135d41680374eb81bf8a", + "libjsoncpp25": "9304db2aec23318d436229b9268268db96829dc4117ac6cd10d2a59ecd95a6cf", + "libk5crypto3": "936728f4181718f42951b881c1e8f1386bf6b2723c4fbc533c374d6f42c71816", + "libkeyutils1": "c62fe37d22203046611112bc10a9e03a39a7f73bec1b061e7c615326166d9ff5", + "libkrb5-3": "936728f4181718f42951b881c1e8f1386bf6b2723c4fbc533c374d6f42c71816", + "libkrb5support0": "936728f4181718f42951b881c1e8f1386bf6b2723c4fbc533c374d6f42c71816", + "libldap2": "b4081907f0d9a4110736fa6540dc8ca9d7d60a20878b5d78f84916cc04907bdc", + "libllvm20": "4c22cf66ddd6dfae2ac4e034342f93a9c96581d5e88c7bb700a125a71cf66cd7", + "liblsan0": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "liblz4-1": "d3be0f43fe14da244f97c451bbae789c0acf868612598a69f5f22a289abeca40", + "liblzma5": "a2dc8fac0f496e1c58aff67018cf0c6b88336316afa5642f9335b71642e28aa8", + "liblzo2-2": "3c412f0f1b0e1aa51d1f7efed5c02f30811bd5d26ddccfcc6760669cd4bc8c93", + "libmagic1t64": "17c4359a008b198cf2012a6b2437c06d2d7f26bba2789aa3697d4e001eae40cf", + "libmd0": "4365ef6255ad553fce69dd4bc0e093472c5d0e41b8ea493a545cc926ce171aa6", + "libmount1": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab", + "libmp3lame0": "f0e4c5098ffedad31473eec38810c26409223858fb8d63dd7685bf225f96bc5d", + "libmpc3": "80763e4cca3963670637b98abff467ee09b83d1737d683ad5ccfb1e919bea94b", + "libmpfr6": "c434a44e656a89e72b5cee13bba6719011e0899bfc626ab9c08205010ab2dbbe", + "libmpg123-0t64": "ba3657ec83ab9af9070bacccc6c9735d1856c8a5adab275708ba3d4c9e41b5f8", + "libncursesw6": "7c9d2c8dbee48b6f4bbbffb1f370cc5858de11cc4f5a70c5c622c274917e1c65", + "libnettle8t64": "2cecdc7ac1913beb31299a41a8092fc936d9e6a1d00872fc3bf6e2263aecf842", + "libnghttp2-14": "a34ae32a5eba61e535835a8de7c398f47e420247d339bc400a3b9c618ba29a3d", + "libnpth0t64": "df6909abb38f4e84d237d8617e948bdbb391b2004ecf573863011d54a88ccf22", + "libogg0": "36bb02bf6a764f4fac0ca9d38d2563932513df6015d98bd162624b18feaf096c", + "libopus0": "893be2dfc7b9d827675d69c43e1f172a89ef10d3745ffd15a48514c08470cace", + "libopusfile0": "28f6f13386e8df6d945d94a05f8d5f11688797b580ece5c99fa2fa3cd6e98050", + "libp11-kit0": "d33af1e069981d1c1b576cb22e4bc260a134ffa2fce6ddd3cbbbb5b181113f0c", + "libpam0g": "9596b9028339d66c285422237728d6170e1e29d0b0d9adf0e9f1bda82e2391ce", + "libpciaccess0": "59f90c1ad1face5bd0a516feb8a71e005075d532c522b64ff06b2bfeebde8fe8", + "libpcre2-16-0": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38", + "libpcre2-32-0": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38", + "libpcre2-8-0": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38", + "libpcre2-posix3": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38", + "libperl5.38t64": "657ac19622868be1f2bf8ac3823a952b28c7a2a467f61353743e9c9d89291921", + "libpixman-1-0": "1b3fb5690861e15ca4fd6640fd4def4b2576617942eec04b6fcb7d8bc6c4c9da", + "libpkgconf3": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba", + "libpng16-16t64": "4620d402b97601a910946acccbbe2e15bdffac11615bf9046520990d3b00f2b9", + "libproc2-0": "90a66617c36d83068b52bbb77cbfef1234f526bfa287156d9d05f7df4d339539", + "libpsl5t64": "bbafb0cf52d34465fc69f5ffd22d724224f6a2f28402c88b045c69d88af06aaf", + "libpulse-mainloop-glib0": "aff3f1d729168de06e34d940dc493cc305a6e4a6278f1ac38ee52ec0f69e342c", + "libpulse0": "aff3f1d729168de06e34d940dc493cc305a6e4a6278f1ac38ee52ec0f69e342c", + "libquadmath0": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libreadline8t64": "854c6f5f249a109a913e1e58528445c4fec96f063ae8ba496bfb2b460bcc6866", + "librhash0": "f0aaef8266168387d80f2b2ca8f69be2f0ea6a8c5161192abcd439f7672dd218", + "librtmp1": "4302abcc1ea1b6cbe8d19c79b892acf78878030220210707c19006828866207c", + "libsasl2-2": "ddb128a81d3a140ef5b3574ae000a1a50d0cb9a2ec59917121a155f91b670166", + "libseccomp2": "6da0653a365b80ab31478dbfe9e7c358e80d58b6d2b52b4e16b4dbb42d7d9f5c", + "libselinux1": "864f1bb189f609075d580b8c4aada16d85a44546884f1687989e1cacc8c56751", + "libsemanage2": "fe5876a94047cafbeb8b164fd98304912d94b6028e19415f08b7a8a2f90645fe", + "libsensors5": "36df1f6d732ef306eba85489352b2afe329f7b54c6850c3e2094f7c5df1bc46b", + "libsepol2": "78d2a34606a0302057ec499a3b3c07bcbc43ca334064ce4c80891a0f69c8f6c6", + "libsframe1": "a81bdd422c2c015deca84bf6ad249bf0d7d19885fc01d1894463291b0b7313e1", + "libsmartcols1": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab", + "libsndfile1": "56bc3dae5803b8b2d59adf72fcd526eef2f4ac929d2442562d12248867222335", + "libsqlite3-0": "78e9df8f8153c91c762ec970b82d224ac4d30f05aa7773e7e17e268141e53873", + "libss2": "1ca415a294c0e56958c4f795b3f1f69ea3479bca53d7811f5cdd020a258949db", + "libssh-4": "1b33ec0b1d0d521afb21d8e6b98fa4abf52b1ffb69a8461a045db9d586ce141f", + "libssl3t64": "6a7da622fe0637a334d2a8fc470852d2ffb77d9a2b2f930f854e32a41ad6ef35", + "libstdc++6": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libsystemd0": "a7d06854714a1ca99f6dbd1a1641dde5bcf28635be149f6554449618f8f427f3", + "libtasn1-6": "572ad60ad184d8f52c6dc66d83a61a68f137db560b3452ce00588c9ecf128a65", + "libtinfo6": "7c9d2c8dbee48b6f4bbbffb1f370cc5858de11cc4f5a70c5c622c274917e1c65", + "libtsan2": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libubsan1": "20390f8a6f3b1e4d7cb45dd8652dabb259bbef688cbad839bcdb0b9ba7252f79", + "libudev1": "a7d06854714a1ca99f6dbd1a1641dde5bcf28635be149f6554449618f8f427f3", + "libunistring5": "dbbfee567c5eb53ad1c75ce666779e596b5f794b52be7f5834685ddd633185ea", + "libuuid1": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab", + "libuv1t64": "27795634f1af1f3db5e8ebb51dc449fa8218f26bbf269eb7bea88997f74c6299", + "libvorbis0a": "47badad9f7f750f92cd12f44db08b6937e5b4d47935ba1cc60de0ea3a2adff9b", + "libvorbisenc2": "47badad9f7f750f92cd12f44db08b6937e5b4d47935ba1cc60de0ea3a2adff9b", + "libvulkan1": "c579213e28f67944a7e407816b8a8e1d2d2406b3d820e420aa923807c450dc07", + "libwayland-client0": "736bf54141fa9808939ffff2691b817888ea53197484c2099e8d03b954b83e3b", + "libwayland-cursor0": "736bf54141fa9808939ffff2691b817888ea53197484c2099e8d03b954b83e3b", + "libwayland-egl1": "736bf54141fa9808939ffff2691b817888ea53197484c2099e8d03b954b83e3b", + "libwayland-server0": "736bf54141fa9808939ffff2691b817888ea53197484c2099e8d03b954b83e3b", + "libx11-6": "0b380a7fd5b2228f26e9585e56f14812efd3350f3df307507d2bc055dfd8de3e", + "libx11-xcb1": "0b380a7fd5b2228f26e9585e56f14812efd3350f3df307507d2bc055dfd8de3e", + "libxau6": "118dd263a7b91c8f21c489f949bf13281dff9e766deea92b829dac4dce66601a", + "libxcb-dri3-0": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcb-glx0": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcb-present0": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcb-randr0": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcb-render0": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcb-shm0": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcb-sync1": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcb-xfixes0": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcb1": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80", + "libxcursor1": "817abc0ccc9e2591057be256b0f6d80edb134221ced29dd545a9eafaf40b423c", + "libxdmcp6": "1bcbb50f8603fe8b86d330bfa460b772ea04fbd3c70e4499f0768b5340b9fd6e", + "libxext6": "bc57e445ca1d9fe082c8d54189dd411ff26caa8552c9c63d44ea06a982f32124", + "libxfixes3": "051ffe073ab38244c504bb379903b4ecda6081fb3d97d0d3dce44bc11712eef2", + "libxi6": "c812f9c54513ae593aab9580b54a4b18219648df7068d49ca1daa47f522ba3fa", + "libxkbcommon0": "5eeaeb1b6e029a0274e1573765bb0bae2926ef96a3679203faa4fd00fdaeaa88", + "libxml2": "ee746b96cfa5be73c3ea3e4cfb1285e9b315d4c9267f99b2ee9c5d911d9fe3f4", + "libxrandr2": "6d8e6239cfe9dd25bbeef06dcf0610d802ebbdc8968db87cf5adf00ff98927d4", + "libxrender1": "ed2a78123a5076b4f11da38cb1ffb85b5cb9fdc26b5746c517b1d7e7a9029704", + "libxshmfence1": "6a76b32c9174c0cb9e3d94c323a14d33d6a42bd2a61579b189cbc5addeb94abe", + "libxss1": "3ebbe713ac6980aefc18dcf407c94f51bbf02852463294bbb6dc1ee1f9fad9c3", + "libxtst6": "304f78731e1e0e78132fbba4e1909c8642878a3b8c4837b36cb2d38891bf2204", + "libxxf86vm1": "8a18884e1be08a37bf1b8fc9a14eba800e65a98d6ea19bb5d8b0ccf35f9bbcc4", + "libxxhash0": "53acfd069ef57d3a68fc03d3ecdbcb7939c8083bae3fe52851da69b0383d7ee0", + "libzstd1": "35e85e8e9bbb71492c5f5dddf39ed65b68775a02ad8d710c25d097b497f06c49", + "mesa-libgallium": "cbf3d2eb38998daf390f52d49969d1be5607a6aee138351e161c12a6976ba49e", + "zlib1g": "9e5b96d63773a5d177ba264254390f792be07e41748ebd94730981c6cac31cc6", + "libasound2-data": "9b356b4fae53b5d5e7668e048f085d68a5039de7b6afbf465f50e81513334062" + }, + "system_package_notice_files": { + "apt": [ + "GPL-2" + ], + "libacl1": [ + "GPL-2", + "LGPL-2.1" + ], + "libapparmor1": [ + "GPL-2", + "LGPL-2.1" + ], + "libapt-pkg6.0t64": [ + "GPL-2" + ], + "libarchive13t64": [ + "Apache-2.0", + "CC0-1.0" + ], + "libasan8": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libasound2t64": [ + "LGPL-2.1" + ], + "libassuan0": [ + "GPL-2", + "GPL-3", + "LGPL-2.1", + "LGPL-3" + ], + "libasyncns0": [ + "LGPL-2.1" + ], + "libatomic1": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libattr1": [ + "GPL-2", + "LGPL-2.1" + ], + "libaudit1": [ + "GPL-1", + "LGPL-2.1" + ], + "libbinutils": [ + "GFDL", + "GPL", + "LGPL" + ], + "libblkid1": [ + "GPL-2", + "GPL-3", + "LGPL", + "LGPL-2", + "LGPL-2.1", + "LGPL-3" + ], + "libbrotli1": [], + "libbsd0": [], + "libbz2-1.0": [ + "GPL-2" + ], + "libc6": [ + "GFDL-1.3", + "GPL-2", + "LGPL-2.1" + ], + "libcairo-gobject2": [ + "LGPL-2.1" + ], + "libcairo2": [ + "LGPL-2.1" + ], + "libcap-ng0": [ + "GPL-2", + "GPL-3", + "LGPL-2.1" + ], + "libcap2": [ + "GPL-2" + ], + "libcc1-0": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libcom-err2": [ + "Apache-2.0", + "GPL", + "GPL-2", + "LGPL-2" + ], + "libcrypt1": [], + "libctf-nobfd0": [ + "GFDL", + "GPL", + "LGPL" + ], + "libctf0": [ + "GFDL", + "GPL", + "LGPL" + ], + "libcurl4t64": [ + "GPL-2" + ], + "libdb5.3t64": [ + "Artistic", + "GPL", + "GPL-3" + ], + "libdbus-1-3": [ + "GPL-2", + "LGPL-2.1" + ], + "libdebconfclient0": [ + "GPL-2" + ], + "libdecor-0-0": [], + "libdrm-amdgpu1": [], + "libdrm-intel1": [], + "libdrm-nouveau2": [], + "libdrm-radeon1": [], + "libdrm2": [], + "libedit2": [], + "libegl-mesa0": [ + "Apache-2.0", + "GPL", + "GPL-1", + "GPL-2" + ], + "libegl1": [ + "Apache-2.0", + "GPL", + "GPL-3" + ], + "libelf1t64": [ + "GFDL-1.3", + "GPL-2", + "GPL-3", + "LGPL-2.1", + "LGPL-3" + ], + "libexpat1": [], + "libext2fs2t64": [ + "Apache-2.0", + "GPL", + "GPL-2", + "LGPL-2" + ], + "libffi8": [ + "GPL" + ], + "libflac12t64": [ + "GFDL-1.2", + "GPL-2", + "LGPL-2", + "LGPL-2.1" + ], + "libfontconfig1": [], + "libfreetype6": [ + "GPL-2", + "GPL-3" + ], + "libgbm1": [ + "Apache-2.0", + "GPL", + "GPL-1", + "GPL-2" + ], + "libgcc-s1": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libgcrypt20": [ + "GPL-2", + "LGPL" + ], + "libgdbm-compat4t64": [ + "GPL-2", + "GPL-3" + ], + "libgdbm6t64": [ + "GPL-2", + "GPL-3" + ], + "libgirepository-2.0-0": [ + "Apache-2.0", + "CC0-1.0", + "GPL-2", + "LGPL-2", + "LGPL-2.1", + "LGPL-3", + "MPL-1.1" + ], + "libgl1": [ + "Apache-2.0", + "GPL", + "GPL-3" + ], + "libglib2.0-0t64": [ + "Apache-2.0", + "CC0-1.0", + "GPL-2", + "LGPL-2", + "LGPL-2.1", + "LGPL-3", + "MPL-1.1" + ], + "libglvnd0": [ + "Apache-2.0", + "GPL", + "GPL-3" + ], + "libglx-mesa0": [ + "Apache-2.0", + "GPL", + "GPL-1", + "GPL-2" + ], + "libglx0": [ + "Apache-2.0", + "GPL", + "GPL-3" + ], + "libgmp10": [ + "GPL-2", + "GPL-3", + "LGPL-3" + ], + "libgnutls30t64": [ + "Apache-2.0", + "GFDL-1.3", + "GPL", + "GPL-3", + "LGPL", + "LGPL-3" + ], + "libgomp1": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libgpg-error0": [ + "GPL-3", + "LGPL-2.1" + ], + "libgprofng0": [ + "GFDL", + "GPL", + "LGPL" + ], + "libgraphite2-3": [ + "Artistic", + "GPL-1", + "GPL-2", + "LGPL-2.1", + "MPL-1.1" + ], + "libgssapi-krb5-2": [ + "GPL-2" + ], + "libharfbuzz-cairo0": [ + "Apache-2.0", + "CC0-1.0", + "GPL-2", + "GPL-3", + "LGPL-2.1" + ], + "libharfbuzz-gobject0": [ + "Apache-2.0", + "CC0-1.0", + "GPL-2", + "GPL-3", + "LGPL-2.1" + ], + "libharfbuzz-icu0": [ + "Apache-2.0", + "CC0-1.0", + "GPL-2", + "GPL-3", + "LGPL-2.1" + ], + "libharfbuzz-subset0": [ + "Apache-2.0", + "CC0-1.0", + "GPL-2", + "GPL-3", + "LGPL-2.1" + ], + "libharfbuzz0b": [ + "Apache-2.0", + "CC0-1.0", + "GPL-2", + "GPL-3", + "LGPL-2.1" + ], + "libhogweed6t64": [ + "GPL", + "GPL-2", + "LGPL", + "LGPL-2" + ], + "libhwasan0": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libicu74": [ + "GPL-3" + ], + "libidn2-0": [ + "GPL-2", + "GPL-3", + "LGPL-3" + ], + "libisl23": [ + "LGPL-2" + ], + "libitm1": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libjansson4": [], + "libjpeg-turbo8": [], + "libjsoncpp25": [ + "GPL-3" + ], + "libk5crypto3": [ + "GPL-2" + ], + "libkeyutils1": [ + "GPL-2", + "LGPL-2" + ], + "libkrb5-3": [ + "GPL-2" + ], + "libkrb5support0": [ + "GPL-2" + ], + "libldap2": [ + "GPL-2", + "GPL-3" + ], + "libllvm20": [ + "Apache-2.0" + ], + "liblsan0": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "liblz4-1": [ + "GPL-2" + ], + "liblzma5": [ + "GPL-2", + "GPL-3", + "LGPL-2", + "LGPL-2.1" + ], + "liblzo2-2": [ + "GPL-2" + ], + "libmagic1t64": [], + "libmd0": [], + "libmount1": [ + "GPL-2", + "GPL-3", + "LGPL", + "LGPL-2", + "LGPL-2.1", + "LGPL-3" + ], + "libmp3lame0": [ + "GPL-1", + "LGPL-2", + "LGPL-2.1" + ], + "libmpc3": [ + "LGPL-3" + ], + "libmpfr6": [ + "GFDL-1.2", + "LGPL-3" + ], + "libmpg123-0t64": [ + "LGPL-2.1" + ], + "libncursesw6": [], + "libnettle8t64": [ + "GPL", + "GPL-2", + "LGPL", + "LGPL-2" + ], + "libnghttp2-14": [ + "GPL-3" + ], + "libnpth0t64": [ + "LGPL-2.1" + ], + "libogg0": [], + "libopus0": [], + "libopusfile0": [], + "libp11-kit0": [ + "Apache-2.0", + "LGPL-2.1" + ], + "libpam0g": [ + "GPL", + "GPL-2", + "GPL-3", + "LGPL-2" + ], + "libpciaccess0": [ + "GPL" + ], + "libpcre2-16-0": [], + "libpcre2-32-0": [], + "libpcre2-8-0": [], + "libpcre2-posix3": [], + "libperl5.38t64": [ + "Artistic", + "GPL-1", + "GPL-2", + "LGPL-2.1" + ], + "libpixman-1-0": [], + "libpkgconf3": [ + "GPL-2" + ], + "libpng16-16t64": [ + "Apache-2.0", + "GPL-2" + ], + "libproc2-0": [ + "GPL-2", + "LGPL-2", + "LGPL-2.1" + ], + "libpsl5t64": [], + "libpulse-mainloop-glib0": [ + "GPL-2", + "LGPL-2", + "LGPL-2.1" + ], + "libpulse0": [ + "GPL-2", + "LGPL-2", + "LGPL-2.1" + ], + "libquadmath0": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libreadline8t64": [ + "GFDL", + "GPL-2", + "GPL-3" + ], + "librhash0": [], + "librtmp1": [ + "GPL-2", + "LGPL-2.1" + ], + "libsasl2-2": [ + "GPL-3" + ], + "libseccomp2": [ + "LGPL-2.1" + ], + "libselinux1": [ + "GPL-2" + ], + "libsemanage2": [ + "GPL-2", + "LGPL-2.1" + ], + "libsensors5": [ + "GPL-2", + "LGPL-2.1" + ], + "libsepol2": [ + "GPL-2", + "LGPL-2.1" + ], + "libsframe1": [ + "GFDL", + "GPL", + "LGPL" + ], + "libsmartcols1": [ + "GPL-2", + "GPL-3", + "LGPL", + "LGPL-2", + "LGPL-2.1", + "LGPL-3" + ], + "libsndfile1": [ + "Apache-2.0", + "GPL-2", + "GPL-3", + "LGPL-2", + "LGPL-2.1" + ], + "libsqlite3-0": [ + "GPL-2" + ], + "libss2": [ + "Apache-2.0", + "GPL", + "GPL-2", + "LGPL-2" + ], + "libssh-4": [ + "LGPL-2.1" + ], + "libssl3t64": [ + "Apache-2.0", + "Artistic", + "GPL-1" + ], + "libstdc++6": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libsystemd0": [ + "CC0-1.0", + "GPL-2", + "LGPL-2.1" + ], + "libtasn1-6": [ + "GFDL-1.3", + "GPL-3", + "LGPL", + "LGPL-2.1" + ], + "libtinfo6": [], + "libtsan2": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libubsan1": [ + "Artistic", + "GFDL-1.2", + "GPL", + "GPL-3", + "LGPL" + ], + "libudev1": [ + "CC0-1.0", + "GPL-2", + "LGPL-2.1" + ], + "libunistring5": [ + "GFDL-1.2", + "GPL-2", + "GPL-3", + "LGPL-3" + ], + "libuuid1": [ + "GPL-2", + "GPL-3", + "LGPL", + "LGPL-2", + "LGPL-2.1", + "LGPL-3" + ], + "libuv1t64": [ + "Apache-2.0" + ], + "libvorbis0a": [], + "libvorbisenc2": [], + "libvulkan1": [ + "Apache-2.0" + ], + "libwayland-client0": [], + "libwayland-cursor0": [], + "libwayland-egl1": [], + "libwayland-server0": [], + "libx11-6": [], + "libx11-xcb1": [], + "libxau6": [], + "libxcb-dri3-0": [], + "libxcb-glx0": [], + "libxcb-present0": [], + "libxcb-randr0": [], + "libxcb-render0": [], + "libxcb-shm0": [], + "libxcb-sync1": [], + "libxcb-xfixes0": [], + "libxcb1": [], + "libxcursor1": [], + "libxdmcp6": [], + "libxext6": [], + "libxfixes3": [], + "libxi6": [], + "libxkbcommon0": [], + "libxml2": [], + "libxrandr2": [], + "libxrender1": [], + "libxshmfence1": [], + "libxss1": [], + "libxtst6": [], + "libxxf86vm1": [], + "libxxhash0": [ + "GPL-2" + ], + "libzstd1": [ + "GPL-2" + ], + "mesa-libgallium": [ + "Apache-2.0", + "GPL", + "GPL-1", + "GPL-2" + ], + "zlib1g": [], + "libasound2-data": [ + "LGPL-2.1" + ] + }, + "system_common_license_sha256": { + "GPL-2": "8177f97513213526df2cf6184d8ff986c675afb514d4e68a404010521b880643", + "LGPL-2.1": "dc626520dcd53a22f727af3ee42c770e56c97a64fe3adb063799d8ab032fe551", + "Apache-2.0": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "CC0-1.0": "a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499", + "Artistic": "b7fd9b73ea99602016a326e0b62e6646060d18febdd065ceca8bb482208c3d88", + "GFDL-1.2": "d8e94ae5fdb5433fcae2961aeb1a8cf17174d6f4a0465d24bf37dd8a038bd439", + "GPL": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986", + "GPL-3": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986", + "LGPL": "e3a994d82e644b03a792a930f574002658412f62407f5fee083f2555c5f23118", + "LGPL-3": "e3a994d82e644b03a792a930f574002658412f62407f5fee083f2555c5f23118", + "GPL-1": "d77d235e41d54594865151f4751e835c5a82322b0e87ace266567c3391a4b912", + "GFDL": "110535522396708cea37c72a802c5e7e81391139f5f7985631c93ef242b206a4", + "LGPL-2": "681e386e44a19d7d0674b4320272c90e66b6610b741e7e6305f8219c42e85366", + "GFDL-1.3": "110535522396708cea37c72a802c5e7e81391139f5f7985631c93ef242b206a4", + "MPL-1.1": "f849fc26a7a99981611a3a370e83078deb617d12a45776d6c4cada4d338be469" + }, + "system_package_licenses": { + "apt": "LicenseRef-apt-5009a047-CopyrightNotice-b4eb5667b1d3", + "libacl1": "LicenseRef-libacl1-ae5df98a-CopyrightNotice-900aa660c1bc", + "libapparmor1": "LicenseRef-libapparmor1-e0c84ed3-CopyrightNotice-5565b6b86acc", + "libapt-pkg6.0t64": "LicenseRef-libapt-pkg6.0t64-0770aed0-CopyrightNotice-6c017558179a", + "libarchive13t64": "LicenseRef-libarchive13t64-1085ebb0-CopyrightNotice-de5bb9c908c4", + "libasan8": "LicenseRef-libasan8-7bff83db-CopyrightNotice-5b277790b596", + "libasound2t64": "LicenseRef-libasound2t64-3ebeae7d-CopyrightNotice-0bcedd3f245a", + "libassuan0": "LicenseRef-libassuan0-dd0c1533-CopyrightNotice-106a5559c4c2", + "libasyncns0": "LicenseRef-libasyncns0-1d015601-CopyrightNotice-2badfa4b1417", + "libatomic1": "LicenseRef-libatomic1-3bb189cc-CopyrightNotice-840a41420869", + "libattr1": "LicenseRef-libattr1-9643aecf-CopyrightNotice-336dbe6172bb", + "libaudit1": "LicenseRef-libaudit1-39f8fe4b-CopyrightNotice-048fc6480599", + "libbinutils": "LicenseRef-libbinutils-b0badd25-CopyrightNotice-29477e51c9a8", + "libblkid1": "LicenseRef-libblkid1-dc6146d8-CopyrightNotice-149c97766b07", + "libbrotli1": "LicenseRef-libbrotli1-f0d4515c-CopyrightNotice-7c5c0eb0f066", + "libbsd0": "LicenseRef-libbsd0-cfb187ff-CopyrightNotice-86bae5320001", + "libbz2-1.0": "LicenseRef-libbz2-1.0-66e87cc3-CopyrightNotice-cd7ea2f774c1", + "libc6": "LicenseRef-libc6-fb9ce3b8-CopyrightNotice-c9adb0421e32", + "libcairo-gobject2": "LicenseRef-libcairo-gobject2-13ed485c-CopyrightNotice-94bfccf24be2", + "libcairo2": "LicenseRef-libcairo2-cbe0e425-CopyrightNotice-9629545f0ae4", + "libcap-ng0": "LicenseRef-libcap-ng0-33b0e9e6-CopyrightNotice-99033d1a9d25", + "libcap2": "LicenseRef-libcap2-90f5f009-CopyrightNotice-fe5be0d60530", + "libcc1-0": "LicenseRef-libcc1-0-8b31e727-CopyrightNotice-c0bd6f38c3dd", + "libcom-err2": "LicenseRef-libcom-err2-fd3d2018-CopyrightNotice-2572a7f1ec29", + "libcrypt1": "LicenseRef-libcrypt1-595004ae-CopyrightNotice-d4a57a1b0bbf", + "libctf-nobfd0": "LicenseRef-libctf-nobfd0-a0f1f622-CopyrightNotice-691aea42a7d1", + "libctf0": "LicenseRef-libctf0-0ed80803-CopyrightNotice-87351fdadd62", + "libcurl4t64": "LicenseRef-libcurl4t64-8bdf0547-CopyrightNotice-d07f2ab19ed3", + "libdb5.3t64": "LicenseRef-libdb5.3t64-ce8c4ba0-CopyrightNotice-a53a46314e16", + "libdbus-1-3": "LicenseRef-libdbus-1-3-f6d43131-CopyrightNotice-bfc91f6181f0", + "libdebconfclient0": "LicenseRef-libdebconfclient0-2cfa8f1b-CopyrightNotice-888ade4ec93e", + "libdecor-0-0": "LicenseRef-libdecor-0-0-0b685fe5-CopyrightNotice-dd9c553250dd", + "libdrm-amdgpu1": "LicenseRef-libdrm-amdgpu1-52cbf57c-CopyrightNotice-7bb0f81c2627", + "libdrm-intel1": "LicenseRef-libdrm-intel1-4037ef94-CopyrightNotice-b766227af648", + "libdrm-nouveau2": "LicenseRef-libdrm-nouveau2-4f60e5c8-CopyrightNotice-a4959f763723", + "libdrm-radeon1": "LicenseRef-libdrm-radeon1-a0ab6600-CopyrightNotice-cd1743da9ecb", + "libdrm2": "LicenseRef-libdrm2-a204aa2c-CopyrightNotice-7f798aa74359", + "libedit2": "LicenseRef-libedit2-d1c4ed13-CopyrightNotice-658e4a21c5a8", + "libegl-mesa0": "LicenseRef-libegl-mesa0-191c40b9-CopyrightNotice-4edfa0b2635f", + "libegl1": "LicenseRef-libegl1-642afc6e-CopyrightNotice-09e5a3a56531", + "libelf1t64": "LicenseRef-libelf1t64-2974d8c7-CopyrightNotice-feae1417919e", + "libexpat1": "LicenseRef-libexpat1-166445e0-CopyrightNotice-6e410708e567", + "libext2fs2t64": "LicenseRef-libext2fs2t64-75c19834-CopyrightNotice-3e6861fa8e2d", + "libffi8": "LicenseRef-libffi8-767ea085-CopyrightNotice-c2ca491b4137", + "libflac12t64": "LicenseRef-libflac12t64-6d85024c-CopyrightNotice-9123643ef5a5", + "libfontconfig1": "LicenseRef-libfontconfig1-aa869416-CopyrightNotice-dc2ad90f418e", + "libfreetype6": "LicenseRef-libfreetype6-82c1434e-CopyrightNotice-618ff3a73adb", + "libgbm1": "LicenseRef-libgbm1-fa942bf9-CopyrightNotice-9a3873f5c354", + "libgcc-s1": "LicenseRef-libgcc-s1-9244dde6-CopyrightNotice-f728a16ab248", + "libgcrypt20": "LicenseRef-libgcrypt20-c4a59ee9-CopyrightNotice-9543b80168e9", + "libgdbm-compat4t64": "LicenseRef-libgdbm-compat4t64-b779fe39-CopyrightNotice-6bcc06724ee3", + "libgdbm6t64": "LicenseRef-libgdbm6t64-445ceafd-CopyrightNotice-94b207e744af", + "libgirepository-2.0-0": "LicenseRef-libgirepository-2.0-0-96a10193-CopyrightNotice-e24eaf633054", + "libgl1": "LicenseRef-libgl1-2d04d315-CopyrightNotice-6d88bcd284d5", + "libglib2.0-0t64": "LicenseRef-libglib2.0-0t64-c92a9adb-CopyrightNotice-b20fdd55059e", + "libglvnd0": "LicenseRef-libglvnd0-ce61fdce-CopyrightNotice-f2fe6079ef68", + "libglx-mesa0": "LicenseRef-libglx-mesa0-9d2a0764-CopyrightNotice-db7bf26dad44", + "libglx0": "LicenseRef-libglx0-af43cc1b-CopyrightNotice-65f795bb327a", + "libgmp10": "LicenseRef-libgmp10-90e4e1b5-CopyrightNotice-3e59433b7873", + "libgnutls30t64": "LicenseRef-libgnutls30t64-591811b5-CopyrightNotice-aa1193b3d4a2", + "libgomp1": "LicenseRef-libgomp1-3c6a6a11-CopyrightNotice-7240b4aefdde", + "libgpg-error0": "LicenseRef-libgpg-error0-9fde1866-CopyrightNotice-fbcf6837220f", + "libgprofng0": "LicenseRef-libgprofng0-ec0028b0-CopyrightNotice-b7ca91176e1c", + "libgraphite2-3": "LicenseRef-libgraphite2-3-804e45c8-CopyrightNotice-6f1d7541f292", + "libgssapi-krb5-2": "LicenseRef-libgssapi-krb5-2-5c08293b-CopyrightNotice-f2a711a448dc", + "libharfbuzz-cairo0": "LicenseRef-libharfbuzz-cairo0-d4d4adcd-CopyrightNotice-d47525b5af6d", + "libharfbuzz-gobject0": "LicenseRef-libharfbuzz-gobject0-c2d00d68-CopyrightNotice-330f92ef3c0a", + "libharfbuzz-icu0": "LicenseRef-libharfbuzz-icu0-f43643a1-CopyrightNotice-a67196541b11", + "libharfbuzz-subset0": "LicenseRef-libharfbuzz-subset0-7f3a5c3e-CopyrightNotice-ee0f4366ea37", + "libharfbuzz0b": "LicenseRef-libharfbuzz0b-7799bca9-CopyrightNotice-b6d3c52fc8b5", + "libhogweed6t64": "LicenseRef-libhogweed6t64-241d32d8-CopyrightNotice-ea127e991383", + "libhwasan0": "LicenseRef-libhwasan0-c91f34b2-CopyrightNotice-c3d9ca1535a4", + "libicu74": "LicenseRef-libicu74-9e2cd655-CopyrightNotice-cbf081cd0308", + "libidn2-0": "LicenseRef-libidn2-0-d63f4bcd-CopyrightNotice-b2647c6fdf65", + "libisl23": "LicenseRef-libisl23-3c15b321-CopyrightNotice-1c84b0522462", + "libitm1": "LicenseRef-libitm1-e0745b1a-CopyrightNotice-b8515b62a1f4", + "libjansson4": "LicenseRef-libjansson4-7212dd54-CopyrightNotice-8147cc2955fe", + "libjpeg-turbo8": "LicenseRef-libjpeg-turbo8-c05d471a-CopyrightNotice-dd349277b270", + "libjsoncpp25": "LicenseRef-libjsoncpp25-db5161ad-CopyrightNotice-bb45b45e09a7", + "libk5crypto3": "LicenseRef-libk5crypto3-75fa54a4-CopyrightNotice-4e5c9807c190", + "libkeyutils1": "LicenseRef-libkeyutils1-08c737f5-CopyrightNotice-64e4b635dc4a", + "libkrb5-3": "LicenseRef-libkrb5-3-289d9bc7-CopyrightNotice-97e0e692f653", + "libkrb5support0": "LicenseRef-libkrb5support0-50b810aa-CopyrightNotice-4eb9ae55c091", + "libldap2": "LicenseRef-libldap2-3f7967b0-CopyrightNotice-6c105358665d", + "libllvm20": "LicenseRef-libllvm20-33b92447-CopyrightNotice-86887d37534d", + "liblsan0": "LicenseRef-liblsan0-913c4819-CopyrightNotice-b74c82aac49b", + "liblz4-1": "LicenseRef-liblz4-1-7e194483-CopyrightNotice-432279f17e16", + "liblzma5": "LicenseRef-liblzma5-d9fac4d2-CopyrightNotice-2e0562559962", + "liblzo2-2": "LicenseRef-liblzo2-2-b060f2f8-CopyrightNotice-91309edcced3", + "libmagic1t64": "LicenseRef-libmagic1t64-4dbd7350-CopyrightNotice-01f0309deee7", + "libmd0": "LicenseRef-libmd0-a157d982-CopyrightNotice-c699101d0e45", + "libmount1": "LicenseRef-libmount1-31d8023a-CopyrightNotice-dcb19a89ce73", + "libmp3lame0": "LicenseRef-libmp3lame0-a899e679-CopyrightNotice-5d281faa35b9", + "libmpc3": "LicenseRef-libmpc3-ad6530e4-CopyrightNotice-b6bb6b1a9be6", + "libmpfr6": "LicenseRef-libmpfr6-d0191ce5-CopyrightNotice-7af5c60843ac", + "libmpg123-0t64": "LicenseRef-libmpg123-0t64-e003ac7f-CopyrightNotice-12bcb71a47f2", + "libncursesw6": "LicenseRef-libncursesw6-b8543e2e-CopyrightNotice-88fb4149c279", + "libnettle8t64": "LicenseRef-libnettle8t64-c63973e0-CopyrightNotice-3ee168f26845", + "libnghttp2-14": "LicenseRef-libnghttp2-14-84dc1400-CopyrightNotice-907d20edc95e", + "libnpth0t64": "LicenseRef-libnpth0t64-6200ec01-CopyrightNotice-6249ad84e79a", + "libogg0": "LicenseRef-libogg0-29993293-CopyrightNotice-c47060fcbe4d", + "libopus0": "LicenseRef-libopus0-a1b50bc9-CopyrightNotice-4ab441a8a410", + "libopusfile0": "LicenseRef-libopusfile0-1bb823fb-CopyrightNotice-2cb5d4b9b6e3", + "libp11-kit0": "LicenseRef-libp11-kit0-a081e1b1-CopyrightNotice-631a8e19cc8a", + "libpam0g": "LicenseRef-libpam0g-4f1a64e9-CopyrightNotice-497023656c7a", + "libpciaccess0": "LicenseRef-libpciaccess0-cee47dd1-CopyrightNotice-a4867af6acda", + "libpcre2-16-0": "LicenseRef-libpcre2-16-0-6d92d170-CopyrightNotice-f90d230c8f65", + "libpcre2-32-0": "LicenseRef-libpcre2-32-0-f3bc6d33-CopyrightNotice-52e647ab7b01", + "libpcre2-8-0": "LicenseRef-libpcre2-8-0-56c6cc5f-CopyrightNotice-3588e508abcc", + "libpcre2-posix3": "LicenseRef-libpcre2-posix3-178bcaf4-CopyrightNotice-984de2e0e4b1", + "libperl5.38t64": "LicenseRef-libperl5.38t64-d5a4b204-CopyrightNotice-10971f7888a1", + "libpixman-1-0": "LicenseRef-libpixman-1-0-183bd3c1-CopyrightNotice-ae3c555a0881", + "libpkgconf3": "LicenseRef-libpkgconf3-80165e42-CopyrightNotice-f3baa1b4e863", + "libpng16-16t64": "LicenseRef-libpng16-16t64-a30a21ee-CopyrightNotice-7af7241c5310", + "libproc2-0": "LicenseRef-libproc2-0-d3052a2a-CopyrightNotice-f31d8f465959", + "libpsl5t64": "LicenseRef-libpsl5t64-202a6cd2-CopyrightNotice-7a0be045328d", + "libpulse-mainloop-glib0": "LicenseRef-libpulse-mainloop-glib0-8682f901-CopyrightNotice-ceac717891e9", + "libpulse0": "LicenseRef-libpulse0-5f225ea3-CopyrightNotice-8b7c7112b0dc", + "libquadmath0": "LicenseRef-libquadmath0-68b12546-CopyrightNotice-d54a5677f588", + "libreadline8t64": "LicenseRef-libreadline8t64-0353419c-CopyrightNotice-143dbfa4f1b1", + "librhash0": "LicenseRef-librhash0-9349d642-CopyrightNotice-e01dea37774f", + "librtmp1": "LicenseRef-librtmp1-75027d3f-CopyrightNotice-e22ffe25d5f7", + "libsasl2-2": "LicenseRef-libsasl2-2-0531e836-CopyrightNotice-3a5de4a34b41", + "libseccomp2": "LicenseRef-libseccomp2-e7cdef4f-CopyrightNotice-5d8820940ea2", + "libselinux1": "LicenseRef-libselinux1-b6abe261-CopyrightNotice-e3aaf9e7e50a", + "libsemanage2": "LicenseRef-libsemanage2-9a7f8284-CopyrightNotice-e5eed31e4ace", + "libsensors5": "LicenseRef-libsensors5-26401f95-CopyrightNotice-c5d1a9e20d05", + "libsepol2": "LicenseRef-libsepol2-51557de9-CopyrightNotice-9f064d97c84c", + "libsframe1": "LicenseRef-libsframe1-305901f3-CopyrightNotice-6f800089fbbd", + "libsmartcols1": "LicenseRef-libsmartcols1-276b0074-CopyrightNotice-e09df46fd4f8", + "libsndfile1": "LicenseRef-libsndfile1-2e722c0d-CopyrightNotice-243f2d454ba1", + "libsqlite3-0": "LicenseRef-libsqlite3-0-8f573ce0-CopyrightNotice-f95f1da4f384", + "libss2": "LicenseRef-libss2-f95f51a9-CopyrightNotice-b4a867e3a096", + "libssh-4": "LicenseRef-libssh-4-4987a0fb-CopyrightNotice-b795652d518c", + "libssl3t64": "LicenseRef-libssl3t64-2e94fb9a-CopyrightNotice-3c198c991926", + "libstdc++6": "LicenseRef-libstdc--6-ee0af3d5-CopyrightNotice-397fc781dec2", + "libsystemd0": "LicenseRef-libsystemd0-62dfc498-CopyrightNotice-a339e465e735", + "libtasn1-6": "LicenseRef-libtasn1-6-91d76c17-CopyrightNotice-8d74225bc72b", + "libtinfo6": "LicenseRef-libtinfo6-d6af4b8f-CopyrightNotice-a1b30a50fa5d", + "libtsan2": "LicenseRef-libtsan2-0ae9ae8f-CopyrightNotice-ff1563de7265", + "libubsan1": "LicenseRef-libubsan1-adddf336-CopyrightNotice-3f57023a2c07", + "libudev1": "LicenseRef-libudev1-c158cce7-CopyrightNotice-4ca564e1d19b", + "libunistring5": "LicenseRef-libunistring5-ce9cdffc-CopyrightNotice-a04f9fd3a0b2", + "libuuid1": "LicenseRef-libuuid1-506cdcff-CopyrightNotice-ca7c5bc05025", + "libuv1t64": "LicenseRef-libuv1t64-92189db9-CopyrightNotice-46994a29cf03", + "libvorbis0a": "LicenseRef-libvorbis0a-924e61c5-CopyrightNotice-3cb3828be89b", + "libvorbisenc2": "LicenseRef-libvorbisenc2-452cfd47-CopyrightNotice-36dec1301a38", + "libvulkan1": "LicenseRef-libvulkan1-2c5e7fb6-CopyrightNotice-6c6292f9edb4", + "libwayland-client0": "LicenseRef-libwayland-client0-a17c3fac-CopyrightNotice-b46edae28f27", + "libwayland-cursor0": "LicenseRef-libwayland-cursor0-eb1755b0-CopyrightNotice-25f4af533dec", + "libwayland-egl1": "LicenseRef-libwayland-egl1-eb9b4016-CopyrightNotice-fdc44a62b966", + "libwayland-server0": "LicenseRef-libwayland-server0-b0fe01b9-CopyrightNotice-eaacd8993203", + "libx11-6": "LicenseRef-libx11-6-1b0ed0ef-CopyrightNotice-3b6c5d532436", + "libx11-xcb1": "LicenseRef-libx11-xcb1-01f11547-CopyrightNotice-b0a47f4df66d", + "libxau6": "LicenseRef-libxau6-6bd64163-CopyrightNotice-b08560495e98", + "libxcb-dri3-0": "LicenseRef-libxcb-dri3-0-17247797-CopyrightNotice-2c516a4b3ceb", + "libxcb-glx0": "LicenseRef-libxcb-glx0-3f4ab5c1-CopyrightNotice-770dd89d30e3", + "libxcb-present0": "LicenseRef-libxcb-present0-04688581-CopyrightNotice-520d0535bfbb", + "libxcb-randr0": "LicenseRef-libxcb-randr0-fca76baa-CopyrightNotice-fe7741b179e3", + "libxcb-render0": "LicenseRef-libxcb-render0-418b5ec9-CopyrightNotice-d376037ebfb5", + "libxcb-shm0": "LicenseRef-libxcb-shm0-d8b103b3-CopyrightNotice-6dc02f4ae14e", + "libxcb-sync1": "LicenseRef-libxcb-sync1-9150c195-CopyrightNotice-f94a81fca235", + "libxcb-xfixes0": "LicenseRef-libxcb-xfixes0-3a5b3fb9-CopyrightNotice-0f60c5d7ce0f", + "libxcb1": "LicenseRef-libxcb1-ccb36e31-CopyrightNotice-3b73f2fdcf30", + "libxcursor1": "LicenseRef-libxcursor1-099acc37-CopyrightNotice-f1baf6baaef0", + "libxdmcp6": "LicenseRef-libxdmcp6-37e90bc4-CopyrightNotice-27b987a7b785", + "libxext6": "LicenseRef-libxext6-0d2493bf-CopyrightNotice-be501b991904", + "libxfixes3": "LicenseRef-libxfixes3-8c62df68-CopyrightNotice-8fcebded8ab2", + "libxi6": "LicenseRef-libxi6-6ffdb15a-CopyrightNotice-92c506cc862d", + "libxkbcommon0": "LicenseRef-libxkbcommon0-82e88570-CopyrightNotice-c762eae806d3", + "libxml2": "LicenseRef-libxml2-e35d532a-CopyrightNotice-44bd245da8aa", + "libxrandr2": "LicenseRef-libxrandr2-232bd4a0-CopyrightNotice-d3ef8a450c2a", + "libxrender1": "LicenseRef-libxrender1-9ce11044-CopyrightNotice-bd9353d19458", + "libxshmfence1": "LicenseRef-libxshmfence1-9c4f8a2b-CopyrightNotice-c20311f72ad7", + "libxss1": "LicenseRef-libxss1-7c3d0f93-CopyrightNotice-bb7df54ba79c", + "libxtst6": "LicenseRef-libxtst6-2203a7f3-CopyrightNotice-048f532b34b0", + "libxxf86vm1": "LicenseRef-libxxf86vm1-907702cf-CopyrightNotice-6e8eaccc10c3", + "libxxhash0": "LicenseRef-libxxhash0-79cc94c0-CopyrightNotice-85dfc11ca288", + "libzstd1": "LicenseRef-libzstd1-1f5cc113-CopyrightNotice-ea4bfe9388ae", + "mesa-libgallium": "LicenseRef-mesa-libgallium-587a0ce3-CopyrightNotice-8b60ee4638cf", + "zlib1g": "LicenseRef-zlib1g-2f69b326-CopyrightNotice-90a7a9ba7a51", + "libasound2-data": "LicenseRef-libasound2-data-019eddec-CopyrightNotice-47a678eb7657" + }, + "system_package_complete_notice_sha256": { + "apt": "b4eb5667b1d3a12bf2d00f210ad0042321c22921d05b613777f525d806e6a7d9", + "libacl1": "900aa660c1bc4718bf1bffc1a325900f66a67187662a7386cf6087fa724bb93b", + "libapparmor1": "5565b6b86accb56679c544e59d05a1fc6f69b0e2e760f3f4c895c3e3e31ab5af", + "libapt-pkg6.0t64": "6c017558179a72509eec146e0a62cffc8de20dc10657b68498240a530e067a61", + "libarchive13t64": "de5bb9c908c4ba75b82891a217ba57af5c58abe9aee808ac3c22939c88e63646", + "libasan8": "5b277790b5967570ef11603f59971270239da9f435eb982377e1b3cd1c3796d4", + "libasound2t64": "0bcedd3f245ad17408145160d2adef9392d360e0c54fdca51bc50b58446712cd", + "libassuan0": "106a5559c4c215180455daa962528d31c4e48cc429aad413271d500179a64f18", + "libasyncns0": "2badfa4b141773cb1e9ebbe6dd995c11767c7ce91c4ab98e12ebb5cf6aef5115", + "libatomic1": "840a41420869187dca92dc7c54a549d0b4c32ed1c3adcaf7d5c95908601822ab", + "libattr1": "336dbe6172bbe70feb525349c12a1bf98380c925152f779c575fd0332a024081", + "libaudit1": "048fc6480599daf197098786e2cc95e4e7b21675b73424e1ddf9f339abcc43ff", + "libbinutils": "29477e51c9a894f271c904df98540e00f5ee8e13ed6935689724eb5cc522ada2", + "libblkid1": "149c97766b07661193a00ebb9ef82cfe25fa320951014c2bbdcb8b85add4b5b9", + "libbrotli1": "7c5c0eb0f0664a991d92248298397f30bebad1073bc1bea8ec8eef553195a203", + "libbsd0": "86bae5320001fa07d7d068fcc63aac4e930636715382f86cf20d7ba452dd50ad", + "libbz2-1.0": "cd7ea2f774c1c6bde1fac5880058db42fc1f839c97909eac3144d65b41506975", + "libc6": "c9adb0421e32d6ae98a8787dddb2b2c42992c85077a8f57f19296df27654431e", + "libcairo-gobject2": "94bfccf24be2dd021cebe63c3d71feedc3e049e3f61d820da799f72230bce2e7", + "libcairo2": "9629545f0ae44dab6a3b7e5defc5eb611ca98b7f00c551858b504611c4c95195", + "libcap-ng0": "99033d1a9d25dbadbab4e731ddd7a3014d54d985fa536648c6113df640770497", + "libcap2": "fe5be0d60530cdf1fed38c2097fd0653f18da50b28a93da72f715f7201303658", + "libcc1-0": "c0bd6f38c3dddee2a0688eeab6d69e9129a2b51ab94f3a90afda4e6f560f017b", + "libcom-err2": "2572a7f1ec294a7487cb362829173697b87065bb1a19c1ec9c7bd6488917c5f0", + "libcrypt1": "d4a57a1b0bbf2eef00f7edfeb6cb4bdd7aec65203cc6a3d292193f2e387e294a", + "libctf-nobfd0": "691aea42a7d181c95c11def27f316e1452f98df8a752cbd988a3bdbf29ded057", + "libctf0": "87351fdadd625bea202ce8abf39d91387379d3a193bff20783b49455ee6f4a1f", + "libcurl4t64": "d07f2ab19ed3f9937a19c3318e3d11f10c7f0d1c907b2a83b89d1f4754214f56", + "libdb5.3t64": "a53a46314e161f0ac8c1a66c1fa6ec054ea9eab32973a6f271a112e53734dec0", + "libdbus-1-3": "bfc91f6181f08626509e5f9ea8a0957f6d8b4fc51662a8b62e629ae31513bdf3", + "libdebconfclient0": "888ade4ec93e8d7cea255b394a19f52de0888edf9512a49664b682ba471e7cdb", + "libdecor-0-0": "dd9c553250ddf52aa5af94575570fb634e46cd9fc57a5bc6be9a6d895816e94e", + "libdrm-amdgpu1": "7bb0f81c26275cea959260d4e5def1b64ad88b4e4a69d2be532a61041dfd4668", + "libdrm-intel1": "b766227af6489b752a033000c0e6c04b2ab3b2524cce8db2ddb975d659d2925e", + "libdrm-nouveau2": "a4959f763723c81db14a1df6f79e945356e9a93a05787aec81280d79984d5e93", + "libdrm-radeon1": "cd1743da9ecb45687b4c749427aa512869ece3990d04afb405b548b0f55093bb", + "libdrm2": "7f798aa74359d0a89feeba5c95125d72698826c4aef6b6767df251be54118639", + "libedit2": "658e4a21c5a8eba3581df1c64e77ce390b96971a5d4f0f614fe6e96d22aa6094", + "libegl-mesa0": "4edfa0b2635fa4c4baae2025ef1e832b92bda1d9a61c8faaf4cbebc0d1cc8996", + "libegl1": "09e5a3a565315c46ff1339b3e6502410e00dcf760f529d5c347c8872e10d945f", + "libelf1t64": "feae1417919e6d2598407eff30b4485a414e2521f517abba763dfc983c498b8f", + "libexpat1": "6e410708e56708d6ab65cfa16e3186e81b436e4eceb9948a2fbf4606aebf117f", + "libext2fs2t64": "3e6861fa8e2da8f7e290d39fff281900b4a8fc77eb782cb9ea8a62a5925f6035", + "libffi8": "c2ca491b413752cc292f3c691ae8a72ae6e58c0cb5d5ab1f7d326e2e8ecf900a", + "libflac12t64": "9123643ef5a5f9ce329ac36d40153ca55d0924bffae70c2ac6b2290dccb06368", + "libfontconfig1": "dc2ad90f418e2d1963b118ee168d446df21912c97747af3c3a4e4b2b8c7dc58b", + "libfreetype6": "618ff3a73adb2a006aecfa62745b34538527fc1e20fdc44a165a939825f3d457", + "libgbm1": "9a3873f5c354dd2e4971d5327600e192c743d545b0aeefa55e0c03cc5dc79048", + "libgcc-s1": "f728a16ab248b3bffc24dad6f23f97cfdcc30612b2ee76dbbfd259f94462d81b", + "libgcrypt20": "9543b80168e91cee1fccfc4f43cf1dc72f5ced13c54292012d4a21af981828d0", + "libgdbm-compat4t64": "6bcc06724ee3df1e9c24d9f103974e5f43245edd5666df48de218b57a025d475", + "libgdbm6t64": "94b207e744afc9b6a166d78242d1cc35523e7f33fe5f92c4909e10ae8fd6c150", + "libgirepository-2.0-0": "e24eaf633054ef3adabf1ac8b4f84b71fa97475e34bb612f295b1c50b3bb10b7", + "libgl1": "6d88bcd284d5f14d7fd9dd028fcd2207b29c10353a7bd8f47beff1c732c71ae3", + "libglib2.0-0t64": "b20fdd55059e5a2da93298bc139793fb713634f9e0f214eaf10a1b358577872b", + "libglvnd0": "f2fe6079ef6867e9247e7977cf712352a9710c44105ac27b72967e2eb288c6dd", + "libglx-mesa0": "db7bf26dad44d129164d3c6022add2d5968dc7578a021309acce3e1689032c16", + "libglx0": "65f795bb327a4c43f31b680fc4c6dcb584e72336d5e375fa3920956c82918271", + "libgmp10": "3e59433b7873d615979dd29651e817d571152d65970819a01a94502ee2533603", + "libgnutls30t64": "aa1193b3d4a28c21d55414929c8e878210f28d54615a13f86213205ec0e40e78", + "libgomp1": "7240b4aefdde349525e3a85eb79cd4292bdb2a38e4dc7c403c7e99d16ccf9914", + "libgpg-error0": "fbcf6837220f3ac49a086f91f3e53aca0c1d4ff6617bf290bda57b09cc71d8d4", + "libgprofng0": "b7ca91176e1cd05356253c7066c3775de362ebff2c6310e2cab1640ed505f768", + "libgraphite2-3": "6f1d7541f29245f515bc4bc213efb39a6f4fb141959021731886e32e69be6328", + "libgssapi-krb5-2": "f2a711a448dcc35ba44fa51bdf836f1a73319e51febf4016a83337dc4332fe85", + "libharfbuzz-cairo0": "d47525b5af6d35c714d1b300fc5e6084c9002c64a778db2c2242a65710eb72ea", + "libharfbuzz-gobject0": "330f92ef3c0ad19f139259238b1cb1d46872cadc4772ab2f460f58dd5a7a613f", + "libharfbuzz-icu0": "a67196541b115e1ceef2af13320a9a62346744def123d9ea57492a54e1ce127b", + "libharfbuzz-subset0": "ee0f4366ea37c14abf13c656eb976ee6af740cb72e1ec9d8e19623849db9a87d", + "libharfbuzz0b": "b6d3c52fc8b54d135b6cdf6f6c5cdd45f681eee4475578101c7a216d2b34e91d", + "libhogweed6t64": "ea127e99138368fd8e05f5b88001730269712a3f9d22bb2099c1bcab5c1eb065", + "libhwasan0": "c3d9ca1535a47e50c87fc5c7a63acbbea96721baf2f955a8cbe715f5c4d842e7", + "libicu74": "cbf081cd03085afee8ac47ca7b9e3a2cc591f36556f389eb9ec2789015b14b47", + "libidn2-0": "b2647c6fdf655ba1dcd8ee3ef89f48b34103a54d75e2c25aae06e41589e04f3b", + "libisl23": "1c84b0522462994e9abe290f6d7504a274a75336fb18ed9172f02dc96e7fc194", + "libitm1": "b8515b62a1f47864acfae314e3a5e4bfb5eafeb7b868c9c8be916b9fc1f59a3a", + "libjansson4": "8147cc2955febe49afa26a53841553e28492f9338fd119f2e87d26aeb8c5ad0a", + "libjpeg-turbo8": "dd349277b270a536c963379db8b907e17362fb8d7833bc1cb20b10fa1717a18f", + "libjsoncpp25": "bb45b45e09a7d80ae7c68689e3744007ca73a927d6f8f79c182a3624b29d2021", + "libk5crypto3": "4e5c9807c190e42238f2764bf114c893c6a805677f7b8570dc18a082f6d847df", + "libkeyutils1": "64e4b635dc4a6adb3fdb345155a406aa71dbb892c249162af1744d9cb9618a85", + "libkrb5-3": "97e0e692f653b50be231bc6cd0ed027633ebc87b09bb400a6066d78ad3e197c3", + "libkrb5support0": "4eb9ae55c091d0aa6da98d2c2ebe27bf5b90eb65f99f3f4dc53d8b4f90e5c221", + "libldap2": "6c105358665d3576880950e40e1c5a6665f8be64bfb7681801e5778c241e11a6", + "libllvm20": "86887d37534df5fc1ade21159aac9a23a3126a61dbd53a8e6c0a208e0124f89c", + "liblsan0": "b74c82aac49b18f5ab4164b93bec13023583f12bfa3f380b04d83c8e20895fea", + "liblz4-1": "432279f17e16d69dd33d61b47d6760860abe35861a438300b723bc70c98d0ea4", + "liblzma5": "2e05625599621565f76dc7e3d082cf10b2e1a68cdc7934df789365f6abd7bf94", + "liblzo2-2": "91309edcced3aa4d60226e4fe1e6a326781449da3a0aecd5b00c43ad89e2f85e", + "libmagic1t64": "01f0309deee7ab475d26e3dcdfce95d052e3d2289e0e123fef4f27d4a4b5335e", + "libmd0": "c699101d0e451fe87195f77558b97bf1c82e26bfd56d30afffaceba1d36c04e1", + "libmount1": "dcb19a89ce73ff8efe0d0ddd594999c876614287308181e25c54a97432a3c916", + "libmp3lame0": "5d281faa35b9577b064ab7df965f1dfe50c5816000d5e37016610abc1817bda8", + "libmpc3": "b6bb6b1a9be69e7244fd807e29a66b88c092411825bfe0718357fdf419d964dc", + "libmpfr6": "7af5c60843ac85237f4dadca3efe5e43003b9f5202ff7297fc1b10af83526de0", + "libmpg123-0t64": "12bcb71a47f216fb33a18f9348f597e6cc7aa6db26d00d8a15d6420e0fbbb375", + "libncursesw6": "88fb4149c27973fedb6bd68e2dbd5ac65261ff85e782f3fa9a7d8f33e7a5ae66", + "libnettle8t64": "3ee168f26845a2c5767010bd8588e1d0f954c110bc053f51344ed516b9557ef7", + "libnghttp2-14": "907d20edc95e95a317f222b08390325f73a62c8187929e4adb06c5ae40e26715", + "libnpth0t64": "6249ad84e79a44668a1767a7eb59cfdc589c3a61a70ec4e32c030f6c853e64ab", + "libogg0": "c47060fcbe4d56cf163c63359205a28bec145716f07ae8a06d282ea0ef7d8f90", + "libopus0": "4ab441a8a4107c3995fcd6d9cbb92f2b9e7512ce9811b1043808d7d16e5717af", + "libopusfile0": "2cb5d4b9b6e38978c73a6a8425e52233538794c8178eccfa6e6f6cc342066e03", + "libp11-kit0": "631a8e19cc8aa08d4ddcb8c3bdff177cda91972ef08c7226252e728d672b1c94", + "libpam0g": "497023656c7a7f7cc043f11bf3c690a88d24d2ae3f8b1715420680b729428931", + "libpciaccess0": "a4867af6acda9032f3a126cc4d5bf0424f72b673b782ec82f19b592a89537f52", + "libpcre2-16-0": "f90d230c8f657bd4ac5960095d9a64ca6a2bdba2bcacb46d312f28dc3384987d", + "libpcre2-32-0": "52e647ab7b01b4740154e2ac13845d5b94a9a23a427a7aa3f3885d760a391541", + "libpcre2-8-0": "3588e508abcc22c4015d186ed4514b1bfca8947051f9261f1b1ccddacc471195", + "libpcre2-posix3": "984de2e0e4b11a17c6edcc595b18fec328f935045dbbfe1aa408188737ea93d4", + "libperl5.38t64": "10971f7888a13c2750751ec63d54d7ed5f6a3ce95dabf05355f7085dba03443b", + "libpixman-1-0": "ae3c555a0881137957641c30804da2ff2450326cba8f1ac6a5470884697d29a7", + "libpkgconf3": "f3baa1b4e863773834e4387b07860771d22e7356dcadc2e91270425ba78b6966", + "libpng16-16t64": "7af7241c53102b77b6a6a95b5e0dd94b4bfe2a30cf893433a9019b9c6009b3d0", + "libproc2-0": "f31d8f4659592a647e62abada642813b3438366007665e348be81f68e054ffd0", + "libpsl5t64": "7a0be045328d3f812cfb0f1ba6d673a8865f68eade8ce9a5b0685b7369884c7e", + "libpulse-mainloop-glib0": "ceac717891e9332c3f898a9dcccbf332a86f297a18025de6c719df290f6bc078", + "libpulse0": "8b7c7112b0dc83af19047f86e64e4d0f3c18006c2e64168f71a9f0587972d89a", + "libquadmath0": "d54a5677f588fcc31e03aca6ac6fab328df3acbb6e2afa0d9aab94167b62d20c", + "libreadline8t64": "143dbfa4f1b1adfd405558b21092a756b6a3c12621f556100be53c4ce906447e", + "librhash0": "e01dea37774f0b01649a44fa11920a143c8588cc3db59c30b56ae13e09aa607d", + "librtmp1": "e22ffe25d5f7ae89fb2dcef7c9678bbf4c3ca682bbbdb9bc176e8b38d1e36de0", + "libsasl2-2": "3a5de4a34b410346f6b43673fcf6f9194c854ccdd6cca7eacc5f71be844c346a", + "libseccomp2": "5d8820940ea2899e94c90a41099f3b6f86f30e68ade9d206d407ea226104c495", + "libselinux1": "e3aaf9e7e50a1af5d367bb33775a3ec09d666e5724d6b82894cdf454ed8c928a", + "libsemanage2": "e5eed31e4ace91747246bc5c3d792b60bf3f139ab5171e369b2a4b7eded492fb", + "libsensors5": "c5d1a9e20d0529dc86048a03c74172a83b9248ef3ef1173cf980a34d8016c216", + "libsepol2": "9f064d97c84ce1392cea676644bffd22cb724e41dba65e994154b48805a52151", + "libsframe1": "6f800089fbbd12996a1ebd397ec65f281cfce69c4b74adc6961322b841843023", + "libsmartcols1": "e09df46fd4f8b8c3a7e87c30e89897966d944ffb2310de08ebba2c98c29c2fff", + "libsndfile1": "243f2d454ba13e5c7ce8df09e4c8089bdc5fcf4ee0cdc61bc0a271d4043a1aa4", + "libsqlite3-0": "f95f1da4f3840422abc7e84a21a0782949a61416aadff1380a5f43c0b699c150", + "libss2": "b4a867e3a096dec46e88fd47c1cb061f366640261332a13015e6d3d73ece509d", + "libssh-4": "b795652d518cb520c8b0e8227361086c6ac597b8c2dcbcf4b0249786926e3f77", + "libssl3t64": "3c198c991926f3068ac7c3fe323f519852cc9d60d6e0ac75e21541ca8a13a291", + "libstdc++6": "397fc781dec283fec995f5c1c167db489152f2f975854468fa46b90d81db0a95", + "libsystemd0": "a339e465e735b7fdc378d46a9dd7cf43bb74e56c0197ead9315a1d1f9eec6eab", + "libtasn1-6": "8d74225bc72b01e29f743f9e5bfb9bbad3c5d4b66daad19cd22b481c6ff75e41", + "libtinfo6": "a1b30a50fa5d4f68fa9df05b66efc4a452b88e3aca45ba343010d46a951bda70", + "libtsan2": "ff1563de7265d412652f195491a2b3dcd1d77e0dc800874c40ac05e040c6fd96", + "libubsan1": "3f57023a2c0789b59a8ff650e5c252223f9fb5d6976bed08b9e269566221695d", + "libudev1": "4ca564e1d19b3f535f0153b110bb1a9edec08d5fe9b60e64a1f4efa2ecb24217", + "libunistring5": "a04f9fd3a0b25036b5fa4c4ddf0adf26a1e797a702aa422b6e78fd50323a5bf9", + "libuuid1": "ca7c5bc050251d4d483a65e0602ef3042ec36e25bd6f089c3dfa4cdd71e0cd70", + "libuv1t64": "46994a29cf03e85fa9fd22761c608440194f36a8888269980a2689a1fad90926", + "libvorbis0a": "3cb3828be89b962fb6ad73f5326c96fb3f6815ca5f5c8c6c59a0e00ff6befbdd", + "libvorbisenc2": "36dec1301a38e59ff707ca35d7bb804fcb7c3088a426dfe23da240e3872f0489", + "libvulkan1": "6c6292f9edb4990867601a983aaaf1c1ef7a360cee9a6bbbb5aae3b18100ea2b", + "libwayland-client0": "b46edae28f277f451fb45c00be864a93a47e1a17f62ef36e52e1ec5fa9122c47", + "libwayland-cursor0": "25f4af533decffd3f5a5328e3a9fa1e8d52fc01d6de8af560d062524423fd2ad", + "libwayland-egl1": "fdc44a62b966c5ad295b0649801a9e46069eb45995f3e0d0a2b24cf0b606518c", + "libwayland-server0": "eaacd8993203c731e290a8c5203b74fde628e5c9bda323336bbca00cd949ff60", + "libx11-6": "3b6c5d53243693102d8b92f10ab23334e1c03903bcb54b9cb612a8da8d70d26c", + "libx11-xcb1": "b0a47f4df66dfeceb155ca905e52721c4e8e478295703395512c912879ff59f9", + "libxau6": "b08560495e9865fb5ccc3ed4a6456aed824a68c84cf387842a43f1ccdee521ba", + "libxcb-dri3-0": "2c516a4b3ceb3798ef65c7539fa506e24961e1769da06a8085471b9b601aa576", + "libxcb-glx0": "770dd89d30e3f8513bca6d8578a8229f40d2b122ca9af8d184e73237ef20e48f", + "libxcb-present0": "520d0535bfbb70940282f77037f674940a404d320acfc344ab9318331bd268ec", + "libxcb-randr0": "fe7741b179e3cb962a2fe9de5c60c6c61229ceaa37ee7ff4dac8b9382c6d1c17", + "libxcb-render0": "d376037ebfb5defb15b4ccd3068ddb89a2779806876a7526aacda78cbb598694", + "libxcb-shm0": "6dc02f4ae14e718d1974a374a85ea0be3910b4a7ec5aed7cc2c328d0c19aafdc", + "libxcb-sync1": "f94a81fca2352c783e213200fd7b4251edc39962da67e608d8a603758a1a6ca4", + "libxcb-xfixes0": "0f60c5d7ce0fb4e4c26993a76920196200e9d6a93efe4e93334b58eda2c751a6", + "libxcb1": "3b73f2fdcf307bad8a85c914bf3b7275586f6bcbf8f4982a860a8088a1431ef3", + "libxcursor1": "f1baf6baaef03d5d0813acade871bef4a7b4eb3c8261a51d2ee8f7b6179b611d", + "libxdmcp6": "27b987a7b785bb9a3fc669df62bc73eadf381d932fe80b70b7381ae131c8c9c0", + "libxext6": "be501b991904dca96fc372cc1743d9e555cb4d05a74fbf9cf83ac18f948cdb75", + "libxfixes3": "8fcebded8ab2d80cce38740dcb7c14dc414bfde277e7876e805997ceb47363bc", + "libxi6": "92c506cc862db25f6125aaba1bd34792b33ab7281879b26fc234995cdc4a5661", + "libxkbcommon0": "c762eae806d3de5a81b33c8101991c5af4c8bfcb8b8c32833d9394ecb3b09776", + "libxml2": "44bd245da8aa19aea0840e3a2712fd7103286ccd86bc03e68c89f25aa62cc884", + "libxrandr2": "d3ef8a450c2a4bd5a89e718416f234a63a678f04cbb8e5e323bebf2b1ab8feaa", + "libxrender1": "bd9353d194587401350a4d70c48927b81f94dd54f2bca210b38dd58aa669bdfe", + "libxshmfence1": "c20311f72ad76273e58ce338062f0685be763ac802563025c3edcc80ba95a9b2", + "libxss1": "bb7df54ba79c665d6dcd168c907e1e5f0897f85aa65ed4361cb12f7566ac4827", + "libxtst6": "048f532b34b00ad0aae1070be113617bdcd620244a5b86b8c6a8be38963a5313", + "libxxf86vm1": "6e8eaccc10c3d1f7b83fd29ec0b0af40bf1a9fb660b90ecf9716ae4a3c02fffc", + "libxxhash0": "85dfc11ca288bfa872a6565f8843fa016d15a92c4d840483ef3f9cb3ca271a4b", + "libzstd1": "ea4bfe9388aed3be2835c5637f8114072713d9fcff6b284e15a9eb3d9868bbbc", + "mesa-libgallium": "8b60ee4638cf3a886313e6bd0ca9565f85c5c3525105dee680be8af5f3f11e4f", + "zlib1g": "90a7a9ba7a51a49b6a70f01b81dfcb8cfa5f23659daf38948d59308ad0b0165e", + "libasound2-data": "47a678eb76579653d645b8b33f02bcf046ff032d40e0b69d163eff7ec5e60561" + }, + "elf_inspection_exceptions": { + "libglib-2.0.so.0": { + "package": "libglib2.0-0t64", + "sha256": "4c4a843991d23faad741a33b99bf049bee88b7b0827956ea1cdc563816a5332b", + "soname": "libglib-2.0.so.0", + "version": "2.80.0-6ubuntu3.9", + "weak_undefined_symbols": [ + "__lsan_enable", + "__lsan_ignore_object" + ] + }, + "libpulsecommon-16.1.so": { + "build_paths": [ + "/build/pulseaudio-zYFgiu/pulseaudio-16.1+dfsg1/obj-x86_64-linux-gnu" + ], + "package": "libpulse0", + "sha256": "00561562aaf1e87c03ca88acdbfb1b33c209e777ffb2fc422ea03146abbe49f4", + "soname": "libpulsecommon-16.1.so", + "version": "1:16.1+dfsg1-2ubuntu10.1" + } + } +} diff --git a/tools/ci/appimage/historical/runtime-probe.c b/tools/ci/appimage/historical/runtime-probe.c new file mode 100644 index 000000000..57afe0322 --- /dev/null +++ b/tools/ci/appimage/historical/runtime-probe.c @@ -0,0 +1,104 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later + * Trusted qualification helper, deliberately outside the distributed AppImage. + */ +#include +#include +#include +#include +#include +#include +#include + +static size_t discard(char *data, size_t size, size_t count, void *context) { + (void)data; + (void)context; + return size * count; +} + +int main(int argc, char **argv) { + if (argc != 3) { + fprintf(stderr, "usage: runtime-probe --assets APPDIR | --curl URL\n"); + return 2; + } + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + return 1; + } + const curl_version_info_data *version = curl_version_info(CURLVERSION_NOW); + if (strcmp(version->version, "8.18.0") != 0 || version->ares == NULL || + strcmp(version->ares, "1.34.6") != 0 || + !(version->features & CURL_VERSION_ASYNCHDNS) || + version->ssl_version == NULL || strstr(version->ssl_version, "OpenSSL/3.5.5") == NULL) { + fprintf(stderr, "wrong curl/c-ares/OpenSSL runtime feature contract\n"); + return 1; + } + if (strcmp(argv[1], "--curl") == 0) { + CURL *handle = curl_easy_init(); + if (handle == NULL) { + return 1; + } + curl_easy_setopt(handle, CURLOPT_URL, argv[2]); + curl_easy_setopt(handle, CURLOPT_NOPROXY, "*"); + curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 5L); + curl_easy_setopt(handle, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, discard); + CURLcode status = curl_easy_perform(handle); + if (status != CURLE_OK) { + fprintf(stderr, "TLS request failed: %s\n", curl_easy_strerror(status)); + } + curl_easy_cleanup(handle); + curl_global_cleanup(); + return status == CURLE_OK ? 0 : 1; + } + if (strcmp(argv[1], "--assets") != 0 || !SDL_Init(0) || !MIX_Init() || !TTF_Init()) { + fprintf(stderr, "dependency initialization failed: %s\n", SDL_GetError()); + return 1; + } + static const char *const expected[] = { + "WAV", "STBVORBIS", "OPUS", "VOC", "AIFF", "AU", "DRMP3", "SINEWAVE", "RAW" + }; + int count = MIX_GetNumAudioDecoders(); + if (count != (int)(sizeof(expected) / sizeof(expected[0]))) { + fprintf(stderr, "unexpected audio decoder count: %d\n", count); + return 1; + } + for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); i++) { + unsigned int matches = 0; + for (int j = 0; j < count; j++) { + const char *actual = MIX_GetAudioDecoder(j); + if (actual != NULL && strcmp(expected[i], actual) == 0) { + matches++; + } + } + if (matches != 1) { + fprintf(stderr, "missing or duplicate decoder: %s\n", expected[i]); + return 1; + } + } + char path[4096]; + if (snprintf(path, sizeof(path), "%s/atrinik.png", argv[2]) >= (int)sizeof(path)) { + return 1; + } + SDL_Surface *image = IMG_Load(path); + if (image == NULL) { + fprintf(stderr, "PNG asset decode failed: %s\n", SDL_GetError()); + return 1; + } + SDL_DestroySurface(image); + if (snprintf(path, sizeof(path), "%s/usr/share/games/atrinik/fonts/arial.ttf", argv[2]) >= + (int)sizeof(path)) { + return 1; + } + TTF_Font *font = TTF_OpenFont(path, 16.0f); + if (font == NULL) { + fprintf(stderr, "font asset decode failed: %s\n", SDL_GetError()); + return 1; + } + TTF_CloseFont(font); + TTF_Quit(); + MIX_Quit(); + SDL_Quit(); + curl_global_cleanup(); + puts("exact mixer decoders, PNG, font, c-ares and OpenSSL runtime features passed"); + return 0; +} diff --git a/tools/ci/appimage/prepare.py b/tools/ci/appimage/prepare.py index a6ec334b8..b8187ce02 100644 --- a/tools/ci/appimage/prepare.py +++ b/tools/ci/appimage/prepare.py @@ -9,11 +9,23 @@ from pathlib import Path import shutil import stat +import sys import tempfile import urllib.request -LLVM_NOTICE_PATHS = tuple('usr/share/doc/atrinik/licenses/llvm/' + name + '-LICENSE.TXT' - for name in ('llvm', 'clang', 'clang-tools-extra', 'lld', 'compiler-rt', 'libunwind')) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'release')) +from resolve_compiler_image import HISTORICAL_APPIMAGE_FILES, HISTORICAL_IMAGE + + +def historical_recipe(): + root = Path(__file__).resolve().parent / 'historical' + if root.is_symlink() or not root.is_dir(): + raise ValueError('retained historical producer must be a regular directory') + for name, expected in HISTORICAL_APPIMAGE_FILES.items(): + path = root / name + if path.is_symlink() or not path.is_file() or digest(path) != expected: + raise ValueError(f'retained historical producer integrity mismatch: {name}') + return root def digest(path): @@ -68,18 +80,9 @@ def pairs(items): if not isinstance(source_lock, dict): raise ValueError('source packaging lock requires a compiler descriptor') if 'compiler' not in source_lock: - # Pre-LLVM schema-3 releases retain their publication lock verbatim. - # The execution recipe remains the approved current LLVM recipe; - # only the compiler record and its six newly required notice entries - # may be absent from the historical source contract. - if trusted_lock['compiler'].get('image') is not None and image != trusted_lock['compiler']['image']: - raise ValueError('--compiler-image differs from the trusted packaging lock') - if any(trusted_lock['notices'].count(path) != 1 for path in LLVM_NOTICE_PATHS): - raise ValueError('trusted SDK lacks the complete LLVM notice contract') - legacy = {key: value for key, value in trusted_lock.items() if key != 'compiler'} - legacy['notices'] = [path for path in trusted_lock['notices'] if path not in LLVM_NOTICE_PATHS] - if json.dumps(source_lock, sort_keys=True) != json.dumps(legacy, sort_keys=True): + if image != HISTORICAL_IMAGE or hashlib.sha256(data).hexdigest() != HISTORICAL_APPIMAGE_FILES['packaging.lock.json']: raise ValueError('source packaging lock differs from the supported pre-LLVM recipe') + historical_recipe() return source_lock, data if not isinstance(source_lock['compiler'], dict): raise ValueError('source packaging lock requires a compiler descriptor') @@ -105,9 +108,10 @@ def main(): trusted_lock = json.loads(original_lock) try: publication_lock, selected_lock_bytes = select_packaging_lock(trusted_lock, args.compiler_image, args.source_packaging_lock) + recipe_root = root if 'compiler' in publication_lock else historical_recipe() except (OSError, ValueError, UnicodeError) as error: parser.error(str(error)) - lock = publication_lock if 'compiler' in publication_lock else trusted_lock + lock = publication_lock if args.output.resolve() == root: parser.error('--output must be a separate generated context directory') args.output.mkdir(parents=True, exist_ok=True) @@ -124,17 +128,23 @@ def main(): jobs.append((entry, args.output / 'tools' / entry['name'])) with ThreadPoolExecutor(max_workers=4) as pool: list(pool.map(lambda job: download(job[0], job[1], cache), jobs)) - (args.output / 'dependency-inputs.lock.json').write_text(json.dumps({k: lock[k] for k in ('sources', 'tools', 'runtime', 'license_inputs', 'runtime_sources', 'base_image', 'compiler')}, sort_keys=True) + '\n') + input_keys = ('sources', 'tools', 'runtime', 'license_inputs', 'runtime_sources', 'base_image') + if 'compiler' in lock: + input_keys += ('compiler',) + (args.output / 'dependency-inputs.lock.json').write_text(json.dumps({k: lock[k] for k in input_keys}, sort_keys=True) + '\n') (args.output / 'compiler-image.txt').write_text(args.compiler_image + '\n') for name in ('runtime-probe.c', 'graphical-probe.c'): - probe = root / name + probe = recipe_root / name if probe.is_file() and probe.resolve() != (args.output / probe.name).resolve(): shutil.copyfile(probe, args.output / probe.name) - for name in ('build-dependencies.sh', 'compiler.py'): - if (root / name).resolve() != (args.output / name).resolve(): - shutil.copyfile(root / name, args.output / name) + for name in ('build-dependencies.sh',) + (('compiler.py',) if 'compiler' in lock else ()): + if (recipe_root / name).resolve() != (args.output / name).resolve(): + shutil.copyfile(recipe_root / name, args.output / name) (args.output / 'packaging.lock.json').write_bytes(selected_lock_bytes if selected_lock_bytes is not None else original_lock.encode()) - (args.output / 'Dockerfile').write_text((root / 'Dockerfile').read_text().replace('FROM ${ATRINIK_COMPILER_IMAGE} AS compiler', f'FROM {args.compiler_image} AS compiler')) + dockerfile = (recipe_root / 'Dockerfile').read_bytes() + if 'compiler' in lock: + dockerfile = dockerfile.replace(b'FROM ${ATRINIK_COMPILER_IMAGE} AS compiler', f'FROM {args.compiler_image} AS compiler'.encode()) + (args.output / 'Dockerfile').write_bytes(dockerfile) if __name__ == '__main__': diff --git a/tools/ci/appimage/test_pre_llvm_recovery.py b/tools/ci/appimage/test_pre_llvm_recovery.py index e13b9b79f..bbc39871e 100644 --- a/tools/ci/appimage/test_pre_llvm_recovery.py +++ b/tools/ci/appimage/test_pre_llvm_recovery.py @@ -1,6 +1,6 @@ # Copyright 2026 The Atrinik Project # SPDX-License-Identifier: GPL-2.0-or-later -"""Exercise the pinned pre-LLVM assembler's lock and complete-notice boundary.""" +"""Exercise the pinned pre-LLVM assembler with its original GCC producer lock.""" import importlib.util import json from pathlib import Path @@ -26,7 +26,7 @@ class StopAfterCompleteNotices(Exception): class HistoricalAssemblerTests(unittest.TestCase): - def test_prepared_historical_lock_passes_actual_assembler_and_keeps_llvm_notices(self): + def test_prepared_gcc_lock_passes_actual_historical_assembler(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / 'source' @@ -36,8 +36,7 @@ def test_prepared_historical_lock_passes_actual_assembler_and_keeps_llvm_notices path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(historical_blob(name)) historical_lock = source / 'tools/ci/appimage/packaging.lock.json' - approved_lock = json.loads(Path(__file__).with_name('packaging.lock.json').read_text()) - image = approved_lock['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 + image = prepare.HISTORICAL_IMAGE context = root / 'context' def materialize(entry, destination, cache): destination.parent.mkdir(parents=True, exist_ok=True) @@ -48,7 +47,7 @@ def materialize(entry, destination, cache): prepare.main() self.assertEqual((context / 'packaging.lock.json').read_bytes(), historical_lock.read_bytes()) execution = json.loads((context / 'dependency-inputs.lock.json').read_text()) - self.assertEqual(execution['compiler'], approved_lock['compiler']) + self.assertNotIn('compiler', execution) prefix = root / 'prefix' prefix.mkdir() @@ -57,10 +56,7 @@ def materialize(entry, destination, cache): shutil.copyfile('/usr/bin/true', prefix / 'bin/openssl') (prefix / 'lib/ossl-modules').mkdir(parents=True) shutil.copyfile('/usr/bin/true', prefix / 'lib/ossl-modules/legacy.so') - notices = prefix / 'share/licenses/llvm' - notices.mkdir(parents=True) - for name in approved_lock['compiler']['notices']: - (notices / name).write_bytes((name + '\nComplete notice fixture\n').encode() * 100) + (prefix / 'share/licenses').mkdir(parents=True) appdir = root / 'AppDir' (appdir / 'usr/share/doc/atrinik').mkdir(parents=True) (appdir / 'usr/bin').mkdir(parents=True) @@ -76,11 +72,9 @@ def copy_until_notices(source_path, destination, *args, **kwargs): with patch.object(assembler.shutil, 'copytree', side_effect=copy_until_notices): with self.assertRaises(StopAfterCompleteNotices): assembler.assemble(appdir, prefix, '5.39.0', 'a' * 40) - # Stop before the platform library closure, after the real historical - # assembler passes its exact SHA gate and copies every LLVM notice. - for name in approved_lock['compiler']['notices']: - self.assertEqual((appdir / 'usr/share/doc/atrinik/licenses/llvm' / name).read_bytes(), - (notices / name).read_bytes()) + # The genuine old assembler passed its exact lock gate and reached + # common licenses. GCC recovery must not inject LLVM compiler notices. + self.assertFalse((appdir / 'usr/share/doc/atrinik/licenses/llvm').exists()) (prefix / 'packaging.lock.json').write_bytes(Path(__file__).with_name('packaging.lock.json').read_bytes()) with self.assertRaisesRegex(ValueError, 'packaging lock differs from source lock'): assembler.assemble(appdir, prefix, '5.39.0', 'a' * 40) diff --git a/tools/ci/appimage/test_prepare.py b/tools/ci/appimage/test_prepare.py index e47045a48..1792abb0c 100644 --- a/tools/ci/appimage/test_prepare.py +++ b/tools/ci/appimage/test_prepare.py @@ -73,55 +73,45 @@ def test_historical_default_argv_and_present_missing_image_fail_closed(self): with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): prepare.select_packaging_lock(approved_lock, image, path) - def test_pre_llvm_projection_preserves_source_bytes_and_current_approved_image(self): - approved_lock = self.approved_lock_fixture() - image = approved_lock['compiler']['image'] - legacy = {key: value for key, value in approved_lock.items() if key != 'compiler'} - legacy['notices'] = [path for path in approved_lock['notices'] if path not in prepare.LLVM_NOTICE_PATHS] - raw = (json.dumps(legacy, indent=7) + '\n\n').encode() + def test_pre_llvm_recipe_requires_exact_audited_bytes_and_original_image(self): + approved = self.approved_lock_fixture() + raw = (Path(__file__).with_name('historical') / 'packaging.lock.json').read_bytes() + legacy = json.loads(raw) with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'legacy-lock.json' path.write_bytes(raw) - self.assertEqual(prepare.select_packaging_lock(approved_lock, image, path), (legacy, raw)) - with self.assertRaisesRegex(ValueError, 'trusted packaging lock'): - prepare.select_packaging_lock(approved_lock, image.replace('a' * 64, 'b' * 64), path) - changed = copy.deepcopy(legacy) - changed['sources'][0]['url'] = 'https://example.invalid/private-source' - path.write_text(json.dumps(changed)) + self.assertEqual(prepare.select_packaging_lock(approved, prepare.HISTORICAL_IMAGE, path), (legacy, raw)) with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): - prepare.select_packaging_lock(approved_lock, image, path) - changed = copy.deepcopy(legacy) - changed['compiler'] = None - path.write_text(json.dumps(changed)) - with self.assertRaisesRegex(ValueError, 'requires a compiler descriptor'): - prepare.select_packaging_lock(approved_lock, image, path) - changed = copy.deepcopy(legacy) - changed['notices'].pop() - path.write_text(json.dumps(changed)) - with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): - prepare.select_packaging_lock(approved_lock, image, path) + prepare.select_packaging_lock(approved, approved['compiler']['image'], path) + for changed in (raw + b'\n', json.dumps(dict(legacy, notices=[])).encode()): + path.write_bytes(changed) + with self.assertRaisesRegex(ValueError, 'supported pre-LLVM recipe'): + prepare.select_packaging_lock(approved, prepare.HISTORICAL_IMAGE, path) + path.write_bytes(raw) + with patch.object(prepare, 'digest', return_value='0' * 64): + with self.assertRaisesRegex(ValueError, 'historical producer integrity mismatch'): + prepare.select_packaging_lock(approved, prepare.HISTORICAL_IMAGE, path) - def test_pre_llvm_context_keeps_execution_compiler_and_all_notice_installation(self): + def test_pre_llvm_context_retains_gcc_recipe_and_original_six_key_inputs(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - approved_lock = json.loads((Path(__file__).with_name('packaging.lock.json')).read_text()) - image = approved_lock['compiler'].get('image') or 'ghcr.io/atrinik/classic-build:1.2.3@sha256:' + 'a' * 64 - legacy = {key: value for key, value in approved_lock.items() if key != 'compiler'} - legacy['notices'] = [path for path in approved_lock['notices'] if path not in prepare.LLVM_NOTICE_PATHS] - raw = (json.dumps(legacy, sort_keys=True, indent=7) + '\n\n').encode() - path = root / 'legacy-lock.json' - path.write_bytes(raw) + historical = Path(__file__).with_name('historical') + path = historical / 'packaging.lock.json' output = root / 'context' def materialize(entry, destination, cache): destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(b'fixture') - args = ['prepare.py', '--output', str(output), '--compiler-image', image, + args = ['prepare.py', '--output', str(output), '--compiler-image', prepare.HISTORICAL_IMAGE, '--source-packaging-lock', str(path)] with patch.object(sys, 'argv', args), patch.object(prepare, 'download', side_effect=materialize): prepare.main() - self.assertEqual((output / 'packaging.lock.json').read_bytes(), raw) + for name in prepare.HISTORICAL_APPIMAGE_FILES: + self.assertEqual((output / name).read_bytes(), (historical / name).read_bytes()) inputs = json.loads((output / 'dependency-inputs.lock.json').read_text()) - self.assertEqual(inputs['compiler'], approved_lock['compiler']) + self.assertEqual(set(inputs), {'sources', 'tools', 'runtime', 'license_inputs', 'runtime_sources', 'base_image'}) + self.assertFalse((output / 'compiler.py').exists()) + self.assertIn(b'build-essential', (output / 'Dockerfile').read_bytes()) + self.assertIn(b' cc -Wall -Wextra -Werror', (output / 'Dockerfile').read_bytes()) def test_source_lock_paths_shapes_and_duplicate_keys_fail_before_materialization(self): approved_lock = self.approved_lock_fixture() diff --git a/tools/release/resolve_compiler_image.py b/tools/release/resolve_compiler_image.py index 5c6069a04..e0308cb47 100644 --- a/tools/release/resolve_compiler_image.py +++ b/tools/release/resolve_compiler_image.py @@ -6,6 +6,8 @@ from __future__ import annotations import argparse +import ast +import hashlib import json from pathlib import Path import re @@ -14,6 +16,68 @@ IMAGE = re.compile(r"ghcr\.io/atrinik/classic-build:[0-9]+\.[0-9]+\.[0-9]+@sha256:[0-9a-f]{64}") LOCK = "tools/ci/appimage/packaging.lock.json" +HISTORICAL_SOURCE_LIMIT = "fd4943252ac872fce2995c286b10a0c6d249aba3" +HISTORICAL_IMAGE = "ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2" +HISTORICAL_APPIMAGE_FILES = { + "packaging.lock.json": "8a98124a11dea9aef6984ee7119c874d991fc18f14fbf1037d58a6345171e7e7", + "Dockerfile": "f170e46418702c9bad138b48545900f33a53e92c367b71e75a53d83f10936e60", + "build-dependencies.sh": "957bd97738c11bd4d374fdea3212a16e4aca2ae1614ea7a01e758faf712846fd", + "runtime-probe.c": "e2ea3d6589160823ae513f34354abd72807df54c0edd1a1390de42d4d6c52f74", + "graphical-probe.c": "299b4da4d3289c6aaad895cf82a0c83f3ccb1859c4838796ef5627804b6d754d", +} +HISTORICAL_DEBIAN_SHA256 = "9ff6f7e13e736b7ee68e42bf0fd19107143460c8784401525e984222ef845323" +HISTORICAL_SCHEMA1_SOURCE = "34bbad5c3f4d9b303c17aec5e2c75b3550ec8983" +HISTORICAL_SCHEMA1_FILES = { + ".github/workflows/build-release-candidate.yml": "8497f85c816911f81ca541b4950843c593b961f58462bef24f533c63beb44f90", + "tools/release/finalize_artifacts.py": "13990807db023fb69167d1ca720f80538f634fcea8b319f32f6ba73e5b082d1c", + "tools/ci/check_installed_library.sh": "82474e87b1d287b0b3089546e80037ec32c5825db216807e81ae09584f43e0d2", +} + + +def git_blob(root: Path, revision: str, path: str) -> bytes: + entry = subprocess.check_output(["git", "-C", str(root), "ls-tree", revision, "--", path], timeout=30) + fields = entry.decode().split() + if len(fields) < 2 or fields[0] not in ("100644", "100755") or fields[1] != "blob": + raise ValueError(f"historical producer input must be a regular Git blob: {path}") + return subprocess.check_output(["git", "-C", str(root), "show", revision + ":" + path], timeout=30) + + +def validate_historical_source(root: Path, revision: str) -> int: + """Accept only audited pre-LLVM history and its original producer contracts.""" + source_lock_bytes(root, revision, HISTORICAL_IMAGE) + ancestry = subprocess.run(["git", "-C", str(root), "merge-base", "--is-ancestor", + revision, HISTORICAL_SOURCE_LIMIT], capture_output=True, timeout=30) + if ancestry.returncode != 0: + raise ValueError("source without a compiler descriptor is outside approved historical history") + workflow = git_blob(root, revision, ".github/workflows/build-release-candidate.yml").decode() + images = re.findall(r"^ CLASSIC_LINUX_IMAGE: (\S+)\s*$", workflow, re.MULTILINE) + if images != [HISTORICAL_IMAGE]: + raise ValueError("historical source does not select the approved original Linux producer") + if revision == HISTORICAL_SCHEMA1_SOURCE: + for path, expected in HISTORICAL_SCHEMA1_FILES.items(): + if hashlib.sha256(git_blob(root, revision, path)).hexdigest() != expected: + raise ValueError(f"historical schema-1 producer input changed: {path}") + entries = subprocess.check_output(["git", "-C", str(root), "ls-tree", revision, "--", + LOCK, "tools/ci/debian-client/Dockerfile"], timeout=30) + if entries: + raise ValueError("historical schema-1 source unexpectedly contains a Linux client package recipe") + return 1 + finalizer = ast.parse(git_blob(root, revision, "tools/release/finalize_artifacts.py")) + schemas = [node.value.value for node in finalizer.body + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) + and any(isinstance(target, ast.Name) and target.id == "RELEASE_ARTIFACT_SCHEMA" + for target in node.targets)] + if len(schemas) != 1 or type(schemas[0]) is not int or schemas[0] not in (2, 3): + raise ValueError("source without a compiler descriptor has no approved historical release schema") + schema = schemas[0] + inputs = ({"tools/ci/appimage/" + name: sha for name, sha in HISTORICAL_APPIMAGE_FILES.items()} + if schema == 3 else {"tools/ci/debian-client/Dockerfile": HISTORICAL_DEBIAN_SHA256}) + for path, expected in inputs.items(): + if hashlib.sha256(git_blob(root, revision, path)).hexdigest() != expected: + raise ValueError(f"historical source differs from the approved original producer: {path}") + if schema == 2 and source_lock_bytes(root, revision, HISTORICAL_IMAGE) is not None: + raise ValueError("historical schema-2 source unexpectedly contains an AppImage lock") + return schema def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: @@ -37,7 +101,7 @@ def git(*arguments: str) -> bytes: raise ValueError("release revision is not the selected commit") entry = git("ls-tree", revision, "--", LOCK) if not entry: - # Pre-AppImage source has no compiler descriptor to constrain. + # Descriptor absence requires a separate historical producer check. return None fields = entry.decode().split() if fields[:2] != ["100644", "blob"]: @@ -66,7 +130,8 @@ def resolve_descriptor(root: Path, revision: str, current_image: str) -> dict[st def resolve_image(root: Path, revision: str, current_image: str, *, source_finalizer: bool = False) -> str | None: compiler = resolve_descriptor(root, revision, current_image) if compiler is None: - return None if source_finalizer else current_image + validate_historical_source(root, revision) + return None if source_finalizer else HISTORICAL_IMAGE return str(compiler["image"]) diff --git a/tools/tests/test_appimage_workflow.py b/tools/tests/test_appimage_workflow.py index e5452a385..99e308890 100644 --- a/tools/tests/test_appimage_workflow.py +++ b/tools/tests/test_appimage_workflow.py @@ -216,14 +216,14 @@ def test_preparation_captures_actual_builder_inventory_and_lock(self) -> None: resolver = workspace / 'tools/release' resolver.mkdir(parents=True) shutil.copy2(ROOT / 'tools/release/resolve_compiler_image.py', resolver) - (trusted / 'prepare.py').write_text("""import argparse, pathlib + (trusted / 'prepare.py').write_text("""import argparse, json, pathlib parser = argparse.ArgumentParser() parser.add_argument('--output', type=pathlib.Path, required=True) parser.add_argument('--compiler-image', required=True) parser.add_argument('--source-packaging-lock', type=pathlib.Path) args = parser.parse_args() assert args.source_packaging_lock is not None -assert args.source_packaging_lock.read_bytes() == b'{}' +assert json.loads(args.source_packaging_lock.read_bytes()) == {'compiler': {'image': 'ghcr.io/atrinik/classic-build:1.16.0@sha256:' + 'a' * 64}} out = args.output out.mkdir(parents=True) (out / 'packaging.lock.json').write_text('{"fixture": "locked inputs"}\\n') @@ -245,7 +245,7 @@ def test_preparation_captures_actual_builder_inventory_and_lock(self) -> None: candidate = workspace / 'candidate-source/tools/ci/appimage' candidate.mkdir(parents=True) (candidate / 'prepare.py').write_text('raise RuntimeError("candidate executed")') - (candidate / 'packaging.lock.json').write_text('{}') + (candidate / 'packaging.lock.json').write_text(json.dumps({'compiler': {'image': 'ghcr.io/atrinik/classic-build:1.16.0@sha256:' + 'a' * 64}})) source = workspace / 'candidate-source' for arguments in (['init', '--quiet'], ['add', '.'], ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', diff --git a/tools/tests/test_compiler_image.py b/tools/tests/test_compiler_image.py index bc637e718..b605c794e 100644 --- a/tools/tests/test_compiler_image.py +++ b/tools/tests/test_compiler_image.py @@ -10,7 +10,7 @@ import textwrap import unittest -from tools.release.resolve_compiler_image import LOCK, resolve_descriptor, resolve_image +from tools.release.resolve_compiler_image import HISTORICAL_IMAGE, HISTORICAL_SOURCE_LIMIT, LOCK, resolve_descriptor, resolve_image OLD_IMAGE = "ghcr.io/atrinik/classic-build:2.0.0@sha256:" + "a" * 64 @@ -62,16 +62,11 @@ def test_preparation_descriptor_is_exact_tagged_identity_data(self) -> None: ["git", "-C", str(self.root), "show", revision + ":" + LOCK])) self.assertEqual(json.loads(output.read_text())["compiler"], compiler) historical = self.commit({}) - subprocess.run(["python3", str(resolver), "--source-root", str(self.root), - "--revision", historical, "--current-image", CURRENT_IMAGE, - "--source-packaging-lock-output", str(output)], check=True, capture_output=True) - self.assertEqual(output.read_bytes(), b"{}") - self.git("rm", LOCK) - self.git("-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "no lock") - subprocess.run(["python3", str(resolver), "--source-root", str(self.root), - "--revision", self.git("rev-parse", "HEAD"), "--current-image", CURRENT_IMAGE, - "--source-packaging-lock-output", str(output)], check=True, capture_output=True) - self.assertEqual(output.read_bytes(), b"") + rejected = subprocess.run(["python3", str(resolver), "--source-root", str(self.root), + "--revision", historical, "--current-image", CURRENT_IMAGE, + "--source-packaging-lock-output", str(output)], capture_output=True) + self.assertNotEqual(rejected.returncode, 0) + self.assertEqual(json.loads(output.read_bytes())["compiler"], compiler) def test_historical_assembler_accepts_exported_original_lock_and_rejects_new_lock(self) -> None: root = Path(__file__).resolve().parents[2] @@ -105,9 +100,26 @@ def test_historical_assembler_accepts_exported_original_lock_and_rejects_new_loc historical["assemble"](self.root / "rejected-appdir", prefix, "5.0.0", baseline) self.assertFalse((self.root / "rejected-appdir").exists()) - def test_historical_gcc_descriptor_absence_uses_approved_current_pin(self) -> None: + def test_approved_historical_schemas_keep_original_gcc_producer(self) -> None: + root = Path(__file__).resolve().parents[2] + for revision in ("34bbad5c3f4d9b303c17aec5e2c75b3550ec8983", HISTORICAL_SOURCE_LIMIT, "994e6fdc20da962be9c11055bda28ef5561a1dea", + "24bee6c8a830aa7372a30be6b842210b07639f73", "c5a8f9fa2bdcfbae9311093fb69907c8d64b35d4", + "2349e0e14276fee251250cd451da6da0e68a3148"): + with self.subTest(revision=revision): + self.assertEqual(resolve_image(root, revision, CURRENT_IMAGE), HISTORICAL_IMAGE) + self.assertIsNone(resolve_image(root, revision, CURRENT_IMAGE, source_finalizer=True)) + + def test_actual_schema1_without_authenticated_producer_is_rejected(self) -> None: + root = Path(__file__).resolve().parents[2] + revision = "5999d3c95c56f490a9005efe009d8300204f789b" + with self.assertRaisesRegex(ValueError, "approved historical release schema"): + resolve_image(root, revision, CURRENT_IMAGE) + + def test_unrecognized_missing_descriptor_never_falls_back(self) -> None: revision = self.commit({"base_image": "historical-sdk"}) - self.assertEqual(resolve_image(self.root, revision, CURRENT_IMAGE), CURRENT_IMAGE) + for finalizer in (False, True): + with self.subTest(finalizer=finalizer), self.assertRaisesRegex(ValueError, "approved historical history"): + resolve_image(self.root, revision, CURRENT_IMAGE, source_finalizer=finalizer) def test_existing_llvm_descriptor_never_falls_back(self) -> None: for compiler in ({}, {"image": "ghcr.io/atrinik/classic-build:latest"}, None): @@ -133,8 +145,10 @@ def test_ambiguous_or_nonregular_tagged_lock_is_rejected(self) -> None: resolve_image(self.root, self.git("rev-parse", "HEAD"), CURRENT_IMAGE) def test_historical_finalizer_workflow_gets_no_unsupported_compiler_argument(self) -> None: - revision = self.commit({"base_image": "historical-sdk"}) source_root = Path(__file__).resolve().parents[2] + self.git("fetch", "--quiet", str(source_root), HISTORICAL_SOURCE_LIMIT) + self.git("checkout", "--quiet", "--detach", HISTORICAL_SOURCE_LIMIT) + revision = HISTORICAL_SOURCE_LIMIT workflow = (source_root / ".github/workflows/build-release-candidate.yml").read_text() step = workflow.split(" - name: Validate and index the complete candidate", 1)[1].split(" - uses:", 1)[0] script = textwrap.dedent(step.split(" run: |\n", 1)[1]) @@ -142,7 +156,7 @@ def test_historical_finalizer_workflow_gets_no_unsupported_compiler_argument(sel trusted.mkdir(parents=True) shutil.copy2(source_root / "tools/release/resolve_compiler_image.py", trusted) historical = self.root / "tools/release" - historical.mkdir(parents=True) + historical.mkdir(parents=True, exist_ok=True) (historical / "finalize_artifacts.py").write_text("""import argparse, json, pathlib, sys parser = argparse.ArgumentParser() for name in ('version', 'revision', 'source-epoch', 'directory'): diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 67d295246..2b5c1d918 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -1542,13 +1542,18 @@ def test_windows_packages_persist_toolchain_bound_compiler_caches(self) -> None: self.assertEqual( candidate.count(f"WINDOWS_BUILD_CACHE_EPOCH: llvm-23.1.3-{WINDOWS_PRODUCER_REVISION}-{digest}"), 1 ) - self.assertEqual(candidate.count(image), 4) + from tools.release.windows_producer import LLVM_IMAGE, MXE_IMAGE + self.assertEqual(LLVM_IMAGE, image) + self.assertNotEqual(MXE_IMAGE, LLVM_IMAGE) + self.assertIn("windows_cache_epoch: ${{ steps.metadata.outputs.windows_cache_epoch }}", candidate) self.assertNotIn("ghcr.io/atrinik/windows-build:1.0.5", candidate) for job, component in ((client_job, "client"), (server_job, "server")): with self.subTest(component=component): self.assertIn(f"path: {component}/build/ccache", job) self.assertIn(f"windows-{component}-ccache-v1-", job) - self.assertEqual(job.count(digest), 2) + self.assertIn("WINDOWS_BUILD_IMAGE: ${{ needs.metadata.outputs.windows_image }}", job) + self.assertIn("WINDOWS_BUILD_CACHE_EPOCH: ${{ needs.metadata.outputs.windows_cache_epoch }}", job) + self.assertEqual(job.count('"${WINDOWS_BUILD_IMAGE}"'), 2) self.assertEqual(job.count("${{ env.WINDOWS_BUILD_CACHE_EPOCH }}"), 2) self.assertIn("${{ runner.os }}-${{ runner.arch }}", job) self.assertIn("${{ needs.metadata.outputs.version }}", job) @@ -1560,13 +1565,12 @@ def test_windows_packages_persist_toolchain_bound_compiler_caches(self) -> None: self.assertIn( f"--env CCACHE_TEMPDIR=/tmp/atrinik-{component}-ccache", job ) - self.assertIn( - "--env PATH=/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:", job - ) + self.assertIn('"${producer_environment[@]}"', job) + self.assertIn('--format docker-env > "${arguments_path}"', job) self.assertEqual(candidate.count("ccache --zero-stats"), 2) self.assertEqual(candidate.count("ccache --show-stats"), 2) self.assertEqual(candidate.count("--env CCACHE_MAXSIZE=500M"), 2) - self.assertEqual(candidate.count("--env PATH=/opt/llvm-mingw/bin:"), 2) + self.assertEqual(candidate.count('"${producer_environment[@]}"'), 2) def test_discord_application_id_is_release_only_package_data(self) -> None: package = self.text("package-release.yml") @@ -1612,15 +1616,74 @@ def test_discord_application_id_is_release_only_package_data(self) -> None: package_script, ) - def test_only_release_metadata_checkouts_require_full_history(self) -> None: + def test_candidate_windows_jobs_execute_the_authenticated_producer_environment(self) -> None: + from tools.release.windows_producer import select_producer + workflow = self.text("build-release-candidate.yml") + for revision in ("5dc27699ccabea180d932265654c3d7a03b8a943", + "fd4943252ac872fce2995c286b10a0c6d249aba3", + "34bbad5c3f4d9b303c17aec5e2c75b3550ec8983", + "994e6fdc20da962be9c11055bda28ef5561a1dea", + "24bee6c8a830aa7372a30be6b842210b07639f73", + "c5a8f9fa2bdcfbae9311093fb69907c8d64b35d4", + "2349e0e14276fee251250cd451da6da0e68a3148"): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) / "source" + subprocess.run(["git", "clone", "--quiet", "--shared", "--no-checkout", str(ROOT), str(workspace)], check=True) + subprocess.run(["git", "-C", str(workspace), "checkout", "--quiet", "--detach", revision], check=True) + expected = select_producer(workspace, revision) + trusted = workspace / "build/windows-automation/tools/release" + trusted.mkdir(parents=True) + shutil.copyfile(ROOT / "tools/release/windows_producer.py", trusted / "windows_producer.py") + binary = workspace / "fixture-bin" + binary.mkdir() + docker = binary / "docker" + docker.write_text("#!/usr/bin/env python3\nimport json,pathlib,sys\npathlib.Path('docker-arguments.json').write_text(json.dumps(sys.argv[1:]))\n") + docker.chmod(0o755) + for job, end in (("client-windows", "server-windows"), ("server-windows", "server-image")): + with self.subTest(revision=revision, job=job): + section = workflow.split(" " + job + ":\n", 1)[1].split(" " + end + ":\n", 1)[0] + self.assertIn("WINDOWS_BUILD_IMAGE: ${{ needs.metadata.outputs.windows_image }}", section) + self.assertIn("WINDOWS_BUILD_CACHE_EPOCH: ${{ needs.metadata.outputs.windows_cache_epoch }}", section) + self.assertIn('docker pull "${WINDOWS_BUILD_IMAGE}"', section) + step = section.split(" - name: Build portable package from monorepo siblings", 1)[1].split(" - uses:", 1)[0] + script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + environment = dict(os.environ, PATH=str(binary) + ":" + os.environ["PATH"], + GITHUB_WORKSPACE=str(workspace), RUNNER_TEMP=str(workspace), + RELEASE_REVISION=revision, RELEASE_VERSION="0.0.0", DISCORD_CONFIG_FILE="", + WINDOWS_BUILD_IMAGE=expected["windows_image"]) + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], cwd=workspace, + env=environment, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + arguments = json.loads((workspace / "docker-arguments.json").read_text()) + selected = dict(argument.split("=", 1) for index, argument in enumerate(arguments) + if index and arguments[index - 1] == "--env") + for name, value in expected["environment"].items(): + self.assertEqual(selected[name], value) + forbidden_prefix = "ATRINIK_WINDOWS_" if expected["windows_mode"] == "mxe" else "MXE_" + self.assertFalse(any(name.startswith(forbidden_prefix) for name in selected)) + self.assertIn(expected["windows_image"], arguments) + self.assertNotIn("classic-check", expected["windows_image"]) + self.assertEqual(arguments[arguments.index("--network") + 1], "none") + # Selector failure must stop the actual workflow before Docker. + (workspace / "client/tools/build-windows-package.sh").write_text("unapproved source") + (workspace / "docker-arguments.json").unlink() + rejected = subprocess.run(["bash", "-euo", "pipefail", "-c", script], cwd=workspace, + env=environment, capture_output=True, text=True) + self.assertNotEqual(rejected.returncode, 0) + self.assertFalse((workspace / "docker-arguments.json").exists()) + + def test_release_producer_validation_checkouts_require_history(self) -> None: candidate = self.text("build-release-candidate.yml") metadata_job = candidate[ candidate.index(" metadata:") : candidate.index(" sources:") ] - remaining_jobs = candidate[candidate.index(" sources:") :] - self.assertEqual(candidate.count("fetch-depth: 0"), 1) + appimage = candidate[candidate.index(" client-appimage:"):candidate.index(" client-windows:")] + finalizer = candidate[candidate.index(" candidate:"):] + self.assertEqual(candidate.count("fetch-depth: 0"), 3) self.assertEqual(metadata_job.count("fetch-depth: 0"), 1) - self.assertNotIn("fetch-depth: 0", remaining_jobs) + self.assertEqual(appimage.count("fetch-depth: 0"), 1) + self.assertIn("path: candidate-source\n persist-credentials: false\n fetch-depth: 0", appimage) + self.assertEqual(finalizer.count("fetch-depth: 0"), 1) def test_core_uploads_exercised_python_release_tool_coverage(self) -> None: workflow = self.text("check.yml") From 5b8db272f2822c3cb38ebcefce42392bb4de9519 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 17:10:41 -0500 Subject: [PATCH 127/148] fix(ci): select native builds for coverage preset tests --- tools/ci/classify_changes.py | 1 + tools/tests/test_classify_changes.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index 2a7817d77..e432ab97f 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -23,6 +23,7 @@ "CMakeLists.txt", "CMakePresets.json", "codecov.yml", + "tools/tests/test_coverage_presets.py", "tools/tests/test_llvm_coverage.py", "tools/tests/test_llvm_coverage_identity.py", "tools/tests/test_llvm_toolchain.py", diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index 4f451845a..bc776a1d5 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -122,6 +122,17 @@ def test_codecov_configuration_selects_both_coverage_components(self) -> None: self.assertTrue(result["client"]) self.assertTrue(result["server"]) + def test_coverage_presets_test_selects_both_native_consumers(self) -> None: + path = "tools/tests/test_coverage_presets.py" + self.assertTrue(classify_changes.is_native_shared(path)) + result = classify_changes.classify([path]) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + self.assertFalse(result["windows"]) + self.assertEqual(result["codeql_languages"], "python") + self.assertFalse(result["benchmark_client"]) + self.assertFalse(result["benchmark_server"]) + def test_shared_windows_runtime_tests_select_both_native_consumers_and_windows(self) -> None: for path in ( "tools/tests/test_stage_windows_runtime.py", From f6a2e77909ba0331dc07822292a01208fa67f96a Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 17:36:36 -0500 Subject: [PATCH 128/148] test(ci): capture bounded Windows close timeout stacks --- tools/ci/smoke_windows_review_bundle.ps1 | 309 ++++++++++++++++++ .../test_compose_windows_review_bundle.py | 35 +- tools/tests/windows_wait_chain_fixture.ps1 | 101 ++++++ 3 files changed, 442 insertions(+), 3 deletions(-) diff --git a/tools/ci/smoke_windows_review_bundle.ps1 b/tools/ci/smoke_windows_review_bundle.ps1 index ce7fbd94e..2fbbc4294 100644 --- a/tools/ci/smoke_windows_review_bundle.ps1 +++ b/tools/ci/smoke_windows_review_bundle.ps1 @@ -372,6 +372,313 @@ public static class AtrinikReviewCloseTimeoutProbe { } } +# Timeout-only x64 diagnostic. Snapshot the owned thread's registers and a bounded +# stack, resume it, then unwind. Never retain raw memory, register values or paths. +function Get-ClientCloseStackSnapshot($Client) { + try { + if (-not ("AtrinikReviewCloseStackProbe" -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Threading; +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] +public static class AtrinikReviewCloseStackProbe { + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenThread(uint access, bool inherit, uint threadId); + [DllImport("kernel32.dll")] + private static extern uint GetProcessIdOfThread(IntPtr thread); + [DllImport("kernel32.dll")] + private static extern uint WaitForSingleObject(IntPtr handle, uint timeout); + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll")] + private static extern bool IsWow64Process2(IntPtr process, out ushort machine, out ushort native); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint SuspendThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetThreadContext(IntPtr thread, IntPtr context); + [DllImport("kernel32.dll")] + private static extern UIntPtr VirtualQueryEx(IntPtr process, IntPtr address, + IntPtr information, UIntPtr length); + [DllImport("kernel32.dll")] + private static extern bool ReadProcessMemory(IntPtr process, IntPtr address, + byte[] bytes, UIntPtr size, out UIntPtr read); + private delegate bool ReadMemory(IntPtr process, ulong address, IntPtr buffer, + uint size, out uint read); + private delegate IntPtr FunctionTable(IntPtr process, ulong address); + private delegate ulong ModuleBase(IntPtr process, ulong address); + [DllImport("dbghelp.dll")] + private static extern bool StackWalk64(uint machine, IntPtr process, IntPtr thread, + IntPtr frame, IntPtr context, ReadMemory read, FunctionTable functions, + ModuleBase modules, IntPtr translate); + + // Windows x64 ABI: winnt.h CONTEXT is 1232 bytes, aligned to 16; dbghelp.h + // STACKFRAME64 is 264 bytes (five ADDRESS64s, fields, 112-byte KDHELP64). + // https://learn.microsoft.com/windows/win32/api/winnt/ns-winnt-context + // https://learn.microsoft.com/windows/win32/api/dbghelp/ns-dbghelp-stackframe64 + // https://learn.microsoft.com/windows/win32/api/dbghelp/nf-dbghelp-stackwalk64 + private const int ContextSize = 1232, FrameSize = 264, StackLimit = 65536; + private static int active; // DbgHelp is single-threaded, including late observers. + // Timeout fencing covers capture as well as unwind: the caller can remove + // our suspend count, and cancellation prevents a late observer suspending. + private sealed class Suspension { + private readonly object gate = new object(); + private IntPtr thread; + private bool held, cancelled; + public bool Begin(IntPtr value) { + lock (gate) { + if (cancelled) return false; + thread = value; + held = SuspendThread(thread) != UInt32.MaxValue; + return held; + } + } + public bool Resume() { + lock (gate) { + if (!held) return true; + if (ResumeThread(thread) == UInt32.MaxValue) return false; + held = false; + return true; + } + } + public void Cancel() { + lock (gate) { cancelled = true; Resume(); } + } + public void Close(IntPtr value) { + lock (gate) { + Resume(); + thread = IntPtr.Zero; + if (value != IntPtr.Zero) CloseHandle(value); + } + } + } + private sealed class Module { + public ulong Address, Size; + public string Name; + public byte[] Functions; + } + private static bool Contains(ulong start, ulong length, ulong address, ulong size) { + return address >= start && size <= length && address - start <= length - size; + } + private static string FormatAddress(string name, ulong start, ulong length, ulong address) { + if (!Contains(start, length, address, 1)) return "unknown_module"; + // Basenames only, even if the caller accidentally supplies a full path. + string basename = name.Replace('\\', '/'); + basename = basename.Substring(basename.LastIndexOf('/') + 1); + basename = Regex.Replace(basename, "[^A-Za-z0-9_.-]", "?"); + if (basename.Length > 128) basename = basename.Substring(0, 128); + return basename + "+0x" + (address - start).ToString("x"); + } + private static Module Find(List modules, ulong address, ulong size) { + foreach (var module in modules) + if (Contains(module.Address, module.Size, address, size)) return module; + return null; + } + private static byte[] Read(IntPtr process, ulong address, int count) { + var bytes = new byte[count]; + UIntPtr read; + if (!ReadProcessMemory(process, new IntPtr(unchecked((long)address)), bytes, + new UIntPtr((uint)count), out read) || read.ToUInt64() != (ulong)count) return null; + return bytes; + } + private static string Collect(Process client, IntPtr process, IntPtr window, uint threadId, + Suspension suspension) { + IntPtr thread = IntPtr.Zero, contextAllocation = IntPtr.Zero; + IntPtr frame = IntPtr.Zero, information = IntPtr.Zero, functionEntry = IntPtr.Zero; + try { + ushort machine, native; + if (IntPtr.Size != 8 || !IsWow64Process2(process, out machine, out native) || + machine != 0 || native != 0x8664) return "stack_unsupported_architecture"; + var modules = new List(); + foreach (ProcessModule module in client.Modules) { + if (modules.Count >= 256) return "stack_module_limit"; + modules.Add(new Module { Address = unchecked((ulong)module.BaseAddress.ToInt64()), + Size = (ulong)module.ModuleMemorySize, Name = module.ModuleName }); + } + // GET_CONTEXT | SUSPEND_RESUME | QUERY_LIMITED_INFORMATION | SYNCHRONIZE. + thread = OpenThread(0x0010080a, false, threadId); + if (thread == IntPtr.Zero) return "stack_thread_unavailable"; + contextAllocation = Marshal.AllocHGlobal(ContextSize + 15); + IntPtr context = new IntPtr((contextAllocation.ToInt64() + 15) & ~15L); + Marshal.Copy(new byte[ContextSize], 0, context, ContextSize); + Marshal.WriteInt32(context, 48, 0x10000b); // AMD64 CONTROL | INTEGER | FLOATING_POINT + information = Marshal.AllocHGlobal(48); + frame = Marshal.AllocHGlobal(FrameSize); + Marshal.Copy(new byte[FrameSize], 0, frame, FrameSize); + functionEntry = Marshal.AllocHGlobal(12); + uint owner; + // The original Process SafeHandle remains pinned throughout: a reused + // PID/TID cannot pass both held-handle liveness and HWND ownership. + if (GetWindowThreadProcessId(window, out owner) != threadId || + owner != (uint)client.Id || GetProcessIdOfThread(thread) != owner || + WaitForSingleObject(process, 0) != 258 || + WaitForSingleObject(thread, 0) != 258) return "stack_owned_thread_changed"; + byte[] stack; + ulong stackAddress; + if (!suspension.Begin(thread)) return "stack_suspend_failed_or_cancelled"; + try { + if (!GetThreadContext(thread, context)) return "stack_context_failed"; + stackAddress = unchecked((ulong)Marshal.ReadInt64(context, 152)); // Rsp + if (VirtualQueryEx(process, new IntPtr(unchecked((long)stackAddress)), information, + new UIntPtr(48)).ToUInt64() != 48) return "stack_region_unavailable"; + ulong region = unchecked((ulong)Marshal.ReadInt64(information, 0)); + ulong size = unchecked((ulong)Marshal.ReadInt64(information, 24)); + if (!Contains(region, size, stackAddress, 1)) return "stack_region_invalid"; + int count = (int)Math.Min((ulong)StackLimit, size - (stackAddress - region)); + stack = Read(process, stackAddress, count); + if (stack == null) return "stack_capture_failed"; + } finally { + // No DbgHelp, symbol lookup, output or callbacks while suspended. + // Remove exactly our one suspend count, never another observer's. + if (!suspension.Resume()) + throw new InvalidOperationException("stack_resume_failed"); + } + ulong instruction = unchecked((ulong)Marshal.ReadInt64(context, 248)); // Rip + Marshal.WriteInt64(frame, 0, unchecked((long)instruction)); + Marshal.WriteInt32(frame, 12, 3); // AddrModeFlat + Marshal.WriteInt64(frame, 32, Marshal.ReadInt64(context, 160)); // Rbp + Marshal.WriteInt32(frame, 44, 3); + Marshal.WriteInt64(frame, 48, unchecked((long)stackAddress)); + Marshal.WriteInt32(frame, 60, 3); + var timer = Stopwatch.StartNew(); + int budget = 1048576; + Func readModule = delegate(ulong address, int count) { + if (count <= 0 || count > budget || timer.ElapsedMilliseconds >= 1500 || + Find(modules, address, (ulong)count) == null) return null; + budget -= count; + return Read(process, address, count); + }; + ReadMemory readMemory = delegate(IntPtr unused, ulong address, IntPtr buffer, + uint count, out uint read) { + read = 0; + try { + if (count > StackLimit || timer.ElapsedMilliseconds >= 1500) return false; + if (Contains(stackAddress, (ulong)stack.Length, address, count)) { + Marshal.Copy(stack, (int)(address - stackAddress), buffer, (int)count); + } else { + var bytes = readModule(address, (int)count); + if (bytes == null) return false; + Marshal.Copy(bytes, 0, buffer, bytes.Length); + } + read = count; + return true; + } catch { return false; } + }; + ModuleBase moduleBase = delegate(IntPtr unused, ulong address) { + var module = Find(modules, address, 1); + return module == null ? 0 : module.Address; + }; + FunctionTable functions = delegate(IntPtr unused, ulong address) { + try { + var module = Find(modules, address, 1); + if (module == null) return IntPtr.Zero; + if (module.Functions == null) { + module.Functions = new byte[0]; // Attempt each module once. + var dos = readModule(module.Address, 64); + if (dos == null || BitConverter.ToUInt16(dos, 0) != 0x5a4d) return IntPtr.Zero; + uint offset = BitConverter.ToUInt32(dos, 60); + if (offset > module.Size || module.Size - offset < 264) return IntPtr.Zero; + var pe = readModule(module.Address + offset, 264); + if (pe == null || BitConverter.ToUInt32(pe, 0) != 0x4550 || + BitConverter.ToUInt16(pe, 4) != 0x8664 || + BitConverter.ToUInt16(pe, 24) != 0x20b || + BitConverter.ToUInt32(pe, 132) < 4) return IntPtr.Zero; + uint rva = BitConverter.ToUInt32(pe, 160), length = BitConverter.ToUInt32(pe, 164); + if (length == 0 || length % 12 != 0 || length > 524288 || + rva > module.Size || length > module.Size - rva) return IntPtr.Zero; + var table = readModule(module.Address + rva, (int)length); + if (table != null) module.Functions = table; + } + byte[] entries = module.Functions; + ulong relative = address - module.Address; + int low = 0, high = entries.Length / 12; + while (low < high) { + int index = low + (high - low) / 2; + uint begin = BitConverter.ToUInt32(entries, index * 12); + uint end = BitConverter.ToUInt32(entries, index * 12 + 4); + if (relative < begin) high = index; + else if (relative >= end) low = index + 1; + else { + Marshal.Copy(entries, index * 12, functionEntry, 12); + return functionEntry; + } + } + } catch { } + return IntPtr.Zero; + }; + var rows = new List(); + ulong previous = 0; + for (int index = 0; index < 32 && timer.ElapsedMilliseconds < 1500; ++index) { + if (instruction == 0 || instruction == previous) break; + var module = Find(modules, instruction, 1); + if (module == null) { rows.Add("unknown_module"); break; } + rows.Add(FormatAddress(module.Name, module.Address, module.Size, instruction)); + previous = instruction; + if (!StackWalk64(0x8664, process, thread, frame, context, readMemory, + functions, moduleBase, IntPtr.Zero)) break; + instruction = unchecked((ulong)Marshal.ReadInt64(frame, 0)); + // StackWalk64's first result can be the initial frame. + if (instruction == previous && index == 0 && + StackWalk64(0x8664, process, thread, frame, context, readMemory, + functions, moduleBase, IntPtr.Zero)) + instruction = unchecked((ulong)Marshal.ReadInt64(frame, 0)); + } + GC.KeepAlive(readMemory); GC.KeepAlive(functions); GC.KeepAlive(moduleBase); + Array.Clear(stack, 0, stack.Length); + return "stack_frames=" + rows.Count + "; " + String.Join("; ", rows); + } catch { return "stack_unavailable"; } + finally { + suspension.Resume(); + if (functionEntry != IntPtr.Zero) Marshal.FreeHGlobal(functionEntry); + if (information != IntPtr.Zero) Marshal.FreeHGlobal(information); + if (frame != IntPtr.Zero) Marshal.FreeHGlobal(frame); + if (contextAllocation != IntPtr.Zero) { + Marshal.Copy(new byte[ContextSize + 15], 0, contextAllocation, ContextSize + 15); + Marshal.FreeHGlobal(contextAllocation); + } + suspension.Close(thread); + } + } + public static string Snapshot(Process client, IntPtr window) { + uint owner; + uint thread = GetWindowThreadProcessId(window, out owner); + if (window == IntPtr.Zero || thread == 0 || owner != (uint)client.Id || + client.Id == Process.GetCurrentProcess().Id) return "stack_owned_window_unavailable"; + if (Interlocked.CompareExchange(ref active, 1, 0) != 0) return "stack_observer_busy"; + var handle = client.SafeHandle; + bool retained = false; + string result = "stack_unavailable"; + var suspension = new Suspension(); + var observer = new Thread(delegate() { + try { result = Collect(client, handle.DangerousGetHandle(), window, thread, suspension); } + finally { handle.DangerousRelease(); Interlocked.Exchange(ref active, 0); } + }); + observer.IsBackground = true; + try { handle.DangerousAddRef(ref retained); observer.Start(); } + catch { + if (retained) handle.DangerousRelease(); + Interlocked.Exchange(ref active, 0); + return "stack_observer_unavailable"; + } + if (observer.Join(2000)) return result; + suspension.Cancel(); + return "stack_observer_timeout"; + } +} +'@ -ErrorAction Stop + } + if ($Client.HasExited) { return "client_exited=True" } + return [AtrinikReviewCloseStackProbe]::Snapshot($Client, $Client.MainWindowHandle) + } catch { return "" } +} + function Write-CloseFailureDiagnostics { try { try { @@ -730,6 +1037,8 @@ try { if (-not $launcherClient.WaitForExit(30000)) { Write-Host ("Client close timeout thread: " + (Get-ClientCloseTimeoutSnapshot $launcherClient)) + Write-Host ("Client close timeout stack: " + + (Get-ClientCloseStackSnapshot $launcherClient)) throw "One-click client did not exit after the normal close request" } $launcherClient.Refresh() diff --git a/tools/tests/test_compose_windows_review_bundle.py b/tools/tests/test_compose_windows_review_bundle.py index 60c961279..c97bdaa05 100644 --- a/tools/tests/test_compose_windows_review_bundle.py +++ b/tools/tests/test_compose_windows_review_bundle.py @@ -303,7 +303,9 @@ def test_close_failure_diagnostics_preserve_shutdown_acceptance(self) -> None: acceptance, r'if \(-not \$launcherClient.WaitForExit\(30000\)\) \{\s*' r'Write-Host \("Client close timeout thread: " \+\s*' - r'\(Get-ClientCloseTimeoutSnapshot \$launcherClient\)\)\s*throw ', + r'\(Get-ClientCloseTimeoutSnapshot \$launcherClient\)\)\s*' + r'Write-Host \("Client close timeout stack: " \+\s*' + r'\(Get-ClientCloseStackSnapshot \$launcherClient\)\)\s*throw ', ) self.assertIn("$launcherClientExitCode -ne 0", acceptance) self.assertIn('Client shutdown complete', acceptance) @@ -358,7 +360,7 @@ def test_window_diagnostics_are_bounded_read_only_and_process_scoped(self) -> No def test_close_timeout_probe_is_owned_bounded_and_observational(self) -> None: smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() - probe = smoke.split("function Get-ClientCloseTimeoutSnapshot", 1)[1].split("function Write-CloseFailureDiagnostics", 1)[0] + probe = smoke.split("function Get-ClientCloseTimeoutSnapshot", 1)[1].split("function Get-ClientCloseStackSnapshot", 1)[0] self.assertIn("mainWindow == IntPtr.Zero || mainThread == 0 || owner != (uint)processId", probe) self.assertIn("process.Id == Process.GetCurrentProcess().Id", probe) self.assertIn("index < threads.Count && index < 4096", probe) @@ -396,7 +398,7 @@ def test_wait_chain_probe_pins_identity_and_does_not_expand_access(self) -> None self.assertIn("GetThreadWaitChain(session, UIntPtr.Zero, 0x1 | 0x4,", probe) for forbidden in ("AdjustTokenPrivileges", "DebugActiveProcess", "ReadProcessMemory", "GetThreadContext", "SuspendThread", "CreateRemoteThread"): - self.assertNotIn(forbidden, smoke) + self.assertNotIn(forbidden, probe) def test_wait_chain_callback_lifetime_and_observer_are_bounded(self) -> None: smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() @@ -432,6 +434,33 @@ def test_wait_chain_output_excludes_object_names_and_external_identities(self) - self.assertIn('type < types.Length ? types[type] : "Unknown"', formatter) self.assertIn('state < statuses.Length ? statuses[state] : "Unknown"', formatter) + def test_stack_probe_retains_only_bounded_addresses_after_resuming(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + probe = smoke.split("function Get-ClientCloseStackSnapshot", 1)[1].split("function Write-CloseFailureDiagnostics", 1)[0] + self.assertIn("DefaultDllImportSearchPaths(DllImportSearchPath.System32)", probe) + self.assertIn("ContextSize = 1232, FrameSize = 264, StackLimit = 65536", probe) + self.assertIn("int budget = 1048576", probe) + self.assertIn("index < 32 && timer.ElapsedMilliseconds < 1500", probe) + self.assertIn("if (observer.Join(2000)) return result;", probe) + self.assertIn("suspension.Cancel();", probe) + self.assertIn("if (cancelled) return false;", probe) + self.assertIn("handle.DangerousAddRef(ref retained)", probe) + self.assertIn("Interlocked.CompareExchange(ref active, 1, 0)", probe) + capture = probe.split("private static string Collect(", 1)[1] + self.assertLess(capture.index("suspension.Resume()"), capture.index("StackWalk64(")) + for check in ("GetWindowThreadProcessId(window, out owner) != threadId", + "GetProcessIdOfThread(thread) != owner", + "WaitForSingleObject(process, 0) != 258", + "WaitForSingleObject(thread, 0) != 258"): + self.assertLess(capture.index(check), capture.index("suspension.Begin(thread)")) + self.assertIn("suspension.Close(thread)", capture) + self.assertIn('rows.Add("unknown_module")', probe) + self.assertIn('FormatAddress(module.Name, module.Address, module.Size, instruction)', probe) + self.assertEqual(smoke.count("(Get-ClientCloseStackSnapshot $launcherClient)"), 1) + for forbidden in ("SymInitialize", "SymFromAddr", "MiniDump", "FileName", "AdjustTokenPrivileges", + "DebugActiveProcess", "SetThreadContext", "WriteProcessMemory", "CreateRemoteThread"): + self.assertNotIn(forbidden, probe) + def test_lifecycle_evidence_is_bounded_and_uploaded_before_cleanup(self) -> None: root = MODULE_PATH.parents[2] smoke = (root / "tools/ci/smoke_windows_review_bundle.ps1").read_text() diff --git a/tools/tests/windows_wait_chain_fixture.ps1 b/tools/tests/windows_wait_chain_fixture.ps1 index e680dda97..8084af9fb 100644 --- a/tools/tests/windows_wait_chain_fixture.ps1 +++ b/tools/tests/windows_wait_chain_fixture.ps1 @@ -2,7 +2,37 @@ # SPDX-License-Identifier: GPL-2.0-or-later # Compile the actual smoke observer and exercise its formatter without invoking # any native Windows API. Run with PowerShell 7 on Linux or Windows. +param([switch]$Native, [switch]$NativeChild) $ErrorActionPreference = "Stop" +if ($NativeChild) { + if (-not $IsWindows) { throw "Native child requires Windows" } + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Threading; +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] +public static class AtrinikStackFixtureWindow { + [DllImport("user32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern IntPtr CreateWindowExW(uint style, string className, string title, + uint windowStyle, int x, int y, int width, int height, IntPtr parent, + IntPtr menu, IntPtr instance, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool DestroyWindow(IntPtr window); + public static void Run() { + IntPtr window = CreateWindowExW(0, "STATIC", "", 0, 0, 0, 1, 1, + IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); + if (window == IntPtr.Zero) throw new InvalidOperationException("fixture_window_failed"); + try { + Console.WriteLine(window.ToInt64()); + Console.Out.Flush(); + Thread.Sleep(5000); // A deterministic blocked HWND thread, without a GPU. + } finally { DestroyWindow(window); } + } +} +'@ + [AtrinikStackFixtureWindow]::Run() + exit 0 +} $smoke = Join-Path $PSScriptRoot "../ci/smoke_windows_review_bundle.ps1" $tokens = $null $errors = $null @@ -88,3 +118,74 @@ try { [System.Runtime.InteropServices.Marshal]::FreeHGlobal($cycle) [System.Runtime.InteropServices.Marshal]::FreeHGlobal($count) } + +# Compile the actual stack observer too. Pure formatting/range fixtures require +# no Windows API, and never manufacture acceptance for native stack unwinding. +$stackBlocks = @([regex]::Matches($source, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@") | + Where-Object { $_.Groups[1].Value.Contains("class AtrinikReviewCloseStackProbe") }) +if ($stackBlocks.Count -ne 1) { throw "Expected one stack observer" } +Add-Type -TypeDefinition $stackBlocks[0].Groups[1].Value +$stackFormatter = [AtrinikReviewCloseStackProbe].GetMethod("FormatAddress", + [System.Reflection.BindingFlags]"NonPublic,Static") +foreach ($case in @( + @("atrinik.exe", 4096, 8192, 4112, "atrinik.exe+0x10"), + @("C:\private\SDL3.dll", 4096, 8192, 4112, "SDL3.dll+0x10"), + @("/private/name`n.dll", 4096, 8192, 4112, "name?.dll+0x10"), + @("private.dll", 4096, 8192, 4095, "unknown_module"), + @("private.dll", 4096, 8192, 12288, "unknown_module") +)) { + $actual = $stackFormatter.Invoke($null, @([string]$case[0], [uint64]$case[1], + [uint64]$case[2], [uint64]$case[3])) + if ($actual -cne $case[4]) { throw "Stack address privacy/boundary fixture mismatch" } +} +$contains = [AtrinikReviewCloseStackProbe].GetMethod("Contains", + [System.Reflection.BindingFlags]"NonPublic,Static") +foreach ($case in @( + @(([uint64]::MaxValue - 16), 16, ([uint64]::MaxValue - 8), 8, $true), + @(([uint64]::MaxValue - 16), 16, ([uint64]::MaxValue - 8), 9, $false), + @(4096, 32, 4096, 33, $false), @(4096, 32, 4095, 1, $false) +)) { + $actual = $contains.Invoke($null, @([uint64]$case[0], [uint64]$case[1], + [uint64]$case[2], [uint64]$case[3])) + if ($actual -ne $case[4]) { throw "Stack range overflow fixture mismatch" } +} +Write-Host "Actual stack observer C# compilation, privacy and overflow fixtures passed." + +if ($Native) { + if (-not $IsWindows -or [IntPtr]::Size -ne 8) { throw "Native stack fixture requires Windows x64" } + $start = [System.Diagnostics.ProcessStartInfo]::new() + $start.FileName = (Get-Process -Id $PID).Path + foreach ($argument in @("-NoProfile", "-File", $PSCommandPath, "-NativeChild")) { + $start.ArgumentList.Add($argument) + } + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $child = [System.Diagnostics.Process]::new() + $child.StartInfo = $start + try { + if (-not $child.Start()) { throw "Native stack fixture child failed to start" } + $line = $child.StandardOutput.ReadLineAsync() + if (-not $line.Wait(10000) -or $line.Result -notmatch "^[1-9][0-9]{0,18}$") { + throw "Native stack fixture did not create an owned window" + } + $window = [IntPtr]::new([long]$line.Result) + $invalid = [AtrinikReviewCloseStackProbe]::Snapshot($child, [IntPtr]::Zero) + if ($invalid -cne "stack_owned_window_unavailable") { throw "Invalid window was accepted" } + $observed = [AtrinikReviewCloseStackProbe]::Snapshot($child, $window) + if ($observed -notmatch "^stack_frames=(?:[2-9]|[12][0-9]|3[0-2]); [A-Za-z0-9_.?-]+\+0x[0-9a-f]+(?:; (?:[A-Za-z0-9_.?-]+\+0x[0-9a-f]+|unknown_module))+$") { + throw "Native stack fixture did not yield bounded module-relative frames: $observed" + } + # Natural completion proves the one owned suspend count was removed. + if (-not $child.WaitForExit(10000) -or $child.ExitCode -ne 0) { + throw "Native stack fixture child did not resume and exit normally" + } + Write-Host "Native owned-window stack capture and natural child exit passed: $observed" + } finally { + if ($child.Id -and -not $child.HasExited) { + $child.Kill($true) + $null = $child.WaitForExit(5000) + } + $child.Dispose() + } +} From 7df5236f9d0d47763315e69dd8870ea789994fc2 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 17:39:18 -0500 Subject: [PATCH 129/148] ci(windows): qualify owned stack diagnostics --- .github/workflows/check.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7ec976727..1323ee61e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -702,6 +702,13 @@ jobs: Remove-Item -LiteralPath $extractRoot -Recurse -Force } } + - name: Verify owned Windows stack diagnostic lifecycle + shell: pwsh + run: | + pwsh -NoProfile -File tools/tests/windows_wait_chain_fixture.ps1 -Native + if ($LASTEXITCODE -ne 0) { + throw "Owned Windows stack diagnostic lifecycle qualification failed" + } - name: Execute private-state and protocol tests on Windows shell: pwsh run: | From d9a910b6f9d43e48854d8cbdd44237dc548bbf8e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 17:38:45 -0500 Subject: [PATCH 130/148] fix(build): bind managed Windows host Python helpers --- client/tools/build-windows-package.sh | 24 ++- server/tools/build-windows-package.sh | 25 ++- .../test_windows_host_python_contract.py | 162 ++++++++++++++++++ tools/tests/test_workflow_contracts.py | 12 +- 4 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 tools/tests/test_windows_host_python_contract.py diff --git a/client/tools/build-windows-package.sh b/client/tools/build-windows-package.sh index 4c30c7f4e..a3b62dd21 100755 --- a/client/tools/build-windows-package.sh +++ b/client/tools/build-windows-package.sh @@ -2,6 +2,23 @@ set -euo pipefail +# atrinik-windows-host-python-contract: 1 +# Managed producers bind host helpers and CMake discovery independently of PATH. +# Generic callers retain their existing interpreter discovery when unset. +host_python=python3 +host_python_arguments=() +if [[ ${ATRINIK_WINDOWS_HOST_PYTHON+x} ]]; then + if [[ ${ATRINIK_WINDOWS_HOST_PYTHON} != /usr/bin/python3 || ! -x /usr/bin/python3 ]]; then + echo "ATRINIK_WINDOWS_HOST_PYTHON must identify /usr/bin/python3" >&2 + exit 1 + fi + host_python=/usr/bin/python3 + host_python_arguments+=( + -DPython3_EXECUTABLE=/usr/bin/python3 + -DPython3_FIND_VIRTUALENV=STANDARD + ) +fi + output_directory=${1:-build/packages} version=${ATRINIK_PACKAGE_VERSION:-} if [[ ! ${version} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -53,10 +70,10 @@ else fi dependency_sync_arguments+=(--cache "${dependency_downloads}" --refresh --offline) fi - python3 tools/dependencies.py sync "${dependency_sync_arguments[@]}" - python3 tools/dependencies.py verify + "${host_python}" tools/dependencies.py sync "${dependency_sync_arguments[@]}" + "${host_python}" tools/dependencies.py verify fi -python3 tools/verify_gpu_fixture_provenance.py +"${host_python}" tools/verify_gpu_fixture_provenance.py mkdir -p "${output_directory}" dependency_arguments=() @@ -93,6 +110,7 @@ cmake -S . -B build/windows-release -G Ninja \ -DATRINIK_WINDOWS_RUNTIME_DIR="${ATRINIK_WINDOWS_RUNTIME_DIR}" \ "-DATRINIK_CA_BUNDLE=${PWD}/ca-bundle.crt" \ "-DATRINIK_DISCORD_APPLICATION_ID_FILE=${discord_config_file}" \ + "${host_python_arguments[@]}" \ "${dependency_arguments[@]}" cmake --build build/windows-release --parallel "$(nproc)" diff --git a/server/tools/build-windows-package.sh b/server/tools/build-windows-package.sh index 846b8018c..f7a4a29a4 100755 --- a/server/tools/build-windows-package.sh +++ b/server/tools/build-windows-package.sh @@ -2,6 +2,23 @@ set -euo pipefail +# atrinik-windows-host-python-contract: 1 +# Managed producers bind host helpers and CMake discovery independently of PATH. +# Generic callers retain their existing interpreter discovery when unset. +host_python=python3 +host_python_arguments=() +if [[ ${ATRINIK_WINDOWS_HOST_PYTHON+x} ]]; then + if [[ ${ATRINIK_WINDOWS_HOST_PYTHON} != /usr/bin/python3 || ! -x /usr/bin/python3 ]]; then + echo "ATRINIK_WINDOWS_HOST_PYTHON must identify /usr/bin/python3" >&2 + exit 1 + fi + host_python=/usr/bin/python3 + host_python_arguments+=( + -DPython3_EXECUTABLE=/usr/bin/python3 + -DPython3_FIND_VIRTUALENV=STANDARD + ) +fi + output_directory=${1:-build/packages} version=${ATRINIK_PACKAGE_VERSION:-} if [[ ! ${version} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -73,8 +90,8 @@ else if [[ -n ${dependency_downloads} ]]; then dependency_sync_arguments+=(--cache "${dependency_downloads}" --refresh --offline) fi - python3 tools/dependencies.py sync "${dependency_sync_arguments[@]}" - python3 tools/dependencies.py verify + "${host_python}" tools/dependencies.py sync "${dependency_sync_arguments[@]}" + "${host_python}" tools/dependencies.py verify fi provenance_arguments=() if [[ -n ${qualification} || -n ${qualification_sha} ]]; then @@ -85,7 +102,7 @@ if [[ -n ${qualification} || -n ${qualification_sha} ]]; then --profile-resources "${profile_resources}" ) fi -python3 ../client/tools/verify_gpu_fixture_provenance.py --content-runtime runtime/content "${provenance_arguments[@]}" +"${host_python}" ../client/tools/verify_gpu_fixture_provenance.py --content-runtime runtime/content "${provenance_arguments[@]}" dependency_arguments=() native_compiler_arguments=() @@ -170,6 +187,7 @@ native_llvm cmake -S . -B "${region_build}" -G Ninja \ -DPACKAGE_TYPE=none \ -DATRINIK_PACKAGE_VERSION="${version}" \ "${native_compiler_arguments[@]}" \ + "${host_python_arguments[@]}" \ "${dependency_arguments[@]}" native_llvm cmake --build "${region_build}" \ --target atrinik-server plugin_arena plugin_python --parallel "$(nproc)" @@ -210,6 +228,7 @@ cmake -S . -B build/windows-release -G Ninja \ -DATRINIK_WINDOWS_PYTHON_LIBRARY="${ATRINIK_WINDOWS_PYTHON_LIBRARY}" \ -DATRINIK_WINDOWS_PYTHON_RUNTIME_DIR="${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}" \ -DATRINIK_SERVER_PACKAGE_ROOT="${repository_root}/${package_root}" \ + "${host_python_arguments[@]}" \ "${dependency_arguments[@]}" cmake --build build/windows-release --parallel "$(nproc)" mkdir -p "${output_directory}" diff --git a/tools/tests/test_windows_host_python_contract.py b/tools/tests/test_windows_host_python_contract.py new file mode 100644 index 000000000..c00c90b9a --- /dev/null +++ b/tools/tests/test_windows_host_python_contract.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Execute packaging entrypoints without application builds or dependency fetches.""" + +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +MARKER = "# atrinik-windows-host-python-contract: 1" +HELPER = '''import json, os, sys +with open(os.environ["FIXTURE_LOG"], "a") as stream: + stream.write(json.dumps({"tool": "helper", "executable": sys.executable, + "argv": sys.argv}) + "\\n") +''' +COMMAND = '''#!/usr/bin/python3 +import json, os, pathlib, shutil, sys +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +with open(os.environ["FIXTURE_LOG"], "a") as stream: + stream.write(json.dumps({"tool": name, "argv": args}) + "\\n") +if name == "python3": + os.execv("/usr/bin/python3", ["/usr/bin/python3", *args]) +if name == "cmake": + if args[0] == "-E": + command, values = args[1], args[2:] + if command == "remove_directory": + for value in values: + shutil.rmtree(value, ignore_errors=True) + elif command == "make_directory": + for value in values: + pathlib.Path(value).mkdir(parents=True, exist_ok=True) + elif command == "copy_directory": + shutil.copytree(*values, dirs_exist_ok=True) + elif command == "copy": + for value in values[:-1]: + shutil.copy(value, values[-1]) + else: + raise SystemExit("unexpected cmake -E operation") + elif args[0] == "--build": + build = pathlib.Path(args[1]) + build.mkdir(parents=True, exist_ok=True) + if "windows-region-generator" in str(build): + executable = build / "atrinik-server" + executable.write_text('#!/bin/bash\\nfor arg; do\\ncase "$arg" in --assetspath=*) mkdir -p "${arg#*=}/client-maps";; esac\\ndone\\n') + executable.chmod(0o755) + for name in ("libplugin_arena.so", "libplugin_python.so"): + (build / name).touch() + else: + (build / "atrinik.exe").write_text("fixture production executable") + elif args[0] != "-S": + raise SystemExit("unexpected cmake invocation") +if name == "cpack": + output = pathlib.Path(args[args.index("-B") + 1]) + output.mkdir(parents=True, exist_ok=True) + (output / ("atrinik-classic-" + pathlib.Path.cwd().name + "-0.0.0-windows-x86_64.zip")).touch() +''' + + +class WindowsHostPythonContractTests(unittest.TestCase): + def fixture(self, directory, component): + root = Path(directory) + module = root / component + (module / "tools").mkdir(parents=True) + script = module / "tools/build-windows-package.sh" + shutil.copy(ROOT / component / "tools/build-windows-package.sh", script) + self.assertIn(MARKER, script.read_text().splitlines()) + (module / "tools/dependencies.py").write_text(HELPER) + client_tools = root / "client/tools" + client_tools.mkdir(parents=True, exist_ok=True) + (client_tools / "verify_gpu_fixture_provenance.py").write_text(HELPER) + toolchain = module / "cmake/toolchains/llvm.cmake" + toolchain.parent.mkdir(parents=True) + toolchain.touch() + for relative in ("runtime/content/maps", "runtime/content/lib", + "runtime/content/attribution", "resources", "install_data"): + (module / relative).mkdir(parents=True) + for relative in ("runtime/content/compatibility.json", "runtime/content/manifest.json", + "server.cfg", "permissions.cfg"): + (module / relative).touch() + binary_directory = root / "bin" + binary_directory.mkdir() + for name in ("python3", "cmake", "cpack", "llvm-readobj"): + binary = binary_directory / name + binary.write_text(COMMAND) + binary.chmod(0o755) + dependency = root / "dependency" + dependency.touch() + # Keep the fixture independent of caller virtual environments and Python paths. + environment = {"PATH": str(binary_directory) + ":/usr/bin:/bin", + "HOME": str(root), "FIXTURE_LOG": str(root / "log.jsonl"), + "ATRINIK_PACKAGE_VERSION": "0.0.0", + "ATRINIK_WINDOWS_TARGET": "x86_64-w64-mingw32", + "VIRTUAL_ENV": str(root / "hostile-venv")} + for name in ("TOOLCHAIN_FILE", "RUNTIME_DIR", "PYTHON_INCLUDE_DIR", + "PYTHON_LIBRARY", "PYTHON_RUNTIME_DIR"): + environment["ATRINIK_WINDOWS_" + name] = str(dependency) + return module, environment + + def run_fixture(self, module, environment): + result = subprocess.run(["/bin/bash", "tools/build-windows-package.sh"], + cwd=module, env=environment, capture_output=True, + text=True, timeout=30) + log = Path(environment["FIXTURE_LOG"]) + records = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + return result, records + + def test_bound_helpers_and_all_configure_entrypoints(self): + for component in ("client", "server"): + with self.subTest(component=component), tempfile.TemporaryDirectory() as directory: + module, environment = self.fixture(directory, component) + environment["ATRINIK_WINDOWS_HOST_PYTHON"] = "/usr/bin/python3" + result, records = self.run_fixture(module, environment) + self.assertEqual(result.returncode, 0, result.stderr) + helpers = [record for record in records if record["tool"] == "helper"] + self.assertEqual(len(helpers), 3) + self.assertTrue(all(record["executable"] == "/usr/bin/python3" for record in helpers)) + self.assertFalse(any(record["tool"] == "python3" for record in records)) + configurations = [record["argv"] for record in records + if record["tool"] == "cmake" and record["argv"][0] == "-S"] + self.assertEqual([args[args.index("-B") + 1] for args in configurations], + ["build/windows-release"] if component == "client" else + ["build/windows-region-generator", "build/windows-release"]) + for args in configurations: + self.assertIn("-DPython3_EXECUTABLE=/usr/bin/python3", args) + self.assertIn("-DPython3_FIND_VIRTUALENV=STANDARD", args) + if component == "server": + for suffix in ("INCLUDE_DIR", "LIBRARY", "RUNTIME_DIR"): + name = "ATRINIK_WINDOWS_PYTHON_" + suffix + self.assertIn("-D" + name + "=" + environment[name], configurations[-1]) + + def test_unset_binding_preserves_generic_discovery(self): + for component in ("client", "server"): + with self.subTest(component=component), tempfile.TemporaryDirectory() as directory: + module, environment = self.fixture(directory, component) + result, records = self.run_fixture(module, environment) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(sum(record["tool"] == "python3" for record in records), 3) + for record in records: + if record["tool"] == "cmake" and record["argv"][0] == "-S": + self.assertFalse(any(arg.startswith("-DPython3_") for arg in record["argv"])) + + def test_unknown_or_empty_binding_fails_before_helpers_and_mutations(self): + for component in ("client", "server"): + for value in ("", "python3", "/usr/local/bin/python3", "/usr/bin/../bin/python3"): + with self.subTest(component=component, value=value), tempfile.TemporaryDirectory() as directory: + module, environment = self.fixture(directory, component) + environment["ATRINIK_WINDOWS_HOST_PYTHON"] = value + result, records = self.run_fixture(module, environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("ATRINIK_WINDOWS_HOST_PYTHON", result.stderr) + self.assertEqual(records, []) + self.assertFalse((module / "build").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 2b5c1d918..26d1651ea 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -415,9 +415,9 @@ def test_gpu_fixture_provenance_is_gated_before_qualification(self) -> None: ) self.assertIn("client-gpu-fixture-provenance", cmake) self.assertIn("DEPENDS client-gpu-fixture-provenance", cmake) - self.assertIn("python3 tools/verify_gpu_fixture_provenance.py", client_package) + self.assertIn('"${host_python}" tools/verify_gpu_fixture_provenance.py', client_package) self.assertIn( - "python3 ../client/tools/verify_gpu_fixture_provenance.py " + '"${host_python}" ../client/tools/verify_gpu_fixture_provenance.py ' "--content-runtime runtime/content", server_package, ) @@ -623,8 +623,8 @@ def test_windows_package_profile_sound_override_is_explicit_and_bounded(self) -> self.assertIn('expected_sound=$(realpath -e sound)', script) self.assertIn('selected_sound=$(realpath -e "${profile_sound}")', script) self.assertIn('find -P "${profile_sound}" ! -type d ! -type f', script) - self.assertIn('python3 tools/dependencies.py sync', script) - self.assertIn('python3 tools/dependencies.py verify', script) + self.assertIn('"${host_python}" tools/dependencies.py sync', script) + self.assertIn('"${host_python}" tools/dependencies.py verify', script) self.assertIn( 'profile_content=${ATRINIK_PROFILE_CONTENT_DIR:-}', server_script ) @@ -636,8 +636,8 @@ def test_windows_package_profile_sound_override_is_explicit_and_bounded(self) -> self.assertIn( "PYTHONDONTWRITEBYTECODE=1 native_llvm ./atrinik-server", server_script ) - self.assertIn('python3 tools/dependencies.py sync', server_script) - self.assertIn('python3 tools/dependencies.py verify', server_script) + self.assertIn('"${host_python}" tools/dependencies.py sync', server_script) + self.assertIn('"${host_python}" tools/dependencies.py verify', server_script) for workflow in ("build-release-candidate.yml", "package-release.yml"): workflow_text = self.text(workflow) self.assertNotIn("ATRINIK_PROFILE_SOUND_DIR", workflow_text) From 8d30e1b8bd1e826c1e59c9b48e34449c79e2a3c0 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 17:53:48 -0500 Subject: [PATCH 131/148] test(client): recognize bound Windows host Python contract --- client/tools/tests/test_windows_package_contract.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/tools/tests/test_windows_package_contract.py b/client/tools/tests/test_windows_package_contract.py index 61b27a68d..68b5931b3 100644 --- a/client/tools/tests/test_windows_package_contract.py +++ b/client/tools/tests/test_windows_package_contract.py @@ -17,7 +17,13 @@ def test_package_build_excludes_test_only_renderer(self) -> None: self.assertNotIn("-DBUILD_TESTING=ON", script) self.assertIn("'--gpu-player-view'", script) self.assertIn("'injected GPU conformance fault'", script) - self.assertIn("python3 tools/verify_gpu_fixture_provenance.py", script) + provenance_command = '"${host_python}" tools/verify_gpu_fixture_provenance.py' + self.assertIn(provenance_command, script) + self.assertIn("# atrinik-windows-host-python-contract: 1", script) + self.assertIn("host_python=python3", script) + self.assertIn("host_python=/usr/bin/python3", script) + self.assertIn("-DPython3_EXECUTABLE=/usr/bin/python3", script) + self.assertLess(script.index(provenance_command), script.index("cmake -S")) self.assertIn( "if (BUILD_TESTING)\n\tlist(APPEND SOURCES src/client/gpu_player_view.c)\nendif ()", sources, From 601b9f1de72d2416efd3d22c5f6dcc8466d651ea Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:00:59 -0500 Subject: [PATCH 132/148] fix(release): authenticate current Windows host Python recipes --- tools/release/windows_producer.py | 4 +-- tools/tests/test_windows_producer.py | 39 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py index 837b22c6a..738cbf045 100644 --- a/tools/release/windows_producer.py +++ b/tools/release/windows_producer.py @@ -54,8 +54,8 @@ "tools/ci/stage_windows_runtime.py": "12d315f854726e46ec768c5c4db6a34b0aabb93e5cab959233f5c5ec1650aeca", } LLVM_RECIPE = { - "client/tools/build-windows-package.sh": "10e25f9e5dd939fc7b34b14ae0d197956540659fec531b4915b6e0e9f43fddba", - "server/tools/build-windows-package.sh": "fd8c5dc43aaa88a9ea772bcbf151f82ea7cd5f2a77ac33734905f585f4df7a03", + "client/tools/build-windows-package.sh": "635c2eba2923b0a90c6d2896ae7f00c85f15685b1acd74b0e78cffe8ce365d99", + "server/tools/build-windows-package.sh": "14b93dc0f22ad945225712833d044d48704134f1b3ce768b10451998dd6916c2", "client/CMakeLists.txt": "f4a8595ffe7f4c4888b77eea2c8ddc248a6e0c74c733bc87b7b7bafb88d19f12", "server/CMakeLists.txt": "e9bac9c28d7f4f180dcf0f6496e8b429722ac5e8a82a60454381ac9125c21e1b", "tools/ci/stage_windows_runtime.py": "2640baf4187bce4afca95da560cff08e4bdf8802840245c3cbf26eff372691e9", diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py index a7d49344f..f55664112 100644 --- a/tools/tests/test_windows_producer.py +++ b/tools/tests/test_windows_producer.py @@ -6,6 +6,7 @@ import json from pathlib import Path import subprocess +import sys import tempfile import unittest from unittest.mock import patch @@ -150,6 +151,44 @@ def test_current_llvm_rejects_absent_malformed_and_dirty_compiler_descriptor(sel with self.assertRaisesRegex(ValueError, 'LLVM compiler descriptor'): producer.select_producer(self.root, self.revision) + def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: + repository = Path(__file__).resolve().parents[2] + revision = subprocess.run(['git', '-C', str(repository), 'rev-parse', 'HEAD'], + check=True, capture_output=True, text=True).stdout.strip() + source = self.root / 'current-source' + subprocess.run(['git', 'clone', '--quiet', '--shared', '--no-checkout', + str(repository), str(source)], check=True, capture_output=True) + subprocess.run(['git', '-C', str(source), 'checkout', '--quiet', '--detach', revision], + check=True, capture_output=True) + result = subprocess.run([sys.executable, str(self.selector), '--source-root', str(source), + '--revision', revision], check=True, capture_output=True, text=True) + selected = json.loads(result.stdout) + self.assertEqual(selected['windows_image'], producer.LLVM_IMAGE) + self.assertEqual(selected['windows_mode'], 'llvm') + self.assertEqual(selected['environment']['ATRINIK_WINDOWS_TARGET'], 'x86_64-w64-mingw32') + self.assertFalse(subprocess.run(['git', '-C', str(source), 'status', '--porcelain'], + check=True, capture_output=True).stdout) + # Authenticate the authored scripts, including rejecting committed drift + # on either helper rather than silently admitting a newer source recipe. + for path in ('client/tools/build-windows-package.sh', 'server/tools/build-windows-package.sh'): + with self.subTest(path=path): + subprocess.run(['git', '-C', str(source), 'checkout', '--quiet', '--detach', revision], + check=True, capture_output=True) + target = source / path + target.write_bytes(target.read_bytes() + b'\n# unreviewed recipe change\n') + subprocess.run(['git', '-C', str(source), 'add', path], check=True, capture_output=True) + subprocess.run(['git', '-C', str(source), '-c', 'commit.gpgsign=false', + '-c', 'user.name=Windows producer fixture', + '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', + '-m', 'unreviewed recipe fixture'], check=True, capture_output=True) + changed = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], + check=True, capture_output=True, text=True).stdout.strip() + rejected = subprocess.run([sys.executable, str(self.selector), '--source-root', + str(source), '--revision', changed], capture_output=True, text=True) + self.assertNotEqual(rejected.returncode, 0) + self.assertEqual(rejected.stdout, '') + self.assertIn('unapproved Windows source recipe: ' + path, rejected.stderr) + def test_actual_reviewed_source_commits_when_history_is_available(self) -> None: repository = Path(__file__).resolve().parents[2] for revision in producer.HISTORICAL: From 49a22bf14b16a7dada61d70f4b299b8fac287b73 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:01:05 -0500 Subject: [PATCH 133/148] fix(ci): route Windows contract fixtures to native jobs --- tools/ci/classify_changes.py | 6 ++++++ tools/tests/test_classify_changes.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index e432ab97f..d420e6d01 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -33,8 +33,11 @@ "tools/tests/test_rendezvous_admission_https_fixture.py", "tools/tests/test_rendezvous_admission_platform.py", "tools/tests/test_stage_windows_runtime.py", + "tools/tests/test_compose_windows_review_bundle.py", + "tools/tests/test_windows_host_python_contract.py", "tools/tests/test_windows_runtime_cmake_arguments.py", "tools/tests/test_windows_trust_notices.py", + "tools/tests/windows_wait_chain_fixture.ps1", "LICENSE.md", "docs/history/component-release-map.json", "docs/history/release-tags.json", @@ -59,8 +62,11 @@ "cmake/toolchains/llvm-macos.cmake", "tools/tests/test_macos_llvm_toolchain.py", "tools/tests/test_llvm_toolchain.py", "tools/tests/test_llvm_runtime_notices.py", "tools/tests/test_stage_windows_runtime.py", + "tools/tests/test_compose_windows_review_bundle.py", + "tools/tests/test_windows_host_python_contract.py", "tools/tests/test_windows_runtime_cmake_arguments.py", "tools/tests/test_windows_trust_notices.py", + "tools/tests/windows_wait_chain_fixture.ps1", } BENCHMARK_CONTRACT_PATHS = { ".github/workflows/pr-benchmarks.yml", diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index bc776a1d5..c1a3f6b4c 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -148,6 +148,23 @@ def test_shared_windows_runtime_tests_select_both_native_consumers_and_windows(s self.assertFalse(result["benchmark_client"]) self.assertFalse(result["benchmark_server"]) + def test_windows_lifecycle_contract_fixtures_select_native_and_windows_jobs(self) -> None: + for path in ( + "tools/tests/test_compose_windows_review_bundle.py", + "tools/tests/test_windows_host_python_contract.py", + "tools/tests/windows_wait_chain_fixture.ps1", + ): + with self.subTest(path=path): + self.assertTrue(classify_changes.is_native_shared(path)) + result = classify_changes.classify([path]) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + self.assertTrue(result["windows"]) + self.assertEqual(result["codeql_languages"], + "python" if path.endswith(".py") else "") + self.assertFalse(result["benchmark_client"]) + self.assertFalse(result["benchmark_server"]) + def test_unrelated_tools_tests_keep_native_builds_skipped(self) -> None: for path in ( "tools/tests/test_select_release_batch.py", From 992396dd52e754b63263d24ad9c81943bd21a1c5 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:05:02 -0500 Subject: [PATCH 134/148] test(release): exercise current Windows recipe in workflow fixture --- tools/tests/test_workflow_contracts.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 26d1651ea..ae2c84d1b 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -1619,7 +1619,13 @@ def test_discord_application_id_is_release_only_package_data(self) -> None: def test_candidate_windows_jobs_execute_the_authenticated_producer_environment(self) -> None: from tools.release.windows_producer import select_producer workflow = self.text("build-release-candidate.yml") - for revision in ("5dc27699ccabea180d932265654c3d7a03b8a943", + # Current recipe authentication follows the exact committed source, + # while the historical cases remain fixed to their reviewed commits. + current_revision = subprocess.run( + ["git", "-C", str(ROOT), "rev-parse", "--verify", "HEAD^{commit}"], + check=True, capture_output=True, text=True, + ).stdout.strip() + for revision in (current_revision, "fd4943252ac872fce2995c286b10a0c6d249aba3", "34bbad5c3f4d9b303c17aec5e2c75b3550ec8983", "994e6fdc20da962be9c11055bda28ef5561a1dea", @@ -1631,6 +1637,11 @@ def test_candidate_windows_jobs_execute_the_authenticated_producer_environment(s subprocess.run(["git", "clone", "--quiet", "--shared", "--no-checkout", str(ROOT), str(workspace)], check=True) subprocess.run(["git", "-C", str(workspace), "checkout", "--quiet", "--detach", revision], check=True) expected = select_producer(workspace, revision) + self.assertEqual(expected["windows_mode"], "llvm" if revision == current_revision else "mxe") + self.assertEqual(subprocess.run( + ["git", "-C", str(workspace), "status", "--porcelain"], + check=True, capture_output=True, text=True, + ).stdout, "") trusted = workspace / "build/windows-automation/tools/release" trusted.mkdir(parents=True) shutil.copyfile(ROOT / "tools/release/windows_producer.py", trusted / "windows_producer.py") From 157555a2c5049a18f25d217edeb5cd79d3cf2d52 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:22:48 -0500 Subject: [PATCH 135/148] fix(release): bind Windows producer host Python environment --- tools/release/windows_producer.py | 1 + tools/tests/test_windows_producer.py | 6 +++++- tools/tests/test_workflow_contracts.py | 5 +++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py index 738cbf045..36065f650 100644 --- a/tools/release/windows_producer.py +++ b/tools/release/windows_producer.py @@ -140,6 +140,7 @@ def select_producer(root: Path, revision: str) -> dict[str, object]: "ATRINIK_WINDOWS_TOOLCHAIN_FILE": "/opt/atrinik-windows/share/cmake/llvm-mingw.cmake", "ATRINIK_WINDOWS_TARGET": "x86_64-w64-mingw32", "ATRINIK_WINDOWS_RUNTIME_DIR": "/opt/atrinik-windows/bin", + "ATRINIK_WINDOWS_HOST_PYTHON": "/usr/bin/python3", } image, mode, epoch = LLVM_IMAGE, "llvm", "llvm-mingw-1.18-01065f08" return {"windows_image": image, "windows_mode": mode, diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py index f55664112..77eabc84d 100644 --- a/tools/tests/test_windows_producer.py +++ b/tools/tests/test_windows_producer.py @@ -53,6 +53,7 @@ def test_current_llvm_image_environment_and_compiler_cache(self) -> None: self.assertIn('llvm-mingw', selected['windows_cache_epoch']) environment = selected['environment'] self.assertEqual(environment['ATRINIK_WINDOWS_TARGET'], 'x86_64-w64-mingw32') + self.assertEqual(environment['ATRINIK_WINDOWS_HOST_PYTHON'], '/usr/bin/python3') self.assertTrue(environment['PATH'].startswith('/opt/llvm-mingw/bin:')) self.assertFalse(any(name.startswith('MXE_') for name in environment)) @@ -131,7 +132,8 @@ def test_cli_success_emits_bounded_metadata_and_docker_argument_lines(self) -> N 'windows_image=' + producer.LLVM_IMAGE, 'windows_mode=llvm', 'windows_cache_epoch=llvm-mingw-1.18-01065f08']) arguments = printed.getvalue().splitlines() - self.assertEqual(arguments[::2], ['--env'] * 4) + self.assertEqual(arguments[::2], ['--env'] * 5) + self.assertIn('ATRINIK_WINDOWS_HOST_PYTHON=/usr/bin/python3', arguments[1::2]) self.assertTrue(all('=' in argument for argument in arguments[1::2])) def test_current_llvm_rejects_absent_malformed_and_dirty_compiler_descriptor(self) -> None: @@ -166,6 +168,7 @@ def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: self.assertEqual(selected['windows_image'], producer.LLVM_IMAGE) self.assertEqual(selected['windows_mode'], 'llvm') self.assertEqual(selected['environment']['ATRINIK_WINDOWS_TARGET'], 'x86_64-w64-mingw32') + self.assertEqual(selected['environment']['ATRINIK_WINDOWS_HOST_PYTHON'], '/usr/bin/python3') self.assertFalse(subprocess.run(['git', '-C', str(source), 'status', '--porcelain'], check=True, capture_output=True).stdout) # Authenticate the authored scripts, including rejecting committed drift @@ -209,6 +212,7 @@ def test_actual_reviewed_source_commits_when_history_is_available(self) -> None: selected = json.loads(result.stdout) self.assertEqual(selected['windows_image'], producer.MXE_IMAGE) self.assertEqual(selected['windows_mode'], 'mxe') + self.assertNotIn('ATRINIK_WINDOWS_HOST_PYTHON', selected['environment']) self.assertEqual(selected['environment']['MXE_TARGET'], 'x86_64-w64-mingw32.shared') diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index ae2c84d1b..1e4ce80b1 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -1670,6 +1670,11 @@ def test_candidate_windows_jobs_execute_the_authenticated_producer_environment(s if index and arguments[index - 1] == "--env") for name, value in expected["environment"].items(): self.assertEqual(selected[name], value) + if revision == current_revision: + self.assertEqual(selected["ATRINIK_WINDOWS_HOST_PYTHON"], "/usr/bin/python3") + self.assertEqual(arguments.count("ATRINIK_WINDOWS_HOST_PYTHON=/usr/bin/python3"), 1) + else: + self.assertNotIn("ATRINIK_WINDOWS_HOST_PYTHON", selected) forbidden_prefix = "ATRINIK_WINDOWS_" if expected["windows_mode"] == "mxe" else "MXE_" self.assertFalse(any(name.startswith(forbidden_prefix) for name in selected)) self.assertIn(expected["windows_image"], arguments) From 5979a29393510d4cc2bee652c6e7f094748fcbeb Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:30:23 -0500 Subject: [PATCH 136/148] fix(ci): preserve historical benchmark compiler cohorts --- .github/workflows/pr-benchmarks.yml | 30 +-- docs/PR_BENCHMARKS.md | 21 ++- tools/ci/run_linux_check.sh | 60 +----- tools/ci/server_benchmark.py | 228 +++++++++++++++++++++++ tools/tests/test_server_benchmark.py | 246 +++++++++++++++++++++++++ tools/tests/test_workflow_contracts.py | 11 +- 6 files changed, 514 insertions(+), 82 deletions(-) create mode 100644 tools/ci/server_benchmark.py create mode 100644 tools/tests/test_server_benchmark.py diff --git a/.github/workflows/pr-benchmarks.yml b/.github/workflows/pr-benchmarks.yml index 48613eee9..e7a15eb0f 100644 --- a/.github/workflows/pr-benchmarks.yml +++ b/.github/workflows/pr-benchmarks.yml @@ -277,6 +277,7 @@ jobs: --epoch "${CLASSIC_LINUX_CCACHE_EPOCH}" \ --material .github/workflows/pr-benchmarks.yml \ --material tools/ci/run_linux_check.sh \ + --material tools/ci/server_benchmark.py \ --material cmake/toolchains/llvm.cmake \ --material cmake/AtrinikLLVM.cmake \ --material tools/ci/llvm_coverage.py \ @@ -288,28 +289,15 @@ jobs: path: ${{ runner.temp }}/classic-ccache/server-benchmark key: ${{ steps.linux-cache.outputs.key }} restore-keys: ${{ steps.linux-cache.outputs.restore_prefix }} - - name: Pull pinned Classic Linux environment - run: docker pull "${CLASSIC_LINUX_IMAGE}" - - name: Prepare isolated server benchmark compiler cache - run: | - mkdir -p "${RUNNER_TEMP}/classic-ccache/server-benchmark" - chmod 1777 "${RUNNER_TEMP}/classic-ccache/server-benchmark" - - name: Build and compare the server content-loader probe + - name: Build and compare the server content-loader probes in their pinned cohorts + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - docker run --rm \ - --user "$(id -u):$(id -g)" \ - --network none \ - --env ATRINIK_LLVM_TOOLCHAIN=1 \ - --env CCACHE_DIR=/cache/ccache \ - --env CCACHE_MAXSIZE=500M \ - --env HOME=/tmp/classic-home \ - --env ATRINIK_BENCHMARK_BASE_SHA="${{ github.event.pull_request.base.sha }}" \ - --env ATRINIK_BENCHMARK_REVISION="${{ github.event.pull_request.head.sha }}" \ - --volume "${GITHUB_WORKSPACE}:/workspace" \ - --volume "${RUNNER_TEMP}/classic-ccache/server-benchmark:/cache/ccache" \ - --workdir /workspace \ - "${CLASSIC_LINUX_IMAGE}" \ - tools/ci/run_linux_check.sh server-benchmark /workspace + python3 tools/ci/server_benchmark.py compare \ + --source-root "${GITHUB_WORKSPACE}" \ + --base "${BASE_SHA}" --head "${HEAD_SHA}" \ + --cache "${RUNNER_TEMP}/classic-ccache/server-benchmark" - name: Publish server benchmark job summary if: always() run: | diff --git a/docs/PR_BENCHMARKS.md b/docs/PR_BENCHMARKS.md index 08d74aaae..1c2242905 100644 --- a/docs/PR_BENCHMARKS.md +++ b/docs/PR_BENCHMARKS.md @@ -30,10 +30,22 @@ retained for 14 days. Same-repository pull requests receive one bounded updated movement-summary comment; fork pull requests retain read-only workflow permissions and publish evidence only through the run summary and artifact. -The workflow uses the same immutable dependency bundle and pinned Linux image -as required validation, then disables container networking while building and -running pull-request code. Applying the label never changes the required -`Classic validation` aggregate, and an absent, removed, or not-applicable +The candidate uses the same immutable dependency bundle and pinned LLVM Linux +image as required validation. The server comparison stages a clean detached base +and authenticates its committed benchmark producer contract. A baseline with the +approved original workflow and runner uses their frozen Classic build 1.16.0 +image and GCC 15.2.0; a current LLVM baseline uses its own source toolchain in +Classic build 1.18.0. Unknown or modified contracts fail before container launch. +The host launches each probe in its own digest-pinned image with networking +disabled, separate compiler caches and build trees, and runs only the required +`server-content-benchmark` test. Evidence records revision, image, compiler and +lock digests for both completed probes; a missing or failed probe produces no +success summary. Historical source commits are selected by producer contract, +not by a fixed commit whitelist. Release recovery approval is separate. + +The client retains its explicit GPU-qualification skip when the current source +has no qualified player-view benchmark. Applying the label never changes the +required `Classic validation` aggregate, and an absent, removed, or not-applicable request cannot leave that aggregate pending or make it fail. Push and merge-queue events run required correctness validation without timed @@ -58,6 +70,7 @@ contracts are network-free: ```sh python3 -m unittest -v tools.tests.test_classify_changes python3 -m unittest -v tools.tests.test_workflow_contracts +python3 -m unittest -v tools.tests.test_server_benchmark bash -n tools/ci/run_linux_check.sh shellcheck tools/ci/run_linux_check.sh actionlint .github/workflows/check.yml .github/workflows/pr-benchmarks.yml diff --git a/tools/ci/run_linux_check.sh b/tools/ci/run_linux_check.sh index 50327e831..6ba5c9c42 100755 --- a/tools/ci/run_linux_check.sh +++ b/tools/ci/run_linux_check.sh @@ -391,7 +391,9 @@ case "${component}" in env ATRINIK_BENCHMARK_REVISION="${benchmark_base_sha}" \ ATRINIK_BENCHMARK_DIRTY=false \ cmake --preset linux-release -B "${release_directory}" \ - "${launcher[@]}" \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_TOOLCHAIN_FILE="${baseline_root}/cmake/toolchains/llvm.cmake" \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${baseline_root}/protocol" \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${baseline_root}/libatrinik" cmake --build "${release_directory}" --target atrinik --parallel "${jobs}" @@ -514,60 +516,10 @@ case "${component}" in cmake --build "${release_directory}" --parallel "${jobs}" popd >/dev/null - baseline_root="${source_root}/build/ci-server-benchmark-baseline" - # Invoked indirectly by the EXIT trap below. - # shellcheck disable=SC2329 - cleanup_server_benchmark_baseline() { - git -C "${source_root}" worktree remove --force "${baseline_root}" >/dev/null 2>&1 || true - } - trap cleanup_server_benchmark_baseline EXIT - git -C "${source_root}" worktree add --detach \ - "${baseline_root}" "${benchmark_base_sha}" - python3 "${source_root}/server/tools/dependencies.py" sync \ - --root "${baseline_root}/server" \ - --lock "${baseline_root}/server/dependencies.lock.json" \ - --cache "${dependency_downloads}" --refresh --offline - python3 "${source_root}/server/tools/dependencies.py" verify \ - --root "${baseline_root}/server" \ - --lock "${baseline_root}/server/dependencies.lock.json" - baseline_pcpnatpmp_source=$(python3 "${source_root}/server/tools/dependencies.py" source \ - --source-lock "${baseline_root}/server/cmake/immutable_sources.lock.json" \ - --source-name libpcpnatpmp \ - --cache "${source_root}/build/dependency-source-cache" \ - --downloads "${dependency_downloads}" \ - --offline) - pushd "${baseline_root}/server" >/dev/null - cmake --preset linux-release -B "${release_directory}" \ - "${launcher[@]}" \ - -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${baseline_root}/protocol" \ - -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${baseline_root}/libatrinik" \ - -DFETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP="${baseline_pcpnatpmp_source}" - cmake --build "${release_directory}" --parallel "${jobs}" - popd >/dev/null - if ! git -C "${baseline_root}" diff --quiet HEAD --; then - echo "server benchmark build modified its immutable base source" >&2 - exit 2 - fi - - ctest --test-dir "${baseline_root}/server/${release_directory}" \ - --verbose -R '^server-content-benchmark$' 2>&1 | \ - tee "${source_root}/build/ci-evidence/server-content-benchmark-base.log" + # The host orchestrator builds and runs the detached base in its own pinned + # compiler/image cohort. This container remains exclusively strict LLVM. ctest --test-dir "${source_root}/server/${release_directory}" \ - --verbose -R '^server-content-benchmark$' 2>&1 | \ - tee "${source_root}/build/ci-evidence/server-content-benchmark-head.log" - # Markdown code spans are intentionally literal. - # shellcheck disable=SC2016 - printf '%s\n' \ - '## Server content benchmark' \ - '' \ - "- Base: \`${benchmark_base_sha}\`" \ - "- Head: \`${ATRINIK_BENCHMARK_REVISION:-unknown}\`" \ - '- Result: both isolated content-loader probes completed successfully.' \ - '- Evidence: `server-content-benchmark-base.log` and `server-content-benchmark-head.log`.' \ - >"${source_root}/build/ci-evidence/server-content-benchmark-summary.md" - - git -C "${source_root}" worktree remove --force "${baseline_root}" - trap - EXIT + --verbose --no-tests=error -R '^server-content-benchmark$' ;; integrated) pushd "${source_root}" >/dev/null diff --git a/tools/ci/server_benchmark.py b/tools/ci/server_benchmark.py new file mode 100644 index 000000000..b1e3c3525 --- /dev/null +++ b/tools/ci/server_benchmark.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Compare server probes using each revision's authenticated benchmark producer.""" +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + +WORKFLOW = ".github/workflows/pr-benchmarks.yml" +RUNNER = "tools/ci/run_linux_check.sh" +LLVM_IMAGE = "ghcr.io/atrinik/classic-build:1.18.0@sha256:86d3c4182dc786db8ace56d9d8dc57ff4f14c1c4f517b728547626f576280449" +GCC_IMAGE = "ghcr.io/atrinik/classic-build:1.16.0@sha256:e1c366dbf83ef987765ff913bbb193868314a139ae1e00cc134b22a3159464f2" +LLVM_COMPILER = "clang-23.1.3-lld-compiler-rt" +GCC_COMPILER = "gcc-15.2.0" +# Authenticate the original producer contract, not a whitelist of source commits. +HISTORICAL_CONTRACT = { + WORKFLOW: "c656631f0d880e0d6f09ab524015a2114ffb19d559e8fd534316583b90115878", + RUNNER: "3df551f030bf55ee1dd23579572727d26865f3d8320e3dc6e1e60d17495817c8", +} +LOCKS = ("client/dependencies.lock.json", "server/dependencies.lock.json", + "server/cmake/immutable_sources.lock.json") + + +def run(arguments: list[str], **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(arguments, check=True, **kwargs) + + +def git(root: Path, *arguments: str) -> bytes: + return run(["git", "-C", str(root), *arguments], capture_output=True, timeout=30).stdout + + +def clean_revision(root: Path, revision: str) -> None: + if re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise ValueError("benchmark revision must be an exact lowercase commit SHA") + if git(root, "rev-parse", "--verify", revision + "^{commit}").decode().strip() != revision: + raise ValueError("benchmark revision is not the selected commit") + if git(root, "rev-parse", "HEAD").decode().strip() != revision or git( + root, "status", "--porcelain", "--untracked-files=all"): + raise ValueError("benchmark checkout must be clean at its exact revision") + + +def committed(root: Path, revision: str, path: str) -> bytes: + entry = git(root, "ls-tree", revision, "--", path).decode().split() + if entry[:2] not in (["100644", "blob"], ["100755", "blob"]): + raise ValueError(f"benchmark contract must be a regular Git blob: {path}") + content = git(root, "show", revision + ":" + path) + material = root / path + if material.is_symlink() or material.read_bytes() != content: + raise ValueError(f"benchmark contract differs from its committed bytes: {path}") + return content + + +def resolve(root: Path, revision: str) -> dict: + clean_revision(root, revision) + workflow = committed(root, revision, WORKFLOW) + fields = {} + for key in ("CLASSIC_LINUX_IMAGE", "CLASSIC_LINUX_IMAGE_DIGEST", "CLASSIC_LINUX_CCACHE_COMPILER"): + values = re.findall(rf"^ {key}: ([^\s]+)$", workflow.decode(), re.MULTILINE) + if len(values) != 1: + raise ValueError(f"benchmark workflow has no unique producer field: {key}") + fields[key] = values[0] + image, compiler = fields["CLASSIC_LINUX_IMAGE"], fields["CLASSIC_LINUX_CCACHE_COMPILER"] + if fields["CLASSIC_LINUX_IMAGE_DIGEST"] != image.split("@")[-1]: + raise ValueError("benchmark image and digest differ") + if (image, compiler) == (GCC_IMAGE, GCC_COMPILER): + for path, digest in HISTORICAL_CONTRACT.items(): + if hashlib.sha256(committed(root, revision, path)).hexdigest() != digest: + raise ValueError("baseline has no approved historical benchmark producer contract") + elif (image, compiler) == (LLVM_IMAGE, LLVM_COMPILER): + committed(root, revision, "cmake/toolchains/llvm.cmake") + committed(root, revision, "cmake/AtrinikLLVM.cmake") + else: + raise ValueError("benchmark source has no approved compiler/image cohort") + return {"revision": revision, "image": image, "compiler": compiler, + "workflow_sha256": hashlib.sha256(workflow).hexdigest(), + "locks": {path: hashlib.sha256(committed(root, revision, path)).hexdigest() + for path in LOCKS}} + + +def baseline_probe(root: Path, revision: str, trusted_root: Path, evidence: Path, + inventory: Path = Path("/usr/local/share/atrinik/classic-toolchain.json")) -> None: + evidence.unlink(missing_ok=True) + descriptor = resolve(root, revision) + toolchain = json.loads(inventory.read_text()) + if toolchain["target"] != "classic-final" or toolchain["platform"] != "linux/amd64": + raise ValueError("baseline producer inventory has the wrong target/platform") + llvm = descriptor["compiler"] == LLVM_COMPILER + tool, version = ("clang", "23.1.3") if llvm else ("gcc", "15.2.0") + if toolchain["tools"].get(tool) != version: + raise ValueError("baseline producer inventory has the wrong compiler") + Path(os.environ.get("HOME", "/tmp/classic-home")).mkdir(parents=True, exist_ok=True) + environment = dict(os.environ, CC="clang" if llvm else "gcc", + CXX="clang++" if llvm else "g++", + ATRINIK_LLVM_TOOLCHAIN="1" if llvm else "0", + CCACHE_BASEDIR=str(root), CCACHE_COMPILERCHECK="content", + CCACHE_NOHASHDIR="true", CCACHE_TEMPDIR=str(root / "build/ccache-temp")) + Path(environment["CCACHE_TEMPDIR"]).mkdir(parents=True, exist_ok=True) + dependencies = trusted_root / "server/tools/dependencies.py" + bundle = trusted_root / "build/dependency-inputs" + run(["python3", str(dependencies), "bundle-verify", "--client-lock", str(root / LOCKS[0]), + "--server-lock", str(root / LOCKS[1]), "--source-lock", str(root / LOCKS[2]), + "--bundle", str(bundle)], env=environment) + for command in ("sync", "verify"): + arguments = ["python3", str(dependencies), command, "--root", str(root / "server"), + "--lock", str(root / LOCKS[1])] + if command == "sync": + arguments += ["--cache", str(bundle / "downloads"), "--refresh", "--offline"] + run(arguments, env=environment) + pcp = run(["python3", str(dependencies), "source", "--source-lock", str(root / LOCKS[2]), + "--source-name", "libpcpnatpmp", "--cache", str(root / "build/dependency-source-cache"), + "--downloads", str(bundle / "downloads"), "--offline"], + env=environment, capture_output=True, text=True).stdout.strip() + build = root / "server/build" / ("llvm-benchmark-baseline" if llvm else "gcc-benchmark-baseline") + arguments = ["cmake", "--preset", "linux-release", "-B", str(build), + "-DCMAKE_C_COMPILER_LAUNCHER=ccache", "-DCMAKE_CXX_COMPILER_LAUNCHER=ccache", + f"-DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL={root / 'protocol'}", + f"-DFETCHCONTENT_SOURCE_DIR_LIBATRINIK={root / 'libatrinik'}", + f"-DFETCHCONTENT_SOURCE_DIR_LIBPCPNATPMP={pcp}"] + if llvm: + arguments.append(f"-DCMAKE_TOOLCHAIN_FILE={root / 'cmake/toolchains/llvm.cmake'}") + run(arguments, cwd=root / "server", env=environment) + run(["cmake", "--build", str(build), "--parallel", str(len(os.sched_getaffinity(0)))], env=environment) + run(["ctest", "--test-dir", str(build), "--verbose", "--no-tests=error", + "-R", "^server-content-benchmark$"], env=environment) + clean_revision(root, revision) + evidence.write_text(json.dumps(dict(descriptor, completed=True), indent=2) + "\n") + + +def container(image: str, root: Path, cache: Path, environment: dict[str, str], + command: list[str]) -> list[str]: + arguments = ["docker", "run", "--rm", "--user", f"{os.getuid()}:{os.getgid()}", "--network", "none", + "--volume", f"{root}:{root}", "--volume", f"{cache}:/cache/ccache", "--workdir", str(root)] + for key, value in dict(CCACHE_DIR="/cache/ccache", CCACHE_MAXSIZE="500M", + HOME="/tmp/classic-home", **environment).items(): + arguments += ["--env", f"{key}={value}"] + return arguments + [image, *command] + + +def compare(root: Path, base: str, head: str, cache: Path) -> None: + if re.fullmatch(r"[0-9a-f]{40}", base) is None: + raise ValueError("benchmark base must be an exact lowercase commit SHA") + if git(root, "rev-parse", "--verify", base + "^{commit}").decode().strip() != base: + raise ValueError("benchmark base is not the selected commit") + candidate = resolve(root, head) + if candidate["compiler"] != LLVM_COMPILER: + raise ValueError("current benchmark candidate must use the strict LLVM producer") + baseline = root / "build/ci-server-benchmark-baseline" + evidence = root / "build/ci-evidence" + evidence.mkdir(parents=True, exist_ok=True) + summary = evidence / "server-content-benchmark-summary.md" + summary.unlink(missing_ok=True) + base_receipt = evidence / "server-content-benchmark-base.json" + base_receipt.unlink(missing_ok=True) + (evidence / "server-content-benchmark-head.json").unlink(missing_ok=True) + run(["git", "-C", str(root), "worktree", "add", "--detach", str(baseline), base]) + try: + original = resolve(baseline, base) + for side, descriptor in (("base", original), ("head", candidate)): + side_cache = cache / side / descriptor["compiler"] + side_cache.mkdir(parents=True, exist_ok=True) + side_cache.chmod(0o1777) + # Pulls remain anonymous and happen on the host, never in a build container. + run(["docker", "pull", descriptor["image"]]) + if side == "base": + command = ["python3", str(root / "tools/ci/server_benchmark.py"), "probe", + "--source-root", str(baseline), "--revision", base, + "--trusted-root", str(root), "--evidence", str(base_receipt)] + environment = {"ATRINIK_LLVM_TOOLCHAIN": "1" if original["compiler"] == LLVM_COMPILER else "0"} + else: + command = [str(root / RUNNER), "server-benchmark", str(root)] + environment = {"ATRINIK_LLVM_TOOLCHAIN": "1", "ATRINIK_BENCHMARK_BASE_SHA": base, + "ATRINIK_BENCHMARK_REVISION": head} + with (evidence / f"server-content-benchmark-{side}.log").open("w") as log: + run(container(descriptor["image"], root, side_cache, environment, command), + stdout=log, stderr=subprocess.STDOUT) + clean_revision(baseline if side == "base" else root, descriptor["revision"]) + if side == "base": + if json.loads(base_receipt.read_text()) != dict(original, completed=True): + raise ValueError("baseline probe did not produce the exact completed producer receipt") + if json.loads(base_receipt.read_text()) != dict(original, completed=True): + raise ValueError("baseline producer receipt changed during candidate execution") + clean_revision(baseline, base) + (evidence / "server-content-benchmark-head.json").write_text( + json.dumps(dict(candidate, completed=True), indent=2) + "\n") + summary.write_text("## Server content benchmark\n\n" + f"- Base: `{base}` ({original['compiler']}, `{original['image']}`)\n" + f"- Head: `{head}` ({candidate['compiler']}, `{candidate['image']}`)\n" + "- Result: both isolated content-loader probes completed successfully.\n" + "- Each compiler/image is a separate environment cohort; cross-cohort timings are informational.\n" + "- Evidence: `server-content-benchmark-base.log`, `server-content-benchmark-head.log` and producer JSON receipts.\n") + finally: + # No force: unexpected baseline changes remain available for inspection. + run(["git", "-C", str(root), "worktree", "remove", str(baseline)]) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + compare_parser = sub.add_parser("compare") + compare_parser.add_argument("--base", required=True) + compare_parser.add_argument("--head", required=True) + compare_parser.add_argument("--cache", type=Path, required=True) + probe_parser = sub.add_parser("probe") + probe_parser.add_argument("--revision", required=True) + probe_parser.add_argument("--trusted-root", type=Path, required=True) + probe_parser.add_argument("--evidence", type=Path, required=True) + for child in (compare_parser, probe_parser): + child.add_argument("--source-root", type=Path, required=True) + args = parser.parse_args() + try: + if args.command == "compare": + compare(args.source_root.resolve(), args.base, args.head, args.cache.resolve()) + else: + baseline_probe(args.source_root.resolve(), args.revision, args.trusted_root.resolve(), args.evidence) + except (ValueError, OSError, KeyError, subprocess.SubprocessError) as error: + parser.exit(1, f"server benchmark failed: {error}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_server_benchmark.py b/tools/tests/test_server_benchmark.py new file mode 100644 index 000000000..d09689254 --- /dev/null +++ b/tools/tests/test_server_benchmark.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from tools.ci import server_benchmark as benchmark + +ROOT = Path(__file__).resolve().parents[2] +HISTORICAL_BASE = "a64ea9f7297772f652802147c2ec103152b3edc9" + + +class BenchmarkTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / "source" + self.root.mkdir() + self.write(".gitignore", "build/\nserver/build/\n") + self.write(benchmark.WORKFLOW, "env:\n" + "\n".join(( + f" CLASSIC_LINUX_IMAGE: {benchmark.LLVM_IMAGE}", + f" CLASSIC_LINUX_IMAGE_DIGEST: {benchmark.LLVM_IMAGE.split('@')[1]}", + f" CLASSIC_LINUX_CCACHE_COMPILER: {benchmark.LLVM_COMPILER}", + )) + "\n") + self.write(benchmark.RUNNER, "#!/bin/bash\nexit 0\n") + self.write("cmake/toolchains/llvm.cmake", "# fixture own toolchain\n") + self.write("cmake/AtrinikLLVM.cmake", "# fixture LLVM policy\n") + for lock in benchmark.LOCKS: + self.write(lock, "{}\n") + self.git("init", "-q") + self.git("config", "user.name", "Benchmark fixture") + self.git("config", "user.email", "benchmark@example.invalid") + self.revision = self.commit() + + def write(self, path, content): + destination = self.root / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content) + + def git(self, *arguments): + return subprocess.run(["git", "-C", str(self.root), *arguments], check=True, + capture_output=True, text=True).stdout.strip() + + def commit(self): + self.git("add", ".") + self.git("-c", "commit.gpgsign=false", "commit", "-qm", "fixture") + return self.git("rev-parse", "HEAD") + + def historical_fixture(self): + self.write(benchmark.WORKFLOW, (self.root / benchmark.WORKFLOW).read_text() + .replace(benchmark.LLVM_IMAGE, benchmark.GCC_IMAGE) + .replace(benchmark.LLVM_IMAGE.split('@')[1], benchmark.GCC_IMAGE.split('@')[1]) + .replace(benchmark.LLVM_COMPILER, benchmark.GCC_COMPILER)) + digest = {path: hashlib.sha256((self.root / path).read_bytes()).hexdigest() + for path in benchmark.HISTORICAL_CONTRACT} + return self.commit(), digest + + def test_current_selection_binds_locks_image_and_compiler(self): + result = benchmark.resolve(self.root, self.revision) + self.assertEqual(result["image"], benchmark.LLVM_IMAGE) + self.assertEqual(result["compiler"], benchmark.LLVM_COMPILER) + self.assertEqual(set(result["locks"]), set(benchmark.LOCKS)) + + def test_exact_original_contract_and_later_source_commits(self): + exists = subprocess.run(["git", "-C", str(ROOT), "cat-file", "-e", + HISTORICAL_BASE + "^{commit}"], capture_output=True) + if exists.returncode: + self.skipTest("original benchmark contract unavailable in shallow history") + source = Path(self.temporary.name) / "historical" + subprocess.run(["git", "clone", "--quiet", "--shared", "--no-checkout", str(ROOT), str(source)], check=True) + subprocess.run(["git", "-C", str(source), "checkout", "--quiet", "--detach", HISTORICAL_BASE], check=True) + result = benchmark.resolve(source, HISTORICAL_BASE) + self.assertEqual(result["image"], benchmark.GCC_IMAGE) + self.assertEqual(result["compiler"], benchmark.GCC_COMPILER) + # Source-only descendants retaining this producer contract also qualify. + (source / "benchmark-descendant.txt").write_text("future source fixture\n") + subprocess.run(["git", "-C", str(source), "add", "benchmark-descendant.txt"], check=True) + subprocess.run(["git", "-C", str(source), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "-c", "commit.gpgsign=false", "commit", "-qm", "future fixture"], check=True) + revision = benchmark.git(source, "rev-parse", "HEAD").decode().strip() + self.assertEqual(benchmark.resolve(source, revision)["compiler"], benchmark.GCC_COMPILER) + + def test_unknown_tampered_dirty_and_nonexact_inputs_rejected(self): + for revision in ("HEAD", self.revision.upper(), "--detach", "f" * 39): + with self.subTest(revision=revision), self.assertRaises((ValueError, subprocess.SubprocessError)): + benchmark.resolve(self.root, revision) + self.write("untracked", "tamper") + with self.assertRaisesRegex(ValueError, "clean"): + benchmark.resolve(self.root, self.revision) + (self.root / "untracked").unlink() + self.write(benchmark.WORKFLOW, (self.root / benchmark.WORKFLOW).read_text().replace("1.18.0", "9.99.0")) + revision = self.commit() + with self.assertRaisesRegex(ValueError, "approved compiler/image"): + benchmark.resolve(self.root, revision) + + def test_historical_compiler_fields_alone_do_not_authorize_gcc(self): + revision, _ = self.historical_fixture() + with self.assertRaisesRegex(ValueError, "approved historical"): + benchmark.resolve(self.root, revision) + + def test_symlinked_committed_toolchain_rejected(self): + path = self.root / "cmake/toolchains/llvm.cmake" + path.unlink() + path.symlink_to("../AtrinikLLVM.cmake") + revision = self.commit() + with self.assertRaisesRegex(ValueError, "regular Git blob"): + benchmark.resolve(self.root, revision) + + def test_container_is_host_launched_offline_without_socket_or_credentials(self): + args = benchmark.container(benchmark.GCC_IMAGE, self.root, self.root / "cache", + {"ATRINIK_LLVM_TOOLCHAIN": "0"}, ["python3", "fixture.py"]) + self.assertEqual(args[:4], ["docker", "run", "--rm", "--user"]) + self.assertIn("none", args) + self.assertIn(f"{self.root}:{self.root}", args) + self.assertIn("ATRINIK_LLVM_TOOLCHAIN=0", args) + self.assertEqual(args[-3:], [benchmark.GCC_IMAGE, "python3", "fixture.py"]) + for forbidden in ("docker.sock", "GH_TOKEN", "--privileged"): + self.assertNotIn(forbidden, " ".join(args)) + + @unittest.skipUnless(shutil.which("cmake") and shutil.which("gcc") and shutil.which("ctest"), "fixture compiler unavailable") + def test_executable_historical_configure_build_and_only_required_probe(self): + self.write("server/CMakeLists.txt", '''cmake_minimum_required(VERSION 3.20) +project(BenchmarkFixture C) +foreach(dependency ATRINIK_PROTOCOL LIBATRINIK LIBPCPNATPMP) + if(NOT FETCHCONTENT_SOURCE_DIR_${dependency}) + message(FATAL_ERROR "Offline sibling source omitted") + endif() +endforeach() +if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU" OR CMAKE_TOOLCHAIN_FILE) + message(FATAL_ERROR "Historical fixture inherited the LLVM toolchain") +endif() +enable_testing() +add_executable(probe main.c) +add_test(NAME server-content-benchmark COMMAND probe) +add_test(NAME unrelated-must-not-run COMMAND ${CMAKE_COMMAND} -E false) +''') + self.write("server/main.c", "int main(void) { return 0; }\n") + self.write("server/CMakePresets.json", json.dumps({"version": 3, "configurePresets": [{ + "name": "linux-release", "generator": "Unix Makefiles", + "cacheVariables": {"CMAKE_BUILD_TYPE": "Release"}}]})) + revision, approved = self.historical_fixture() + trusted = Path(self.temporary.name) / "trusted" + dependencies = trusted / "server/tools/dependencies.py" + dependencies.parent.mkdir(parents=True) + dependencies.write_text("import sys\nif sys.argv[1] == 'source': print('/fixture/pcp')\n") + inventory = trusted / "inventory.json" + inventory.write_text(json.dumps({"target": "classic-final", "platform": "linux/amd64", "tools": {"gcc": "15.2.0"}})) + binaries = trusted / "bin" + binaries.mkdir() + launcher = binaries / "ccache" + launcher.write_text('#!/bin/sh\nexec "$@"\n') + launcher.chmod(0o755) + receipt = trusted / "receipt.json" + with patch.object(benchmark, "HISTORICAL_CONTRACT", approved), patch.dict( + os.environ, {"PATH": str(binaries) + os.pathsep + os.environ["PATH"], + "HOME": str(trusted / "home"), "ATRINIK_LLVM_TOOLCHAIN": "1", "CC": "false"}): + benchmark.baseline_probe(self.root, revision, trusted, receipt, inventory) + result = json.loads(receipt.read_text()) + self.assertEqual(result["compiler"], benchmark.GCC_COMPILER) + self.assertTrue(result["completed"]) + cache = (self.root / "server/build/gcc-benchmark-baseline/CMakeCache.txt").read_text() + self.assertIn("CMAKE_C_COMPILER:FILEPATH=" + shutil.which("gcc"), cache) + self.assertNotIn("CMAKE_TOOLCHAIN_FILE", cache) + # A matching regex that finds no test is an error, not successful evidence. + self.write("server/CMakeLists.txt", (self.root / "server/CMakeLists.txt").read_text() + .replace("NAME server-content-benchmark", "NAME unrelated-probe")) + revision = self.commit() + with patch.object(benchmark, "HISTORICAL_CONTRACT", approved), patch.dict( + os.environ, {"PATH": str(binaries) + os.pathsep + os.environ["PATH"], + "HOME": str(trusted / "home")}): + with self.assertRaises(subprocess.CalledProcessError): + benchmark.baseline_probe(self.root, revision, trusted, receipt, inventory) + self.assertFalse(receipt.exists()) + + def test_probe_wrong_inventory_fails_before_configure(self): + inventory = Path(self.temporary.name) / "inventory.json" + inventory.write_text(json.dumps({"target": "classic-final", "platform": "linux/amd64", "tools": {"clang": "22.0.0"}})) + with patch.object(benchmark, "run", wraps=benchmark.run) as runner: + with self.assertRaisesRegex(ValueError, "wrong compiler"): + benchmark.baseline_probe(self.root, self.revision, self.root, self.root / "receipt", inventory) + self.assertFalse(any(call.args[0][0] == "cmake" for call in runner.call_args_list)) + + def test_compare_routes_cohorts_and_failure_cannot_publish_success(self): + historical, approved = self.historical_fixture() + self.write(benchmark.WORKFLOW, (self.root / benchmark.WORKFLOW).read_text() + .replace(benchmark.GCC_IMAGE, benchmark.LLVM_IMAGE) + .replace(benchmark.GCC_IMAGE.split('@')[1], benchmark.LLVM_IMAGE.split('@')[1]) + .replace(benchmark.GCC_COMPILER, benchmark.LLVM_COMPILER)) + head = self.commit() + actual_run = benchmark.run + commands = [] + + def execution(arguments, **kwargs): + if arguments[0] != "docker": + return actual_run(arguments, **kwargs) + commands.append(arguments) + if arguments[1] == "run" and benchmark.GCC_IMAGE in arguments: + root = Path(arguments[arguments.index("--source-root") + 1]) + receipt = Path(arguments[arguments.index("--evidence") + 1]) + receipt.write_text(json.dumps(dict(benchmark.resolve(root, historical), completed=True))) + return subprocess.CompletedProcess(arguments, 0) + + with patch.object(benchmark, "HISTORICAL_CONTRACT", approved), patch.object(benchmark, "run", side_effect=execution): + benchmark.compare(self.root, historical, head, Path(self.temporary.name) / "cache") + evidence = self.root / "build/ci-evidence" + self.assertTrue((evidence / "server-content-benchmark-summary.md").exists()) + launches = [args for args in commands if args[1] == "run"] + self.assertEqual(len(launches), 2) + self.assertIn(benchmark.GCC_IMAGE, launches[0]) + self.assertIn("ATRINIK_LLVM_TOOLCHAIN=0", launches[0]) + self.assertIn(benchmark.LLVM_IMAGE, launches[1]) + self.assertIn("ATRINIK_LLVM_TOOLCHAIN=1", launches[1]) + self.assertTrue(any("/base/gcc-15.2.0:/cache/ccache" in part for part in launches[0])) + self.assertTrue(any("/head/clang-23.1.3-lld-compiler-rt:/cache/ccache" in part for part in launches[1])) + + def fail(arguments, **kwargs): + if arguments[0] == "docker" and benchmark.LLVM_IMAGE in arguments: + raise subprocess.CalledProcessError(9, arguments) + return execution(arguments, **kwargs) + + with patch.object(benchmark, "HISTORICAL_CONTRACT", approved), patch.object(benchmark, "run", side_effect=fail): + with self.assertRaises(subprocess.CalledProcessError): + benchmark.compare(self.root, historical, head, Path(self.temporary.name) / "cache") + self.assertFalse((evidence / "server-content-benchmark-summary.md").exists()) + self.assertFalse((evidence / "server-content-benchmark-head.json").exists()) + self.assertFalse((self.root / "build/ci-server-benchmark-baseline").exists()) + + def test_current_runner_keeps_llvm_and_requires_one_head_probe(self): + runner = (ROOT / benchmark.RUNNER).read_text() + server = runner[runner.index(" server-benchmark)\n"):runner.index(" integrated)\n")] + self.assertIn('"${launcher[@]}"', server) + self.assertIn("--no-tests=error -R '^server-content-benchmark$'", server) + self.assertNotIn("worktree add", server) + self.assertIn("export CC=clang CXX=clang++", runner) + self.assertNotIn("export CC=gcc", runner) + self.assertIn('"-DCMAKE_TOOLCHAIN_FILE=${source_root}/cmake/toolchains/llvm.cmake"', runner) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index 1e4ce80b1..c57438187 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -1217,7 +1217,7 @@ def test_linux_validation_routes_every_noncoverage_operation_to_selected_tree(se self.assertIn('-B "${sanitizer_directory}"', line) for root in ("source_root", "baseline_root"): self.assertIn(f'${{{root}}}/client/${{release_directory}}/atrinik', runner) - self.assertIn(f'${{{root}}}/server/${{release_directory}}', runner) + self.assertIn('${source_root}/server/${release_directory}', runner) for operation in ( 'cmake -S "${source_root}/protocol" -B "${source_root}/protocol/${protocol_directory}"', 'cmake --build "${source_root}/protocol/${protocol_directory}"', @@ -1496,7 +1496,9 @@ def test_timed_pr_benchmarks_are_explicit_isolated_and_fork_safe(self) -> None: ] for material in benchmark_materials: self.assertEqual(client_benchmark.count(f"--material {material}"), 1) - self.assertIn("tools/ci/run_linux_check.sh server-benchmark", workflow) + self.assertIn("python3 tools/ci/server_benchmark.py compare", workflow) + self.assertIn('--base "${BASE_SHA}" --head "${HEAD_SHA}"', workflow) + self.assertNotIn("tools/ci/run_linux_check.sh server-benchmark /workspace", workflow) self.assertNotIn("recover_attested_dependency_bundle.sh", workflow) self.assertNotIn("--trusted-bundle", workflow) self.assertIn("github.event.pull_request.base.sha", workflow) @@ -2169,7 +2171,10 @@ def test_check_stages_one_verified_bundle_for_offline_consumers(self) -> None: encoding="utf-8" ) self.assertIn("bundle-verify", runner) - self.assertEqual(runner.count("--offline"), 8) + self.assertEqual(runner.count("--offline"), 6) + benchmark = (ROOT / "tools/ci/server_benchmark.py").read_text() + self.assertEqual(benchmark.count('"--offline"'), 2) + self.assertIn('"bundle-verify"', benchmark) self.assertIn('build/dependency-source-cache', runner) self.assertIn('--downloads "${dependency_downloads}"', runner) self.assertIn('"${baseline_root}/client/dependencies.lock.json"', runner) From a7ccbf7feac352de24be4fdf307a1e1a6a148e1d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:30:34 -0500 Subject: [PATCH 137/148] fix(client): avoid draining primary work for first minimap allocation --- client/src/client/gpu_map_renderer.c | 18 ++++++++- client/src/include/gpu_map_renderer.h | 2 + client/src/tests/gpu_renderer_integration.c | 41 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/client/src/client/gpu_map_renderer.c b/client/src/client/gpu_map_renderer.c index 73c963a6a..8127f20f7 100644 --- a/client/src/client/gpu_map_renderer.c +++ b/client/src/client/gpu_map_renderer.c @@ -272,6 +272,9 @@ static gpu_map_pending_submission_t *pending_submissions; static gpu_map_pending_submission_t *pending_submissions_tail; static gpu_map_pending_submission_t *abandoned_submissions; static size_t pending_submission_count; +#ifdef ATRINIK_GPU_CONFORMANCE_TESTS +static bool pending_poll_deferred; +#endif static uint64_t next_pending_submission_id = 1; static uint64_t next_map_generation = 1; static SDL_Surface *solid_surface; @@ -1445,6 +1448,9 @@ static void gpu_map_pending_abandoned_destroy(void) { static void gpu_map_pending_poll(void) { #ifdef ATRINIK_GPU_CONFORMANCE_TESTS + if (pending_poll_deferred) { + return; + } if (pending_submissions != NULL && gpu_renderer_conformance_fault_take(GPU_RENDERER_CONFORMANCE_FAULT_FENCE)) { gpu_map_pending_abandon_all(); @@ -1751,7 +1757,10 @@ static bool gpu_map_world_instance_buffers_reserve(gpu_map_target_set_t *target, SDL_ReleaseGPUTransferBuffer(map_device, transfer); return false; } - if (!gpu_map_pending_wait_all()) { + /* A new target has no previous instance resources to protect. In + * particular, allocating the first minimap must not drain unrelated + * primary-map work before the event loop can present or accept close. */ + if (target->world_instance_buffer != NULL && !gpu_map_pending_wait_all()) { SDL_ReleaseGPUBuffer(map_device, buffer); SDL_ReleaseGPUTransferBuffer(map_device, transfer); return false; @@ -2771,6 +2780,9 @@ void gpu_map_renderer_set_invalidation_hint(gpu_renderer_map_invalidation_reason } void gpu_map_renderer_destroy(void) { +#ifdef ATRINIK_GPU_CONFORMANCE_TESTS + pending_poll_deferred = false; +#endif gpu_map_presentation_release(staged_scene); staged_scene = NULL; gpu_map_presentation_release(presented_scene); @@ -4287,6 +4299,10 @@ size_t gpu_map_renderer_lit_instance_count(bool auxiliary) { return count; } +void gpu_map_renderer_conformance_defer_completion(bool defer) { + pending_poll_deferred = defer; +} + size_t gpu_map_renderer_pending_submission_count(void) { return pending_submission_count; } diff --git a/client/src/include/gpu_map_renderer.h b/client/src/include/gpu_map_renderer.h index 644b4a6f7..bffb03fae 100644 --- a/client/src/include/gpu_map_renderer.h +++ b/client/src/include/gpu_map_renderer.h @@ -107,6 +107,8 @@ size_t gpu_map_renderer_atlas_page_count(void); size_t gpu_map_renderer_atlas_allocation_count(void); /** Count active retained primary or auxiliary instances with compact-light owners. */ size_t gpu_map_renderer_lit_instance_count(bool auxiliary); +/** Hold nonblocking retirement to exercise pending work independently of GPU speed. */ +void gpu_map_renderer_conformance_defer_completion(bool defer); /** Return the number of submitted map command buffers not yet retired. */ size_t gpu_map_renderer_pending_submission_count(void); #endif diff --git a/client/src/tests/gpu_renderer_integration.c b/client/src/tests/gpu_renderer_integration.c index 8f80b930d..912aa7f21 100644 --- a/client/src/tests/gpu_renderer_integration.c +++ b/client/src/tests/gpu_renderer_integration.c @@ -498,6 +498,43 @@ static bool recover_and_republish(SDL_Window *window, uint16_t light_level); static bool projected_alpha_recovery_checkpoint(SDL_Window *window, bool qualified); +/** First minimap allocation must not drain a pending primary-map submission. */ +static bool first_auxiliary_allocation_checkpoint(SDL_Surface *source) { + SDL_FRect destination = {0.0f, 0.0f, 16.0f, 16.0f}; + gpu_map_renderer_conformance_defer_completion(true); + bool success = gpu_renderer_begin_frame() && gpu_renderer_map_begin(32, 32) && + gpu_renderer_draw_surface(source, NULL, &destination) && + gpu_renderer_map_end() && gpu_map_renderer_pending_submission_count() == 1; + gpu_renderer_statistics_reset(); + success = success && gpu_renderer_map_begin_auxiliary(24, 24) && + gpu_renderer_draw_surface(source, NULL, &destination) && gpu_renderer_map_end(); + gpu_renderer_statistics_t first; + gpu_renderer_statistics_get(&first); + success = success && gpu_map_renderer_pending_submission_count() == 2 && + first.timings[GPU_RENDERER_TIMING_COMPLETION].calls == 0; + + /* Replacing an existing pair still drains its pending consumers. Holding + * query retirement makes both assertions deterministic on a fast GPU. */ + gpu_renderer_statistics_reset(); + success = success && gpu_renderer_map_begin_auxiliary(24, 24); + for (size_t index = 0; success && index < 1025; index++) { + gpu_renderer_map_set_instance_identity(index + 1U, 0); + success = gpu_renderer_draw_surface(source, NULL, &destination); + } + success = success && gpu_renderer_map_end(); + gpu_renderer_statistics_t replacement; + gpu_renderer_statistics_get(&replacement); + success = success && replacement.timings[GPU_RENDERER_TIMING_COMPLETION].calls > 0 && + gpu_map_renderer_pending_submission_count() == 1; + gpu_map_renderer_conformance_defer_completion(false); + bool drained = gpu_renderer_wait_idle(); + if (!success || !drained) { + SDL_SetError("first auxiliary allocation waited or instance replacement lost its drain"); + return false; + } + return true; +} + static bool async_map_submission_checkpoint(SDL_Surface *source) { SDL_FRect destination = {0.0f, 0.0f, 32.0f, 32.0f}; if (!gpu_renderer_wait_idle()) { @@ -2743,6 +2780,10 @@ int main(void) { NULL, SDL_MapSurfaceRGBA(sources[3], 32, 192, 64, SDL_ALPHA_OPAQUE))); + GPU_REQUIRE(first_auxiliary_allocation_checkpoint(source)); + gpu_renderer_destroy(); + GPU_REQUIRE(qualified ? gpu_renderer_create(window) : gpu_renderer_create_conformance(window)); + /* The production minimap can be the first GPU map target. Keep that path * covered before the primary map allocates the shared projected-light * binding. */ From b327a1c5dc411fcebc691988d7fa6014c0e3798e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:35:46 -0500 Subject: [PATCH 138/148] fix(release): authenticate integrated protocol 1084 recipe --- tools/release/windows_producer.py | 4 ++-- tools/tests/test_windows_producer.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py index 36065f650..0081a2baf 100644 --- a/tools/release/windows_producer.py +++ b/tools/release/windows_producer.py @@ -56,8 +56,8 @@ LLVM_RECIPE = { "client/tools/build-windows-package.sh": "635c2eba2923b0a90c6d2896ae7f00c85f15685b1acd74b0e78cffe8ce365d99", "server/tools/build-windows-package.sh": "14b93dc0f22ad945225712833d044d48704134f1b3ce768b10451998dd6916c2", - "client/CMakeLists.txt": "f4a8595ffe7f4c4888b77eea2c8ddc248a6e0c74c733bc87b7b7bafb88d19f12", - "server/CMakeLists.txt": "e9bac9c28d7f4f180dcf0f6496e8b429722ac5e8a82a60454381ac9125c21e1b", + "client/CMakeLists.txt": "f13c97b203a198cdf5848a48bcbc218641d2787422eb6c194a2eeb37b3474e83", + "server/CMakeLists.txt": "ecdfbfd98d6864d7f43541409a21e63e5db024ac0c65ba8b161c5dc22e8a941a", "tools/ci/stage_windows_runtime.py": "2640baf4187bce4afca95da560cff08e4bdf8802840245c3cbf26eff372691e9", "cmake/toolchains/llvm.cmake": "b1eb85208a5f44a5b28956c33b92d86c4f056da1cac9355b483a84b46d55a513", } diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py index 77eabc84d..38cfb9195 100644 --- a/tools/tests/test_windows_producer.py +++ b/tools/tests/test_windows_producer.py @@ -171,9 +171,10 @@ def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: self.assertEqual(selected['environment']['ATRINIK_WINDOWS_HOST_PYTHON'], '/usr/bin/python3') self.assertFalse(subprocess.run(['git', '-C', str(source), 'status', '--porcelain'], check=True, capture_output=True).stdout) - # Authenticate the authored scripts, including rejecting committed drift - # on either helper rather than silently admitting a newer source recipe. - for path in ('client/tools/build-windows-package.sh', 'server/tools/build-windows-package.sh'): + # Authenticate both package helpers and their configure entrypoints; + # committed drift must not silently admit a newer source recipe. + for path in ('client/tools/build-windows-package.sh', 'server/tools/build-windows-package.sh', + 'client/CMakeLists.txt', 'server/CMakeLists.txt'): with self.subTest(path=path): subprocess.run(['git', '-C', str(source), 'checkout', '--quiet', '--detach', revision], check=True, capture_output=True) From 45b79b1263a0a97d3ee988d0f9bf7bc0e4de986d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:59:13 -0500 Subject: [PATCH 139/148] fix(release): authenticate shared Classic CMake recipe inputs --- tools/release/windows_producer.py | 2 ++ tools/tests/test_windows_producer.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py index 0081a2baf..baa7637fb 100644 --- a/tools/release/windows_producer.py +++ b/tools/release/windows_producer.py @@ -58,6 +58,8 @@ "server/tools/build-windows-package.sh": "14b93dc0f22ad945225712833d044d48704134f1b3ce768b10451998dd6916c2", "client/CMakeLists.txt": "f13c97b203a198cdf5848a48bcbc218641d2787422eb6c194a2eeb37b3474e83", "server/CMakeLists.txt": "ecdfbfd98d6864d7f43541409a21e63e5db024ac0c65ba8b161c5dc22e8a941a", + "libatrinik/CMakeLists.txt": "a6bc61acf194bd33b43e86f77dd6068e1ee6be17005390a7f17ddde585efb6a9", + "libatrinik/cmake/RequireClassicProtocol.cmake": "774325cab773ea8277eb3936238e4708141838169b4384d914a83fd7ee888734", "tools/ci/stage_windows_runtime.py": "2640baf4187bce4afca95da560cff08e4bdf8802840245c3cbf26eff372691e9", "cmake/toolchains/llvm.cmake": "b1eb85208a5f44a5b28956c33b92d86c4f056da1cac9355b483a84b46d55a513", } diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py index 38cfb9195..534c93b4f 100644 --- a/tools/tests/test_windows_producer.py +++ b/tools/tests/test_windows_producer.py @@ -171,10 +171,11 @@ def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: self.assertEqual(selected['environment']['ATRINIK_WINDOWS_HOST_PYTHON'], '/usr/bin/python3') self.assertFalse(subprocess.run(['git', '-C', str(source), 'status', '--porcelain'], check=True, capture_output=True).stdout) - # Authenticate both package helpers and their configure entrypoints; + # Authenticate package helpers, configure entrypoints and shared protocol checks; # committed drift must not silently admit a newer source recipe. for path in ('client/tools/build-windows-package.sh', 'server/tools/build-windows-package.sh', - 'client/CMakeLists.txt', 'server/CMakeLists.txt'): + 'client/CMakeLists.txt', 'server/CMakeLists.txt', + 'libatrinik/CMakeLists.txt', 'libatrinik/cmake/RequireClassicProtocol.cmake'): with self.subTest(path=path): subprocess.run(['git', '-C', str(source), 'checkout', '--quiet', '--detach', revision], check=True, capture_output=True) From 15fdae1df1856abed8047d24f7c0554dc237897f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 18:59:08 -0500 Subject: [PATCH 140/148] fix(ci): select server benchmark contract changes --- tools/ci/classify_changes.py | 4 ++++ tools/tests/test_classify_changes.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index d420e6d01..3b55bbcb5 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -24,6 +24,7 @@ "CMakePresets.json", "codecov.yml", "tools/tests/test_coverage_presets.py", + "tools/tests/test_server_benchmark.py", "tools/tests/test_llvm_coverage.py", "tools/tests/test_llvm_coverage_identity.py", "tools/tests/test_llvm_toolchain.py", @@ -57,6 +58,7 @@ "tools/ci/", ) WINDOWS_TEST_PATHS = { + "tools/tests/test_server_benchmark.py", "CMakeLists.txt", "cmake/AtrinikLLVM.cmake", "cmake/InstallLLVMRuntime.cmake", "cmake/InstallWindowsRuntime.cmake", "cmake/toolchains/llvm.cmake", "cmake/toolchains/llvm-macos.cmake", "tools/tests/test_macos_llvm_toolchain.py", @@ -104,6 +106,8 @@ SERVER_BENCHMARK_PATHS = { "server/CMakeLists.txt", "server/CMakePresets.json", + "tools/ci/server_benchmark.py", + "tools/tests/test_server_benchmark.py", } diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index c1a3f6b4c..b0a070ef9 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -49,6 +49,36 @@ def test_benchmark_paths_select_only_relevant_suites(self) -> None: ) self.assertTrue(benchmark_tool["benchmark_client"]) + def test_server_benchmark_contract_paths_select_server_suite_and_native_jobs(self) -> None: + for path in ( + "tools/ci/server_benchmark.py", + "tools/tests/test_server_benchmark.py", + ): + with self.subTest(path=path): + self.assertTrue(classify_changes.is_native_shared(path)) + result = classify_changes.classify([path]) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + self.assertTrue(result["windows"]) + self.assertTrue(result["benchmark_server"]) + self.assertFalse(result["benchmark_client"]) + self.assertEqual(result["codeql_languages"], "python") + + def test_server_benchmark_contract_routing_uses_exact_paths(self) -> None: + for path in ( + "tools/ci/server_benchmark.py.bak", + "tools/tests/test_server_benchmark.py.bak", + "tools/tests/test_other_benchmark.py", + ): + with self.subTest(path=path): + result = classify_changes.classify([path]) + self.assertFalse(result["benchmark_server"]) + self.assertFalse(result["benchmark_client"]) + if path.startswith("tools/tests/"): + self.assertFalse(result["client"]) + self.assertFalse(result["server"]) + self.assertFalse(result["windows"]) + def test_benchmark_contract_change_selects_both_suites(self) -> None: for path in ( ".github/workflows/pr-benchmarks.yml", From a2690161450b99ef54c3925bb08699001a6e41b3 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 19:02:44 -0500 Subject: [PATCH 141/148] fix(server): budget the long freeze coverage regression --- server/src/tests/unit/server/living.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/tests/unit/server/living.c b/server/src/tests/unit/server/living.c index badbee161..c004b5e1a 100644 --- a/server/src/tests/unit/server/living.c +++ b/server/src/tests/unit/server/living.c @@ -384,10 +384,19 @@ static Suite *suite(void) { tcase_add_test(tc_core, test_poison_encumbrance_regression_uses_recoverable_floor); tcase_add_test(tc_core, test_paralysis_timing_remains_in_speed_credit); tcase_add_test(tc_core, test_administrative_freeze_keeps_requested_tick_duration); - tcase_add_test(tc_core, test_long_administrative_freeze_does_not_gain_roundoff_tick); tcase_add_test(tc_core, test_non_player_speed_is_not_clamped); tcase_add_test(tc_core, test_monster_living_speed_is_not_player_clamped); + /* Keep all 13,146 real event ticks: shorter runs do not exercise the + * accumulated roundoff regression. Atomic LLVM coverage counters make + * this case exceed Check's default five-second per-test budget. */ + TCase *tc_long_freeze = tcase_create("Long freeze"); + tcase_set_timeout(tc_long_freeze, 30); + tcase_add_unchecked_fixture(tc_long_freeze, check_setup, check_teardown); + tcase_add_checked_fixture(tc_long_freeze, check_test_setup, check_test_teardown); + suite_add_tcase(s, tc_long_freeze); + tcase_add_test(tc_long_freeze, test_long_administrative_freeze_does_not_gain_roundoff_tick); + return s; } From 3ab0fe050e711bdd97eeb4bd960578736a2d86c5 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 19:28:56 -0500 Subject: [PATCH 142/148] fix(release): guard selected Windows producer policy and routing --- docs/RELEASING.md | 12 +++++- tools/release/windows_producer.py | 39 +++++++++++++++++ tools/tests/test_windows_producer.py | 64 +++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fb49cfaa3..d3efe5d11 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -583,7 +583,17 @@ helper, with no Linux client package recipe. The four approved first-parent schema-2 revisions from `994e6fdc20da962be9c11055bda28ef5561a1dea` through `2349e0e14276fee251250cd451da6da0e68a3148` retain their Debian client. Other schema-1 source requires separate producer authentication before rebuilding. -Windows candidate metadata independently authenticates its producer recipe. +Windows candidate metadata checks drift in selected producer policy files and +their routing. The current LLVM recipe authenticates the listed compiler/version, +runtime/sound staging and dependency-acquisition CMake policy files, and rejects +component-local alternatives that would shadow their canonical paths. It does +not authenticate every executable source input or independently attest compiler +origin. Ordinary C and source-list CMake changes, protocol/pathfinding source +graphs, generators and the compiled native worldmaker remain trusted reviewed +inputs at the exact selected source revision. Source review and fresh hosted +build logs establish LLVM compiler use; package inspectors establish layout and +runtime identities, not compiler origin. Final build and artifact checks remain +required. Approved historical source uses the original general SDK 1.16 MXE image and environment; current source uses the general SDK 1.18 LLVM environment. Both Windows package jobs consume that selection and partition compiler caches by diff --git a/tools/release/windows_producer.py b/tools/release/windows_producer.py index baa7637fb..c2cb09a7d 100644 --- a/tools/release/windows_producer.py +++ b/tools/release/windows_producer.py @@ -6,6 +6,16 @@ source fails closed. A source recipe change requires reviewing and updating its digest registry; an absent compiler descriptor never grants an automatic historical fallback. + +The current recipe guards drift in selected compiler/version policy, runtime +and sound staging, dependency-acquisition CMake policy and their module routing. +It rejects preferred local modules that would shadow those canonical inputs. +It does not authenticate the entire executable source graph or independently +attest compiler origin. Ordinary C, source-list CMake, protocol/pathfinding source +graphs, generators and the compiled native worldmaker remain trusted reviewed +inputs at the selected Git commit. Source review and fresh hosted build evidence +establish compiler use; artifact inspection establishes package layout and runtime +identities, not compiler origin. """ # Copyright 2026 The Atrinik Project # SPDX-License-Identifier: GPL-2.0-or-later @@ -62,7 +72,28 @@ "libatrinik/cmake/RequireClassicProtocol.cmake": "774325cab773ea8277eb3936238e4708141838169b4384d914a83fd7ee888734", "tools/ci/stage_windows_runtime.py": "2640baf4187bce4afca95da560cff08e4bdf8802840245c3cbf26eff372691e9", "cmake/toolchains/llvm.cmake": "b1eb85208a5f44a5b28956c33b92d86c4f056da1cac9355b483a84b46d55a513", + "server/cmake/pcpnatpmp.cmake": "f020a35f4ff28a37465d244fd49b6eb3091ee45836c2ba78346fe0844cf5ac70", + "server/cmake/immutable_source_cache.cmake": "0d83c3c2bd4e3d2790a4cf749ee52d5b0de8bcd7ded827386bf6dc2a9b1ba2f3", + "server/cmake/apply_patch_idempotent.cmake": "df536def2f9dd910cc1e6b7e96925b0047099c258f7b3b388541371600815fef", + "cmake/AtrinikLLVM.cmake": "5a87ecf9c1f5cadc0dcf3391c3d461eac90a6dce06b4238e625f86beed0727bc", + "cmake/AtrinikVersion.cmake": "49ba744105d0bdbe8e90d46d19470f4357612ac3019b26bca1c4f25e3a412266", + "cmake/InstallWindowsRuntime.cmake": "18efb62084ec7e2324a0a533295b0517f89f642e495659c4001cb5c4168f3c38", + "client/cmake/verify_windows_client_sound.cmake": "5b933e4c70315925980f38ec32d59fbc43caead94baf380281052d632f4482d9", } +# Current monorepo entrypoints must keep using the authenticated shared modules. +# Standalone source-archive overlays are not admitted by this producer recipe. +LLVM_RECIPE_ALTERNATIVES = ( + "client/cmake/AtrinikVersion.cmake", + "client/cmake/AtrinikLLVM.cmake", + "server/cmake/AtrinikVersion.cmake", + "server/cmake/AtrinikLLVM.cmake", + "protocol/cmake/AtrinikVersion.cmake", + "client/cmake/InstallWindowsRuntime.cmake", + "server/cmake/InstallWindowsRuntime.cmake", + "server/cmake/toolchains/llvm.cmake", + "client/tools/ci/stage_windows_runtime.py", + "server/tools/ci/stage_windows_runtime.py", +) SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -86,6 +117,13 @@ def verify_recipe(root: Path, revision: str, recipe: dict[str, str]) -> None: raise ValueError(f"Windows checkout differs from selected source: {path}") +def verify_llvm_recipe_alternatives(root: Path, revision: str) -> None: + for path in LLVM_RECIPE_ALTERNATIVES: + local = root / path + if git(root, "ls-tree", revision, "--", path) or local.exists() or local.is_symlink(): + raise ValueError(f"unapproved Windows source recipe alternative: {path}") + + def verify_llvm_descriptor(root: Path, revision: str) -> None: path = "tools/ci/appimage/packaging.lock.json" entry = git(root, "ls-tree", revision, "--", path).decode().split() @@ -136,6 +174,7 @@ def select_producer(root: Path, revision: str) -> dict[str, object]: image, mode, epoch = MXE_IMAGE, "mxe", "mxe-gcc-general-1.16-4266256d" else: verify_recipe(root, revision, LLVM_RECIPE) + verify_llvm_recipe_alternatives(root, revision) verify_llvm_descriptor(root, revision) environment = { "PATH": "/opt/llvm-mingw/bin:/opt/atrinik-windows/bin:" + SYSTEM_PATH, diff --git a/tools/tests/test_windows_producer.py b/tools/tests/test_windows_producer.py index 534c93b4f..a31919a4a 100644 --- a/tools/tests/test_windows_producer.py +++ b/tools/tests/test_windows_producer.py @@ -153,7 +153,7 @@ def test_current_llvm_rejects_absent_malformed_and_dirty_compiler_descriptor(sel with self.assertRaisesRegex(ValueError, 'LLVM compiler descriptor'): producer.select_producer(self.root, self.revision) - def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: + def actual_current_source(self) -> tuple[Path, str]: repository = Path(__file__).resolve().parents[2] revision = subprocess.run(['git', '-C', str(repository), 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip() @@ -162,6 +162,19 @@ def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: str(repository), str(source)], check=True, capture_output=True) subprocess.run(['git', '-C', str(source), 'checkout', '--quiet', '--detach', revision], check=True, capture_output=True) + return source, revision + + def commit_actual_source(self, source: Path) -> str: + subprocess.run(['git', '-C', str(source), 'add', '.'], check=True, capture_output=True) + subprocess.run(['git', '-C', str(source), '-c', 'commit.gpgsign=false', + '-c', 'user.name=Windows producer fixture', + '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', + '-m', 'source recipe fixture'], check=True, capture_output=True) + return subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], + check=True, capture_output=True, text=True).stdout.strip() + + def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: + source, revision = self.actual_current_source() result = subprocess.run([sys.executable, str(self.selector), '--source-root', str(source), '--revision', revision], check=True, capture_output=True, text=True) selected = json.loads(result.stdout) @@ -175,7 +188,11 @@ def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: # committed drift must not silently admit a newer source recipe. for path in ('client/tools/build-windows-package.sh', 'server/tools/build-windows-package.sh', 'client/CMakeLists.txt', 'server/CMakeLists.txt', - 'libatrinik/CMakeLists.txt', 'libatrinik/cmake/RequireClassicProtocol.cmake'): + 'libatrinik/CMakeLists.txt', 'libatrinik/cmake/RequireClassicProtocol.cmake', + 'cmake/AtrinikLLVM.cmake', 'cmake/AtrinikVersion.cmake', + 'cmake/InstallWindowsRuntime.cmake', 'client/cmake/verify_windows_client_sound.cmake', + 'server/cmake/pcpnatpmp.cmake', 'server/cmake/immutable_source_cache.cmake', + 'server/cmake/apply_patch_idempotent.cmake'): with self.subTest(path=path): subprocess.run(['git', '-C', str(source), 'checkout', '--quiet', '--detach', revision], check=True, capture_output=True) @@ -194,6 +211,49 @@ def test_actual_current_recipe_selects_without_registry_overrides(self) -> None: self.assertEqual(rejected.stdout, '') self.assertIn('unapproved Windows source recipe: ' + path, rejected.stderr) + def test_actual_current_recipe_rejects_local_and_committed_shadow_alternatives(self) -> None: + source, revision = self.actual_current_source() + # Keep expectations independent of the selector's own alternatives list. + alternatives = ( + 'client/cmake/AtrinikVersion.cmake', 'client/cmake/AtrinikLLVM.cmake', + 'server/cmake/AtrinikVersion.cmake', 'server/cmake/AtrinikLLVM.cmake', + 'protocol/cmake/AtrinikVersion.cmake', 'client/cmake/InstallWindowsRuntime.cmake', + 'server/cmake/InstallWindowsRuntime.cmake', 'server/cmake/toolchains/llvm.cmake', + 'client/tools/ci/stage_windows_runtime.py', 'server/tools/ci/stage_windows_runtime.py', + ) + for path in alternatives: + with self.subTest(path=path): + subprocess.run(['git', '-C', str(source), 'checkout', '--quiet', '--detach', revision], + check=True, capture_output=True) + target = source / path + self.assertFalse(target.exists()) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text('# unreviewed preferred recipe overlay\n') + with self.assertRaisesRegex(ValueError, 'unapproved Windows source recipe alternative'): + producer.select_producer(source, revision) + target.unlink() + target.symlink_to('absent-overlay-target') + with self.assertRaisesRegex(ValueError, 'unapproved Windows source recipe alternative'): + producer.select_producer(source, revision) + target.unlink() + target.write_text('# unreviewed preferred recipe overlay\n') + changed = self.commit_actual_source(source) + rejected = subprocess.run([sys.executable, str(self.selector), '--source-root', + str(source), '--revision', changed], capture_output=True, text=True) + self.assertNotEqual(rejected.returncode, 0) + self.assertEqual(rejected.stdout, '') + self.assertIn('unapproved Windows source recipe alternative: ' + path, rejected.stderr) + + def test_actual_current_recipe_admits_ordinary_source_revision(self) -> None: + source, revision = self.actual_current_source() + target = source / 'server/src/server/main.c' + target.write_bytes(target.read_bytes() + b'\n/* ordinary source change fixture */\n') + changed = self.commit_actual_source(source) + self.assertNotEqual(changed, revision) + result = subprocess.run([sys.executable, str(self.selector), '--source-root', str(source), + '--revision', changed], check=True, capture_output=True, text=True) + self.assertEqual(json.loads(result.stdout)['windows_mode'], 'llvm') + def test_actual_reviewed_source_commits_when_history_is_available(self) -> None: repository = Path(__file__).resolve().parents[2] for revision in producer.HISTORICAL: From d3b25afd24389447cd663bd89278a77a6ff8ef76 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 19:30:54 -0500 Subject: [PATCH 143/148] fix(ci): validate toolkit dependency interface changes --- tools/ci/classify_changes.py | 1 + tools/tests/test_classify_changes.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/tools/ci/classify_changes.py b/tools/ci/classify_changes.py index 3b55bbcb5..04367ce27 100755 --- a/tools/ci/classify_changes.py +++ b/tools/ci/classify_changes.py @@ -25,6 +25,7 @@ "codecov.yml", "tools/tests/test_coverage_presets.py", "tools/tests/test_server_benchmark.py", + "tools/tests/test_toolkit_dependency_interfaces.py", "tools/tests/test_llvm_coverage.py", "tools/tests/test_llvm_coverage_identity.py", "tools/tests/test_llvm_toolchain.py", diff --git a/tools/tests/test_classify_changes.py b/tools/tests/test_classify_changes.py index b0a070ef9..a3de12f92 100644 --- a/tools/tests/test_classify_changes.py +++ b/tools/tests/test_classify_changes.py @@ -129,6 +129,31 @@ def test_shared_native_change_selects_both_components(self) -> None: self.assertFalse(result["codeql_server"]) self.assertTrue(result["codeql_core_cpp"]) + def test_toolkit_dependency_interface_contract_selects_both_native_consumers(self) -> None: + path = "tools/tests/test_toolkit_dependency_interfaces.py" + self.assertTrue(classify_changes.is_native_shared(path)) + result = classify_changes.classify([path]) + self.assertTrue(result["client"]) + self.assertTrue(result["server"]) + self.assertFalse(result["windows"]) + self.assertFalse(result["benchmark_client"]) + self.assertFalse(result["benchmark_server"]) + self.assertEqual(result["codeql_languages"], "python") + + def test_toolkit_dependency_interface_routing_uses_exact_path(self) -> None: + for path in ( + "tools/tests/test_toolkit_dependency_interfaces.py.bak", + "tools/tests/test_other_dependency_interfaces.py", + ): + with self.subTest(path=path): + self.assertFalse(classify_changes.is_native_shared(path)) + result = classify_changes.classify([path]) + self.assertFalse(result["client"]) + self.assertFalse(result["server"]) + self.assertFalse(result["windows"]) + self.assertFalse(result["benchmark_client"]) + self.assertFalse(result["benchmark_server"]) + def test_llvm_contract_changes_select_both_native_consumers(self) -> None: for path in ("cmake/toolchains/llvm.cmake", "cmake/toolchains/llvm-macos.cmake", "cmake/AtrinikLLVM.cmake", "tools/ci/llvm_coverage.py", From 24f6ecb12b02a8f73534141ef49ecc388ce285c1 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 19:18:44 -0500 Subject: [PATCH 144/148] fix(client): trace bounded GPU presentation progress in review smoke --- client/src/client/gpu_renderer.c | 88 +++++++++++++++++-- tools/ci/smoke_windows_review_bundle.ps1 | 53 ++++++++++- .../test_compose_windows_review_bundle.py | 70 +++++++++++++++ tools/tests/windows_wait_chain_fixture.ps1 | 35 +++++++- 4 files changed, 239 insertions(+), 7 deletions(-) diff --git a/client/src/client/gpu_renderer.c b/client/src/client/gpu_renderer.c index e938525ea..0613c7254 100644 --- a/client/src/client/gpu_renderer.c +++ b/client/src/client/gpu_renderer.c @@ -106,6 +106,54 @@ static gpu_renderer_statistics_t statistics; #ifdef ATRINIK_GPU_CONFORMANCE_TESTS static gpu_renderer_conformance_fault_t conformance_fault; #endif +/* Process-lifetime observations, independent of resettable benchmark statistics. + * Only the owned smoke fixture opts in; never query or wait on a GPU fence here. */ +static uint64_t progress_map_submitted; +static uint64_t progress_map_completed; +static uint64_t progress_frame; +static unsigned progress_frames_logged; +static bool progress_identity_logged; + +static bool gpu_renderer_progress_enabled(void) { + /* Renderer operations and these observations stay on the main thread. */ + static bool initialized; + static bool enabled; + if (!initialized) { + const char *value = + SDL_GetEnvironmentVariable(SDL_GetEnvironment(), "ATRINIK_GPU_PROGRESS"); + const char *review = + SDL_GetEnvironmentVariable(SDL_GetEnvironment(), "ATRINIK_REVIEW_NO_PAUSE"); + enabled = + value != NULL && strcmp(value, "1") == 0 && review != NULL && strcmp(review, "1") == 0; + initialized = true; + } + return enabled; +} + +static const char *gpu_renderer_progress_label(const char *value) { + size_t length = strlen(value); + if (length == 0 || length > 96) { + return "unavailable"; + } + for (size_t i = 0; i < length; i++) { + unsigned char c = (unsigned char)value[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + strchr(" ._()+,-", c) != NULL)) { + return "unavailable"; + } + } + return value; +} + +static void gpu_renderer_progress_log(const char *phase) { + LOG(INFO, + "GPU progress frame=%" PRIu64 " phase=%s map_submitted=%" PRIu64 " map_completed=%" PRIu64 + ".", + progress_frame, + phase, + progress_map_submitted, + progress_map_completed); +} static char backend[32]; static char device_name[256]; static char driver_name[256]; @@ -779,6 +827,15 @@ static bool gpu_renderer_create_internal(SDL_Window *window, bool require_hardwa device_name, driver_name, driver_version); + if (gpu_renderer_progress_enabled() && !progress_identity_logged) { + LOG(INFO, + "GPU progress identity backend=[%s] device=[%s] driver=[%s] version=[%s].", + gpu_renderer_progress_label(backend), + gpu_renderer_progress_label(device_name), + gpu_renderer_progress_label(driver_name), + gpu_renderer_progress_label(driver_version)); + progress_identity_logged = true; + } return true; } @@ -1084,11 +1141,26 @@ bool gpu_renderer_present(void) { } #endif uint64_t started = gpu_renderer_timing_begin(); - bool result = - SDL_SetRenderTarget(renderer, NULL) && - SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE) && - SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE) && SDL_RenderClear(renderer) && - SDL_RenderTexture(renderer, frame_target, NULL, NULL) && SDL_RenderPresent(renderer); + bool result = SDL_SetRenderTarget(renderer, NULL) && + SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE) && + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE) && + SDL_RenderClear(renderer) && + SDL_RenderTexture(renderer, frame_target, NULL, NULL); + if (result) { + bool progress_log = gpu_renderer_progress_enabled() && progress_map_submitted != 0 && + progress_frames_logged < 64; + if (progress_log) { + if (progress_frame != UINT64_MAX) { + progress_frame++; + } + progress_frames_logged++; + gpu_renderer_progress_log("before-present"); + } + result = SDL_RenderPresent(renderer); + if (progress_log) { + gpu_renderer_progress_log(result ? "after-present" : "present-error"); + } + } statistics.draws++; gpu_renderer_timing_end(GPU_RENDERER_TIMING_PRESENT_WAIT, started); gpu_map_renderer_presentation_finish(result); @@ -2194,6 +2266,9 @@ static void gpu_renderer_statistics_add(uint64_t *total, uint64_t value) { } void gpu_renderer_statistics_map_submission(size_t queue_depth) { + if (gpu_renderer_progress_enabled() && progress_map_submitted != UINT64_MAX) { + progress_map_submitted++; + } statistics.map_submissions++; statistics.map_queue_depth_samples++; gpu_renderer_statistics_add(&statistics.map_queue_depth_total, (uint64_t)queue_depth); @@ -2202,6 +2277,9 @@ void gpu_renderer_statistics_map_submission(size_t queue_depth) { } void gpu_renderer_statistics_map_completion(uint64_t queue_age_ns, uint64_t frame_latency_ns) { + if (gpu_renderer_progress_enabled() && progress_map_completed != UINT64_MAX) { + progress_map_completed++; + } statistics.map_completions++; gpu_renderer_statistics_add(&statistics.map_queue_age_total_ns, queue_age_ns); gpu_renderer_statistics_add(&statistics.map_frame_latency_total_ns, frame_latency_ns); diff --git a/tools/ci/smoke_windows_review_bundle.ps1 b/tools/ci/smoke_windows_review_bundle.ps1 index 2fbbc4294..57b91659a 100644 --- a/tools/ci/smoke_windows_review_bundle.ps1 +++ b/tools/ci/smoke_windows_review_bundle.ps1 @@ -61,6 +61,49 @@ function Get-LauncherLogTail([string]$Path) { } } +# Observe only the retained owned Process handle. PID reuse, disposal, exit and +# counters moving backwards make this evidence unavailable, never successful. +function Get-ClientCpuSample($Client) { + try { + $Client.Refresh() + $handle = $Client.SafeHandle + if ($Client.HasExited -or $handle.IsClosed -or $handle.IsInvalid) { return $null } + $retained = $false + try { + $handle.DangerousAddRef([ref]$retained) + $sample = [pscustomobject]@{ + ProcessId = $Client.Id + Handle = $handle.DangerousGetHandle() + StartTicks = $Client.StartTime.ToUniversalTime().Ticks + CpuTicks = $Client.TotalProcessorTime.Ticks + ElapsedTicks = [System.Environment]::TickCount64 + } + if ($Client.HasExited) { return $null } + return $sample + } finally { + if ($retained) { $handle.DangerousRelease() } + } + } catch { return $null } +} + +function Get-ClientCpuProgress($Client, $Baseline) { + try { + $sample = Get-ClientCpuSample $Client + if ($null -eq $Baseline -or $null -eq $sample -or + $sample.ProcessId -ne $Baseline.ProcessId -or + $sample.Handle -ne $Baseline.Handle -or + $sample.StartTicks -ne $Baseline.StartTicks -or + $sample.CpuTicks -lt $Baseline.CpuTicks -or + $sample.ElapsedTicks -lt $Baseline.ElapsedTicks) { + return "" + } + $cpuMilliseconds = [long][System.Math]::Floor( + ($sample.CpuTicks - $Baseline.CpuTicks) / 10000.0) + $elapsedMilliseconds = $sample.ElapsedTicks - $Baseline.ElapsedTicks + return "pid=$($sample.ProcessId) cpu_ms=$cpuMilliseconds elapsed_ms=$elapsedMilliseconds" + } catch { return "" } +} + # Retain only fixed lifecycle messages from the actual launcher log before its # temporary directory is removed. The closed grammar excludes private payloads. function Save-ClientLifecycleEvidence([string]$Path) { @@ -80,9 +123,12 @@ function Save-ClientLifecycleEvidence([string]$Path) { } if ($count -le 16777216) { $text = [string]::new($buffer, 0, $count) - $pattern = "(?m)^\[[0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}\] INFO[ ]+\[(?:socket_command_player|main|Event_PollInputDevice|system_end):[0-9]{1,10}\] (?:Gameplay ready\.|First gameplay frame presented\.|Client (?:window close event received|quit event received|quit event polling completed|main loop exited|shutdown complete)\.|Client shutdown stage: (?:UI cleanup|network connection|directory workers|audio|settings and assets|image workers|image packs|asset requests|toolkit, GPU, window and SDL)\.)\r?$" + $pattern = "(?m)^\[[0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}\] INFO[ ]+\[(?:socket_command_player|(?:SDL_)?main|Event_PollInputDevice|system_end):[0-9]{1,10}\] (?:Gameplay ready\.|First gameplay frame presented\.|Client (?:window close event received|quit event received|quit event polling completed|main loop exited|shutdown complete)\.|Client shutdown stage: (?:UI cleanup|network connection|directory workers|audio|settings and assets|image workers|image packs|asset requests|toolkit, GPU, window and SDL)\.)\r?$" $lines = @([regex]::Matches($text, $pattern) | Select-Object -First 32 | ForEach-Object { $_.Value.TrimEnd([char]13) }) + $gpuPattern = "(?m)^\[[0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}\] INFO[ ]+\[(?:gpu_renderer_progress_log|gpu_renderer_create_internal):[0-9]{1,10}\] GPU progress (?:frame=[0-9]{1,20} phase=(?:before-present|after-present|present-error) map_submitted=[0-9]{1,20} map_completed=[0-9]{1,20}\.|identity backend=\[[A-Za-z0-9 ._()+,-]{1,96}\] device=\[[A-Za-z0-9 ._()+,-]{1,96}\] driver=\[[A-Za-z0-9 ._()+,-]{1,96}\] version=\[[A-Za-z0-9 ._()+,-]{1,96}\]\.)\r?$" + $lines += @([regex]::Matches($text, $gpuPattern) | Select-Object -First 129 | + ForEach-Object { $_.Value.TrimEnd([char]13) }) if ($lines.Count -eq 0) { $lines = @("") } } else { $lines = @("") @@ -895,6 +941,8 @@ try { $launcherStartInfo.RedirectStandardOutput = $true $launcherStartInfo.RedirectStandardError = $true $launcherStartInfo.Environment["ATRINIK_REVIEW_NO_PAUSE"] = "1" + # Child-only opt-in; the invoking shell's environment is never changed. + $launcherStartInfo.Environment["ATRINIK_GPU_PROGRESS"] = "1" $launcherStartInfo.ArgumentList.Add("/c") $launcherStartInfo.ArgumentList.Add("call") $launcherStartInfo.ArgumentList.Add($launchers[0].FullName) @@ -1030,11 +1078,14 @@ try { $clientCloseStarted = $true Write-Host ("Client windows before normal close: " + (Get-ClientWindowSnapshot $launcherClient)) + $clientCpuBaseline = Get-ClientCpuSample $launcherClient if (-not $launcherClient.CloseMainWindow()) { throw "Could not request a normal close of the one-click client window" } Write-Host "Client normal close request accepted; waiting up to 30 seconds." if (-not $launcherClient.WaitForExit(30000)) { + Write-Host ("Client close timeout CPU: " + + (Get-ClientCpuProgress $launcherClient $clientCpuBaseline)) Write-Host ("Client close timeout thread: " + (Get-ClientCloseTimeoutSnapshot $launcherClient)) Write-Host ("Client close timeout stack: " + diff --git a/tools/tests/test_compose_windows_review_bundle.py b/tools/tests/test_compose_windows_review_bundle.py index c97bdaa05..a202b23c1 100644 --- a/tools/tests/test_compose_windows_review_bundle.py +++ b/tools/tests/test_compose_windows_review_bundle.py @@ -302,6 +302,8 @@ def test_close_failure_diagnostics_preserve_shutdown_acceptance(self) -> None: self.assertRegex( acceptance, r'if \(-not \$launcherClient.WaitForExit\(30000\)\) \{\s*' + r'Write-Host \("Client close timeout CPU: " \+\s*' + r'\(Get-ClientCpuProgress \$launcherClient \$clientCpuBaseline\)\)\s*' r'Write-Host \("Client close timeout thread: " \+\s*' r'\(Get-ClientCloseTimeoutSnapshot \$launcherClient\)\)\s*' r'Write-Host \("Client close timeout stack: " \+\s*' @@ -491,6 +493,7 @@ def test_lifecycle_evidence_keeps_only_fixed_raw_stage_lines(self) -> None: messages = ( ("socket_command_player", "Gameplay ready."), ("main", "First gameplay frame presented."), + ("SDL_main", "First gameplay frame presented."), ("Event_PollInputDevice", "Client window close event received."), ("Event_PollInputDevice", "Client quit event received."), ("main", "Client quit event polling completed."), @@ -519,6 +522,73 @@ def test_lifecycle_evidence_keeps_only_fixed_raw_stage_lines(self) -> None: repeated = "\n".join(expected * 4) self.assertEqual(len(list(re.finditer(pattern, repeated))[:32]), 32) + def test_gpu_progress_evidence_is_bounded_and_closed(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + pattern = re.search(r'\$gpuPattern = "([^"]+)"', smoke).group(1) + stamp = "[2026/10/11 11:36:14.936572] INFO " + identity = stamp + ("[gpu_renderer_create_internal:123] GPU progress identity " + "backend=[direct3d12] device=[Microsoft Basic Render Driver] " + "driver=[D3D12] version=[1.2.3].") + before = stamp + ("[gpu_renderer_progress_log:123] GPU progress frame=1 " + "phase=before-present map_submitted=2 map_completed=0.") + after = before.replace("before-present", "after-present") + rejected = [ + identity.replace("direct3d12", "https://private.invalid"), + identity.replace("D3D12", "C:\\private\\driver"), + identity.replace("1.2.3", "a" * 97), + before + " token=private-value", + before.replace("frame=1", "frame=-1"), + before.replace("frame=1", "frame=" + "1" * 21), + before.replace("before-present", "private-value"), + before.replace("INFO", "\x1bINFO"), + ] + for newline in ("\n", "\r\n"): + text = newline.join(rejected + [identity, before, after]) + newline + self.assertEqual([m.group(0).rstrip("\r") for m in re.finditer(pattern, text)], + [identity, before, after]) + self.assertIn("Select-Object -First 129", smoke) + self.assertIn('$launcherStartInfo.Environment["ATRINIK_GPU_PROGRESS"] = "1"', smoke) + self.assertNotIn("$env:ATRINIK_GPU_PROGRESS", smoke) + + def test_cpu_evidence_fences_handle_and_generation(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + helper = smoke.split("function Get-ClientCpuSample", 1)[1].split( + "# Retain only fixed lifecycle", 1)[0] + for proof in ("$Client.SafeHandle", "$Client.StartTime.ToUniversalTime().Ticks", + "$handle.DangerousAddRef([ref]$retained)", + "$handle.DangerousRelease()", "$sample.ProcessId -ne $Baseline.ProcessId", + "$sample.Handle -ne $Baseline.Handle", + "$sample.StartTicks -ne $Baseline.StartTicks", + "$sample.CpuTicks -lt $Baseline.CpuTicks", + "$sample.ElapsedTicks -lt $Baseline.ElapsedTicks"): + self.assertIn(proof, helper) + self.assertLess(smoke.index("$clientCpuBaseline = Get-ClientCpuSample $launcherClient"), + smoke.index("$launcherClient.CloseMainWindow()")) + for forbidden in (".Kill(", "WaitForExit(", "Start-Sleep", "FileName", "Path"): + self.assertNotIn(forbidden, helper) + + def test_progress_is_release_observation_with_dual_opt_in(self) -> None: + root = MODULE_PATH.parents[2] + source = (root / "client/src/client/gpu_renderer.c").read_text() + helper = source.split("static bool gpu_renderer_progress_enabled", 1)[1].split( + "static char backend", 1)[0] + self.assertIn('"ATRINIK_GPU_PROGRESS"', helper) + self.assertIn('"ATRINIK_REVIEW_NO_PAUSE"', helper) + self.assertNotIn("#ifdef", helper) + self.assertNotIn("SDL_QueryGPUFence", helper) + self.assertNotIn("SDL_Wait", helper) + present = source.split("bool gpu_renderer_present(void)", 1)[1].split( + "bool gpu_renderer_frame_valid", 1)[0] + self.assertIn("progress_frames_logged < 64", present) + self.assertLess(present.index('gpu_renderer_progress_log("before-present")'), + present.index("result = SDL_RenderPresent(renderer)")) + self.assertLess(present.index("result = SDL_RenderPresent(renderer)"), + present.index('"after-present"')) + self.assertIn("progress_map_submitted != UINT64_MAX", source) + self.assertIn("progress_map_completed != UINT64_MAX", source) + self.assertNotIn("progress_map_submitted = 0", source) + self.assertNotIn("progress_map_completed = 0", source) + def test_rejects_path_traversal(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/tools/tests/windows_wait_chain_fixture.ps1 b/tools/tests/windows_wait_chain_fixture.ps1 index 8084af9fb..0a09cfd7f 100644 --- a/tools/tests/windows_wait_chain_fixture.ps1 +++ b/tools/tests/windows_wait_chain_fixture.ps1 @@ -36,9 +36,42 @@ public static class AtrinikStackFixtureWindow { $smoke = Join-Path $PSScriptRoot "../ci/smoke_windows_review_bundle.ps1" $tokens = $null $errors = $null -$null = [System.Management.Automation.Language.Parser]::ParseFile( +$ast = [System.Management.Automation.Language.Parser]::ParseFile( $smoke, [ref]$tokens, [ref]$errors) if ($errors.Count -ne 0) { throw "Smoke script has PowerShell parse errors" } +foreach ($name in @("Get-ClientCpuSample", "Get-ClientCpuProgress")) { + $definition = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, $true) + if ($null -eq $definition) { throw "Expected actual CPU diagnostic helper" } + . ([scriptblock]::Create($definition.Extent.Text)) +} +$cpuClient = [System.Diagnostics.Process]::GetCurrentProcess() +try { + $baseline = Get-ClientCpuSample $cpuClient + if ($null -eq $baseline) { throw "Actual owned-process CPU sample unavailable" } + $progress = Get-ClientCpuProgress $cpuClient $baseline + if ($progress -notmatch '^pid=[0-9]+ cpu_ms=[0-9]+ elapsed_ms=[0-9]+$') { + throw "Owned-process CPU progress grammar mismatch" + } + foreach ($field in @("ProcessId", "Handle", "StartTicks", "CpuTicks", "ElapsedTicks")) { + $changed = $baseline.PSObject.Copy() + if ($field -eq "Handle") { $changed.$field = [IntPtr]::Zero } + elseif ($field -in @("CpuTicks", "ElapsedTicks")) { $changed.$field = [long]::MaxValue } + else { $changed.$field++ } + if ((Get-ClientCpuProgress $cpuClient $changed) -cne '') { + throw "CPU diagnostic accepted changed identity or regressed clock" + } + } + if ((Get-ClientCpuProgress $cpuClient $null) -cne '') { + throw "CPU diagnostic accepted missing baseline" + } +} finally { $cpuClient.Dispose() } +if ($null -ne (Get-ClientCpuSample $cpuClient)) { + throw "CPU diagnostic accepted a disposed owned process" +} +Write-Host "Actual owned-process CPU diagnostics and generation rejection fixtures passed." $source = Get-Content -Raw -LiteralPath $smoke $blocks = @([regex]::Matches($source, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@") | Where-Object { $_.Groups[1].Value.Contains("class AtrinikReviewCloseTimeoutProbe") }) From 665b4c0cfe0df177180590993a04c64b17dc54cf Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 20:31:27 -0500 Subject: [PATCH 145/148] fix(libatrinik): avoid DNS during HTTPS fixture binding --- libatrinik/tests/rendezvous_admission_https.py | 8 ++++++++ .../tests/test_rendezvous_admission_platform.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/libatrinik/tests/rendezvous_admission_https.py b/libatrinik/tests/rendezvous_admission_https.py index bc0b9fa88..2edfa5a10 100644 --- a/libatrinik/tests/rendezvous_admission_https.py +++ b/libatrinik/tests/rendezvous_admission_https.py @@ -9,6 +9,7 @@ import os from pathlib import Path import re +import socketserver import ssl import subprocess import sys @@ -72,6 +73,13 @@ class Fixture(http.server.ThreadingHTTPServer): daemon_threads = True block_on_close = False + def server_bind(self): + # HTTPServer.server_bind reverse-resolves the numeric loopback address. + # Host resolver delays must not consume the native admission deadline. + socketserver.TCPServer.server_bind(self) + self.server_name = "localhost" + self.server_port = self.server_address[1] + def handle_error(self, request, client_address): # libcurl may finish TLS before rejecting the certificate hostname, # then reset the stream before sending any HTTP bytes. This is expected diff --git a/tools/tests/test_rendezvous_admission_platform.py b/tools/tests/test_rendezvous_admission_platform.py index 3199d204b..04855fc39 100644 --- a/tools/tests/test_rendezvous_admission_platform.py +++ b/tools/tests/test_rendezvous_admission_platform.py @@ -1,16 +1,32 @@ # Copyright 2026 The Atrinik Project # SPDX-License-Identifier: GPL-2.0-or-later +import importlib.util from pathlib import Path import shutil import subprocess import tempfile import unittest +from unittest import mock ROOT = Path(__file__).resolve().parents[2] HTTPS = ROOT / "libatrinik/tests/rendezvous_admission_https.c" +class AdmissionHTTPSFixtureTests(unittest.TestCase): + def test_numeric_loopback_bind_does_not_use_host_resolver(self): + path = ROOT / "libatrinik/tests/rendezvous_admission_https.py" + spec = importlib.util.spec_from_file_location("admission_https_fixture", path) + fixture = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fixture) + with mock.patch("socket.getfqdn", side_effect=AssertionError("unexpected DNS lookup")): + with fixture.Fixture(("127.0.0.1", 0), fixture.Handler) as server: + self.assertEqual(server.server_name, "localhost") + self.assertEqual(server.server_address[0], "127.0.0.1") + self.assertGreater(server.server_port, 0) + self.assertEqual(server.server_port, server.socket.getsockname()[1]) + + @unittest.skipUnless(shutil.which("cc"), "A synthetic fixture C compiler is required") class AdmissionPlatformTests(unittest.TestCase): """Test instrumentation fixtures; these do not qualify a native platform.""" From 280aa3d02a023d2dfb864fa2625e86419ca14649 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 21:09:35 -0500 Subject: [PATCH 146/148] test(ci): isolate benchmark fixture Git maintenance --- tools/tests/test_server_benchmark.py | 71 ++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/tools/tests/test_server_benchmark.py b/tools/tests/test_server_benchmark.py index d09689254..e0ecc11d3 100644 --- a/tools/tests/test_server_benchmark.py +++ b/tools/tests/test_server_benchmark.py @@ -5,6 +5,7 @@ import os from pathlib import Path import shutil +import shlex import subprocess import tempfile import unittest @@ -16,6 +17,14 @@ HISTORICAL_BASE = "a64ea9f7297772f652802147c2ec103152b3edc9" +def fixture_git(*arguments, **kwargs): + # Commits can return while detached auto-maintenance still writes the clone. + # Keep task-owned fixtures independent of host maintenance, hooks and templates. + return subprocess.run(["git", "-c", "maintenance.auto=false", "-c", "gc.auto=0", + "-c", "core.hooksPath=/dev/null", "-c", "init.templateDir=", + *arguments], **kwargs) + + class BenchmarkTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() @@ -44,8 +53,8 @@ def write(self, path, content): destination.write_text(content) def git(self, *arguments): - return subprocess.run(["git", "-C", str(self.root), *arguments], check=True, - capture_output=True, text=True).stdout.strip() + return fixture_git("-C", str(self.root), *arguments, check=True, + capture_output=True, text=True).stdout.strip() def commit(self): self.git("add", ".") @@ -68,24 +77,68 @@ def test_current_selection_binds_locks_image_and_compiler(self): self.assertEqual(set(result["locks"]), set(benchmark.LOCKS)) def test_exact_original_contract_and_later_source_commits(self): - exists = subprocess.run(["git", "-C", str(ROOT), "cat-file", "-e", - HISTORICAL_BASE + "^{commit}"], capture_output=True) + exists = fixture_git("-C", str(ROOT), "cat-file", "-e", + HISTORICAL_BASE + "^{commit}", capture_output=True) if exists.returncode: self.skipTest("original benchmark contract unavailable in shallow history") source = Path(self.temporary.name) / "historical" - subprocess.run(["git", "clone", "--quiet", "--shared", "--no-checkout", str(ROOT), str(source)], check=True) - subprocess.run(["git", "-C", str(source), "checkout", "--quiet", "--detach", HISTORICAL_BASE], check=True) + fixture_git("clone", "--quiet", "--shared", "--no-checkout", str(ROOT), str(source), check=True) + fixture_git("-C", str(source), "checkout", "--quiet", "--detach", HISTORICAL_BASE, check=True) result = benchmark.resolve(source, HISTORICAL_BASE) self.assertEqual(result["image"], benchmark.GCC_IMAGE) self.assertEqual(result["compiler"], benchmark.GCC_COMPILER) # Source-only descendants retaining this producer contract also qualify. (source / "benchmark-descendant.txt").write_text("future source fixture\n") - subprocess.run(["git", "-C", str(source), "add", "benchmark-descendant.txt"], check=True) - subprocess.run(["git", "-C", str(source), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", - "-c", "commit.gpgsign=false", "commit", "-qm", "future fixture"], check=True) + fixture_git("-C", str(source), "add", "benchmark-descendant.txt", check=True) + fixture_git("-C", str(source), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "-c", "commit.gpgsign=false", "commit", "-qm", "future fixture", check=True) revision = benchmark.git(source, "rev-parse", "HEAD").decode().strip() self.assertEqual(benchmark.resolve(source, revision)["compiler"], benchmark.GCC_COMPILER) + def test_fixture_git_disables_background_maintenance_and_inherited_hooks(self): + control = Path(self.temporary.name) / "host-policy" + control.mkdir() + hooks = control / "hooks" + hooks.mkdir() + marker = control / "hook-ran" + for name in ("post-checkout", "pre-commit", "post-commit"): + hook = hooks / name + hook.write_text(f"#!/bin/sh\ntouch {shlex.quote(str(marker))}\nexit 1\n") + hook.chmod(0o755) + templates = control / "templates" + (templates / "hooks").mkdir(parents=True) + shutil.copy2(hooks / "post-commit", templates / "hooks/post-commit") + config = control / "gitconfig" + config.write_text(f"""[maintenance] + auto = true +[maintenance "commit-graph"] + enabled = true + auto = 1 +[gc] + auto = 1 +[core] + hooksPath = {hooks} +[init] + templateDir = {templates} +""") + trace = control / "trace.jsonl" + case = type(self)("test_exact_original_contract_and_later_source_commits") + outcome = unittest.TestResult() + with patch.dict(os.environ, {"GIT_CONFIG_GLOBAL": str(config), "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TRACE2_EVENT": str(trace)}): + case.run(outcome) + if outcome.skipped: + self.skipTest(outcome.skipped[0][1]) + self.assertTrue(outcome.wasSuccessful(), outcome.errors + outcome.failures) + self.assertFalse(marker.exists(), "host hooks must not execute in fixture repositories") + self.assertFalse(Path(case.temporary.name).exists(), "strict cleanup must remove the complete fixture") + events = [json.loads(line) for line in trace.read_text().splitlines()] + self.assertTrue(any(event.get("name") == "commit" for event in events)) + self.assertFalse(any(event.get("name") in ("maintenance", "gc") or + (event.get("event") == "child_start" and + any(word in ("maintenance", "gc") for word in event.get("argv", []))) + for event in events), "fixture Git must not launch background writers") + def test_unknown_tampered_dirty_and_nonexact_inputs_rejected(self): for revision in ("HEAD", self.revision.upper(), "--detach", "f" * 39): with self.subTest(revision=revision), self.assertRaises((ValueError, subprocess.SubprocessError)): From 31585a03f079357db53b2be51405062928ad79e6 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 21:13:42 -0500 Subject: [PATCH 147/148] test(ci): isolate benchmark comparison worktree Git --- tools/tests/test_server_benchmark.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/tools/tests/test_server_benchmark.py b/tools/tests/test_server_benchmark.py index e0ecc11d3..62509e4e1 100644 --- a/tools/tests/test_server_benchmark.py +++ b/tools/tests/test_server_benchmark.py @@ -122,18 +122,23 @@ def test_fixture_git_disables_background_maintenance_and_inherited_hooks(self): templateDir = {templates} """) trace = control / "trace.jsonl" - case = type(self)("test_exact_original_contract_and_later_source_commits") - outcome = unittest.TestResult() - with patch.dict(os.environ, {"GIT_CONFIG_GLOBAL": str(config), "GIT_CONFIG_NOSYSTEM": "1", - "GIT_TRACE2_EVENT": str(trace)}): - case.run(outcome) - if outcome.skipped: - self.skipTest(outcome.skipped[0][1]) - self.assertTrue(outcome.wasSuccessful(), outcome.errors + outcome.failures) - self.assertFalse(marker.exists(), "host hooks must not execute in fixture repositories") - self.assertFalse(Path(case.temporary.name).exists(), "strict cleanup must remove the complete fixture") + for name in ("test_exact_original_contract_and_later_source_commits", + "test_compare_routes_cohorts_and_failure_cannot_publish_success"): + with self.subTest(fixture=name): + case = type(self)(name) + outcome = unittest.TestResult() + with patch.dict(os.environ, {"GIT_CONFIG_GLOBAL": str(config), "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TRACE2_EVENT": str(trace)}): + case.run(outcome) + self.assertTrue(outcome.wasSuccessful(), outcome.errors + outcome.failures) + if outcome.skipped: + self.skipTest(outcome.skipped[0][1]) + self.assertFalse(marker.exists(), "host hooks must not execute in fixture repositories") + self.assertFalse(Path(case.temporary.name).exists(), "strict cleanup must remove the complete fixture") events = [json.loads(line) for line in trace.read_text().splitlines()] self.assertTrue(any(event.get("name") == "commit" for event in events)) + self.assertGreaterEqual(sum(event.get("name") == "worktree" for event in events), 4, + "successful and failing comparisons must both add and remove a worktree") self.assertFalse(any(event.get("name") in ("maintenance", "gc") or (event.get("event") == "child_start" and any(word in ("maintenance", "gc") for word in event.get("argv", []))) @@ -250,6 +255,8 @@ def test_compare_routes_cohorts_and_failure_cannot_publish_success(self): commands = [] def execution(arguments, **kwargs): + if arguments[0] == "git": + return fixture_git(*arguments[1:], check=True, **kwargs) if arguments[0] != "docker": return actual_run(arguments, **kwargs) commands.append(arguments) From 9f2e4321a8176a0687b85cb6f6b222f88f91d820 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 21:51:30 -0500 Subject: [PATCH 148/148] feat(ci): diagnose CPU-busy owned Windows threads after close timeout --- tools/ci/smoke_windows_review_bundle.ps1 | 195 +++++++++++++++++- .../test_compose_windows_review_bundle.py | 31 ++- tools/tests/windows_wait_chain_fixture.ps1 | 107 +++++++++- 3 files changed, 321 insertions(+), 12 deletions(-) diff --git a/tools/ci/smoke_windows_review_bundle.ps1 b/tools/ci/smoke_windows_review_bundle.ps1 index 57b91659a..57196e689 100644 --- a/tools/ci/smoke_windows_review_bundle.ps1 +++ b/tools/ci/smoke_windows_review_bundle.ps1 @@ -20,6 +20,7 @@ $launcherClient = $null $launcherClientLog = $null $bodySucceeded = $false $clientCloseStarted = $false +$clientThreadCpuBaseline = $null $portProbe = [System.Net.Sockets.UdpClient]::new(0) try { $serverPort = ([System.Net.IPEndPoint]$portProbe.Client.LocalEndPoint).Port @@ -420,7 +421,7 @@ public static class AtrinikReviewCloseTimeoutProbe { # Timeout-only x64 diagnostic. Snapshot the owned thread's registers and a bounded # stack, resume it, then unwind. Never retain raw memory, register values or paths. -function Get-ClientCloseStackSnapshot($Client) { +function Get-ClientCloseStackSnapshot($Client, [switch]$Initialize) { try { if (-not ("AtrinikReviewCloseStackProbe" -as [type])) { Add-Type -TypeDefinition @' @@ -439,6 +440,9 @@ public static class AtrinikReviewCloseStackProbe { [DllImport("kernel32.dll")] private static extern uint GetProcessIdOfThread(IntPtr thread); [DllImport("kernel32.dll")] + private static extern bool GetThreadTimes(IntPtr thread, out long created, + out long exited, out long kernel, out long user); + [DllImport("kernel32.dll")] private static extern uint WaitForSingleObject(IntPtr handle, uint timeout); [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle); @@ -472,6 +476,155 @@ public static class AtrinikReviewCloseStackProbe { // https://learn.microsoft.com/windows/win32/api/dbghelp/nf-dbghelp-stackwalk64 private const int ContextSize = 1232, FrameSize = 264, StackLimit = 65536; private static int active; // DbgHelp is single-threaded, including late observers. + // Query-only samples use the documented FILETIME (100 ns) counters: + // https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-getthreadtimes + // Threads born after baseline or already exited are deliberately unavailable. + private const int ThreadLimit = 256; + private sealed class CpuReading { + public uint Id; + public long Created, Cpu; + public IntPtr Handle; + } + public sealed class CpuBaseline : IDisposable { + internal readonly object Gate = new object(); + private readonly List readings = new List(); + private readonly Microsoft.Win32.SafeHandles.SafeProcessHandle process; + private readonly int processId; + private readonly long started, elapsed; + private bool retained, disposed; + internal CpuBaseline(Process client) { + processId = client.Id; + started = client.StartTime.ToUniversalTime().Ticks; + process = client.SafeHandle; + process.DangerousAddRef(ref retained); + elapsed = Environment.TickCount64; + } + public void Dispose() { + lock (Gate) { + if (disposed) return; + disposed = true; + foreach (var reading in readings) CloseHandle(reading.Handle); + readings.Clear(); + if (retained) { process.DangerousRelease(); retained = false; } + } + } + internal bool Capture() { + if (processId == Process.GetCurrentProcess().Id) return false; + var timer = Stopwatch.StartNew(); + // The original process handle pins the generation while the separate + // Process object supplies only a bounded list of thread IDs. + using (var client = Process.GetProcessById(processId)) { + if (client.StartTime.ToUniversalTime().Ticks != started) return false; + var threads = client.Threads; + try { + if (threads.Count > ThreadLimit) return false; + foreach (ProcessThread item in threads) { + if (timer.ElapsedMilliseconds >= 500) return false; + IntPtr thread = OpenThread(0x00100800, false, (uint)item.Id); + if (thread == IntPtr.Zero) continue; + bool keep = false; + try { + long created, cpu; + if (ReadCpu(process.DangerousGetHandle(), processId, thread, + out created, out cpu)) { + readings.Add(new CpuReading { Id = (uint)item.Id, + Created = created, Cpu = cpu, Handle = thread }); + keep = true; + } + } finally { if (!keep) CloseHandle(thread); } + } + return readings.Count != 0; + } finally { foreach (ProcessThread item in threads) item.Dispose(); } + } + } + internal string Progress(Process client) { + lock (Gate) { + if (disposed || client.Id != processId || client.HasExited || + client.StartTime.ToUniversalTime().Ticks != started || + client.SafeHandle.DangerousGetHandle() != process.DangerousGetHandle() || + Environment.TickCount64 < elapsed) return "thread_cpu_generation_unavailable"; + var current = new List(); + long elapsedMilliseconds = Environment.TickCount64 - elapsed; + var timer = Stopwatch.StartNew(); + foreach (var reading in readings) { + if (timer.ElapsedMilliseconds >= 500) return "thread_cpu_sample_timeout"; + long created, cpu; + if (ReadCpu(process.DangerousGetHandle(), processId, reading.Handle, + out created, out cpu)) + current.Add(new CpuReading { Id = reading.Id, Created = created, + Cpu = cpu, Handle = reading.Handle }); + } + var ranked = RankCpu(readings, current); + var rows = new List(); + foreach (var reading in ranked) { + rows.Add("thread=" + reading.Id + " cpu_ms=" + reading.Cpu / 10000 + + "; " + SnapshotThread(client, IntPtr.Zero, reading.Id, reading.Created)); + } + return "thread_cpu_samples=" + current.Count + " baseline=" + readings.Count + + " elapsed_ms=" + elapsedMilliseconds + " ranked=" + ranked.Count + + "; " + String.Join("; ", rows); + } + } + } + private static bool ReadCpu(IntPtr process, int processId, IntPtr thread, + out long created, out long cpu) { + created = cpu = 0; + long exited, kernel, user; + if (WaitForSingleObject(process, 0) != 258 || + GetProcessIdOfThread(thread) != (uint)processId || + WaitForSingleObject(thread, 0) != 258 || + !GetThreadTimes(thread, out created, out exited, out kernel, out user) || + created <= 0 || kernel < 0 || user < 0 || kernel > Int64.MaxValue - user || + WaitForSingleObject(thread, 0) != 258) return false; + cpu = kernel + user; + return true; + } + private static List RankCpu(List before, List after) { + var ranked = new List(); + if (before.Count > ThreadLimit || after.Count > ThreadLimit) return ranked; + foreach (var current in after) { + foreach (var old in before) { + if (old.Id != current.Id || old.Created <= 0 || old.Created != current.Created || + old.Handle == IntPtr.Zero || old.Handle != current.Handle || + old.Cpu < 0 || current.Cpu <= old.Cpu) continue; + ranked.Add(new CpuReading { Id = current.Id, Created = current.Created, + Handle = current.Handle, Cpu = current.Cpu - old.Cpu }); + break; + } + } + ranked.Sort((left, right) => { + int cpu = right.Cpu.CompareTo(left.Cpu); + return cpu != 0 ? cpu : left.Id.CompareTo(right.Id); + }); + if (ranked.Count > 2) ranked.RemoveRange(2, ranked.Count - 2); + return ranked; + } + public static CpuBaseline CaptureCpuBaseline(Process client) { + CpuBaseline baseline = null; + try { + baseline = new CpuBaseline(client); + var gate = new object(); + bool cancelled = false, ready = false; + var observer = new Thread(delegate() { + bool captured = false; + try { captured = baseline.Capture(); } catch { } + lock (gate) { + if (cancelled || !captured) baseline.Dispose(); + else ready = true; + } + }); + observer.IsBackground = true; + observer.Start(); + if (observer.Join(1000)) return ready ? baseline : null; + // The worker retains all handles until its late cleanup completes. + lock (gate) { cancelled = true; if (ready) baseline.Dispose(); } + return null; + } catch { if (baseline != null) baseline.Dispose(); return null; } + } + public static string CpuProgress(Process client, CpuBaseline baseline) { + try { return baseline == null ? "thread_cpu_baseline_unavailable" : baseline.Progress(client); } + catch { return "thread_cpu_unavailable"; } + } // Timeout fencing covers capture as well as unwind: the caller can remove // our suspend count, and cancellation prevents a late observer suspending. private sealed class Suspension { @@ -535,7 +688,7 @@ public static class AtrinikReviewCloseStackProbe { return bytes; } private static string Collect(Process client, IntPtr process, IntPtr window, uint threadId, - Suspension suspension) { + long expectedCreated, Suspension suspension) { IntPtr thread = IntPtr.Zero, contextAllocation = IntPtr.Zero; IntPtr frame = IntPtr.Zero, information = IntPtr.Zero, functionEntry = IntPtr.Zero; try { @@ -559,13 +712,18 @@ public static class AtrinikReviewCloseStackProbe { frame = Marshal.AllocHGlobal(FrameSize); Marshal.Copy(new byte[FrameSize], 0, frame, FrameSize); functionEntry = Marshal.AllocHGlobal(12); - uint owner; + uint owner = (uint)client.Id; // The original Process SafeHandle remains pinned throughout: a reused - // PID/TID cannot pass both held-handle liveness and HWND ownership. - if (GetWindowThreadProcessId(window, out owner) != threadId || + // PID/TID must pass held-handle liveness plus HWND ownership or the + // CPU baseline's creation time checked against this opened handle. + if ((window != IntPtr.Zero && GetWindowThreadProcessId(window, out owner) != threadId) || owner != (uint)client.Id || GetProcessIdOfThread(thread) != owner || WaitForSingleObject(process, 0) != 258 || WaitForSingleObject(thread, 0) != 258) return "stack_owned_thread_changed"; + long created, exited, kernel, user; + if (expectedCreated != 0 && + (!GetThreadTimes(thread, out created, out exited, out kernel, out user) || + created != expectedCreated)) return "stack_thread_generation_changed"; byte[] stack; ulong stackAddress; if (!suspension.Begin(thread)) return "stack_suspend_failed_or_cancelled"; @@ -697,13 +855,17 @@ public static class AtrinikReviewCloseStackProbe { uint thread = GetWindowThreadProcessId(window, out owner); if (window == IntPtr.Zero || thread == 0 || owner != (uint)client.Id || client.Id == Process.GetCurrentProcess().Id) return "stack_owned_window_unavailable"; + return SnapshotThread(client, window, thread, 0); + } + private static string SnapshotThread(Process client, IntPtr window, uint thread, long created) { + if (client.Id == Process.GetCurrentProcess().Id) return "stack_invalid_observer_pid"; if (Interlocked.CompareExchange(ref active, 1, 0) != 0) return "stack_observer_busy"; var handle = client.SafeHandle; bool retained = false; string result = "stack_unavailable"; var suspension = new Suspension(); var observer = new Thread(delegate() { - try { result = Collect(client, handle.DangerousGetHandle(), window, thread, suspension); } + try { result = Collect(client, handle.DangerousGetHandle(), window, thread, created, suspension); } finally { handle.DangerousRelease(); Interlocked.Exchange(ref active, 0); } }); observer.IsBackground = true; @@ -720,11 +882,24 @@ public static class AtrinikReviewCloseStackProbe { } '@ -ErrorAction Stop } + if ($Initialize) { return } if ($Client.HasExited) { return "client_exited=True" } return [AtrinikReviewCloseStackProbe]::Snapshot($Client, $Client.MainWindowHandle) } catch { return "" } } +function Get-ClientThreadCpuBaseline($Client) { + try { + $null = Get-ClientCloseStackSnapshot $Client -Initialize + return [AtrinikReviewCloseStackProbe]::CaptureCpuBaseline($Client) + } catch { return $null } +} + +function Get-ClientThreadCpuProgress($Client, $Baseline) { + try { return [AtrinikReviewCloseStackProbe]::CpuProgress($Client, $Baseline) } + catch { return "thread_cpu_unavailable" } +} + function Write-CloseFailureDiagnostics { try { try { @@ -749,6 +924,8 @@ function Write-CloseFailureDiagnostics { } try { + # Compile before starting any owned application, never on its close path. + $null = Get-ClientCloseStackSnapshot $null -Initialize Expand-Archive -LiteralPath $packagePath -DestinationPath $smokeRoot $packageRoots = @(Get-ChildItem -LiteralPath $smokeRoot -Directory) $rootFiles = @(Get-ChildItem -LiteralPath $smokeRoot -File) @@ -1078,6 +1255,9 @@ try { $clientCloseStarted = $true Write-Host ("Client windows before normal close: " + (Get-ClientWindowSnapshot $launcherClient)) + # Retain query-only handles before close. Sampling never + # suspends a thread; stacks are collected only after the unchanged failure. + $clientThreadCpuBaseline = Get-ClientThreadCpuBaseline $launcherClient $clientCpuBaseline = Get-ClientCpuSample $launcherClient if (-not $launcherClient.CloseMainWindow()) { throw "Could not request a normal close of the one-click client window" @@ -1090,6 +1270,8 @@ try { (Get-ClientCloseTimeoutSnapshot $launcherClient)) Write-Host ("Client close timeout stack: " + (Get-ClientCloseStackSnapshot $launcherClient)) + Write-Host ("Client close timeout CPU-ranked threads: " + + (Get-ClientThreadCpuProgress $launcherClient $clientThreadCpuBaseline)) throw "One-click client did not exit after the normal close request" } $launcherClient.Refresh() @@ -1134,6 +1316,7 @@ try { throw } finally { Save-ClientLifecycleEvidence $launcherClientLog + if ($null -ne $clientThreadCpuBaseline) { $clientThreadCpuBaseline.Dispose() } $cleanupFailures = [System.Collections.Generic.List[string]]::new() if ($null -ne $process) { try { diff --git a/tools/tests/test_compose_windows_review_bundle.py b/tools/tests/test_compose_windows_review_bundle.py index a202b23c1..ffaef9ab2 100644 --- a/tools/tests/test_compose_windows_review_bundle.py +++ b/tools/tests/test_compose_windows_review_bundle.py @@ -307,7 +307,9 @@ def test_close_failure_diagnostics_preserve_shutdown_acceptance(self) -> None: r'Write-Host \("Client close timeout thread: " \+\s*' r'\(Get-ClientCloseTimeoutSnapshot \$launcherClient\)\)\s*' r'Write-Host \("Client close timeout stack: " \+\s*' - r'\(Get-ClientCloseStackSnapshot \$launcherClient\)\)\s*throw ', + r'\(Get-ClientCloseStackSnapshot \$launcherClient\)\)\s*' + r'Write-Host \("Client close timeout CPU-ranked threads: " \+\s*' + r'\(Get-ClientThreadCpuProgress \$launcherClient \$clientThreadCpuBaseline\)\)\s*throw ', ) self.assertIn("$launcherClientExitCode -ne 0", acceptance) self.assertIn('Client shutdown complete', acceptance) @@ -463,6 +465,33 @@ def test_stack_probe_retains_only_bounded_addresses_after_resuming(self) -> None "DebugActiveProcess", "SetThreadContext", "WriteProcessMemory", "CreateRemoteThread"): self.assertNotIn(forbidden, probe) + def test_thread_cpu_probe_preserves_generation_and_close_boundary(self) -> None: + smoke = (MODULE_PATH.parents[2] / "tools/ci/smoke_windows_review_bundle.ps1").read_text() + probe = smoke.split("public sealed class CpuBaseline", 1)[1].split( + "// Timeout fencing covers capture", 1)[0] + for guard in ("client.StartTime.ToUniversalTime().Ticks != started", + "client.SafeHandle.DangerousGetHandle() != process.DangerousGetHandle()", + "old.Created != current.Created", "old.Handle != current.Handle", + "current.Cpu <= old.Cpu", "threads.Count > ThreadLimit", + "timer.ElapsedMilliseconds >= 500", "observer.Join(1000)", + "if (cancelled || !captured) baseline.Dispose()", + "foreach (var reading in readings) CloseHandle(reading.Handle)"): + self.assertIn(guard, probe) + self.assertIn("OpenThread(0x00100800, false", probe) + self.assertIn("ranked.RemoveRange(2, ranked.Count - 2)", probe) + self.assertNotIn("SuspendThread(", probe) + capture = smoke.split("private static string Collect(", 1)[1] + self.assertLess(capture.index("created != expectedCreated"), + capture.index("suspension.Begin(thread)")) + self.assertLess(smoke.index("$null = Get-ClientCloseStackSnapshot $null -Initialize"), + smoke.index("Expand-Archive -LiteralPath $packagePath")) + self.assertLess(smoke.index("$clientThreadCpuBaseline = Get-ClientThreadCpuBaseline"), + smoke.index("$launcherClient.CloseMainWindow()")) + self.assertGreater(smoke.index("(Get-ClientThreadCpuProgress $launcherClient"), + smoke.index("$launcherClient.WaitForExit(30000)")) + cleanup = smoke.split("} finally {\n Save-ClientLifecycleEvidence", 1)[1] + self.assertIn("$clientThreadCpuBaseline.Dispose()", cleanup) + def test_lifecycle_evidence_is_bounded_and_uploaded_before_cleanup(self) -> None: root = MODULE_PATH.parents[2] smoke = (root / "tools/ci/smoke_windows_review_bundle.ps1").read_text() diff --git a/tools/tests/windows_wait_chain_fixture.ps1 b/tools/tests/windows_wait_chain_fixture.ps1 index 0a09cfd7f..507b10ccb 100644 --- a/tools/tests/windows_wait_chain_fixture.ps1 +++ b/tools/tests/windows_wait_chain_fixture.ps1 @@ -8,6 +8,7 @@ if ($NativeChild) { if (-not $IsWindows) { throw "Native child requires Windows" } Add-Type -TypeDefinition @' using System; +using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; [assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] @@ -18,15 +19,37 @@ public static class AtrinikStackFixtureWindow { IntPtr menu, IntPtr instance, IntPtr parameter); [DllImport("user32.dll")] private static extern bool DestroyWindow(IntPtr window); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); public static void Run() { IntPtr window = CreateWindowExW(0, "STATIC", "", 0, 0, 0, 1, 1, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); if (window == IntPtr.Zero) throw new InvalidOperationException("fixture_window_failed"); + uint busyId = 0; + int stop = 0; + using var ready = new ManualResetEventSlim(false); + var busy = new Thread(() => { + busyId = GetCurrentThreadId(); + ready.Set(); + var timer = Stopwatch.StartNew(); + while (Volatile.Read(ref stop) == 0 && timer.ElapsedMilliseconds < 25000) + Thread.SpinWait(1000); + }); + busy.Start(); + ready.Wait(); try { - Console.WriteLine(window.ToInt64()); + Console.WriteLine(window.ToInt64() + ":" + busyId); Console.Out.Flush(); - Thread.Sleep(5000); // A deterministic blocked HWND thread, without a GPU. - } finally { DestroyWindow(window); } + // Keep both fixture threads alive until all snapshots finish. A + // watchdog still bounds a failed/missing parent without a GPU. + var release = System.Threading.Tasks.Task.Run(() => Console.ReadLine()); + if (!release.Wait(20000) || release.Result != "release") + throw new InvalidOperationException("fixture_release_failed"); + } finally { + Volatile.Write(ref stop, 1); + busy.Join(2000); + DestroyWindow(window); + } } } '@ @@ -73,6 +96,11 @@ if ($null -ne (Get-ClientCpuSample $cpuClient)) { } Write-Host "Actual owned-process CPU diagnostics and generation rejection fixtures passed." $source = Get-Content -Raw -LiteralPath $smoke +$fixtureSource = Get-Content -Raw -LiteralPath $PSCommandPath +$childBlocks = @([regex]::Matches($fixtureSource, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@") | + Where-Object { $_.Groups[1].Value.Contains("class AtrinikStackFixtureWindow") }) +if ($childBlocks.Count -ne 1) { throw "Expected one native fixture child" } +Add-Type -TypeDefinition $childBlocks[0].Groups[1].Value $blocks = @([regex]::Matches($source, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@") | Where-Object { $_.Groups[1].Value.Contains("class AtrinikReviewCloseTimeoutProbe") }) if ($blocks.Count -ne 1) { throw "Expected one actual timeout observer" } @@ -184,6 +212,46 @@ foreach ($case in @( } Write-Host "Actual stack observer C# compilation, privacy and overflow fixtures passed." +# Exercise the actual ranking logic without Win32: TID alone must never let a +# reused thread, changed handle or regressed CPU counter enter the top two. +$readingType = [AtrinikReviewCloseStackProbe].GetNestedType("CpuReading", "NonPublic") +$listType = [System.Collections.Generic.List``1].MakeGenericType($readingType) +$rank = [AtrinikReviewCloseStackProbe].GetMethod("RankCpu", [System.Reflection.BindingFlags]"NonPublic,Static") +function New-CpuReadings($Rows) { + $list = [Activator]::CreateInstance($listType) + foreach ($row in $Rows) { + $reading = [Activator]::CreateInstance($readingType, $true) + $reading.Id = [uint32]$row[0] + $reading.Created = [long]$row[1] + $reading.Cpu = [long]$row[2] + $reading.Handle = [IntPtr]::new([long]$row[3]) + $list.Add($reading) + } + return ,$list +} +$before = New-CpuReadings @(@(10, 100, 1000, 50), @(20, 200, 1000, 60), @(30, 300, 1000, 70)) +$after = New-CpuReadings @(@(30, 300, 5000, 70), @(10, 100, 2000, 50), @(20, 200, 5000, 60)) +$ranked = $rank.Invoke($null, @($before, $after)) +if ($ranked.Count -ne 2 -or $ranked[0].Id -ne 20 -or $ranked[1].Id -ne 30 -or + $ranked[0].Cpu -ne 4000) { throw "CPU delta/top-two/tie ranking mismatch" } +foreach ($row in @(@(10, 101, 5000, 50), @(10, 100, 5000, 51), + @(10, 100, 999, 50), @(10, 100, 1000, 50), @(99, 100, 5000, 50))) { + $rejected = New-CpuReadings @($row) + if ($rank.Invoke($null, @($before, $rejected)).Count -ne 0) { + throw "CPU rank accepted changed generation/handle/counter or missing baseline" + } +} +$large = New-CpuReadings @(1..257 | ForEach-Object { ,@($_, 100, 5000, $_) }) +if ($rank.Invoke($null, @($before, $large)).Count -ne 0) { throw "CPU thread limit escaped" } +if ([AtrinikReviewCloseStackProbe]::CpuProgress($null, $null) -cne "thread_cpu_baseline_unavailable") { + throw "Missing CPU baseline was accepted" +} +Write-Host "Actual CPU ranking, generation, handle, regression, tie and cap fixtures passed." +$suspensionType = [AtrinikReviewCloseStackProbe].GetNestedType("Suspension", "NonPublic") +$cancelled = [Activator]::CreateInstance($suspensionType, $true) +$cancelled.Cancel() +if ($cancelled.Begin([IntPtr]::Zero)) { throw "Cancelled observer attempted a late suspension" } + if ($Native) { if (-not $IsWindows -or [IntPtr]::Size -ne 8) { throw "Native stack fixture requires Windows x64" } $start = [System.Diagnostics.ProcessStartInfo]::new() @@ -194,15 +262,41 @@ if ($Native) { $start.UseShellExecute = $false $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true + $start.RedirectStandardInput = $true $child = [System.Diagnostics.Process]::new() $child.StartInfo = $start try { if (-not $child.Start()) { throw "Native stack fixture child failed to start" } $line = $child.StandardOutput.ReadLineAsync() - if (-not $line.Wait(10000) -or $line.Result -notmatch "^[1-9][0-9]{0,18}$") { + if (-not $line.Wait(10000) -or $line.Result -notmatch "^([1-9][0-9]{0,18}):([1-9][0-9]{0,9})$") { throw "Native stack fixture did not create an owned window" } - $window = [IntPtr]::new([long]$line.Result) + $window = [IntPtr]::new([long]$Matches[1]) + $busyThread = [uint32]$Matches[2] + $threadBaseline = [AtrinikReviewCloseStackProbe]::CaptureCpuBaseline($child) + if ($null -eq $threadBaseline) { throw "Native thread CPU baseline unavailable" } + try { + $readingsField = $threadBaseline.GetType().GetField("readings", + [System.Reflection.BindingFlags]"NonPublic,Instance") + $busyReading = @($readingsField.GetValue($threadBaseline) | + Where-Object { $_.Id -eq $busyThread }) + if ($busyReading.Count -ne 1) { throw "Native busy thread generation unavailable" } + $captureThread = [AtrinikReviewCloseStackProbe].GetMethod("SnapshotThread", + [System.Reflection.BindingFlags]"NonPublic,Static") + $changed = $captureThread.Invoke($null, @($child, [IntPtr]::Zero, + $busyThread, ([long]$busyReading[0].Created + 1))) + if ($changed -cne "stack_thread_generation_changed") { + throw "Native stack observer accepted a changed thread generation" + } + Start-Sleep -Milliseconds 500 + $cpuRanked = [AtrinikReviewCloseStackProbe]::CpuProgress($child, $threadBaseline) + if ($cpuRanked -notmatch "^thread_cpu_samples=[1-9][0-9]{0,2} baseline=[1-9][0-9]{0,2} elapsed_ms=[0-9]+ ranked=[12]; thread=$busyThread cpu_ms=[1-9][0-9]*; stack_frames=(?:[1-9]|[12][0-9]|3[0-2]); ") { + throw "Native busy thread was not CPU-ranked and captured: $cpuRanked" + } + } finally { $threadBaseline.Dispose() } + if ([AtrinikReviewCloseStackProbe]::CpuProgress($child, $threadBaseline) -cne "thread_cpu_generation_unavailable") { + throw "Disposed thread baseline was accepted" + } $invalid = [AtrinikReviewCloseStackProbe]::Snapshot($child, [IntPtr]::Zero) if ($invalid -cne "stack_owned_window_unavailable") { throw "Invalid window was accepted" } $observed = [AtrinikReviewCloseStackProbe]::Snapshot($child, $window) @@ -210,10 +304,13 @@ if ($Native) { throw "Native stack fixture did not yield bounded module-relative frames: $observed" } # Natural completion proves the one owned suspend count was removed. + $child.StandardInput.WriteLine("release") + $child.StandardInput.Flush() if (-not $child.WaitForExit(10000) -or $child.ExitCode -ne 0) { throw "Native stack fixture child did not resume and exit normally" } Write-Host "Native owned-window stack capture and natural child exit passed: $observed" + Write-Host "Native owned CPU-busy worker ranking and bounded stack passed: $cpuRanked" } finally { if ($child.Id -and -not $child.HasExited) { $child.Kill($true)