diff --git a/.github/workflows/measure-classic-check.yml b/.github/workflows/measure-classic-check.yml index 01b661d..de36941 100644 --- a/.github/workflows/measure-classic-check.yml +++ b/.github/workflows/measure-classic-check.yml @@ -15,6 +15,8 @@ on: - tools/build-sdl3-mixer.sh - tools/measure-classic-check-images.sh - tools/smoke-classic-check.sh + - tools/curl-probe/** + - tools/run-classic-native-tests.ps1 - tools/smoke-git-lfs.sh - tools/tests/** - tools/validate-toolchains.sh @@ -192,21 +194,4 @@ jobs: - name: Execute every Classic Check native test shell: pwsh run: | - $bundle = (Resolve-Path "build/native-windows-tests").Path - $env:PATH = "${bundle};${env:PATH}" - $inventoryPath = Join-Path $bundle "classic-check-toolchain.json" - $tests = (Get-Content -Raw $inventoryPath | ConvertFrom-Json).verification.native_tests - if ($tests.Count -ne 6) { throw "Expected six declared native tests" } - foreach ($test in $tests) { - $executable = Join-Path $bundle $test.executable - $arguments = @($test.arguments | ForEach-Object { - if ([IO.Path]::IsPathRooted($_) -or $_ -like "../*" -or $_ -like "..\\*") { - throw "Unsafe native-test argument: $_" - } - Join-Path $bundle $_ - }) - & $executable @arguments - if ($LASTEXITCODE -ne 0) { - throw "$($test.executable) failed: ${LASTEXITCODE}" - } - } + & (Join-Path (Resolve-Path "build/native-windows-tests").Path "run-classic-native-tests.ps1") diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml index 1a80641..ab8bc92 100644 --- a/.github/workflows/publish-linux.yml +++ b/.github/workflows/publish-linux.yml @@ -109,6 +109,48 @@ jobs: type=gha,scope=classic-build-image cache-to: type=gha,mode=max,scope=classic-build-image,ignore-error=true + - name: Read Classic cancellation qualification consumer + id: curl-consumer + run: echo "commit=$(jq -er '.consumer_validation.commit' classic-toolchain.json)" >> "${GITHUB_OUTPUT}" + + - name: Check out Classic cancellation qualification consumer + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: atrinik/classic + ref: ${{ steps.curl-consumer.outputs.commit }} + path: build/curl-classic + persist-credentials: false + + - name: Load Classic cancellation qualification image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 + with: + context: . + file: linux/Dockerfile + target: classic-final + platforms: linux/amd64 + load: true + tags: atrinik-classic-curl:qualification + cache-from: type=gha,scope=classic-build-image + + - name: Qualify Classic cancellation and public TLS before publication + run: tools/smoke-classic-curl.sh atrinik-classic-curl:qualification build/curl-classic + + - name: Load broad Linux cancellation qualification image + if: ${{ !inputs.candidate_only }} + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 + with: + context: . + file: linux/Dockerfile + target: final + platforms: linux/amd64 + load: true + tags: atrinik-linux-curl:qualification + cache-from: type=gha,scope=linux-build-image + + - name: Qualify broad Linux cancellation and public TLS before publication + if: ${{ !inputs.candidate_only }} + run: tools/smoke-classic-curl.sh atrinik-linux-curl:qualification build/curl-classic + # The portable target has its own ABI and real consumer; validate it before aliases move. - name: Read portable consumer revision if: ${{ !inputs.candidate_only }} diff --git a/.github/workflows/publish-windows.yml b/.github/workflows/publish-windows.yml index 0fad8b9..85cfb38 100644 --- a/.github/workflows/publish-windows.yml +++ b/.github/workflows/publish-windows.yml @@ -178,25 +178,7 @@ jobs: - name: Execute every Classic Check native test shell: pwsh run: | - $bundle = (Resolve-Path "build/native-windows-tests").Path - $env:PATH = "${bundle};${env:PATH}" - $inventoryPath = Join-Path $bundle "classic-check-toolchain.json" - $tests = (Get-Content -Raw $inventoryPath | ConvertFrom-Json).verification.native_tests - if ($tests.Count -ne 6) { throw "Expected six declared native tests" } - foreach ($test in $tests) { - $executable = Join-Path $bundle $test.executable - $arguments = @($test.arguments | ForEach-Object { - if ([IO.Path]::IsPathRooted($_) -or $_ -like "../*" -or $_ -like "..\\*") { - throw "Unsafe native-test argument: $_" - } - Join-Path $bundle $_ - }) - & $executable @arguments - if ($LASTEXITCODE -ne 0) { - throw "$($test.executable) failed: ${LASTEXITCODE}" - } - } - + & (Join-Path (Resolve-Path "build/native-windows-tests").Path "run-classic-native-tests.ps1") promote: name: Promote verified Windows image aliases needs: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7aaa9f5..59bd191 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -53,7 +53,8 @@ jobs: classic-vulkan-toolchain.json | \ classic-vulkan-toolchain.spdx.json | \ classic-vulkan-packages.lock | \ - toolchains.json | tools/audio/* | \ + toolchains.json | classic-toolchain.json | windows/classic-check-toolchain.json | classic-curl-toolchain.json | tools/build-classic-curl.sh | tools/verify-classic-curl.sh | \ + tools/curl-probe/* | tools/smoke-classic-curl.sh | tools/audio/* | \ tools/build-sdl3-mixer.sh | \ tools/install_classic_vulkan_toolchain.py | \ tools/require-image-checks.sh | \ @@ -68,7 +69,9 @@ jobs: .dockerignore | .github/workflows/publish-linux.yml | \ .github/workflows/validate.yml | linux/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ - classic-packages.lock | classic-toolchain.json | \ + classic-packages.lock | classic-toolchain.json | windows/classic-check-toolchain.json | \ + classic-curl-toolchain.json | tools/build-classic-curl.sh | tools/verify-classic-curl.sh | \ + tools/curl-probe/* | tools/smoke-classic-curl.sh | \ classic-shader-toolchain.json | \ classic-shader-toolchain.spdx.json | \ classic-vulkan-toolchain.json | \ @@ -93,6 +96,8 @@ jobs: .github/workflows/publish-windows.yml | \ .github/workflows/validate.yml | windows/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ + classic-curl-toolchain.json | tools/verify-classic-curl.sh | \ + tools/curl-probe/* | tools/run-classic-native-tests.ps1 | \ tools/audio/* | \ tools/build-sdl3-mixer.sh | \ tools/measure-classic-check-images.sh | \ @@ -124,6 +129,9 @@ jobs: - name: Test Classic dependency preflight run: python3 -m unittest tools/tests/test_verify_classic_check_dependencies.py + - name: Test Classic package runtime closure + run: python3 -m unittest tools/tests/test_verify_classic_check_package.py + - name: Test Classic shader toolchain installer run: python3 -m unittest tools/tests/test_install_classic_shader_toolchain.py @@ -157,6 +165,21 @@ jobs: cache-from: type=gha,scope=linux-build-image cache-to: type=gha,mode=max,scope=linux-build-image,ignore-error=true + - name: Read broad Linux cancellation qualification consumer + id: curl-consumer + run: echo "commit=$(jq -er '.consumer_validation.commit' classic-toolchain.json)" >> "${GITHUB_OUTPUT}" + + - name: Check out broad Linux cancellation qualification consumer + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: atrinik/classic + ref: ${{ steps.curl-consumer.outputs.commit }} + path: build/curl-classic + persist-credentials: false + + - name: Qualify broad Linux cancellation and public TLS + run: tools/smoke-classic-curl.sh atrinik-linux-validation:ci build/curl-classic + - name: Verify non-root gh-stack contract run: | docker run --rm \ @@ -344,25 +367,7 @@ jobs: - name: Execute every Classic Check native test shell: pwsh run: | - $bundle = (Resolve-Path "build/native-windows-tests").Path - $env:PATH = "${bundle};${env:PATH}" - $inventoryPath = Join-Path $bundle "classic-check-toolchain.json" - $tests = (Get-Content -Raw $inventoryPath | ConvertFrom-Json).verification.native_tests - if ($tests.Count -ne 6) { throw "Expected six declared native tests" } - foreach ($test in $tests) { - $executable = Join-Path $bundle $test.executable - $arguments = @($test.arguments | ForEach-Object { - if ([IO.Path]::IsPathRooted($_) -or $_ -like "../*" -or $_ -like "..\\*") { - throw "Unsafe native-test argument: $_" - } - Join-Path $bundle $_ - }) - & $executable @arguments - if ($LASTEXITCODE -ne 0) { - throw "$($test.executable) failed: ${LASTEXITCODE}" - } - } - + & (Join-Path (Resolve-Path "build/native-windows-tests").Path "run-classic-native-tests.ps1") classic: name: Classic CI image needs: changes @@ -408,6 +413,10 @@ jobs: repository: atrinik/classic ref: ${{ steps.consumer.outputs.commit }} path: classic + persist-credentials: false + + - name: Qualify Classic resolver cancellation and public TLS + run: tools/smoke-classic-curl.sh atrinik-classic-build:validation classic - name: Run Classic client and server checks as the runner user run: | diff --git a/AGENTS.md b/AGENTS.md index 60d8bcf..c92f2a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,16 @@ revision, shader-toolchain inventory, GPU runtime, smoke/SBOM checks, and published tags synchronized. Do not make it inherit the broad replacement/development toolchain. +- `classic-curl-toolchain.json` pins the Classic c-ares resolver producer. Both + Linux images install its curl/c-ares closure in `/usr/local` without replacing + system OpenSSL 3.5.5; both MXE images explicitly pin OpenSSL 3.5.5 and compile + curl with c-ares and no threaded resolver. Preserve generated configuration, + header/runtime/license closure and native cancellation qualification; ASYNCHDNS + or a non-null c-ares field alone does not prove the selected backend. + Both Linux publication paths must run `tools/smoke-classic-curl.sh` against + their actual loaded candidate before publication; `candidate_only` retains + this gate. Windows validation/measurement/publication share the bundled + native runner, including cancellation, QUIC and pinned public-CA checks. - The broad Linux and `classic-final` images include the snapshot-pinned Git LFS client. Keep the Linux tool manifests, Classic package lock, non-root version checks, and isolated worktree/payload smoke synchronized when changing this diff --git a/README.md b/README.md index fb473d2..60e23ce 100644 --- a/README.md +++ b/README.md @@ -467,3 +467,45 @@ Redistributors must retain the pinned source archives, notices, build recipes, and Debian source coordinates and satisfy the corresponding-source and LGPL replacement/relinking obligations in the contract. Authored game media remain `content@main`, resources and sound inputs owned by their respective repositories. + +### Classic curl resolver + +The broad and slim Linux images install curl 8.18.0 and c-ares 1.34.6 in +`/usr/local`; the existing system OpenSSL 3.5.5 remains the TLS provider. +CMake and pkg-config use the installed headers and libraries there, and +`ldconfig` registers `libcurl.so.4` and `libcares.so.2`. System curl packages +remain recorded in the package lock for reproducibility; they are not the +Classic application's selected resolver provider. + +Both MXE Windows images retain curl 8.21.0 in the standard shared target +prefix, add `libcares-2.dll` to that runtime directory, and explicitly pin +OpenSSL 3.5.5. The source pins are in `classic-curl-toolchain.json`. LDAP/LDAPS and RTMP are +excluded explicitly; HTTP/TLS, HTTP2, compression, PSL and IDN remain supported. +The generated curl configuration is retained in `share/atrinik/curl` and +`atrinik-verify-classic-curl` rejects threaded resolver macros: ASYNCHDNS and +a non-null c-ares version alone are insufficient for a pure c-ares backend. + +Producer build checks establish the selected source and resolver configuration. +Activation additionally requires actual stalled-DNS cancellation through easy, +multi and global cleanup, HTTP/TLS including the public CA bundle, Classic +QUIC, and Linux ELF / Windows DLL closure checks against the immutable image. +Linux cross-build success does not establish native Windows execution. + +Classic producer qualification runs the real library cancellation helper with +witnessed stalled DNS and HTTP, then verifies the pinned Classic public CA bundle +against `https://curl.se/`. Both Linux variants run these checks before candidate +or release publication. Windows candidates stage the portable helper probe and +native QUIC test with their resolved DLL closure; validation, measurement and +release pipelines execute all eight native tests with process deadlines and +verify actual public TLS before alias promotion. `tools/curl-probe` is explicitly +GPL-2.0-or-later, matching the linked Classic library; its sources and Classic +license/source coordinates accompany the native qualification bundle. + +The Windows producer and native qualification bundle retain the exact c-ares +1.34.6 MIT notice, including its named copyright holders. The Windows inventory +pins its upstream source, installed path and checksum. + +Windows smoke prepares and validates the GPU shader cohort through the pinned +Classic `tools/ci/prepare_gpu_shaders.sh` workflow before its offline MXE build. +The container consumes generated artifacts, so host shader-tool binaries do not +become container ABI dependencies. diff --git a/classic-curl-toolchain.json b/classic-curl-toolchain.json new file mode 100644 index 0000000..40b67eb --- /dev/null +++ b/classic-curl-toolchain.json @@ -0,0 +1,61 @@ +{ + "schema_version": 1, + "resolver": "c-ares", + "threaded_resolver": false, + "cares": { + "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" + }, + "linux": { + "curl_version": "8.18.0", + "curl_url": "https://curl.se/download/curl-8.18.0.tar.xz", + "curl_sha256": "40df79166e74aa20149365e11ee4c798a46ad57c34e4f68fd13100e2c9a91946", + "prefix": "/usr/local", + "ca_bundle": "/etc/ssl/certs/ca-certificates.crt", + "openssl_version": "3.5.5", + "build_packages": { + "libpsl-dev": "0.21.2-1.1build2" + }, + "payload": { + "include": [ + "include/curl", + "include/ares.h", + "include/ares_version.h", + "include/ares_build.h", + "include/ares_dns.h", + "include/ares_dns_record.h", + "include/ares_nameser.h" + ], + "library_families": [ + "lib/libcurl.so*", + "lib/libcares.so*" + ], + "pkg_config": [ + "lib/pkgconfig/libcurl.pc", + "lib/pkgconfig/libcares.pc" + ], + "licenses": [ + "share/licenses/curl/COPYING", + "share/licenses/c-ares/LICENSE.md" + ], + "evidence": [ + "share/atrinik/classic-curl-toolchain.json", + "share/atrinik/curl/curl_config.h", + "share/atrinik/curl/config.log" + ] + } + }, + "windows": { + "curl_version": "8.21.0", + "prefix": "/opt/mxe/usr/x86_64-w64-mingw32.shared", + "openssl_version": "3.5.5", + "openssl_sha256": "b28c91532a8b65a1f983b4c28b7488174e4a01008e29ce8e69bd789f28bc2a89", + "cares_runtime": "libcares-2.dll" + }, + "excluded_features": [ + "LDAP", + "LDAPS", + "RTMP" + ] +} diff --git a/classic-packages.lock b/classic-packages.lock index 90e1d80..e7deac3 100644 --- a/classic-packages.lock +++ b/classic-packages.lock @@ -8,14 +8,15 @@ flex=2.6.4-8.2build2 g++=4:15.2.0-5ubuntu1 gcc=4:15.2.0-5ubuntu1 gcovr=7.2+really-2 -git=1:2.53.0-1ubuntu1 git-lfs=3.7.1-1 +git=1:2.53.0-1ubuntu1 jq=1.8.1-4ubuntu2 libcurl4-openssl-dev=8.18.0-1ubuntu2.3 libdrm2=2.4.131-1 libgd-dev=2.3.3-13ubuntu2 libidn2-dev=2.3.8-4build1 libminiupnpc-dev=2.3.3-2build1 +libpsl-dev=0.21.2-1.1build2 libreadline-dev=8.3-4 libsdl3-dev=3.4.2+ds-1ubuntu1 libsdl3-image-dev=3.4.0+ds-1 @@ -29,13 +30,13 @@ libwayland-client0=1.24.0-2 libxml2-dev=2.15.2+dfsg-0.1ubuntu0.1 mesa-vulkan-drivers=26.0.3-1ubuntu1 ninja-build=1.13.2-1 -openssl=3.5.5-1ubuntu3.3 openssl-provider-legacy=3.5.5-1ubuntu3.3 +openssl=3.5.5-1ubuntu3.3 pkgconf=2.5.1-4 -python3=3.14.3-0ubuntu2 python3-dev=3.14.3-0ubuntu2 +python3=3.14.3-0ubuntu2 vulkan-tools=1.4.341.0+dfsg1-1 xauth=1:1.1.2-1.1build1 xvfb=2:21.1.22-1ubuntu1 -zlib1g=1:1.3.dfsg+really1.3.1-1ubuntu3 zlib1g-dev=1:1.3.dfsg+really1.3.1-1ubuntu3 +zlib1g=1:1.3.dfsg+really1.3.1-1ubuntu3 diff --git a/classic-toolchain.json b/classic-toolchain.json index c665fa5..ebd08ca 100644 --- a/classic-toolchain.json +++ b/classic-toolchain.json @@ -36,6 +36,7 @@ ], "consumer_validation": { "repository": "atrinik/classic", - "commit": "8fec1db157bcfdd050c1ba360e77365bce701bba" - } + "commit": "9136e13efabc0f6edd513517b3a437c927b5edea" + }, + "curl_contract": "/usr/local/share/atrinik/classic-curl-toolchain.json" } diff --git a/linux/Dockerfile b/linux/Dockerfile index 666a848..0aad025 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -150,6 +150,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ libgd-dev \ libidn2-dev \ libminiupnpc-dev \ + libpsl-dev=0.21.2-1.1build2 \ libreadline-dev \ libsdl3-dev \ libsdl3-image-dev \ @@ -212,6 +213,15 @@ ARG GH_STACK_SOURCE_COMMIT=a1b4a3d4d0bcde9ec3a78ab99b2d63af121857a9 ARG GH_STACK_LICENSE_SHA256=2510b446bc1f0cf9702453075d20cd88631e20e5642658edb7325d9c1eb534f7 ARG GIT_VERSION=2.53.0 ARG GIT_LFS_VERSION=3.7.1 +# The system OpenSSL 3.5.5 remains the TLS provider. Install the same curl +# release with the c-ares resolver and explicitly exclude the threaded backend. +COPY classic-curl-toolchain.json /tmp/classic-curl-toolchain.json +COPY tools/build-classic-curl.sh /tmp/build-classic-curl.sh +COPY tools/verify-classic-curl.sh /usr/local/bin/atrinik-verify-classic-curl +RUN /tmp/build-classic-curl.sh /tmp/classic-curl-toolchain.json /usr/local 2 \ + && /usr/local/bin/atrinik-verify-classic-curl /usr/local \ + && rm /tmp/build-classic-curl.sh /tmp/classic-curl-toolchain.json + ARG GO_VERSION=1.26.5 ARG GO_SHA256=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053 ARG NODE_VERSION=24.18.1 @@ -562,6 +572,15 @@ RUN test "${TARGETARCH}" = amd64 \ && rm -f /etc/apt/apt.conf.d/80-snapshot-retries \ && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb +# The system OpenSSL 3.5.5 remains the TLS provider. Install the same curl +# release with the c-ares resolver and explicitly exclude the threaded backend. +COPY classic-curl-toolchain.json /tmp/classic-curl-toolchain.json +COPY tools/build-classic-curl.sh /tmp/build-classic-curl.sh +COPY tools/verify-classic-curl.sh /usr/local/bin/atrinik-verify-classic-curl +RUN /tmp/build-classic-curl.sh /tmp/classic-curl-toolchain.json /usr/local 2 \ + && /usr/local/bin/atrinik-verify-classic-curl /usr/local \ + && rm /tmp/build-classic-curl.sh /tmp/classic-curl-toolchain.json + COPY tools/smoke-git-lfs.sh /usr/local/bin/atrinik-git-lfs-smoke RUN chmod 0755 /usr/local/bin/atrinik-git-lfs-smoke \ && runuser -u ubuntu -- env HOME=/home/ubuntu \ diff --git a/toolchains.json b/toolchains.json index d254098..5f47dda 100644 --- a/toolchains.json +++ b/toolchains.json @@ -36,5 +36,6 @@ "atrinik/renderer", "atrinik/server", "atrinik/website" - ] + ], + "curl_contract": "/usr/local/share/atrinik/classic-curl-toolchain.json" } diff --git a/tools/build-classic-curl.sh b/tools/build-classic-curl.sh new file mode 100755 index 0000000..5ca9999 --- /dev/null +++ b/tools/build-classic-curl.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +set -euo pipefail +manifest=${1:?usage: build-classic-curl.sh MANIFEST [PREFIX] [JOBS]} +manifest=$(realpath "$manifest") +prefix=${2:-/usr/local} +jobs=${3:-2} +test "$(pkg-config --modversion openssl)" = "$(jq -r '.linux.openssl_version' "$manifest")" +work=$(mktemp -d) +trap 'rm -rf -- "$work"' EXIT +fetch() { + curl --fail --location --silent --show-error "$1" --output "$2" + printf '%s %s\n' "$3" "$2" | sha256sum --check --status +} +fetch "$(jq -r '.cares.url' "$manifest")" "$work/cares.tar.gz" "$(jq -r '.cares.sha256' "$manifest")" +fetch "$(jq -r '.linux.curl_url' "$manifest")" "$work/curl.tar.xz" "$(jq -r '.linux.curl_sha256' "$manifest")" +tar --no-same-owner -xf "$work/cares.tar.gz" -C "$work" +tar --no-same-owner -xf "$work/curl.tar.xz" -C "$work" +cmake -S "$work/c-ares-$(jq -r '.cares.version' "$manifest")" -B "$work/cares-build" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$prefix" -DCMAKE_INSTALL_LIBDIR=lib \ + -DCARES_STATIC=OFF -DCARES_SHARED=ON -DCARES_BUILD_TOOLS=OFF -DCARES_BUILD_TESTS=OFF +cmake --build "$work/cares-build" --parallel "$jobs" +cmake --install "$work/cares-build" +cd "$work/curl-$(jq -r '.linux.curl_version' "$manifest")" +PKG_CONFIG_PATH="$prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" \ + ./configure --prefix="$prefix" --libdir="$prefix/lib" --with-openssl \ + --enable-ares="$prefix" --disable-threaded-resolver --disable-static --enable-versioned-symbols \ + --with-ca-bundle="$(jq -r '.linux.ca_bundle' "$manifest")" \ + --with-ca-path=/etc/ssl/certs --disable-ldap --disable-ldaps --without-librtmp +# ASYNCHDNS and a non-null ares field alone also describe newer hybrid backends. +# Reject the macros that take precedence over USE_ARES in curl_setup.h. +grep -Eq '^#define USE_ARES 1$' lib/curl_config.h +if grep -Eq '^#define USE_THREADS_(POSIX|WIN32) 1$' lib/curl_config.h; then + echo 'threaded resolver compiled into curl' >&2 + exit 1 +fi +make --jobs="$jobs" +make install +install -d "$prefix/share/atrinik/curl" +cp lib/curl_config.h "$prefix/share/atrinik/curl/curl_config.h" +cp config.log "$prefix/share/atrinik/curl/config.log" +cp "$manifest" "$prefix/share/atrinik/classic-curl-toolchain.json" +install -d "$prefix/share/licenses/curl" "$prefix/share/licenses/c-ares" +cp COPYING "$prefix/share/licenses/curl/COPYING" +cp "$work/c-ares-$(jq -r '.cares.version' "$manifest")/LICENSE.md" "$prefix/share/licenses/c-ares/LICENSE.md" +ldconfig +"$prefix/bin/curl-config" --configure | grep -F -- --disable-threaded-resolver diff --git a/tools/curl-probe/CMakeLists.txt b/tools/curl-probe/CMakeLists.txt new file mode 100644 index 0000000..f1115a3 --- /dev/null +++ b/tools/curl-probe/CMakeLists.txt @@ -0,0 +1,85 @@ +cmake_minimum_required(VERSION 3.21) + +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2026 The Atrinik Project +# This bridge builds only the pinned protocol/library, never the game. +project(atrinik-curl-qualification LANGUAGES C) +include(CTest) + +set(ATRINIK_CLASSIC_SOURCE_DIR "" CACHE PATH "Clean pinned Classic source checkout") +set(ATRINIK_CLASSIC_QUALIFICATION_COMMIT + "9136e13efabc0f6edd513517b3a437c927b5edea" CACHE STRING + "Full Classic commit from the producer's committed consumer manifest") +set(qualification_commit "${ATRINIK_CLASSIC_QUALIFICATION_COMMIT}") +string(LENGTH "${qualification_commit}" qualification_commit_length) +if (NOT qualification_commit MATCHES "^[0-9a-f]+$" OR + NOT qualification_commit_length EQUAL 40) + message(FATAL_ERROR "ATRINIK_CLASSIC_QUALIFICATION_COMMIT must be a full lowercase Git SHA") +endif () +if (NOT EXISTS "${ATRINIK_CLASSIC_SOURCE_DIR}/libatrinik/curl.c") + message(FATAL_ERROR "ATRINIK_CLASSIC_SOURCE_DIR must name the pinned Classic checkout") +endif () +file(REAL_PATH "${ATRINIK_CLASSIC_SOURCE_DIR}" ATRINIK_CLASSIC_SOURCE_DIR) +include("${ATRINIK_CLASSIC_SOURCE_DIR}/cmake/AtrinikVersion.cmake") +execute_process( + COMMAND ${ATRINIK_OWNER_GIT_COMMAND} -C "${ATRINIK_CLASSIC_SOURCE_DIR}" rev-parse HEAD + OUTPUT_VARIABLE actual_commit OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE git_result) +if (NOT git_result EQUAL 0 OR NOT actual_commit STREQUAL qualification_commit) + message(FATAL_ERROR "Classic cancellation qualification requires ${qualification_commit}") +endif () +execute_process( + COMMAND ${ATRINIK_OWNER_GIT_COMMAND} -C "${ATRINIK_CLASSIC_SOURCE_DIR}" diff --quiet HEAD -- + RESULT_VARIABLE dirty_result) +if (NOT dirty_result EQUAL 0) + message(FATAL_ERROR "Classic cancellation qualification requires clean tracked source") +endif () + +if (NOT TARGET Atrinik::Core) + # libatrinik inherits this target policy when embedded. Its sibling protocol + # reads the pinned owner's version, not this qualification project's version. + 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") + target_compile_options(${target} PRIVATE -Wall -Wextra -Wno-unused-parameter + -fno-common -Werror) + endif () + endfunction() + if (MINGW) + add_compile_definitions(HAVE_STRUCT_TIMESPEC NOCRYPT __USE_MINGW_ANSI_STDIO=0) + set(PLUGIN_SUFFIX ".dll") + else () + set(LINUX true) + set(PLUGIN_SUFFIX ".so") + endif () + set(LIBATRINIK_PACKAGE_LAYOUT ON CACHE BOOL "Namespaced Classic headers" FORCE) + # Avoid unrelated unit executables; this bridge registers its own test below. + set(BUILD_TESTING OFF) + add_subdirectory("${ATRINIK_CLASSIC_SOURCE_DIR}/protocol" "classic-protocol") + add_subdirectory("${ATRINIK_CLASSIC_SOURCE_DIR}/libatrinik" "classic-libatrinik") +else () + # An embedding consumer may reuse its already-built target, but not an + # installed or mismatched library with an unverified cancellation helper. + get_target_property(core_source Atrinik::Core SOURCE_DIR) + file(REAL_PATH "${core_source}" core_source) + if (NOT core_source STREQUAL "${ATRINIK_CLASSIC_SOURCE_DIR}/libatrinik") + message(FATAL_ERROR "Existing Atrinik::Core does not belong to the pinned checkout") + endif () +endif () + +find_package(Threads REQUIRED) +add_executable(atrinik-curl-cancellation-probe cancellation.c) +target_compile_features(atrinik-curl-cancellation-probe PRIVATE c_std_17) +target_link_libraries(atrinik-curl-cancellation-probe PRIVATE Atrinik::Core Threads::Threads) +if (WIN32) + target_link_libraries(atrinik-curl-cancellation-probe PRIVATE ws2_32) +endif () +if (MINGW) + target_compile_definitions(atrinik-curl-cancellation-probe PRIVATE HAVE_STRUCT_TIMESPEC) +endif () +if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(atrinik-curl-cancellation-probe PRIVATE -Wall -Wextra -Werror) +endif () +add_test(NAME atrinik-curl-cancellation-qualification COMMAND atrinik-curl-cancellation-probe) +set_tests_properties(atrinik-curl-cancellation-qualification PROPERTIES TIMEOUT 15) diff --git a/tools/curl-probe/README.md b/tools/curl-probe/README.md new file mode 100644 index 0000000..d97b7f1 --- /dev/null +++ b/tools/curl-probe/README.md @@ -0,0 +1,72 @@ +# Classic cancellation producer qualification + +`atrinik-curl-cancellation-probe` links the real `Atrinik::Core` +`curl_perform_cancellable` helper from a clean Classic commit. The default is +`9136e13efabc0f6edd513517b3a437c927b5edea`; producers can pass a full lowercase +`ATRINIK_CLASSIC_QUALIFICATION_COMMIT` SHA from their committed consumer manifest. +The bridge requires actual Git HEAD and clean tracked source to match that SHA. +The C17 source uses Winsock and a +Windows thread on native Windows, and sockets/pthreads on POSIX. It is a GPL +qualification executable linked with the GPL Classic library; it is not a +replacement implementation of the helper. + +Configure the bridge with the producer's normal dependency and toolchain flags: + +```sh +cmake -S tools/curl-probe -B build/curl-probe \ + -DATRINIK_CLASSIC_SOURCE_DIR=/absolute/clean/classic +cmake --build build/curl-probe --target atrinik-curl-cancellation-probe +ctest --test-dir build/curl-probe --output-on-failure \ + -R '^atrinik-curl-cancellation-qualification$' +``` + +For MXE supply the producer's CMake toolchain file during configuration. The +target emits `atrinik-curl-cancellation-probe.exe` on Windows. Bundle its normal +runtime DLL closure, then execute it **natively on Windows** under the runner's +15-second process watchdog. A cross-build or Wine run does not substitute for +native Windows qualification. CTest registers the same watchdog on POSIX. +The bridge can also be added to an existing CMake build that already supplies +`Atrinik::Core`; it verifies that target's source directory against the pinned +checkout before linking. Standalone configuration builds only protocol and +libatrinik, without game targets or unrelated library tests. No downloads are +initiated by this bridge. + +Both cases use ephemeral IPv4 loopback ports and disable proxies. DNS injects +only the fixture's UDP server using `CURLOPT_DNS_SERVERS`; the fixture witnesses +a DNS question and never responds. HTTP witnesses complete request headers +and never sends a response. The fixture records its witness time, and a separate +controller publishes cancellation at that time plus 100 ms, independently of +when the helper polls its callback. Each case requires +`CURLE_ABORTED_BY_CALLBACK` and at most 1000 ms from that scheduled cancellation +through helper return (including multi cleanup), easy/global cleanup, and both +controller and fixture stops/joins. The fixture stays alive throughout transfer +cleanup and is then explicitly stopped and joined. A missing witness cancels after three +seconds to keep failure bounded, but always fails qualification. Transfer +timeouts are ten seconds, so a timeout result cannot pass. All cleanup runs +normally; there is no forced process exit or quick-exit success path. + +The executable rejects runtime providers other than curl 8.18.0 or 8.21.0, +c-ares 1.34.6 and `OpenSSL/3.5.5`, including OpenSSL 4. The output records the +runtime curl/c-ares/OpenSSL versions, each fixture witness, +result, cleanup duration, deadline and PASS/FAIL. The producer must separately +verify generated curl configuration, header/runtime/provider identity and +DLL imports: ASYNCHDNS or a c-ares version alone cannot qualify the backend. +Keep the producer's pinned OpenSSL 3.5.5 provider and dependency closure. + +Native Windows public-CA qualification is a separate invocation: + +```sh +atrinik-curl-cancellation-probe.exe --public-ca ca-bundle.crt https://curl.se/ +``` + +Use the exact Classic client bundle and verify its manifest hash before invoking +the probe. This mode admits only that fixed endpoint, explicitly enables peer +and hostname verification, sets `CAINFO` to the supplied bundle, clears `CAPATH`, +disables redirects/proxies, discards response data, and requires a successful +2xx response with verification result zero. Transfer/connect timeouts and the +independent cancellation deadline are ten seconds; complete cleanup and joining +must finish within an additional second. Diagnostics include only the fixed +origin hostname, status, verification result, provider and timing. A missing, +empty, malformed or untrusted CA bundle must fail; the same command with a +nonempty invalid bundle can be used as a negative check. This real TLS request +requires network access and is separate from loopback cancellation qualification. diff --git a/tools/curl-probe/cancellation.c b/tools/curl-probe/cancellation.c new file mode 100644 index 0000000..cde91d2 --- /dev/null +++ b/tools/curl-probe/cancellation.c @@ -0,0 +1,439 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later + * Copyright 2026 The Atrinik Project + * Qualification executable linked with Classic Atrinik::Core. + */ +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +typedef SOCKET probe_socket_t; +typedef HANDLE probe_thread_t; +#define PROBE_INVALID INVALID_SOCKET +#define probe_close closesocket +#else +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +typedef int probe_socket_t; +typedef pthread_t probe_thread_t; +#define PROBE_INVALID (-1) +#define probe_close close +#endif + +#include +#include +#include +#include +#include +#include + +enum { FIXTURE_WAIT_MS = 3000, CANCEL_DELAY_MS = 100, CLEANUP_LIMIT_MS = 1000 }; + +typedef struct fixture { + probe_socket_t listener; + bool dns; + atomic_bool stop; + atomic_uint_fast64_t received_ms; + atomic_bool failed; +} fixture_t; + +typedef struct cancellation { + fixture_t *fixture; + uint64_t started_ms; + unsigned timeout_ms; + atomic_bool stop; + atomic_uint_fast64_t cancelled_ms; +} cancellation_t; + +static uint64_t monotonic_ms(void) { +#ifdef _WIN32 + /* QPC works on the supported native Windows toolchain without depending + * on the legacy WINVER selected by Classic's porting header. */ + LARGE_INTEGER now, frequency; + QueryPerformanceCounter(&now); + QueryPerformanceFrequency(&frequency); + return (uint64_t)(now.QuadPart / frequency.QuadPart) * 1000U + + (uint64_t)(now.QuadPart % frequency.QuadPart) * 1000U / + (uint64_t)frequency.QuadPart; +#else + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (uint64_t)now.tv_sec * 1000U + (uint64_t)now.tv_nsec / 1000000U; +#endif +} + +static void sleep_ms(unsigned milliseconds) { +#ifdef _WIN32 + Sleep(milliseconds); +#else + struct timespec delay = {.tv_sec = milliseconds / 1000U, + .tv_nsec = (long)(milliseconds % 1000U) * 1000000L}; + nanosleep(&delay, NULL); +#endif +} + +static void fixture_received(fixture_t *fixture) { + uint_fast64_t expected = 0; + atomic_compare_exchange_strong(&fixture->received_ms, &expected, monotonic_ms()); +} + +/* All sockets have one owner, and reads happen only after readiness. The + * short select timeout is the fixture's explicit cooperative stop event. */ +static int readable(probe_socket_t socket_handle) { + fd_set readers; + FD_ZERO(&readers); + FD_SET(socket_handle, &readers); + struct timeval timeout = {.tv_sec = 0, .tv_usec = 20000}; +#ifdef _WIN32 + return select(0, &readers, NULL, NULL, &timeout); +#else + return select(socket_handle + 1, &readers, NULL, NULL, &timeout); +#endif +} + +#ifdef _WIN32 +static DWORD WINAPI fixture_run(LPVOID context) { +#else +static void *fixture_run(void *context) { +#endif + fixture_t *fixture = context; + probe_socket_t client = PROBE_INVALID; + char request[8192]; + size_t used = 0; + while (!atomic_load(&fixture->stop)) { + probe_socket_t active = client != PROBE_INVALID ? client : fixture->listener; + int ready = readable(active); + if (ready < 0) { + atomic_store(&fixture->failed, true); + break; + } + if (ready == 0) + continue; + if (!fixture->dns && client == PROBE_INVALID) { + client = accept(fixture->listener, NULL, NULL); + if (client == PROBE_INVALID) { + atomic_store(&fixture->failed, true); + break; + } +#ifndef _WIN32 + if (client >= FD_SETSIZE) { + atomic_store(&fixture->failed, true); + break; + } +#endif + continue; + } + int received = recv(active, request + used, (int)(sizeof(request) - used - 1), 0); + if (received <= 0) { + /* EOF after cancellation is expected, but it cannot qualify an + * HTTP fixture which never saw a complete request. */ + if (!atomic_load(&fixture->received_ms)) + atomic_store(&fixture->failed, true); + break; + } + if (fixture->dns) { + /* A DNS question has a header, QR=0, and a nonzero QDCOUNT. + * Silently discard it: no DNS response or fallback server. */ + if (received >= 12 && !((unsigned char)request[2] & 0x80) && + ((unsigned char)request[4] || (unsigned char)request[5])) + fixture_received(fixture); + continue; + } + used += (size_t)received; + request[used] = '\0'; + if (strstr(request, "\r\n\r\n") != NULL) { + if (strncmp(request, "GET /cancel-probe HTTP/", 23) == 0) + fixture_received(fixture); + else + atomic_store(&fixture->failed, true); + /* Continue waiting without sending any HTTP response. */ + used = 0; + } else if (used == sizeof(request) - 1) { + atomic_store(&fixture->failed, true); + break; + } + } + if (client != PROBE_INVALID) + probe_close(client); +#ifdef _WIN32 + return 0; +#else + return NULL; +#endif +} + +static bool fixture_start(fixture_t *fixture, bool dns, unsigned *port, probe_thread_t *thread) { + memset(fixture, 0, sizeof(*fixture)); + atomic_init(&fixture->stop, false); + atomic_init(&fixture->received_ms, 0); + atomic_init(&fixture->failed, false); + fixture->dns = dns; + fixture->listener = socket(AF_INET, dns ? SOCK_DGRAM : SOCK_STREAM, 0); + if (fixture->listener == PROBE_INVALID) + return false; +#ifndef _WIN32 + if (fixture->listener >= FD_SETSIZE) { + probe_close(fixture->listener); + return false; + } +#endif + struct sockaddr_in address = {0}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); +#ifdef _WIN32 + int size = sizeof(address); +#else + socklen_t size = sizeof(address); +#endif + if (bind(fixture->listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + (!dns && listen(fixture->listener, 1) != 0) || + getsockname(fixture->listener, (struct sockaddr *)&address, &size) != 0) { + probe_close(fixture->listener); + return false; + } + *port = ntohs(address.sin_port); +#ifdef _WIN32 + *thread = CreateThread(NULL, 0, fixture_run, fixture, 0, NULL); + bool started = *thread != NULL; +#else + bool started = pthread_create(thread, NULL, fixture_run, fixture) == 0; +#endif + if (!started) + probe_close(fixture->listener); + return started; +} + +static bool fixture_stop(fixture_t *fixture, probe_thread_t thread) { + atomic_store(&fixture->stop, true); +#ifdef _WIN32 + bool joined = WaitForSingleObject(thread, INFINITE) == WAIT_OBJECT_0; + CloseHandle(thread); +#else + bool joined = pthread_join(thread, NULL) == 0; +#endif + probe_close(fixture->listener); + return joined; +} + +#ifdef _WIN32 +static DWORD WINAPI cancellation_run(LPVOID context) { +#else +static void *cancellation_run(void *context) { +#endif + cancellation_t *cancel = context; + while (!atomic_load(&cancel->stop)) { + uint64_t scheduled_ms = cancel->started_ms + cancel->timeout_ms; + if (cancel->fixture != NULL) { + uint64_t received_ms = atomic_load(&cancel->fixture->received_ms); + if (received_ms != 0 && received_ms + CANCEL_DELAY_MS < scheduled_ms) + scheduled_ms = received_ms + CANCEL_DELAY_MS; + } + if (monotonic_ms() >= scheduled_ms) { + /* Publish the scheduled deadline, not when the helper next polls + * or when this controller happens to be scheduled by the OS. */ + atomic_store(&cancel->cancelled_ms, scheduled_ms); + break; + } + sleep_ms(1); + } +#ifdef _WIN32 + return 0; +#else + return NULL; +#endif +} + +static bool cancellation_start(cancellation_t *cancel, fixture_t *fixture, + unsigned timeout_ms, probe_thread_t *thread) { + cancel->fixture = fixture; + cancel->started_ms = monotonic_ms(); + cancel->timeout_ms = timeout_ms; + atomic_init(&cancel->stop, false); + atomic_init(&cancel->cancelled_ms, 0); +#ifdef _WIN32 + *thread = CreateThread(NULL, 0, cancellation_run, cancel, 0, NULL); + return *thread != NULL; +#else + return pthread_create(thread, NULL, cancellation_run, cancel) == 0; +#endif +} + +static bool cancellation_stop(cancellation_t *cancel, probe_thread_t thread) { + atomic_store(&cancel->stop, true); +#ifdef _WIN32 + bool joined = WaitForSingleObject(thread, INFINITE) == WAIT_OBJECT_0; + CloseHandle(thread); + return joined; +#else + return pthread_join(thread, NULL) == 0; +#endif +} + +static bool cancelled(void *context) { + cancellation_t *cancel = context; + return atomic_load(&cancel->cancelled_ms) != 0; +} + +static bool qualify(bool dns) { + fixture_t fixture; + probe_thread_t thread; + unsigned port; + if (!fixture_start(&fixture, dns, &port, &thread)) { + fprintf(stderr, "%s fixture failed to start\n", dns ? "DNS" : "HTTP"); + return false; + } + bool initialized = curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK; + CURL *easy = initialized ? curl_easy_init() : NULL; + char url[128], dns_server[64]; + snprintf(url, sizeof(url), dns ? "http://cancel-probe.invalid/cancel-probe" : + "http://127.0.0.1:%u/cancel-probe", port); + snprintf(dns_server, sizeof(dns_server), "127.0.0.1:%u", port); + bool configured = easy != NULL; +#define CONFIGURE(option, value) \ + do { if (configured && curl_easy_setopt(easy, option, value) != CURLE_OK) \ + configured = false; } while (0) + CONFIGURE(CURLOPT_URL, url); + CONFIGURE(CURLOPT_PROXY, ""); + CONFIGURE(CURLOPT_NOPROXY, "*"); + CONFIGURE(CURLOPT_NOSIGNAL, 1L); + CONFIGURE(CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); + CONFIGURE(CURLOPT_DNS_CACHE_TIMEOUT, 0L); + CONFIGURE(CURLOPT_TIMEOUT_MS, 10000L); + CONFIGURE(CURLOPT_CONNECTTIMEOUT_MS, 10000L); + if (dns) + CONFIGURE(CURLOPT_DNS_SERVERS, dns_server); +#undef CONFIGURE + cancellation_t state; + probe_thread_t controller; + bool controller_started = cancellation_start(&state, &fixture, FIXTURE_WAIT_MS, &controller); + configured = configured && controller_started; + curl_cancel_t cancel = {.cancelled = cancelled, .context = &state}; + CURLcode result = configured ? curl_perform_cancellable(easy, &cancel) : CURLE_FAILED_INIT; + /* Helper return includes remove_handle and multi_cleanup. Measure through + * easy/global cleanup while the fixtures remain stalled and alive. */ + if (easy != NULL) + curl_easy_cleanup(easy); + if (initialized) + curl_global_cleanup(); + bool controller_joined = controller_started && cancellation_stop(&state, controller); + bool joined = fixture_stop(&fixture, thread); + uint64_t finished_ms = monotonic_ms(); + uint64_t received_ms = atomic_load(&fixture.received_ms); + uint64_t cancelled_ms = atomic_load(&state.cancelled_ms); + bool received = received_ms != 0; + bool failed = atomic_load(&fixture.failed); + uint64_t elapsed = cancelled_ms == 0 ? UINT64_MAX : finished_ms - cancelled_ms; + bool passed = configured && received && !failed && joined && controller_joined && + cancelled_ms == received_ms + CANCEL_DELAY_MS && + result == CURLE_ABORTED_BY_CALLBACK && elapsed <= CLEANUP_LIMIT_MS; + printf("%s fixture_received=%d result=%d cancel_to_cleanup_ms=%llu limit_ms=%d %s\n", + dns ? "stalled_dns" : "stalled_http", received, (int)result, + (unsigned long long)elapsed, CLEANUP_LIMIT_MS, passed ? "PASS" : "FAIL"); + return passed; +} + +static size_t discard_response(char *data, size_t size, size_t count, void *context) { + (void)data; + (void)context; + return size * count; +} + +static bool qualify_public_ca(const char *bundle) { + /* This mode intentionally admits only the declared public qualification + * endpoint. Neither CA contents nor URL paths enter diagnostics. */ + FILE *ca = fopen(bundle, "rb"); + if (ca == NULL) { + fprintf(stderr, "Public-CA bundle is unreadable\n"); + return false; + } + bool nonempty = fgetc(ca) != EOF; + fclose(ca); + if (!nonempty) { + fprintf(stderr, "Public-CA bundle is empty\n"); + return false; + } + bool initialized = curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK; + CURL *easy = initialized ? curl_easy_init() : NULL; + bool configured = easy != NULL; +#define CONFIGURE(option, value) \ + do { if (configured && curl_easy_setopt(easy, option, value) != CURLE_OK) \ + configured = false; } while (0) + CONFIGURE(CURLOPT_URL, "https://curl.se/"); + CONFIGURE(CURLOPT_PROXY, ""); + CONFIGURE(CURLOPT_NOPROXY, "*"); + CONFIGURE(CURLOPT_NOSIGNAL, 1L); + CONFIGURE(CURLOPT_SSL_VERIFYPEER, 1L); + CONFIGURE(CURLOPT_SSL_VERIFYHOST, 2L); + CONFIGURE(CURLOPT_CAINFO, bundle); + CONFIGURE(CURLOPT_CAPATH, NULL); + CONFIGURE(CURLOPT_FOLLOWLOCATION, 0L); + CONFIGURE(CURLOPT_TIMEOUT_MS, 10000L); + CONFIGURE(CURLOPT_CONNECTTIMEOUT_MS, 10000L); + CONFIGURE(CURLOPT_WRITEFUNCTION, discard_response); +#undef CONFIGURE + cancellation_t state; + probe_thread_t controller; + bool started = cancellation_start(&state, NULL, 10000, &controller); + configured = configured && started; + curl_cancel_t cancel = {.cancelled = cancelled, .context = &state}; + CURLcode result = configured ? curl_perform_cancellable(easy, &cancel) : CURLE_FAILED_INIT; + long status = 0, verification = -1; + bool info = easy != NULL && + curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &status) == CURLE_OK && + curl_easy_getinfo(easy, CURLINFO_SSL_VERIFYRESULT, &verification) == CURLE_OK; + if (easy != NULL) + curl_easy_cleanup(easy); + if (initialized) + curl_global_cleanup(); + bool joined = started && cancellation_stop(&state, controller); + uint64_t duration_ms = monotonic_ms() - state.started_ms; + bool passed = configured && joined && info && result == CURLE_OK && verification == 0 && + status >= 200 && status < 300 && duration_ms <= 11000; + printf("public_ca origin=curl.se status=%ld verification=%ld result=%d elapsed_ms=%llu %s\n", + status, verification, (int)result, (unsigned long long)duration_ms, + passed ? "PASS" : "FAIL"); + return passed; +} + +int main(int argc, char **argv) { + bool public_ca = argc == 4 && strcmp(argv[1], "--public-ca") == 0 && + strcmp(argv[3], "https://curl.se/") == 0; + if (argc != 1 && !public_ca) { + fprintf(stderr, "Usage: atrinik-curl-cancellation-probe [--public-ca BUNDLE https://curl.se/]\n"); + return 1; + } +#ifdef _WIN32 + WSADATA data; + if (WSAStartup(MAKEWORD(2, 2), &data) != 0) + return 1; +#endif + const curl_version_info_data *version = curl_version_info(CURLVERSION_NOW); + if (version == NULL || version->ares == NULL || version->ssl_version == NULL || + !(version->features & CURL_VERSION_ASYNCHDNS) || + (strcmp(version->version, "8.18.0") != 0 && strcmp(version->version, "8.21.0") != 0) || + strcmp(version->ares, "1.34.6") != 0 || strcmp(version->ssl_version, "OpenSSL/3.5.5") != 0) { + fprintf(stderr, "Qualification requires curl 8.18.0/8.21.0, c-ares 1.34.6 and OpenSSL/3.5.5\n"); +#ifdef _WIN32 + WSACleanup(); +#endif + return 1; + } + printf("Atrinik::Core qualification curl=%s c-ares=%s ssl=%s\n", + version->version, version->ares, version->ssl_version); + bool passed; + if (public_ca) { + passed = qualify_public_ca(argv[2]); + } else { + bool dns_passed = qualify(true); + bool http_passed = qualify(false); + passed = dns_passed && http_passed; + } +#ifdef _WIN32 + WSACleanup(); +#endif + return passed ? 0 : 1; +} diff --git a/tools/run-classic-native-tests.ps1 b/tools/run-classic-native-tests.ps1 new file mode 100644 index 0000000..c97af73 --- /dev/null +++ b/tools/run-classic-native-tests.ps1 @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: MIT +# Copyright 2026 The Atrinik Project +$ErrorActionPreference = 'Stop' +$bundle = $PSScriptRoot +$env:PATH = "${bundle};${env:PATH}" +$inventory = Get-Content -Raw (Join-Path $bundle 'classic-check-toolchain.json') | ConvertFrom-Json +$tests = $inventory.verification.native_tests +if ($tests.Count -ne 8) { throw 'Expected eight declared native tests' } +$caresNotice = $inventory.runtime_contract.cares_license +$noticePath = Join-Path $bundle $caresNotice.bundle_name +if ((Get-FileHash -Algorithm SHA256 $noticePath).Hash.ToLowerInvariant() -ne $caresNotice.sha256) { + throw 'c-ares license notice does not match the pinned producer' +} + +function Invoke-QualifiedNativeTest { + param([string]$Executable, [string[]]$Arguments, [int]$TimeoutMs) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = Join-Path $bundle $Executable + $start.WorkingDirectory = $bundle + $start.UseShellExecute = $false + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::Start($start) + try { + if (-not $process.WaitForExit($TimeoutMs)) { + $process.Kill($true) + [void]$process.WaitForExit(5000) + throw "$Executable exceeded its ${TimeoutMs}-ms qualification deadline" + } + if ($process.ExitCode -ne 0) { throw "$Executable failed: $($process.ExitCode)" } + } finally { + $process.Dispose() + } +} + +foreach ($test in $tests) { + $arguments = @($test.arguments | ForEach-Object { + if ([IO.Path]::IsPathRooted($_) -or $_ -like '../*' -or $_ -like '..\*') { + throw "Unsafe native-test argument: $_" + } + Join-Path $bundle $_ + }) + $limit = if ($test.executable -eq 'atrinik-curl-cancellation-probe.exe') { 15000 } else { 60000 } + Invoke-QualifiedNativeTest $test.executable $arguments $limit +} + +$ca = Join-Path $bundle 'ca-bundle.crt' +$expected = $inventory.verification.public_ca.sha256 +if ((Get-FileHash -Algorithm SHA256 $ca).Hash.ToLowerInvariant() -ne $expected) { + throw 'Public CA bundle does not match the pinned Classic consumer' +} +if ($inventory.verification.public_ca.endpoint -ne 'https://curl.se/') { + throw 'Unexpected public TLS qualification endpoint' +} +Invoke-QualifiedNativeTest 'atrinik-curl-cancellation-probe.exe' @('--public-ca', $ca, 'https://curl.se/') 15000 diff --git a/tools/smoke-classic-check.sh b/tools/smoke-classic-check.sh index be7c213..cc9275b 100755 --- a/tools/smoke-classic-check.sh +++ b/tools/smoke-classic-check.sh @@ -48,7 +48,25 @@ for consumer_job in "${consumer_jobs[@]}"; do fi done +mapfile -t native_fixture_arguments < <(jq -r \ + '.verification.native_tests[].arguments[]' \ + "${image_checkout}/windows/classic-check-toolchain.json") +for fixture in "${native_fixture_arguments[@]}"; do + if [[ ${fixture} != fixtures/* || + ! -f ${classic_checkout}/libatrinik/tests/${fixture} ]]; then + echo "Declared native fixture is missing from the pinned Classic source: ${fixture}" >&2 + exit 1 + fi +done + python3 "${classic_checkout}/client/tools/dependencies.py" sync +# Follow the pinned consumer workflow: validate a host-generated shader cohort +# before the offline MXE build rather than copying host compiler binaries. +CMAKE_BUILD_PARALLEL_LEVEL=4 \ + bash "${classic_checkout}/tools/ci/prepare_gpu_shaders.sh" \ + "${classic_checkout}" \ + "${classic_checkout}/build/gpu-shader-downloads" \ + "${classic_checkout}/build/gpu-shaders" umask 077 mkdir -p "${classic_checkout}/build" discord_test_file=$(mktemp \ @@ -62,6 +80,7 @@ docker run --rm --user "$(id -u):$(id -g)" --network none \ --env CCACHE_TEMPDIR=/tmp/atrinik-classic-check-ccache-tmp \ --env CCACHE_MAXSIZE=250M \ --env ATRINIK_PACKAGE_VERSION=0.0.0 \ + --env ATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders \ --env ATRINIK_DISCORD_APPLICATION_ID_FILE="/workspace/${discord_test_relative}" \ --volume "${classic_checkout}:/workspace" \ --volume "${image_checkout}:/image-source:ro" \ @@ -94,10 +113,23 @@ docker run --rm --user "$(id -u):$(id -g)" --network none \ mapfile -t native_targets < <(python3 -c \ "import json,sys; value=json.load(open(sys.argv[1])); print(*(item[\"build_target\"] for item in value[\"verification\"][\"native_tests\"] if item[\"build_target\"] is not None), sep=chr(10))" \ /image-source/windows/classic-check-toolchain.json) - test "${#native_targets[@]}" -eq 5 + test "${#native_targets[@]}" -eq 6 cmake --build libatrinik/build/windows-tests \ --target "${native_targets[@]}" --parallel "$(nproc)" + qualification_commit=$(python3 -c \ + "import json,sys; print(json.load(open(sys.argv[1]))[\"consumer\"][\"validation_commit\"])" \ + /image-source/windows/classic-check-toolchain.json) + x86_64-w64-mingw32.shared-cmake \ + -S /image-source/tools/curl-probe \ + -B libatrinik/build/windows-curl-probe \ + -G Ninja \ + -DATRINIK_CLASSIC_SOURCE_DIR=/workspace \ + -DATRINIK_CLASSIC_QUALIFICATION_COMMIT="${qualification_commit}" \ + -DCMAKE_BUILD_TYPE=Release + cmake --build libatrinik/build/windows-curl-probe \ + --target atrinik-curl-cancellation-probe --parallel "$(nproc)" + cd client bash tools/build-windows-package.sh build/windows-pr-package mapfile -t packages < <(find build/windows-pr-package -maxdepth 1 -type f \ @@ -105,21 +137,53 @@ docker run --rm --user "$(id -u):$(id -g)" --network none \ test "${#packages[@]}" -eq 1 package=${packages[0]} python3 /image-source/tools/verify-classic-check-package.py \ - "${package}" x86_64-w64-mingw32.shared-objdump + "${package}" x86_64-w64-mingw32.shared-objdump \ + /image-source/windows/classic-check-toolchain.json cd .. + # The production package disables tests. Build the native client test in the + # separate directory used by the pinned consumer workflow. + x86_64-w64-mingw32.shared-cmake \ + -S client \ + -B client/build/windows-tests \ + -G Ninja \ + -DBUILD_TESTING=ON \ + -DCMAKE_BUILD_TYPE=Release \ + -DPACKAGE_TYPE=none \ + -DATRINIK_PACKAGE_VERSION=0.0.0 \ + -DATRINIK_GPU_SHADER_DIRECTORY=/workspace/build/gpu-shaders \ + -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)" + stage=libatrinik/build/windows-test-bundle cmake -E remove_directory "${stage}" mapfile -t native_sources < <(python3 -c \ "import json,sys; value=json.load(open(sys.argv[1])); print(*(item[\"source\"] for item in value[\"verification\"][\"native_tests\"]), sep=chr(10))" \ /image-source/windows/classic-check-toolchain.json) - test "${#native_sources[@]}" -eq 6 + test "${#native_sources[@]}" -eq 8 python3 tools/ci/stage_windows_runtime.py \ --objdump x86_64-w64-mingw32.shared-objdump \ --runtime-dir "${MXE_RUNTIME_DIR}" \ --output-dir "${stage}" \ "${native_sources[@]}" cmake -E copy_directory libatrinik/tests/fixtures "${stage}/fixtures" + test "$(sha256sum client/ca-bundle.crt | cut -d " " -f 1)" = \ + "$(python3 -c "import json; print(json.load(open(\"/image-source/windows/classic-check-toolchain.json\"))[\"verification\"][\"public_ca\"][\"sha256\"])")" + cmake -E copy client/ca-bundle.crt "${stage}/ca-bundle.crt" + cmake -E copy LICENSE.md "${stage}/Classic-LICENSE.md" + cmake -E copy ATTRIBUTIONS.md "${stage}/ATTRIBUTIONS.md" + cmake -E copy docs/CA-BUNDLE.md "${stage}/CA-BUNDLE.md" + cares_license=/opt/mxe/usr/x86_64-w64-mingw32.shared/share/licenses/c-ares/LICENSE.md + test "$(sha256sum "${cares_license}" | cut -d " " -f 1)" = \ + "$(python3 -c "import json; print(json.load(open(\"/image-source/windows/classic-check-toolchain.json\"))[\"runtime_contract\"][\"cares_license\"][\"sha256\"])")" + cmake -E copy "${cares_license}" "${stage}/c-ares-LICENSE.md" + cmake -E copy_directory /image-source/tools/curl-probe "${stage}/sources/curl-probe" + printf "%s\n" "Classic source: https://github.com/atrinik/classic/tree/${qualification_commit}" \ + > "${stage}/sources/Classic-source.txt" + cmake -E copy /image-source/tools/run-classic-native-tests.ps1 \ + "${stage}/run-classic-native-tests.ps1" cmake -E copy /image-source/windows/classic-check-toolchain.json \ "${stage}/classic-check-toolchain.json" ccache --show-stats diff --git a/tools/smoke-classic-curl.sh b/tools/smoke-classic-curl.sh new file mode 100755 index 0000000..ef5a664 --- /dev/null +++ b/tools/smoke-classic-curl.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Copyright 2026 The Atrinik Project +set -euo pipefail +image=${1:?usage: smoke-classic-curl.sh IMAGE CLASSIC_CHECKOUT} +classic=$(realpath "${2:?Classic checkout required}") +source=$(git rev-parse --show-toplevel) +expected=$(jq -er '.consumer_validation.commit' "$source/classic-toolchain.json") +test "$(git -C "$classic" rev-parse HEAD)" = "$expected" +git -C "$classic" diff --quiet HEAD -- +ca_hash=$(jq -er '.verification.public_ca.sha256' "$source/windows/classic-check-toolchain.json") +test "$(sha256sum "$classic/client/ca-bundle.crt" | cut -d' ' -f1)" = "$ca_hash" +docker run --rm --init --read-only --cap-drop ALL --security-opt no-new-privileges \ + --cpus 4 --memory 6g --memory-swap 6g --pids-limit 512 \ + --label org.atrinik.task=classic-curl-qualification \ + --user "$(id -u):$(id -g)" --env HOME=/tmp/classic-curl-home \ + --env QUALIFICATION_COMMIT="$expected" \ + --tmpfs /tmp:rw,exec,nosuid,nodev,mode=1777 \ + --mount "type=bind,source=$source,target=/image-source,readonly" \ + --mount "type=bind,source=$classic,target=/workspace,readonly" \ + --workdir /workspace "$image" bash -euo pipefail -c ' + cmake -S /image-source/tools/curl-probe -B /tmp/curl-probe -G Ninja \ + -DATRINIK_CLASSIC_SOURCE_DIR=/workspace \ + -DATRINIK_CLASSIC_QUALIFICATION_COMMIT="$QUALIFICATION_COMMIT" \ + -DCMAKE_BUILD_TYPE=Release + cmake --build /tmp/curl-probe --target atrinik-curl-cancellation-probe --parallel 4 + ctest --test-dir /tmp/curl-probe --output-on-failure --no-tests=error \ + -R "^atrinik-curl-cancellation-qualification$" + /tmp/curl-probe/atrinik-curl-cancellation-probe \ + --public-ca /workspace/client/ca-bundle.crt https://curl.se/ + ' diff --git a/tools/tests/test_verify_classic_check_package.py b/tools/tests/test_verify_classic_check_package.py new file mode 100644 index 0000000..f43a88d --- /dev/null +++ b/tools/tests/test_verify_classic_check_package.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import importlib.util +import hashlib +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock +import zipfile + + +SPEC = importlib.util.spec_from_file_location( + "verify_classic_check_package", + Path(__file__).resolve().parents[1] / "verify-classic-check-package.py", +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class PackageClosureTests(unittest.TestCase): + def verify(self, application_imports: list[str], runtime_imports: list[str], + *, bad_ca: bool = False, missing_notice: bool = False) -> int: + with tempfile.TemporaryDirectory() as temporary: + package = Path(temporary) / "client.zip" + ca = b"pinned CA input" + notice = b"pinned full dependency notice" + manifest = Path(temporary) / "manifest.json" + manifest.write_text(json.dumps({ + "verification": {"public_ca": {"sha256": hashlib.sha256(ca).hexdigest()}}, + "runtime_contract": {"cares_license": { + "bundle_name": "c-ares-LICENSE.md", + "sha256": hashlib.sha256(notice).hexdigest(), + }}, + }), encoding="utf-8") + with zipfile.ZipFile(package, "w") as archive: + archive.writestr("client/atrinik.exe", b"application") + archive.writestr("client/ca-bundle.crt", b"system CA" if bad_ca else ca) + if not missing_notice: + archive.writestr("client/c-ares-LICENSE.md", notice) + archive.writestr( + "client/data/discord-application-id", b"123456789012345678\n" + ) + for name in MODULE.EXPECTED_RUNTIME_DLLS: + archive.writestr("client/" + name, b"runtime") + + def inspect(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + imports = ( + application_imports + if Path(command[-1]).name == "atrinik.exe" + else runtime_imports + ) + return subprocess.CompletedProcess( + command, 0, "".join("DLL Name: " + name + "\n" for name in imports), "" + ) + + with mock.patch.object(MODULE.sys, "argv", ["verify", str(package), "objdump", str(manifest)]), mock.patch.object( + MODULE.subprocess, "run", side_effect=inspect + ): + return MODULE.main() + + def test_inbox_d3d12_is_not_required_in_client_package(self) -> None: + self.assertEqual(self.verify(["D3D12.dll", "DXGI.dll", "SDL3.dll"], ["KERNEL32.dll", "MSIMG32.dll"]), 0) + + def test_transitive_private_runtime_still_must_be_packaged(self) -> None: + with self.assertRaisesRegex(RuntimeError, "libcares-2.dll"): + self.verify(["D3D12.dll", "SDL3.dll"], ["libcares-2.dll"]) + + def test_package_cannot_replace_pinned_ca_with_system_ca(self) -> None: + with self.assertRaisesRegex(RuntimeError, "ca-bundle.crt checksum"): + self.verify([], [], bad_ca=True) + + def test_package_must_retain_dependency_notice(self) -> None: + with self.assertRaisesRegex(RuntimeError, "c-ares-LICENSE.md"): + self.verify([], [], missing_notice=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate-classic-image.sh b/tools/validate-classic-image.sh index cfad644..72b484a 100755 --- a/tools/validate-classic-image.sh +++ b/tools/validate-classic-image.sh @@ -2,6 +2,8 @@ set -euo pipefail +/usr/local/bin/atrinik-verify-classic-curl /usr/local + if [[ $# -ne 9 ]]; then echo "usage: $0 PACKAGE_LOCK EXPECTED_INVENTORY INSTALLED_INVENTORY AUDIO_INVENTORY DOCKERFILE SHADER_INVENTORY SHADER_INSTALLED SHADER_SPDX SHADER_SPDX_INSTALLED" >&2 exit 2 diff --git a/tools/validate-toolchains.sh b/tools/validate-toolchains.sh index 14d7284..d1bc367 100755 --- a/tools/validate-toolchains.sh +++ b/tools/validate-toolchains.sh @@ -23,6 +23,7 @@ jq -e ' ' "${expected}" >/dev/null if [[ -n ${installed} ]]; then + /usr/local/bin/atrinik-verify-classic-curl /usr/local cmp --silent "${expected}" "${installed}" test "$(go env GOVERSION)" = "go$(jq -r '.tools.go' "${expected}")" @@ -130,7 +131,7 @@ fi if [[ -n ${classic_check_expected} ]]; then jq -e ' keys == [ - "$schema", "base", "consumer", "excluded", "host_packages", "mxe", + "$schema", "base", "consumer", "curl_contract", "excluded", "host_packages", "mxe", "runtime_contract", "schema_version", "staging", "target", "verification" ] and .["$schema"] == "https://json-schema.org/draft/2020-12/schema" @@ -150,7 +151,7 @@ if [[ -n ${classic_check_expected} ]]; then ] and .base == { "image": "mcr.microsoft.com/devcontainers/base:bookworm", - "digest": "sha256:73d85a96694a2cadca1ba3fcb5721f2312a64f1d571dd86f6c77e10a708931dc" + "digest": "sha256:86165cfc170e9b2aa8df90b847127eea97b08eb9987021e6e6ec6c3a96545d7c" } and .host_packages == [ "ca-certificates", "cmake", "git", "ninja-build", "python3" @@ -163,7 +164,7 @@ if [[ -n ${classic_check_expected} ]]; then and .mxe.commit == "8784776b145a8ddd350ce32aa0908ac10977060c" and .mxe.target == "x86_64-w64-mingw32.shared" and .mxe.packages == [ - "cc", "cmake", "curl", "libidn2", "libxml2", "openssl", "sdl3", + "c-ares", "cc", "cmake", "curl", "libidn2", "libxml2", "openssl", "sdl3", "sdl3_image", "sdl3_ttf", "zlib" ] and .mxe.additional_libraries == [ @@ -206,17 +207,30 @@ if [[ -n ${classic_check_expected} ]]; then "inventory": "/usr/local/share/atrinik/audio-toolchain.json", "sbom": "/usr/local/share/atrinik/audio-toolchain.spdx.json", "probe": "/opt/mxe/usr/x86_64-w64-mingw32.shared/bin/atrinik-sdl3-mixer-probe.exe", - "import_contract_source": "audio-toolchain.json#windows" + "import_contract_source": "audio-toolchain.json#windows", + "cares_license": { + "source":"/opt/mxe/usr/x86_64-w64-mingw32.shared/share/licenses/c-ares/LICENSE.md", + "upstream":"https://github.com/c-ares/c-ares/blob/v1.34.6/LICENSE.md", + "sha256":"460f5e768fda3752ca2169a95df062578a10fb126bfd65f3b9b1a1bed2f84807", + "bundle_name":"c-ares-LICENSE.md" + } } - and (.verification | keys == ["native_tests"]) + and (.verification | keys == ["native_tests", "public_ca"]) and .verification.native_tests == [ {"executable":"libatrinik-path.exe","build_target":"libatrinik-path","source":"libatrinik/build/windows-tests/libatrinik-path.exe","arguments":[]}, - {"executable":"libatrinik-rendezvous.exe","build_target":"libatrinik-rendezvous","source":"libatrinik/build/windows-tests/libatrinik-rendezvous.exe","arguments":["fixtures/rendezvous-invite-v1.json","fixtures/rendezvous-invite-v1-negative.json"]}, - {"executable":"libatrinik-metaserver-publisher.exe","build_target":"libatrinik-metaserver-publisher","source":"libatrinik/build/windows-tests/libatrinik-metaserver-publisher.exe","arguments":["fixtures/metaserver-publisher-v1.json"]}, + {"executable":"libatrinik-rendezvous.exe","build_target":"libatrinik-rendezvous","source":"libatrinik/build/windows-tests/libatrinik-rendezvous.exe","arguments":[]}, + {"executable":"libatrinik-metaserver-publisher.exe","build_target":"libatrinik-metaserver-publisher","source":"libatrinik/build/windows-tests/libatrinik-metaserver-publisher.exe","arguments":["fixtures/metaserver-classic-publisher-v3.json"]}, {"executable":"libatrinik-metaserver-url.exe","build_target":"libatrinik-metaserver-url","source":"libatrinik/build/windows-tests/libatrinik-metaserver-url.exe","arguments":[]}, {"executable":"libatrinik-stun.exe","build_target":"libatrinik-stun","source":"libatrinik/build/windows-tests/libatrinik-stun.exe","arguments":[]}, - {"executable":"client-rich-presence-tests.exe","build_target":null,"source":"client/build/windows-release/client-rich-presence-tests.exe","arguments":[]} + {"executable":"libatrinik-socket-quic.exe","build_target":"libatrinik-socket-quic","source":"libatrinik/build/windows-tests/libatrinik-socket-quic.exe","arguments":[]}, + {"executable":"atrinik-curl-cancellation-probe.exe","build_target":null,"source":"libatrinik/build/windows-curl-probe/atrinik-curl-cancellation-probe.exe","arguments":[]}, + {"executable":"client-rich-presence-tests.exe","build_target":null,"source":"client/build/windows-tests/client-rich-presence-tests.exe","arguments":[]} ] + and .verification.public_ca == { + "source":"client/ca-bundle.crt", + "sha256":"a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505", + "endpoint":"https://curl.se/" + } and .excluded == { "paths": [ "/opt/mxe/.git", "/opt/mxe/.ccache/ccache", "/opt/mxe/log", diff --git a/tools/verify-classic-check-package.py b/tools/verify-classic-check-package.py index 7250439..22645da 100755 --- a/tools/verify-classic-check-package.py +++ b/tools/verify-classic-check-package.py @@ -4,6 +4,8 @@ from __future__ import annotations from pathlib import Path +import hashlib +import json import re import subprocess import sys @@ -28,6 +30,7 @@ "crypt32.dll", "d2d1.dll", "d3d11.dll", + "d3d12.dll", "dbghelp.dll", "dinput8.dll", "dnsapi.dll", @@ -38,6 +41,7 @@ "imm32.dll", "iphlpapi.dll", "kernel32.dll", + "msimg32.dll", "msvcrt.dll", "ncrypt.dll", "normaliz.dll", @@ -74,14 +78,20 @@ def is_system_dll(name: str) -> bool: def main() -> int: - if len(sys.argv) != 3: + if len(sys.argv) != 4: print( - f"usage: {Path(sys.argv[0]).name} PACKAGE OBJDUMP", + f"usage: {Path(sys.argv[0]).name} PACKAGE OBJDUMP TOOLCHAIN_MANIFEST", file=sys.stderr, ) return 2 package = Path(sys.argv[1]) objdump = sys.argv[2] + manifest = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8")) + expected_payloads = { + "ca-bundle.crt": manifest["verification"]["public_ca"]["sha256"], + manifest["runtime_contract"]["cares_license"]["bundle_name"]: + manifest["runtime_contract"]["cares_license"]["sha256"], + } application_id = b"123456789012345678\n" with zipfile.ZipFile(package) as archive, tempfile.TemporaryDirectory() as temporary: names = archive.namelist() @@ -97,6 +107,14 @@ def main() -> int: for name in names: if not name.endswith("/"): archive_by_basename.setdefault(Path(name).name.lower(), []).append(name) + for basename, expected_digest in expected_payloads.items(): + if not re.fullmatch(r"[0-9a-f]{64}", expected_digest): + raise RuntimeError(f"invalid expected checksum for {basename}") + matches = archive_by_basename.get(basename.lower(), []) + if len(matches) != 1: + raise RuntimeError(f"package must contain exactly one {basename}") + if hashlib.sha256(archive.read(matches[0])).hexdigest() != expected_digest: + raise RuntimeError(f"packaged {basename} checksum does not match toolchain contract") missing_expected = EXPECTED_RUNTIME_DLLS - archive_by_basename.keys() if missing_expected: raise RuntimeError( diff --git a/tools/verify-classic-curl.sh b/tools/verify-classic-curl.sh new file mode 100755 index 0000000..7ed2f66 --- /dev/null +++ b/tools/verify-classic-curl.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +set -euo pipefail +prefix=${1:-/usr/local} +config="$prefix/share/atrinik/curl/curl_config.h" +grep -Eq '^#define USE_ARES 1$' "$config" +if grep -Eq '^#define USE_THREADS_(POSIX|WIN32) 1$' "$config"; then + echo 'curl has a threaded resolver backend' >&2 + exit 1 +fi +"$prefix/bin/curl-config" --configure | grep -F -- --disable-threaded-resolver +"$prefix/bin/curl-config" --ssl-backends | grep -Fx OpenSSL +features=$("$prefix/bin/curl-config" --features) +protocols=$("$prefix/bin/curl-config" --protocols) +for feature in AsynchDNS HTTP2 IDN PSL SSL libz brotli zstd; do + grep -Fx "$feature" <<< "$features" +done +for protocol in HTTP HTTPS WS WSS; do + grep -Fx "$protocol" <<< "$protocols" +done +if grep -Eq '^(LDAP|LDAPS|RTMP|RTMPS|RTMPE|RTMPT|RTMPTE|RTMPTS)$' <<< "$protocols"; then + echo 'excluded LDAP or RTMP protocol present' >&2 + exit 1 +fi +if [[ $prefix == /usr/local ]]; then + test "$(pkg-config --modversion libcurl)" = 8.18.0 + test "$(pkg-config --variable=prefix libcurl)" = "$prefix" + test "$(pkg-config --modversion libcares)" = 1.34.6 + test "$(pkg-config --modversion openssl)" = 3.5.5 + "$prefix/bin/curl" --version | grep -F 'c-ares/1.34.6' + "$prefix/bin/curl" --version | grep -F 'OpenSSL/3.5.5' +else + pkg=/opt/mxe/usr/bin/x86_64-w64-mingw32.shared-pkg-config + test "$($pkg --modversion libcurl)" = 8.21.0 + test "$($pkg --modversion libcares)" = 1.34.6 + test "$($pkg --modversion openssl)" = 3.5.5 + test -f "$prefix/bin/libcares-2.dll" +fi diff --git a/windows/Dockerfile b/windows/Dockerfile index 1c92029..95693fa 100644 --- a/windows/Dockerfile +++ b/windows/Dockerfile @@ -65,7 +65,9 @@ RUN git clone --filter=blob:none https://github.com/mxe/mxe.git /opt/mxe \ && git -C /opt/mxe checkout "${MXE_REF}" COPY windows/curl-openssl.patch /tmp/curl-openssl.patch -RUN git -C /opt/mxe apply /tmp/curl-openssl.patch \ +COPY windows/openssl-3.5.patch /tmp/openssl-3.5.patch +COPY windows/c-ares.mk /opt/mxe/src/c-ares.mk +RUN git -C /opt/mxe apply /tmp/curl-openssl.patch /tmp/openssl-3.5.patch \ && chown -R vscode:vscode /opt/mxe ENV MXE_ROOT=/opt/mxe \ @@ -98,8 +100,14 @@ RUN git -C /opt/mxe apply /tmp/sdl3-image-disable-tiff.patch \ RUN make -C /opt/mxe --jobs="${MXE_BUILD_JOBS}" JOBS="${MXE_BUILD_JOBS}" \ MXE_TARGETS=x86_64-w64-mingw32.shared \ sdl3_ttf -RUN test "$(/opt/mxe/usr/x86_64-w64-mingw32.shared/bin/curl-config --ssl-backends)" \ - = "OpenSSL" +COPY --chown=vscode:vscode tools/verify-classic-curl.sh /tmp/verify-classic-curl.sh +RUN /tmp/verify-classic-curl.sh /opt/mxe/usr/x86_64-w64-mingw32.shared \ + && test "$(/opt/mxe/usr/x86_64-w64-mingw32.shared/bin/curl-config --ssl-backends)" \ + = "OpenSSL" \ + && test "$(/opt/mxe/usr/bin/x86_64-w64-mingw32.shared-pkg-config --modversion openssl)" = "3.5.5" \ + && test "$(/opt/mxe/usr/bin/x86_64-w64-mingw32.shared-pkg-config --modversion libcares)" = "1.34.6" \ + && /opt/mxe/usr/x86_64-w64-mingw32.shared/bin/curl-config --configure \ + | grep -F -- --disable-threaded-resolver # Install the audio manifest reader after the expensive MXE foundation so this # small dependency does not invalidate the established cross-toolchain cache. @@ -175,6 +183,10 @@ RUN mxe_commit=$(jq -er '.mxe.commit' \ && rm -rf /tmp/miniupnp /tmp/miniupnp-build \ /tmp/classic-check-toolchain.json +# Retain the exact notice for the new resolver DLL after the MXE foundation. +COPY --chown=vscode:vscode windows/licenses/c-ares-1.34.6.LICENSE.md \ + /opt/mxe/usr/x86_64-w64-mingw32.shared/share/licenses/c-ares/LICENSE.md + FROM windows-shared AS windows-build RUN mkdir -p "${ATRINIK_WINDOWS_PYTHON_RUNTIME_DIR}" /tmp/python-sdk \ @@ -220,6 +232,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ zlib1g-dev COPY windows/mxe-path.sh /etc/profile.d/mxe-path.sh +COPY classic-curl-toolchain.json /usr/local/share/atrinik/classic-curl-toolchain.json COPY audio-toolchain.json /usr/local/share/atrinik/audio-toolchain.json COPY audio-toolchain.spdx.json /usr/local/share/atrinik/audio-toolchain.spdx.json @@ -308,6 +321,7 @@ ENV MXE_ROOT=/opt/mxe \ COPY windows/mxe-path.sh /etc/profile.d/mxe-path.sh COPY windows/classic-check-toolchain.json \ /usr/local/share/atrinik/classic-check-toolchain.json +COPY classic-curl-toolchain.json /usr/local/share/atrinik/classic-curl-toolchain.json COPY audio-toolchain.json /usr/local/share/atrinik/audio-toolchain.json COPY audio-toolchain.spdx.json \ /usr/local/share/atrinik/audio-toolchain.spdx.json diff --git a/windows/c-ares.mk b/windows/c-ares.mk new file mode 100644 index 0000000..188f842 --- /dev/null +++ b/windows/c-ares.mk @@ -0,0 +1,17 @@ +# Atrinik c-ares MXE recipe. SPDX-License-Identifier: MIT +PKG := c-ares +$(PKG)_WEBSITE := https://c-ares.org/ +$(PKG)_DESCR := asynchronous DNS resolver +$(PKG)_VERSION := 1.34.6 +$(PKG)_CHECKSUM := 912dd7cc3b3e8a79c52fd7fb9c0f4ecf0aaa73e45efda880266a2d6e26b84ef5 +$(PKG)_SUBDIR := c-ares-$($(PKG)_VERSION) +$(PKG)_FILE := c-ares-$($(PKG)_VERSION).tar.gz +$(PKG)_URL := https://github.com/c-ares/c-ares/releases/download/v$($(PKG)_VERSION)/$($(PKG)_FILE) +$(PKG)_DEPS := cc + +define $(PKG)_BUILD + cd '$(BUILD_DIR)' && $(SOURCE_DIR)/configure \ + $(MXE_CONFIGURE_OPTS) --disable-tests --disable-tools + $(MAKE) -C '$(BUILD_DIR)' -j '$(JOBS)' + $(MAKE) -C '$(BUILD_DIR)' -j 1 install +endef diff --git a/windows/classic-check-toolchain.json b/windows/classic-check-toolchain.json index 6300730..986867b 100644 --- a/windows/classic-check-toolchain.json +++ b/windows/classic-check-toolchain.json @@ -4,7 +4,7 @@ "target": "classic-check", "consumer": { "repository": "atrinik/classic", - "validation_commit": "8fec1db157bcfdd050c1ba360e77365bce701bba", + "validation_commit": "9136e13efabc0f6edd513517b3a437c927b5edea", "workflow": ".github/workflows/check.yml", "jobs": [ "Build native Windows tests", @@ -17,7 +17,7 @@ }, "base": { "image": "mcr.microsoft.com/devcontainers/base:bookworm", - "digest": "sha256:73d85a96694a2cadca1ba3fcb5721f2312a64f1d571dd86f6c77e10a708931dc" + "digest": "sha256:86165cfc170e9b2aa8df90b847127eea97b08eb9987021e6e6ec6c3a96545d7c" }, "host_packages": [ "ca-certificates", @@ -31,6 +31,7 @@ "commit": "8784776b145a8ddd350ce32aa0908ac10977060c", "target": "x86_64-w64-mingw32.shared", "packages": [ + "c-ares", "cc", "cmake", "curl", @@ -98,7 +99,13 @@ "inventory": "/usr/local/share/atrinik/audio-toolchain.json", "sbom": "/usr/local/share/atrinik/audio-toolchain.spdx.json", "probe": "/opt/mxe/usr/x86_64-w64-mingw32.shared/bin/atrinik-sdl3-mixer-probe.exe", - "import_contract_source": "audio-toolchain.json#windows" + "import_contract_source": "audio-toolchain.json#windows", + "cares_license": { + "source": "/opt/mxe/usr/x86_64-w64-mingw32.shared/share/licenses/c-ares/LICENSE.md", + "upstream": "https://github.com/c-ares/c-ares/blob/v1.34.6/LICENSE.md", + "sha256": "460f5e768fda3752ca2169a95df062578a10fb126bfd65f3b9b1a1bed2f84807", + "bundle_name": "c-ares-LICENSE.md" + } }, "verification": { "native_tests": [ @@ -112,17 +119,14 @@ "executable": "libatrinik-rendezvous.exe", "build_target": "libatrinik-rendezvous", "source": "libatrinik/build/windows-tests/libatrinik-rendezvous.exe", - "arguments": [ - "fixtures/rendezvous-invite-v1.json", - "fixtures/rendezvous-invite-v1-negative.json" - ] + "arguments": [] }, { "executable": "libatrinik-metaserver-publisher.exe", "build_target": "libatrinik-metaserver-publisher", "source": "libatrinik/build/windows-tests/libatrinik-metaserver-publisher.exe", "arguments": [ - "fixtures/metaserver-publisher-v1.json" + "fixtures/metaserver-classic-publisher-v3.json" ] }, { @@ -137,13 +141,30 @@ "source": "libatrinik/build/windows-tests/libatrinik-stun.exe", "arguments": [] }, + { + "executable": "libatrinik-socket-quic.exe", + "build_target": "libatrinik-socket-quic", + "source": "libatrinik/build/windows-tests/libatrinik-socket-quic.exe", + "arguments": [] + }, + { + "executable": "atrinik-curl-cancellation-probe.exe", + "build_target": null, + "source": "libatrinik/build/windows-curl-probe/atrinik-curl-cancellation-probe.exe", + "arguments": [] + }, { "executable": "client-rich-presence-tests.exe", "build_target": null, - "source": "client/build/windows-release/client-rich-presence-tests.exe", + "source": "client/build/windows-tests/client-rich-presence-tests.exe", "arguments": [] } - ] + ], + "public_ca": { + "source": "client/ca-bundle.crt", + "sha256": "a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505", + "endpoint": "https://curl.se/" + } }, "excluded": { "paths": [ @@ -162,5 +183,6 @@ "general-purpose MXE development checkout" ], "reason": "Classic Check builds and stages only libatrinik and client native Windows tests; server packaging and worldmaker remain on the general-purpose image." - } + }, + "curl_contract": "/usr/local/share/atrinik/classic-curl-toolchain.json" } diff --git a/windows/curl-openssl.patch b/windows/curl-openssl.patch index 8bc7ac2..2eefb5c 100644 --- a/windows/curl-openssl.patch +++ b/windows/curl-openssl.patch @@ -1,12 +1,41 @@ diff --git a/src/curl.mk b/src/curl.mk --- a/src/curl.mk +++ b/src/curl.mk -@@ -21,6 +21,7 @@ +@@ -9,7 +9,7 @@ + $(PKG)_SUBDIR := curl-$($(PKG)_VERSION) + $(PKG)_FILE := curl-$($(PKG)_VERSION).tar.xz + $(PKG)_URL := https://curl.haxx.se/download/$($(PKG)_FILE) +-$(PKG)_DEPS := cc brotli libidn2 libpsl libssh2 nghttp2 pthreads zstd ++$(PKG)_DEPS := cc brotli c-ares openssl libidn2 libpsl libssh2 nghttp2 pthreads zstd + + define $(PKG)_UPDATE + $(WGET) -q -O- 'https://curl.haxx.se/download/?C=M;O=D' | \ +@@ -20,7 +20,13 @@ define $(PKG)_BUILD cd '$(BUILD_DIR)' && $(SOURCE_DIR)/configure \ $(MXE_CONFIGURE_OPTS) \ - --with-schannel \ + --with-openssl='$(PREFIX)/$(TARGET)' \ + --without-schannel \ ++ --enable-ares='$(PREFIX)/$(TARGET)' \ ++ --disable-threaded-resolver \ ++ --disable-ldap \ ++ --disable-ldaps \ ++ --without-librtmp \ --with-libidn2 \ --enable-sspi \ + --enable-ipv6 \ +@@ -28,6 +31,13 @@ + --with-nghttp2 \ + CPPFLAGS="`'$(TARGET)-pkg-config' libnghttp2 --cflags`" \ + LIBS="`'$(TARGET)-pkg-config' libpsl libbrotlidec pthreads --libs` -lnetio" ++ grep -Eq '^#define USE_ARES 1$$' '$(BUILD_DIR)/lib/curl_config.h' ++ ! grep -Eq '^#define USE_THREADS_(POSIX|WIN32) 1$$' '$(BUILD_DIR)/lib/curl_config.h' ++ $(INSTALL) -d '$(PREFIX)/$(TARGET)/share/atrinik/curl' ++ $(INSTALL) -m 644 '$(BUILD_DIR)/lib/curl_config.h' \ ++ '$(PREFIX)/$(TARGET)/share/atrinik/curl/curl_config.h' ++ $(INSTALL) -m 644 '$(BUILD_DIR)/config.log' \ ++ '$(PREFIX)/$(TARGET)/share/atrinik/curl/config.log' + $(MAKE) -C '$(BUILD_DIR)' -j '$(JOBS)' $(MXE_DISABLE_DOCS) + $(MAKE) -C '$(BUILD_DIR)' -j 1 install $(MXE_DISABLE_DOCS) + ln -sf '$(PREFIX)/$(TARGET)/bin/curl-config' '$(PREFIX)/bin/$(TARGET)-curl-config' diff --git a/windows/licenses/c-ares-1.34.6.LICENSE.md b/windows/licenses/c-ares-1.34.6.LICENSE.md new file mode 100644 index 0000000..910ddde --- /dev/null +++ b/windows/licenses/c-ares-1.34.6.LICENSE.md @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 1998 Massachusetts Institute of Technology +Copyright (c) 2007 - 2023 Daniel Stenberg with many contributors, see AUTHORS +file. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/windows/openssl-3.5.patch b/windows/openssl-3.5.patch new file mode 100644 index 0000000..743ccb4 --- /dev/null +++ b/windows/openssl-3.5.patch @@ -0,0 +1,14 @@ +diff --git a/src/openssl.mk b/src/openssl.mk +--- a/src/openssl.mk ++++ b/src/openssl.mk +@@ -3,8 +3,8 @@ + PKG := openssl + $(PKG)_WEBSITE := https://www.openssl.org/ + $(PKG)_IGNORE := +-$(PKG)_VERSION := 4.0.1 +-$(PKG)_CHECKSUM := 2db3f3a0d6ea4b59e1f094ace2c8cd536dffb87cdc39084c5afa1e6f7f37dd09 ++$(PKG)_VERSION := 3.5.5 ++$(PKG)_CHECKSUM := b28c91532a8b65a1f983b4c28b7488174e4a01008e29ce8e69bd789f28bc2a89 + $(PKG)_GH_CONF := openssl/openssl/releases,openssl- + $(PKG)_SUBDIR := openssl-$($(PKG)_VERSION) + $(PKG)_FILE := openssl-$($(PKG)_VERSION).tar.gz